diff --git a/backend/frontend/frontend_excluded.go b/backend/frontend/frontend_excluded.go index d7c652f6..fd203d26 100644 --- a/backend/frontend/frontend_excluded.go +++ b/backend/frontend/frontend_excluded.go @@ -4,9 +4,8 @@ package frontend import ( "github.com/gin-gonic/gin" - "github.com/pocket-id/pocket-id/backend/internal/service" ) -func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) error { +func RegisterFrontend(router *gin.Engine) error { return ErrFrontendNotIncluded } diff --git a/backend/frontend/frontend_included.go b/backend/frontend/frontend_included.go index 10bb6519..e35e3c0f 100644 --- a/backend/frontend/frontend_included.go +++ b/backend/frontend/frontend_included.go @@ -16,8 +16,6 @@ import ( "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/middleware" - "github.com/pocket-id/pocket-id/backend/internal/service" - "golang.org/x/time/rate" ) //go:embed all:dist/* @@ -55,7 +53,7 @@ func init() { } } -func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) error { +func RegisterFrontend(router *gin.Engine) error { distFS, err := fs.Sub(frontendFS, "dist") if err != nil { return fmt.Errorf("failed to create sub FS: %w", err) @@ -87,19 +85,6 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro if isSPARequest(path, distFS) { nonce := middleware.GetCSPNonce(c) - // For an authorization request that responds via response_mode=form_post, the - // consent page auto-submits a form to the client's callback, so that callback - // must be allowed in the CSP form-action directive - isAuthPostRequest, redirectURI := isOAuth2AuthorizationPostRequest(c, oidcService) - if isAuthPostRequest { - clientID := c.Query("client_id") - // In that case, we need to validate and allow form submissions to the redirect_uri - validatedRedirectURI, err := oidcService.ResolveAllowedCallbackURL(c.Request.Context(), clientID, redirectURI) - if err == nil { - c.Header("Content-Security-Policy", middleware.BuildCSP(nonce, validatedRedirectURI)) - } - } - // Do not cache the HTML shell, as it embeds a per-request nonce c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Cache-Control", "no-store") @@ -115,55 +100,11 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro fileServer.ServeHTTP(c.Writer, c.Request) } - rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(300*time.Millisecond), 50) - router.NoRoute(rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware, oidcService, distFS), handler) + router.NoRoute(handler) return nil } -func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.HandlerFunc, oidcService *service.OidcService, distFS fs.FS) gin.HandlerFunc { - return func(c *gin.Context) { - path := strings.TrimPrefix(c.Request.URL.Path, "/") - - if isSPARequest(path, distFS) { - isAuthPostRequest, _ := isOAuth2AuthorizationPostRequest(c, oidcService) - if isAuthPostRequest { - rateLimitMiddleware(c) - return - } - } - - c.Next() - } -} - -// isOAuth2AuthorizationRequest checks if this is an OAuth2 authorization request with response_mode=form_post -// In that case, we need to validate and allow form submissions to the redirect_uri -func isOAuth2AuthorizationPostRequest(c *gin.Context, oidcService *service.OidcService) (bool, string) { - responseMode := c.Query("response_mode") - redirectURI := c.Query("redirect_uri") - clientID := c.Query("client_id") - requestUri := c.Query("request_uri") - - if c.Request.URL.Path != "/authorize" { - return false, "" - } - - if requestUri != "" && clientID != "" { - par, err := oidcService.GetPushedAuthorizationRequest(c.Request.Context(), clientID, requestUri) - if err != nil { - return false, "" - } - - responseMode = par.Parameters.ResponseMode - redirectURI = par.Parameters.RedirectURI - - } - - ok := responseMode == "form_post" && redirectURI != "" && clientID != "" - return ok, redirectURI -} - func isSPARequest(path string, distFS fs.FS) bool { if path == "" { return true diff --git a/backend/frontend/frontend_included_test.go b/backend/frontend/frontend_included_test.go index b5421342..0e755abc 100644 --- a/backend/frontend/frontend_included_test.go +++ b/backend/frontend/frontend_included_test.go @@ -3,12 +3,9 @@ package frontend import ( - "net/http" - "net/http/httptest" "testing" "testing/fstest" - "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" ) @@ -29,109 +26,3 @@ func TestIsSPARequest(t *testing.T) { assert.True(t, isSPARequest("authorize", distFS)) }) } - -func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) { - gin.SetMode(gin.TestMode) - - distFS := fstest.MapFS{ - "assets/app.js": &fstest.MapFile{Data: []byte("console.log('test')")}, - } - - t.Run("rate limits spa form_post request", func(t *testing.T) { - rateLimited := false - nextCalled := false - middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) { - rateLimited = true - c.Abort() - }, nil, distFS) - - router := gin.New() - router.NoRoute( - middleware, - func(c *gin.Context) { - nextCalled = true - }, - ) - - recorder := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/authorize?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil) - router.ServeHTTP(recorder, req) - - assert.True(t, rateLimited) - assert.False(t, nextCalled) - }) - - t.Run("does not rate limit page request with no form_post params", func(t *testing.T) { - rateLimited := false - nextCalled := false - middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) { - rateLimited = true - c.Abort() - }, nil, distFS) - - router := gin.New() - router.NoRoute( - middleware, - func(c *gin.Context) { - nextCalled = true - }, - ) - - recorder := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/authorize", nil) - router.ServeHTTP(recorder, req) - - assert.False(t, rateLimited) - assert.True(t, nextCalled) - }) - - t.Run("does not rate limit static asset request with form_post params", func(t *testing.T) { - rateLimited := false - nextCalled := false - middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) { - rateLimited = true - c.Abort() - }, nil, distFS) - - router := gin.New() - router.NoRoute( - middleware, - func(c *gin.Context) { - nextCalled = true - }, - ) - - recorder := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/assets/app.js?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil) - router.ServeHTTP(recorder, req) - - assert.False(t, rateLimited) - assert.True(t, nextCalled) - }) - - t.Run("does not rate limit non-authorize spa path with form_post params", func(t *testing.T) { - rateLimited := false - nextCalled := false - // oidcService is nil: a non-/authorize path is rejected by the path guard - // before the request_uri branch that would dereference it. - middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) { - rateLimited = true - c.Abort() - }, nil, distFS) - - router := gin.New() - router.NoRoute( - middleware, - func(c *gin.Context) { - nextCalled = true - }, - ) - - recorder := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/settings?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil) - router.ServeHTTP(recorder, req) - - assert.False(t, rateLimited) - assert.True(t, nextCalled) - }) -} diff --git a/backend/go.mod b/backend/go.mod index 958909f4..ffbfd7a7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -22,6 +22,7 @@ require ( github.com/glebarez/go-sqlite v1.22.0 github.com/glebarez/sqlite v1.11.0 github.com/go-co-op/gocron/v2 v2.21.2 + github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-playground/validator/v10 v10.30.3 github.com/go-webauthn/webauthn v0.17.4 @@ -36,6 +37,7 @@ require ( github.com/mattn/go-isatty v0.0.22 github.com/mileusna/useragent v1.3.5 github.com/orandin/slog-gorm v1.4.0 + github.com/ory/fosite v0.49.1-0.20250703093431-a5f0b09bf31c github.com/oschwald/maxminddb-golang/v2 v2.4.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 @@ -62,6 +64,7 @@ require ( require ( github.com/Azure/go-ntlmssp v0.1.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect @@ -82,8 +85,10 @@ require ( github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.7 // indirect + github.com/cristalhq/jwt/v5 v5.4.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect github.com/disintegration/gift v1.2.1 // indirect github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d // indirect github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d // indirect @@ -109,18 +114,24 @@ require ( github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gogo/googleapis v1.4.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/geo v0.0.0-20250319145452-ed1c8b99c3d7 // indirect github.com/google/go-github/v39 v39.2.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/go-tpm v0.9.8 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/h2non/filetype v1.1.3 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.9.1 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jaegertracing/jaeger-idl v0.9.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect @@ -134,13 +145,21 @@ require ( github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/lib/pq v1.12.3 // indirect github.com/mattn/go-sqlite3 v1.14.42 // indirect + github.com/mattn/goveralls v0.0.12 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/nlnwa/whatwg-url v0.6.2 // indirect + github.com/openzipkin/zipkin-go v0.4.3 // indirect + github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe // indirect + github.com/ory/go-convenience v0.1.0 // indirect + github.com/ory/pop/v6 v6.4.1 // indirect + github.com/ory/x v0.0.729 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -151,8 +170,15 @@ require ( github.com/quic-go/quic-go v0.59.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 // indirect github.com/segmentio/asm v1.2.1 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect @@ -161,6 +187,11 @@ require ( go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 // indirect + go.opentelemetry.io/contrib/propagators/b3 v1.44.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.44.0 // indirect + go.opentelemetry.io/contrib/samplers/jaegerremote v0.37.1 // indirect + go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect @@ -172,15 +203,20 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/zipkin v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/mock v0.6.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.27.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.46.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + golang.org/x/tools v0.45.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab // indirect google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect @@ -190,3 +226,5 @@ require ( modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.48.2 // indirect ) + +replace github.com/ory/fosite => github.com/pocket-id/fosite v0.0.0-20260617200813-dd5303674b39 diff --git a/backend/go.sum b/backend/go.sum index 86dd29d6..3a28269b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,11 +1,13 @@ -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g= @@ -68,12 +70,18 @@ github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmC github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cristalhq/jwt/v5 v5.4.0 h1:Wxi1TocFHaijyV608j7v7B9mPc4ZNjvWT3LKBO0d4QI= +github.com/cristalhq/jwt/v5 v5.4.0/go.mod h1:+b/BzaCWEpFDmXxspJ5h4SdJ1N/45KMjKOetWzmHvDA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU= +github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= @@ -125,8 +133,12 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= @@ -152,6 +164,8 @@ github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWE github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -180,6 +194,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0= +github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -213,10 +229,18 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -231,6 +255,8 @@ github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jaegertracing/jaeger-idl v0.9.0 h1:dI4olA7ArW3cjXwVbic/aYKDbdlfe7V+9wPQqAdzu8Y= +github.com/jaegertracing/jaeger-idl v0.9.0/go.mod h1:W+9vbcr2cVZyS6z/cbr540EOzSkKYml3hmaWEavxkB0= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= @@ -256,10 +282,20 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= +github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/json v0.1.0 h1:dzSZl5pf5bBcW0Acnu20Djleto19T0CfHcvZ14NJ6fU= +github.com/knadh/koanf/parsers/json v0.1.0/go.mod h1:ll2/MlXcZ2BfXD6YJcjVFzhG9P0TdJ207aIBKQhV2hY= +github.com/knadh/koanf/providers/rawbytes v0.1.0 h1:dpzgu2KO6uf6oCb4aP05KDmKmAmI51k5pe8RYKQ0qME= +github.com/knadh/koanf/providers/rawbytes v0.1.0/go.mod h1:mMTB1/IcJ/yE++A2iEZbY1MLygX7vttU+C+S/YmPu9c= +github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= +github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -286,21 +322,31 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/goveralls v0.0.12 h1:PEEeF0k1SsTjOBQ8FOmrOAoCu4ytuMaWCnWe94zxbCg= +github.com/mattn/goveralls v0.0.12/go.mod h1:44ImGEUfmqH8bBtaMrYKsM65LXfNLWmwaxFGjZwgMSQ= github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws= github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -309,12 +355,30 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q= github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk= +github.com/nyaruka/phonenumbers v1.5.0 h1:0M+Gd9zl53QC4Nl5z1Yj1O/zPk2XXBUwR/vlzdXSJv4= +github.com/nyaruka/phonenumbers v1.5.0/go.mod h1:gv+CtldaFz+G3vHHnasBSirAi3O2XLqZzVWz4V1pl2E= +github.com/oleiade/reflections v1.1.0 h1:D+I/UsXQB4esMathlt0kkZRJZdUDmhv5zGi/HOwYTWo= +github.com/oleiade/reflections v1.1.0/go.mod h1:mCxx0QseeVCHs5Um5HhJeCKVC7AwS8kO67tky4rdisA= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= +github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU= github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA= +github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe h1:rvu4obdvqR0fkSIJ8IfgzKOWwZ5kOT2UNfLq81Qk7rc= +github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe/go.mod h1:z4n3u6as84LbV4YmgjHhnwtccQqzf4cZlSk9f1FhygI= +github.com/ory/go-convenience v0.1.0 h1:zouLKfF2GoSGnJwGq+PE/nJAE6dj2Zj5QlTgmMTsTS8= +github.com/ory/go-convenience v0.1.0/go.mod h1:uEY/a60PL5c12nYz4V5cHY03IBmwIAEm8TWB0yn9KNs= +github.com/ory/herodot v0.10.3-0.20250318104651-3179543efba8 h1:bBFBzJ+sy1l/9+uYaz5TLGNNe0GWeXPMyqLhUEy9gPg= +github.com/ory/herodot v0.10.3-0.20250318104651-3179543efba8/go.mod h1:aq2fDNzFXlh8wF6+ILtlEin2oZSrqR79/Zdsi05WEVA= +github.com/ory/jsonschema/v3 v3.0.9-0.20250317235931-280c5fc7bf0e h1:4tUrC7x4YWRVMFp+c64KACNSGchW1zXo4l6Pa9/1hA8= +github.com/ory/jsonschema/v3 v3.0.9-0.20250317235931-280c5fc7bf0e/go.mod h1:XWLxVK4un/iuIcrw+6lCeanbF3NZwO5k6RdLeu/loQk= +github.com/ory/pop/v6 v6.4.1 h1:mxwfgwIB+kRlE4hvcoeEuxFqXZai6TWgQ23sOCBTERo= +github.com/ory/pop/v6 v6.4.1/go.mod h1:o+a3+gdnfWUd/IpFCTKidX7sRgQ6GdPmH02XYiMH8cw= +github.com/ory/x v0.0.729 h1:7ttCYNCjCdspI6X0oaxGAXoiYWSBrwGRz6w/IG8s3I4= +github.com/ory/x v0.0.729/go.mod h1:qdUK3Sp4K4nRbYJG0sEnFO1tDLN/Ct53G+ymre0JhCU= github.com/oschwald/maxminddb-golang/v2 v2.4.0 h1:3ftnrR1/XwiQ788bWIRhsE1DK3GOgJ6tm6S2qTktLm8= github.com/oschwald/maxminddb-golang/v2 v2.4.0/go.mod h1:7jcFtmhWVDEV+UopVv9NjcPm200uMyEHN14LIVV4hW8= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= @@ -326,6 +390,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pocket-id/fosite v0.0.0-20260617200813-dd5303674b39 h1:+gvFcRlW9RprrtKe0ltCWERkFkKlEBdFaQWMwBWGznQ= +github.com/pocket-id/fosite v0.0.0-20260617200813-dd5303674b39/go.mod h1:KeQ7tTIBm3DyeBnKcKLnPbSdrd6ttM6w3TD3yy9x8rM= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -347,16 +413,29 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 h1:0b8DF5kR0PhRoRXDiEEdzrgBc8UqVY4JWLkQJCRsLME= +github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761/go.mod h1:/THDZYi7F/BsVEcYzYPqdcWFQ+1C2InkawTKfLOAnzg= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -366,16 +445,30 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zitadel/exifremove v0.1.0 h1:qD50ezWsfeeqfcvs79QyyjVfK+snN12v0U0deaU8aKg= github.com/zitadel/exifremove v0.1.0/go.mod h1:rzKJ3woL/Rz2KthVBiSBKIBptNTvgmk9PLaeUKTm+ek= @@ -391,12 +484,20 @@ go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0ax go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 h1:u5gsfBL8t1Km4ROhQKAs0cA0t9CzUE7nfkASj/UjAtI= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0/go.mod h1:W6FFYCZQuntC5hxVesXpu7Ppd9sT0a84njildAijc+k= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 h1:MCcYL7J6Vt/X0kjqbMZkekCmwsurbQRbL69vkiye2lk= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0/go.mod h1:3jnStNwSufK+f5ktjL4EPcwtig4rtd81NS70lqHuXl8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/contrib/propagators/b3 v1.44.0 h1:1IFH4oFKK8KupzIelCl3u+bkxpGRps1oWRjQI2+TTWs= go.opentelemetry.io/contrib/propagators/b3 v1.44.0/go.mod h1:JqWFXsc7VDaqIyubFhEd2cPHqsrzqP0Lvn783SUwyro= +go.opentelemetry.io/contrib/propagators/jaeger v1.44.0 h1:OyzvsAMc/zHt0DRPcfstn0wgfq8ApDkeY0ABMcueweM= +go.opentelemetry.io/contrib/propagators/jaeger v1.44.0/go.mod h1:44kghcGX+BNxy9UTiWtd6VDt8Nd4EypGBkH2+v2Dqrc= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.37.1 h1:pV2nZ1iE87X9ym+crkD9k15zTLbN08+IC4C7sk9sQYM= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.37.1/go.mod h1:nvgrM8LaG2+5G7WxbtjEPiSkg87+d0/ltZZK70p7FVo= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= +go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww= @@ -419,6 +520,8 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0= +go.opentelemetry.io/otel/exporters/zipkin v1.44.0 h1:zv7PRYGLrQHkdeZj0c5SNAZOJcw55XgaTezUkNpwA+w= +go.opentelemetry.io/otel/exporters/zipkin v1.44.0/go.mod h1:3+VZyCi6hFW+UuxFF+wSOvwsOwncfBpQfP7Qdb3JXKg= go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= @@ -443,10 +546,13 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= @@ -460,23 +566,30 @@ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aI golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY= golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= @@ -488,6 +601,8 @@ golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAG golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= @@ -497,12 +612,15 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -514,6 +632,7 @@ golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXct golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= @@ -535,21 +654,26 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab h1:Foefixyu0l973HSYkX8Etw/fPxAmKRhyMGwuqXFiVI0= +google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab h1:cY0oV1VnAqvaim8VsR8ZyEKAudzbRJMRGwD3W/L7yOw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index caeceb1a..4fb21e1e 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -109,7 +109,7 @@ func registerGlobalMiddleware(r *gin.Engine) { func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error { - err := frontend.RegisterFrontend(r, svc.oidcService) + err := frontend.RegisterFrontend(r) if errors.Is(err, frontend.ErrFrontendNotIncluded) { slog.Warn("Frontend is not included in the build. Skipping frontend registration.") } else if err != nil { @@ -122,9 +122,11 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error { apiRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 100) apiGroup := r.Group("/api", apiRateLimitMiddleware) + baseGroup := r.Group("/", apiRateLimitMiddleware) + controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService) controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService) - controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService) + controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService) controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.webauthnService, svc.appConfigService) controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService) controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService) @@ -135,9 +137,12 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error { controller.NewScimController(apiGroup, authMiddleware, svc.scimService) controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService) + optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add() + browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add() + svc.oidcModule.RegisterRoutes(baseGroup, apiGroup, optionalBrowserAuth, browserAuth) + registerTestRoutes(apiGroup, db, svc) - baseGroup := r.Group("/", apiRateLimitMiddleware) controller.NewWellKnownController(baseGroup, svc.jwtService) // These are not rate-limited. diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 069b814c..e20aed55 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -8,6 +8,8 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/job" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/storage" ) @@ -32,6 +34,8 @@ type services struct { appLockService *service.AppLockService userSignUpService *service.UserSignUpService oneTimeAccessService *service.OneTimeAccessService + + oidcModule *oidc.Module } // Initializes all services @@ -67,7 +71,24 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima svc.scimService = service.NewScimService(db, scheduler, httpClient) - svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, svc.scimService, httpClient, fileStorage) + svc.oidcModule, err = oidc.New(ctx, oidc.Dependencies{ + DB: db, + HTTPClient: httpClient, + Config: oidc.Config{ + BaseURL: common.EnvConfig.AppURL, + TokenBaseURL: common.EnvConfig.AppURL, + Secret: string(common.EnvConfig.EncryptionKey), + }, + Signer: svc.jwtService, + CustomClaims: svc.customClaimService, + Reauth: svc.webauthnService, + AuditLog: svc.auditLogService, + }) + if err != nil { + return nil, fmt.Errorf("failed to create OIDC module: %w", err) + } + + svc.oidcService, err = service.NewOidcService(db, svc.jwtService, svc.appConfigService, svc.oidcModule.Preview, svc.scimService, httpClient, fileStorage) if err != nil { return nil, fmt.Errorf("failed to create OIDC service: %w", err) } diff --git a/backend/internal/common/claims.go b/backend/internal/common/claims.go new file mode 100644 index 00000000..cd9a3275 --- /dev/null +++ b/backend/internal/common/claims.go @@ -0,0 +1,8 @@ +package common + +// AuthenticationMethodsClaim is the JWT claim ("amr") used to identify how the user +// authenticated. It is shared between the session JWTs and the OIDC tokens. +const AuthenticationMethodsClaim = "amr" + +// TokenTypeClaim is the JWT claim ("type") used to identify the type of token. +const TokenTypeClaim = "type" diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index fd4c87b6..873054de 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -62,43 +62,6 @@ type OidcMissingAuthorizationError struct{} func (e OidcMissingAuthorizationError) Error() string { return "missing authorization" } func (e OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden } -type OidcGrantTypeNotSupportedError struct{} - -func (e OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" } -func (e OidcGrantTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest } - -type OidcMissingClientCredentialsError struct{} - -func (e OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" } -func (e OidcMissingClientCredentialsError) HttpStatusCode() int { return http.StatusBadRequest } - -type OidcClientSecretInvalidError struct{} - -func (e OidcClientSecretInvalidError) Error() string { return "invalid client secret" } -func (e OidcClientSecretInvalidError) HttpStatusCode() int { return http.StatusUnauthorized } - -type OidcClientAssertionInvalidError struct{} - -func (e OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" } -func (e OidcClientAssertionInvalidError) HttpStatusCode() int { return http.StatusUnauthorized } - -type OidcInvalidAuthorizationCodeError struct{} - -func (e OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" } -func (e OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return http.StatusBadRequest } - -type OidcClientNotFoundError struct{} - -func (e OidcClientNotFoundError) Error() string { return "client not found" } -func (e OidcClientNotFoundError) HttpStatusCode() int { return http.StatusNotFound } - -type OidcMissingCallbackURLError struct{} - -func (e OidcMissingCallbackURLError) Error() string { - return "unable to detect callback url, it might be necessary for an admin to fix this" -} -func (e OidcMissingCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest } - type OidcInvalidCallbackURLError struct{} func (e OidcInvalidCallbackURLError) Error() string { @@ -189,16 +152,6 @@ func (e DuplicateClaimError) Error() string { } func (e DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest } -type OidcInvalidCodeVerifierError struct{} - -func (e OidcInvalidCodeVerifierError) Error() string { return "Invalid code verifier" } -func (e OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest } - -type OidcMissingCodeChallengeError struct{} - -func (e OidcMissingCodeChallengeError) Error() string { return "Missing code challenge" } -func (e OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest } - type LdapUserUpdateError struct{} func (e LdapUserUpdateError) Error() string { return "LDAP users can't be updated" } @@ -221,13 +174,6 @@ func (e OidcClientIdNotMatchingError) Error() string { } func (e OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest } -type OidcNoCallbackURLError struct{} - -func (e OidcNoCallbackURLError) Error() string { - return "No callback URL provided" -} -func (e OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest } - type UiConfigDisabledError struct{} func (e UiConfigDisabledError) Error() string { @@ -279,21 +225,6 @@ func (e APIKeyAuthNotAllowedError) Error() string { } func (e APIKeyAuthNotAllowedError) HttpStatusCode() int { return http.StatusForbidden } -type OidcInvalidRefreshTokenError struct{} - -func (e OidcInvalidRefreshTokenError) Error() string { return "refresh token is invalid or expired" } -func (e OidcInvalidRefreshTokenError) HttpStatusCode() int { return http.StatusBadRequest } - -type OidcMissingRefreshTokenError struct{} - -func (e OidcMissingRefreshTokenError) Error() string { return "refresh token is required" } -func (e OidcMissingRefreshTokenError) HttpStatusCode() int { return http.StatusBadRequest } - -type OidcMissingAuthorizationCodeError struct{} - -func (e OidcMissingAuthorizationCodeError) Error() string { return "authorization code is required" } -func (e OidcMissingAuthorizationCodeError) HttpStatusCode() int { return http.StatusBadRequest } - type UserDisabledError struct{} func (e UserDisabledError) Error() string { return "User account is disabled" } @@ -315,16 +246,6 @@ type OidcInvalidDeviceCodeError struct{} func (e OidcInvalidDeviceCodeError) Error() string { return "invalid device code" } func (e OidcInvalidDeviceCodeError) HttpStatusCode() int { return http.StatusBadRequest } -type OidcSlowDownError struct{} - -func (e OidcSlowDownError) Error() string { return "polling too frequently" } -func (e OidcSlowDownError) HttpStatusCode() int { return http.StatusTooManyRequests } - -type OidcAuthorizationPendingError struct{} - -func (e OidcAuthorizationPendingError) Error() string { return "authorization is still pending" } -func (e OidcAuthorizationPendingError) HttpStatusCode() int { return http.StatusBadRequest } - type ReauthenticationRequiredError struct{} func (e ReauthenticationRequiredError) Error() string { return "reauthentication required" } @@ -354,22 +275,6 @@ func (e ImageNotFoundError) Error() string { return "Image not found" } func (e ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound } -type OidcPARNotSupportedForPublicClientsError struct{} - -func (e OidcPARNotSupportedForPublicClientsError) Error() string { - return "pushed authorization requests are not supported for public clients" -} -func (e OidcPARNotSupportedForPublicClientsError) HttpStatusCode() int { - return http.StatusBadRequest -} - -type OidcInvalidRequestURIError struct{} - -func (e OidcInvalidRequestURIError) Error() string { - return "invalid or expired request_uri" -} -func (e OidcInvalidRequestURIError) HttpStatusCode() int { return http.StatusBadRequest } - type OidcPARRequiredError struct{} func (e OidcPARRequiredError) Error() string { @@ -382,35 +287,3 @@ type InvalidEmailVerificationTokenError struct{} func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" } func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest } - -// OIDC prompt parameter errors - used for redirect error responses - -type OidcLoginRequiredError struct{} - -func (e OidcLoginRequiredError) Error() string { return "login_required" } -func (e OidcLoginRequiredError) HttpStatusCode() int { return http.StatusBadRequest } - -type OidcConsentRequiredError struct{} - -func (e OidcConsentRequiredError) Error() string { return "consent_required" } -func (e OidcConsentRequiredError) HttpStatusCode() int { return http.StatusBadRequest } - -type OidcInteractionRequiredError struct{} - -func (e OidcInteractionRequiredError) Error() string { return "interaction_required" } -func (e OidcInteractionRequiredError) HttpStatusCode() int { return http.StatusBadRequest } - -type OidcInvalidRequestError struct{ description string } - -func NewOidcInvalidRequestError(description string) *OidcInvalidRequestError { - return &OidcInvalidRequestError{description: description} -} - -func (e OidcInvalidRequestError) Error() string { return "invalid_request" } -func (e OidcInvalidRequestError) HttpStatusCode() int { return http.StatusBadRequest } -func (e OidcInvalidRequestError) Description() string { return e.description } - -type OidcAccountSelectionRequiredError struct{} - -func (e OidcAccountSelectionRequiredError) Error() string { return "account_selection_required" } -func (e OidcAccountSelectionRequiredError) HttpStatusCode() int { return http.StatusBadRequest } diff --git a/backend/internal/controller/e2etest_controller.go b/backend/internal/controller/e2etest_controller.go index 62937c66..d15bfbfb 100644 --- a/backend/internal/controller/e2etest_controller.go +++ b/backend/internal/controller/e2etest_controller.go @@ -14,6 +14,7 @@ func NewTestController(group *gin.RouterGroup, testService *service.TestService) testController := &TestController{TestService: testService} group.POST("/test/reset", testController.resetAndSeedHandler) + group.POST("/test/accesstoken", testController.signAccessToken) group.POST("/test/refreshtoken", testController.signRefreshToken) group.GET("/externalidp/jwks.json", testController.externalIdPJWKS) @@ -108,6 +109,27 @@ func (tc *TestController) externalIdPSignToken(c *gin.Context) { c.Writer.WriteString(token) } +func (tc *TestController) signAccessToken(c *gin.Context) { + var input struct { + UserID string `json:"user"` + ClientID string `json:"client"` + Expired bool `json:"expired"` + } + err := c.ShouldBindJSON(&input) + if err != nil { + _ = c.Error(err) + return + } + + token, err := tc.TestService.SignAccessToken(c.Request.Context(), input.UserID, input.ClientID, input.Expired) + if err != nil { + _ = c.Error(err) + return + } + + c.Writer.WriteString(token) +} + func (tc *TestController) signRefreshToken(c *gin.Context) { var input struct { UserID string `json:"user"` @@ -120,7 +142,7 @@ func (tc *TestController) signRefreshToken(c *gin.Context) { return } - token, err := tc.TestService.SignRefreshToken(input.UserID, input.ClientID, input.RefreshToken) + token, err := tc.TestService.SignRefreshToken(c.Request.Context(), input.UserID, input.ClientID, input.RefreshToken) if err != nil { _ = c.Error(err) return diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index 919361a6..0033e918 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -1,11 +1,7 @@ package controller import ( - "context" - "errors" - "log/slog" "net/http" - "net/url" "strconv" "strings" "time" @@ -17,33 +13,17 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils" - "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" ) // NewOidcController creates a new controller for OIDC related endpoints // @Summary OIDC controller // @Description Initializes all OIDC-related API endpoints for authentication and client management // @Tags OIDC -func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) { +func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService) { oc := &OidcController{ - oidcService: oidcService, - jwtService: jwtService, - createTokens: oidcService.CreateTokens, + oidcService: oidcService, } - group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler) - group.POST("/oidc/authorize/callback-url", oc.authorizeCallbackURLHandler) - group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler) - group.GET("/oidc/par-request-info", authMiddleware.WithAdminNotRequired().Add(), oc.parRequestInfoHandler) - - group.POST("/oidc/token", oc.createTokensHandler) - group.POST("/oidc/par", oc.pushedAuthorizationRequestHandler) - group.GET("/oidc/userinfo", oc.userInfoHandler) - group.POST("/oidc/userinfo", oc.userInfoHandler) - group.POST("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler) - group.GET("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler) - group.POST("/oidc/introspect", oc.introspectTokenHandler) - group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler) group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler) group.GET("/oidc/clients/:id", authMiddleware.Add(), oc.getClientHandler) @@ -60,10 +40,6 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler) - group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler) - group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler) - group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler) - group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler) group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler) @@ -76,429 +52,7 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi } type OidcController struct { - oidcService *service.OidcService - jwtService *service.JwtService - createTokens func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) -} - -// authorizeHandler godoc -// @Summary Authorize OIDC client -// @Description Start the OIDC authorization process for a client -// @Tags OIDC -// @Accept json -// @Produce json -// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters" -// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL" -// @Router /api/oidc/authorize [post] -func (oc *OidcController) authorizeHandler(c *gin.Context) { - var input dto.AuthorizeOidcClientRequestDto - err := c.ShouldBindJSON(&input) - if err != nil { - _ = c.Error(err) - return - } - - code, callbackURL, err := oc.oidcService.Authorize( - c.Request.Context(), - input, - c.GetString("userID"), - c.GetString("authenticationMethod"), - c.ClientIP(), - c.Request.UserAgent(), - ) - if err != nil { - // Check if this is a prompt-related error that should be returned as a redirect error - if isOidcPromptError(err) { - c.JSON(http.StatusOK, gin.H{ - "error": err.Error(), - "requiresRedirect": true, - "callbackURL": callbackURL, - }) - return - } - _ = c.Error(err) - return - } - - response := dto.AuthorizeOidcClientResponseDto{ - Code: code, - CallbackURL: callbackURL, - Issuer: common.EnvConfig.AppURL, - } - - c.JSON(http.StatusOK, response) -} - -// authorizeCallbackURLHandler godoc -// @Summary Resolve a validated callback URL for an OIDC authorization request -// @Description Resolves the redirect URI against the client's configured callback URLs without consuming PAR records. -// @Tags OIDC -// @Accept json -// @Produce json -// @Param request body dto.AuthorizeOidcClientCallbackRequestDto true "Authorization callback parameters" -// @Success 200 {object} dto.AuthorizeOidcClientCallbackResponseDto "Resolved callback URL" -// @Router /api/oidc/authorize/callback-url [post] -func (oc *OidcController) authorizeCallbackURLHandler(c *gin.Context) { - var input dto.AuthorizeOidcClientCallbackRequestDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return - } - - callbackURL, err := oc.oidcService.ResolveAuthorizeCallbackURL( - c.Request.Context(), - input.ClientID, - input.CallbackURL, - input.RequestURI, - ) - if err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, dto.AuthorizeOidcClientCallbackResponseDto{CallbackURL: callbackURL}) -} - -// isOidcPromptError checks if an error is a prompt-related OIDC error that should trigger a redirect -func isOidcPromptError(err error) bool { - var loginReq *common.OidcLoginRequiredError - var consentReq *common.OidcConsentRequiredError - var interactionReq *common.OidcInteractionRequiredError - var accountSelectionReq *common.OidcAccountSelectionRequiredError - - return errors.As(err, &loginReq) || - errors.As(err, &consentReq) || - errors.As(err, &interactionReq) || - errors.As(err, &accountSelectionReq) -} - -// authorizationConfirmationRequiredHandler godoc -// @Summary Check if authorization confirmation is required -// @Description Check if the user needs to confirm authorization for the client -// @Tags OIDC -// @Accept json -// @Produce json -// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters" -// @Success 200 {object} object "{ \"authorizationRequired\": true/false }" -// @Router /api/oidc/authorization-required [post] -func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) { - var input dto.AuthorizationRequiredDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return - } - - authorizationRequired, scope, err := oc.oidcService.AuthorizationRequired(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope, input.RequestURI) - if err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, gin.H{"authorizationRequired": authorizationRequired, "scope": scope}) -} - -// parRequestInfoHandler godoc -// @Summary Resolve stored authorization request parameters -// @Description Resolve the parameters of a stored request_uri request so the consent page can render the requested scope and build the final redirect. Does not consume the request. -// @Tags OIDC -// @Produce json -// @Param client_id query string true "Client ID" -// @Param request_uri query string true "Request URI returned from the PAR endpoint" -// @Success 200 {object} dto.OidcAuthorizeRequestInfoDto "Resolved authorization request parameters" -// @Router /api/oidc/par-request-info [get] -func (oc *OidcController) parRequestInfoHandler(c *gin.Context) { - clientID := c.Query("client_id") - requestURI := c.Query("request_uri") - if clientID == "" { - _ = c.Error(&common.ValidationError{Message: "client_id is required"}) - return - } - if requestURI == "" { - _ = c.Error(&common.ValidationError{Message: "request_uri is required"}) - return - } - - info, err := oc.oidcService.GetPushedAuthorizationRequest(c.Request.Context(), clientID, requestURI) - if err != nil { - _ = c.Error(err) - return - } - - var infoDto dto.OidcAuthorizeRequestInfoDto - err = dto.MapStruct(info.Parameters, &infoDto) - if err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, infoDto) -} - -// createTokensHandler godoc -// @Summary Create OIDC tokens -// @Description Exchange authorization code or refresh token for access tokens -// @Tags OIDC -// @Produce json -// @Param client_id formData string false "Client ID (if not using Basic Auth)" -// @Param client_secret formData string false "Client secret (if not using Basic Auth or client assertions)" -// @Param code formData string false "Authorization code (required for 'authorization_code' grant)" -// @Param grant_type formData string true "Grant type ('authorization_code' or 'refresh_token')" -// @Param code_verifier formData string false "PKCE code verifier (for authorization_code with PKCE)" -// @Param refresh_token formData string false "Refresh token (required for 'refresh_token' grant)" -// @Param client_assertion formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)" -// @Param client_assertion_type formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)" -// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token" -// @Router /api/oidc/token [post] -func (oc *OidcController) createTokensHandler(c *gin.Context) { - // Per RFC-6749, parameters passed to the /token endpoint MUST be passed in the body of the request - // Gin's "ShouldBind" by default reads from the query string too, so we need to reset all query string args before invoking ShouldBind - c.Request.URL.RawQuery = "" - - var input dto.OidcCreateTokensDto - err := c.ShouldBind(&input) - if err != nil { - _ = c.Error(err) - return - } - - // Validate that code is provided for authorization_code grant type - if input.GrantType == service.GrantTypeAuthorizationCode && input.Code == "" { - _ = c.Error(&common.OidcMissingAuthorizationCodeError{}) - return - } - - // Validate that refresh_token is provided for refresh_token grant type - if input.GrantType == service.GrantTypeRefreshToken && input.RefreshToken == "" { - _ = c.Error(&common.OidcMissingRefreshTokenError{}) - return - } - - // Check if the client ID / secret are passed in the Authorization header (RFC 6749) - parseBasicAuth(c.Request, &input.ClientSecret, &input.ClientID) - - tokens, err := oc.createTokens(c.Request.Context(), input) - - switch { - case errors.Is(err, &common.OidcAuthorizationPendingError{}): - c.JSON(http.StatusBadRequest, gin.H{ - "error": "authorization_pending", - }) - return - case errors.Is(err, &common.OidcSlowDownError{}): - c.JSON(http.StatusBadRequest, gin.H{ - "error": "slow_down", - }) - return - case err != nil: - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, dto.OidcTokenResponseDto{ - AccessToken: tokens.AccessToken, - TokenType: "Bearer", - ExpiresIn: int(tokens.ExpiresIn.Seconds()), - IdToken: tokens.IdToken, // May be empty - RefreshToken: tokens.RefreshToken, // May be empty - }) -} - -// pushedAuthorizationRequestHandler godoc -// @Summary Pushed Authorization Request (PAR) -// @Description RFC 9126: Push authorization request parameters and receive a request_uri. Only confidential clients may use this endpoint. -// @Tags OIDC -// @Accept application/x-www-form-urlencoded -// @Produce json -// @Success 201 {object} dto.OidcPARResponseDto -// @Router /api/oidc/par [post] -func (oc *OidcController) pushedAuthorizationRequestHandler(c *gin.Context) { - // Per RFC 9126, parameters MUST be passed in the request body - c.Request.URL.RawQuery = "" - - var input dto.OidcPARRequestDto - if err := c.ShouldBind(&input); err != nil { - _ = c.Error(err) - return - } - - // Client id and secret can also be passed over the Authorization header - if input.ClientID == "" && input.ClientSecret == "" { - input.ClientID, input.ClientSecret, _ = utils.OAuthClientBasicAuth(c.Request) - } - - creds := service.ClientAuthCredentials{ - ClientID: input.ClientID, - ClientSecret: input.ClientSecret, - ClientAssertion: input.ClientAssertion, - ClientAssertionType: input.ClientAssertionType, - } - - requestURI, expiresIn, err := oc.oidcService.CreatePushedAuthorizationRequest(c.Request.Context(), creds, input) - if err != nil { - _ = c.Error(err) - return - } - - // RFC 9126 §2.2 requires HTTP 201 Created for successful PAR responses - c.JSON(http.StatusCreated, dto.OidcPARResponseDto{ - RequestURI: requestURI, - ExpiresIn: expiresIn, - }) -} - -// userInfoHandler godoc -// @Summary Get user information -// @Description Get user information based on the access token -// @Tags OIDC -// @Accept json -// @Produce json -// @Success 200 {object} object "User claims based on requested scopes" -// @Security OAuth2AccessToken -// @Router /api/oidc/userinfo [get] -func (oc *OidcController) userInfoHandler(c *gin.Context) { - _, authToken, ok := strings.Cut(c.GetHeader("Authorization"), " ") - if !ok || authToken == "" { - _ = c.Error(&common.MissingAccessToken{}) - return - } - - token, err := oc.jwtService.VerifyOAuthAccessToken(authToken) - if err != nil { - _ = c.Error(err) - return - } - userID, ok := token.Subject() - if !ok { - _ = c.Error(&common.TokenInvalidError{}) - return - } - clientID, ok := token.Audience() - if !ok || len(clientID) != 1 { - _ = c.Error(&common.TokenInvalidError{}) - return - } - claims, err := oc.oidcService.GetUserClaimsForClient(c.Request.Context(), userID, clientID[0]) - if err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, claims) -} - -// EndSessionHandler godoc -// @Summary End OIDC session -// @Description End user session and handle OIDC logout -// @Tags OIDC -// @Accept application/x-www-form-urlencoded -// @Param id_token_hint query string false "ID token" -// @Param post_logout_redirect_uri query string false "URL to redirect to after logout" -// @Param state query string false "State parameter to include in the redirect" -// @Success 302 "Redirect to post-logout URL or application logout page" -// @Router /api/oidc/end-session [get] -func (oc *OidcController) EndSessionHandler(c *gin.Context) { - var input dto.OidcLogoutDto - - // Bind query parameters to the struct - switch c.Request.Method { - case http.MethodGet: - if err := c.ShouldBindQuery(&input); err != nil { - _ = c.Error(err) - return - } - case http.MethodPost: - // Bind form parameters to the struct - if err := c.ShouldBind(&input); err != nil { - _ = c.Error(err) - return - } - } - - callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID")) - if err != nil { - // If the validation fails, the user has to confirm the logout manually and doesn't get redirected - slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err) - c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout") - return - } - - // The validation was successful, so we can log out and redirect the user to the callback URL without confirmation - cookie.AddAccessTokenCookie(c, 0, "") - - // Callback URL can be empty if none is configured - if callbackURL == "" { - c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout") - return - } - - logoutCallbackURL, _ := url.Parse(callbackURL) - if input.State != "" { - q := logoutCallbackURL.Query() - q.Set("state", input.State) - logoutCallbackURL.RawQuery = q.Encode() - } - - c.Redirect(http.StatusFound, logoutCallbackURL.String()) -} - -// EndSessionHandler godoc (POST method) -// @Summary End OIDC session (POST method) -// @Description End user session and handle OIDC logout using POST -// @Tags OIDC -// @Accept application/x-www-form-urlencoded -// @Produce html -// @Param id_token_hint formData string false "ID token" -// @Param post_logout_redirect_uri formData string false "URL to redirect to after logout" -// @Param state formData string false "State parameter to include in the redirect" -// @Success 302 "Redirect to post-logout URL or application logout page" -// @Router /api/oidc/end-session [post] -func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) { - // Implementation is the same as GET -} - -// introspectToken godoc -// @Summary Introspect OIDC tokens -// @Description Pass an access_token to verify if it is considered valid. -// @Tags OIDC -// @Produce json -// @Param token formData string true "The token to be introspected." -// @Success 200 {object} dto.OidcIntrospectionResponseDto "Response with the introspection result." -// @Router /api/oidc/introspect [post] -func (oc *OidcController) introspectTokenHandler(c *gin.Context) { - var input dto.OidcIntrospectDto - if err := c.ShouldBind(&input); err != nil { - _ = c.Error(err) - return - } - - // Client id and secret have to be passed over the Authorization header. This kind of - // authentication allows us to keep the endpoint protected (since it could be used to - // find valid tokens) while still allowing it to be used by an application that is - // supposed to interact with our IdP (since that needs to have a client_id - // and client_secret anyway). - var ( - creds service.ClientAuthCredentials - ok bool - ) - creds.ClientID, creds.ClientSecret, ok = utils.OAuthClientBasicAuth(c.Request) - if !ok { - // If there's no basic auth, check if we have a bearer token (used as client assertion) - bearer, ok := utils.BearerAuth(c.Request) - if ok { - creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer - creds.ClientAssertion = bearer - // When using client assertions, client_id can be passed as a form field - creds.ClientID = input.ClientID - } - } - - response, err := oc.oidcService.IntrospectToken(c.Request.Context(), creds, input.Token) - if err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, response) + oidcService *service.OidcService } // getClientMetaDataHandler godoc @@ -810,46 +364,6 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) { c.JSON(http.StatusOK, oidcClientDto) } -// This method can modify the value of clientSecret and clientID -func parseBasicAuth(r *http.Request, clientSecret *string, clientID *string) { - // Client id and secret can also be passed via the Authorization header (RFC 6749, section 2.3.1 "client_secret_basic") - // When PKCE is used, some libraries send client_id in the body for the code_verifier binding while keeping the secret only in the Authorization header - // We therefore fall back to Basic auth whenever the secret is missing, not only when both fields are empty - if *clientSecret == "" { - basicID, basicSecret, ok := utils.OAuthClientBasicAuth(r) - if ok { - if *clientID == "" { - *clientID = basicID - } - *clientSecret = basicSecret - } - } -} - -func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) { - // Per RFC 8628 (OAuth 2.0 Device Authorization Grant), parameters for the device authorization request MUST be sent in the body of the POST request - // Gin's "ShouldBind" by default reads from the query string too, so we need to reset all query string args before invoking ShouldBind - c.Request.URL.RawQuery = "" - - var input dto.OidcDeviceAuthorizationRequestDto - err := c.ShouldBind(&input) - if err != nil { - _ = c.Error(err) - return - } - - // Check if the client ID / secret are passed in the Authorization header (RFC 6749) - parseBasicAuth(c.Request, &input.ClientSecret, &input.ClientID) - - response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input) - if err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, response) -} - // listOwnAuthorizedClientsHandler godoc // @Summary List authorized clients for current user // @Description Get a paginated list of OIDC clients that the current user has authorized @@ -951,49 +465,6 @@ func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) { }) } -func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) { - userCode := c.Query("code") - if userCode == "" { - _ = c.Error(&common.ValidationError{Message: "code is required"}) - return - } - - // Get IP address and user agent from the request context - ipAddress := c.ClientIP() - userAgent := c.Request.UserAgent() - - err := oc.oidcService.VerifyDeviceCode( - c.Request.Context(), - userCode, - c.GetString("userID"), - c.GetString("authenticationMethod"), - ipAddress, - userAgent) - - if err != nil { - _ = c.Error(err) - return - } - - c.Status(http.StatusNoContent) -} - -func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) { - userCode := c.Query("code") - if userCode == "" { - _ = c.Error(&common.ValidationError{Message: "code is required"}) - return - } - - deviceCodeInfo, err := oc.oidcService.GetDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID")) - if err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, deviceCodeInfo) -} - // getClientPreviewHandler godoc // @Summary Preview OIDC client data for user // @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user diff --git a/backend/internal/controller/oidc_controller_test.go b/backend/internal/controller/oidc_controller_test.go deleted file mode 100644 index 28421448..00000000 --- a/backend/internal/controller/oidc_controller_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package controller - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/pocket-id/pocket-id/backend/internal/common" - "github.com/pocket-id/pocket-id/backend/internal/dto" - "github.com/pocket-id/pocket-id/backend/internal/service" -) - -func TestCreateTokensHandler(t *testing.T) { - createTestContext := func(t *testing.T, rawURL string, form url.Values, authHeader string, noCT bool) (*gin.Context, *httptest.ResponseRecorder) { - t.Helper() - - mode := gin.Mode() - gin.SetMode(gin.TestMode) - t.Cleanup(func() { gin.SetMode(mode) }) - - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) - - req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, rawURL, strings.NewReader(form.Encode())) - require.NoError(t, err) - - if !noCT { - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } - if authHeader != "" { - req.Header.Set("Authorization", authHeader) - } - - c.Request = req - return c, recorder - } - - t.Run("Ignores Query String Parameters For Binding", func(t *testing.T) { - oc := &OidcController{} - - c, _ := createTestContext( - t, - "http://example.com/oidc/token?grant_type=refresh_token&refresh_token=query-value", - url.Values{}, - "", - false, - ) - - oc.createTokensHandler(c) - - require.Len(t, c.Errors, 1) - assert.Contains(t, c.Errors[0].Err.Error(), "GrantType") - }) - - t.Run("Missing Authorization Code", func(t *testing.T) { - oc := &OidcController{} - - c, _ := createTestContext( - t, - "http://example.com/oidc/token", - url.Values{ - "grant_type": {service.GrantTypeAuthorizationCode}, - }, - "", - false, - ) - - oc.createTokensHandler(c) - - require.Len(t, c.Errors, 1) - var missingCodeErr *common.OidcMissingAuthorizationCodeError - require.ErrorAs(t, c.Errors[0].Err, &missingCodeErr) - }) - - t.Run("Missing Refresh Token", func(t *testing.T) { - oc := &OidcController{} - - c, _ := createTestContext( - t, - "http://example.com/oidc/token", - url.Values{ - "grant_type": {service.GrantTypeRefreshToken}, - }, - "", - false, - ) - - oc.createTokensHandler(c) - - require.Len(t, c.Errors, 1) - var missingRefreshErr *common.OidcMissingRefreshTokenError - require.ErrorAs(t, c.Errors[0].Err, &missingRefreshErr) - }) - - t.Run("Uses Basic Auth Credentials When Body Credentials Missing", func(t *testing.T) { - var capturedInput dto.OidcCreateTokensDto - oc := &OidcController{ - createTokens: func(_ context.Context, input dto.OidcCreateTokensDto) (service.CreatedTokens, error) { - capturedInput = input - return service.CreatedTokens{ - AccessToken: "access-token", - IdToken: "id-token", - RefreshToken: "refresh-token", - ExpiresIn: 2 * time.Minute, - }, nil - }, - } - - basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("client-id:client-secret")) - c, recorder := createTestContext( - t, - "http://example.com/oidc/token", - url.Values{ - "grant_type": {service.GrantTypeRefreshToken}, - "refresh_token": {"input-refresh-token"}, - }, - basicAuth, - false, - ) - - oc.createTokensHandler(c) - - require.Empty(t, c.Errors) - assert.Equal(t, "client-id", capturedInput.ClientID) - assert.Equal(t, "client-secret", capturedInput.ClientSecret) - assert.Equal(t, "input-refresh-token", capturedInput.RefreshToken) - - require.Equal(t, http.StatusOK, recorder.Code) - var response dto.OidcTokenResponseDto - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) - assert.Equal(t, "access-token", response.AccessToken) - assert.Equal(t, "Bearer", response.TokenType) - assert.Equal(t, "id-token", response.IdToken) - assert.Equal(t, "refresh-token", response.RefreshToken) - assert.Equal(t, 120, response.ExpiresIn) - }) - - t.Run("Uses Basic Auth Secret When Body Has Only Client ID (PKCE)", func(t *testing.T) { - // Some OIDC libraries (e.g. jumbojett/openid-connect-php) combine client_secret_basic with PKCE by sending client_id in the body alongside code_verifier while keeping the secret only in the Authorization header - // RFC 6749 §2.3.1 permits this; the body client_id must not block the Basic auth fallback for the secret. - var capturedInput dto.OidcCreateTokensDto - oc := &OidcController{ - createTokens: func(_ context.Context, input dto.OidcCreateTokensDto) (service.CreatedTokens, error) { - capturedInput = input - return service.CreatedTokens{ - AccessToken: "access-token", - IdToken: "id-token", - RefreshToken: "refresh-token", - ExpiresIn: 2 * time.Minute, - }, nil - }, - } - - basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("client-id:client-secret")) - c, recorder := createTestContext( - t, - "http://example.com/oidc/token", - url.Values{ - "grant_type": {service.GrantTypeRefreshToken}, - "refresh_token": {"input-refresh-token"}, - "client_id": {"client-id"}, - }, - basicAuth, - false, - ) - - oc.createTokensHandler(c) - - require.Empty(t, c.Errors) - assert.Equal(t, "client-id", capturedInput.ClientID) - assert.Equal(t, "client-secret", capturedInput.ClientSecret) - require.Equal(t, http.StatusOK, recorder.Code) - }) - - t.Run("Maps Authorization Pending Error", func(t *testing.T) { - oc := &OidcController{ - createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) { - return service.CreatedTokens{}, &common.OidcAuthorizationPendingError{} - }, - } - - c, recorder := createTestContext( - t, - "http://example.com/oidc/token", - url.Values{ - "grant_type": {service.GrantTypeRefreshToken}, - "refresh_token": {"input-refresh-token"}, - }, - "", - false, - ) - - oc.createTokensHandler(c) - - require.Empty(t, c.Errors) - require.Equal(t, http.StatusBadRequest, recorder.Code) - var response map[string]string - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) - assert.Equal(t, "authorization_pending", response["error"]) - }) - - t.Run("Maps Slow Down Error", func(t *testing.T) { - oc := &OidcController{ - createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) { - return service.CreatedTokens{}, &common.OidcSlowDownError{} - }, - } - - c, recorder := createTestContext( - t, - "http://example.com/oidc/token", - url.Values{ - "grant_type": {service.GrantTypeRefreshToken}, - "refresh_token": {"input-refresh-token"}, - }, - "", - false, - ) - - oc.createTokensHandler(c) - - require.Empty(t, c.Errors) - require.Equal(t, http.StatusBadRequest, recorder.Code) - var response map[string]string - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) - assert.Equal(t, "slow_down", response["error"]) - }) - - t.Run("Returns Generic Service Error In Context", func(t *testing.T) { - expectedErr := errors.New("boom") - oc := &OidcController{ - createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) { - return service.CreatedTokens{}, expectedErr - }, - } - - c, _ := createTestContext( - t, - "http://example.com/oidc/token", - url.Values{ - "grant_type": {service.GrantTypeRefreshToken}, - "refresh_token": {"input-refresh-token"}, - }, - "", - false, - ) - - oc.createTokensHandler(c) - - require.Len(t, c.Errors, 1) - assert.ErrorIs(t, c.Errors[0].Err, expectedErr) - }) -} diff --git a/backend/internal/controller/webauthn_controller.go b/backend/internal/controller/webauthn_controller.go index 7fee14be..c4c0315a 100644 --- a/backend/internal/controller/webauthn_controller.go +++ b/backend/internal/controller/webauthn_controller.go @@ -203,5 +203,6 @@ func (wc *WebauthnController) reauthenticateHandler(c *gin.Context) { } } - c.JSON(http.StatusOK, gin.H{"reauthenticationToken": token}) + cookie.AddReauthenticationTokenCookie(c, token) + c.Status(http.StatusNoContent) } diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go index 221c1475..5c7a2479 100644 --- a/backend/internal/controller/well_known_controller.go +++ b/backend/internal/controller/well_known_controller.go @@ -85,14 +85,14 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) { "jwks_uri": internalAppUrl + "/.well-known/jwks.json", "grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials}, "scopes_supported": []string{"openid", "profile", "email", "groups"}, - "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"}, + "claims_supported": []string{"sub", "given_name", "family_name", "name", "display_name", "email", "email_verified", "preferred_username", "picture", "groups", "auth_time", "amr"}, "response_types_supported": []string{"code", "id_token"}, "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{alg.String()}, "authorization_response_iss_parameter_supported": true, "code_challenge_methods_supported": []string{"plain", "S256"}, "prompt_values_supported": []string{"none", "login", "consent", "select_account"}, - "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "private_key_jwt", "none"}, + "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"}, "pushed_authorization_request_endpoint": internalAppUrl + "/api/oidc/par", "require_pushed_authorization_requests": false, } diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index dfebd7a2..6982fef3 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -59,93 +59,11 @@ type OidcClientCredentialsDto struct { } type OidcClientFederatedIdentityDto struct { - Issuer string `json:"issuer"` - Subject string `json:"subject,omitempty"` - Audience string `json:"audience,omitempty"` - JWKS string `json:"jwks,omitempty"` -} - -type AuthorizeOidcClientRequestDto struct { - ClientID string `json:"clientID" binding:"required"` - Scope string `json:"scope" binding:"required_without=RequestURI"` - CallbackURL string `json:"callbackURL"` - Nonce string `json:"nonce"` - CodeChallenge string `json:"codeChallenge"` - CodeChallengeMethod string `json:"codeChallengeMethod"` - ReauthenticationToken string `json:"reauthenticationToken"` - Prompt string `json:"prompt"` - ResponseMode string `json:"responseMode" binding:"omitempty,response_mode"` - RequestURI string `json:"requestURI"` -} - -type OidcPARRequestDto struct { - ClientID string `form:"client_id"` - ClientSecret string `form:"client_secret"` - ClientAssertion string `form:"client_assertion"` - ClientAssertionType string `form:"client_assertion_type"` - ResponseType string `form:"response_type" binding:"required"` - Scope string `form:"scope" binding:"required"` - RedirectURI string `form:"redirect_uri"` - State string `form:"state"` - Nonce string `form:"nonce"` - CodeChallenge string `form:"code_challenge"` - CodeChallengeMethod string `form:"code_challenge_method"` - Prompt string `form:"prompt"` - ResponseMode string `form:"response_mode" binding:"omitempty,response_mode"` -} - -type OidcPARResponseDto struct { - RequestURI string `json:"request_uri"` - ExpiresIn int `json:"expires_in"` -} - -type AuthorizeOidcClientResponseDto struct { - Code string `json:"code"` - CallbackURL string `json:"callbackURL"` - Issuer string `json:"issuer"` -} - -type AuthorizeOidcClientCallbackRequestDto struct { - ClientID string `json:"clientID" binding:"required"` - CallbackURL string `json:"callbackURL"` - RequestURI string `json:"requestURI"` -} - -type AuthorizeOidcClientCallbackResponseDto struct { - CallbackURL string `json:"callbackURL"` -} - -type AuthorizationRequiredDto struct { - ClientID string `json:"clientID" binding:"required"` - Scope string `json:"scope"` - RequestURI string `json:"requestURI"` -} - -type OidcAuthorizeRequestInfoDto struct { - Scope string `json:"scope"` - RedirectURI string `json:"redirectURI"` - State string `json:"state,omitempty"` - Nonce string `json:"nonce,omitempty"` - ResponseMode string `json:"responseMode,omitempty"` - Prompt string `json:"prompt,omitempty"` -} - -type OidcCreateTokensDto struct { - GrantType string `form:"grant_type" binding:"required"` - Code string `form:"code"` - DeviceCode string `form:"device_code"` - ClientID string `form:"client_id"` - ClientSecret string `form:"client_secret"` - CodeVerifier string `form:"code_verifier"` - RefreshToken string `form:"refresh_token"` - ClientAssertion string `form:"client_assertion"` - ClientAssertionType string `form:"client_assertion_type"` - Resource string `form:"resource"` -} - -type OidcIntrospectDto struct { - Token string `form:"token" binding:"required"` - ClientID string `form:"client_id"` + Issuer string `json:"issuer"` + Subject string `json:"subject,omitempty"` + Audience string `json:"audience,omitempty"` + JWKS string `json:"jwks,omitempty"` + ReplayProtection bool `json:"replayProtection"` } type OidcUpdateAllowedUserGroupsDto struct { @@ -159,36 +77,6 @@ type OidcLogoutDto struct { State string `form:"state"` } -type OidcTokenResponseDto struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - IdToken string `json:"id_token,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` - ExpiresIn int `json:"expires_in"` -} - -type OidcIntrospectionResponseDto struct { - Active bool `json:"active"` - TokenType string `json:"token_type,omitempty"` - Scope string `json:"scope,omitempty"` - Expiration int64 `json:"exp,omitempty"` - IssuedAt int64 `json:"iat,omitempty"` - NotBefore int64 `json:"nbf,omitempty"` - Subject string `json:"sub,omitempty"` - Audience []string `json:"aud,omitempty"` - Issuer string `json:"iss,omitempty"` - Identifier string `json:"jti,omitempty"` -} - -type OidcDeviceAuthorizationRequestDto struct { - ClientID string `form:"client_id" binding:"required"` - Scope string `form:"scope" binding:"required"` - ClientSecret string `form:"client_secret"` - ClientAssertion string `form:"client_assertion"` - ClientAssertionType string `form:"client_assertion_type"` - Nonce string `form:"nonce"` -} - type OidcDeviceAuthorizationResponseDto struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` @@ -196,20 +84,13 @@ type OidcDeviceAuthorizationResponseDto struct { VerificationURIComplete string `json:"verification_uri_complete"` ExpiresIn int `json:"expires_in"` Interval int `json:"interval"` - RequiresAuthorization bool `json:"requires_authorization"` -} - -type OidcDeviceTokenRequestDto struct { - GrantType string `form:"grant_type" binding:"required,eq=urn:ietf:params:oauth:grant-type:device_code"` - DeviceCode string `form:"device_code" binding:"required"` - ClientID string `form:"client_id"` - ClientSecret string `form:"client_secret"` } type DeviceCodeInfoDto struct { - Scope string `json:"scope"` - AuthorizationRequired bool `json:"authorizationRequired"` - Client OidcClientMetaDataDto `json:"client"` + Scope []string `json:"scope"` + AuthorizationRequired bool `json:"authorizationRequired"` + ReauthenticationRequired bool `json:"reauthenticationRequired"` + Client OidcClientMetaDataDto `json:"client"` } type AuthorizedOidcClientDto struct { diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go index 72035972..9d99d6cf 100644 --- a/backend/internal/dto/validations.go +++ b/backend/internal/dto/validations.go @@ -47,9 +47,6 @@ func init() { "callback_url_pattern": func(fl validator.FieldLevel) bool { return ValidateCallbackURLPattern(fl.Field().String()) }, - "response_mode": func(fl validator.FieldLevel) bool { - return ValidateResponseMode(fl.Field().String()) - }, } for k, v := range validators { err := engine.RegisterValidation(k, v) @@ -85,22 +82,7 @@ func ValidateCallbackURL(str string) bool { } } -// ValidateCallbackURLPattern validates callback URL patterns, with support for wildcards +// ValidateCallbackURLPattern validates callback URL patterns, with support for wildcards. func ValidateCallbackURLPattern(raw string) bool { - err := utils.ValidateCallbackURLPattern(raw) - return err == nil -} - -// ValidateResponseMode validates response_mode parameter -// If responseMode is present, it must be "form_post", "query", or "fragment" -// Empty responseMode is allowed (field not provided, use default) -func ValidateResponseMode(responseMode string) bool { - switch responseMode { - case "form_post", "query", "fragment": - return true - case "": - return true - default: - return false - } + return utils.ValidateCallbackURLPattern(raw) == nil } diff --git a/backend/internal/dto/validations_test.go b/backend/internal/dto/validations_test.go index d8b84621..4d557ad1 100644 --- a/backend/internal/dto/validations_test.go +++ b/backend/internal/dto/validations_test.go @@ -58,26 +58,6 @@ func TestValidateClientID(t *testing.T) { } } -func TestValidateResponseMode(t *testing.T) { - tests := []struct { - name string - input string - expected bool - }{ - {"valid form_post", "form_post", true}, - {"valid query", "query", true}, - {"valid fragment", "fragment", true}, - {"valid empty", "", true}, - {"invalid unknown", "unknown", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, ValidateResponseMode(tt.input)) - }) - } -} - func TestValidateCallbackURL(t *testing.T) { tests := []struct { name string @@ -102,3 +82,26 @@ func TestValidateCallbackURL(t *testing.T) { }) } } + +func TestValidateCallbackURLPattern(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"valid exact URL", "https://example.com/callback", true}, + {"valid wildcard URL", "https://*.example.com/callback", true}, + {"valid custom scheme", "pocketid://callback", true}, + {"valid global wildcard", "*", true}, + {"invalid relative URL", "/callback", false}, + {"invalid malformed URL", "http://[::1", false}, + {"rejects javascript scheme", "javascript:alert(1)", false}, + {"rejects data scheme", "data:text/html;base64,PGgxPkhlbGxvPC9oMT4=", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, ValidateCallbackURLPattern(tt.input)) + }) + } +} diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go index c3377348..913b440a 100644 --- a/backend/internal/job/db_cleanup_job.go +++ b/backend/internal/job/db_cleanup_job.go @@ -13,6 +13,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/service" ) @@ -34,11 +35,11 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), - s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", jobDefWithJitter(24*time.Hour), jobs.clearOidcAuthorizationCodes, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), - s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), + s.RegisterJob(ctx, "ClearOAuth2Sessions", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2Sessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), + s.RegisterJob(ctx, "ClearOAuth2JTIs", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2JTIs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), + s.RegisterJob(ctx, "ClearInteractionSessions", jobDefWithJitter(24*time.Hour), jobs.clearInteractionSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), - s.RegisterJob(ctx, "ClearOidcPushedAuthorizationRequests", jobDefWithJitter(24*time.Hour), jobs.clearOidcPushedAuthorizationRequests, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), ) } @@ -89,30 +90,39 @@ func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error { return nil } -// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired -func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error { - st := j.db. - WithContext(ctx). - Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())) - if st.Error != nil { - return fmt.Errorf("failed to clean expired OIDC authorization codes: %w", st.Error) +// clearOAuth2Sessions deletes expired and invalidated OAuth2 sessions. What counts as +// expired is owned by the oidc module. +func (j *DbCleanupJobs) clearOAuth2Sessions(ctx context.Context) error { + count, err := oidc.CleanupExpiredOAuth2Sessions(ctx, j.db) + if err != nil { + return fmt.Errorf("failed to clean OAuth2 sessions: %w", err) } - slog.InfoContext(ctx, "Cleaned expired OIDC authorization codes", slog.Int64("count", st.RowsAffected)) + slog.InfoContext(ctx, "Cleaned OAuth2 sessions", slog.Int64("count", count)) return nil } -// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired -func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error { - st := j.db. - WithContext(ctx). - Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())) - if st.Error != nil { - return fmt.Errorf("failed to clean expired OIDC refresh tokens: %w", st.Error) +// clearOAuth2JTIs deletes expired JWT IDs used for client assertion replay protection. +func (j *DbCleanupJobs) clearOAuth2JTIs(ctx context.Context) error { + count, err := oidc.CleanupExpiredClientAssertionJTIs(ctx, j.db) + if err != nil { + return fmt.Errorf("failed to clean OAuth2 client assertion JTIs: %w", err) } - slog.InfoContext(ctx, "Cleaned expired OIDC refresh tokens", slog.Int64("count", st.RowsAffected)) + slog.InfoContext(ctx, "Cleaned OAuth2 client assertion JTIs", slog.Int64("count", count)) + + return nil +} + +// clearInteractionSessions deletes abandoned OIDC interaction sessions. +func (j *DbCleanupJobs) clearInteractionSessions(ctx context.Context) error { + count, err := oidc.CleanupAbandonedInteractionSessions(ctx, j.db) + if err != nil { + return fmt.Errorf("failed to clean interaction sessions: %w", err) + } + + slog.InfoContext(ctx, "Cleaned interaction sessions", slog.Int64("count", count)) return nil } @@ -147,20 +157,6 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error { return nil } -// clearOidcPushedAuthorizationRequests deletes PAR records that have expired without being consumed -func (j *DbCleanupJobs) clearOidcPushedAuthorizationRequests(ctx context.Context) error { - st := j.db. - WithContext(ctx). - Delete(&model.OidcPushedAuthorizationRequest{}, "expires_at < ?", datatype.DateTime(time.Now())) - if st.Error != nil { - return fmt.Errorf("failed to clean expired pushed authorization requests: %w", st.Error) - } - - slog.InfoContext(ctx, "Cleaned expired pushed authorization requests", slog.Int64("count", st.RowsAffected)) - - return nil -} - // ClearEmailVerificationTokens deletes email verification tokens that have expired func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error { st := j.db. diff --git a/backend/internal/middleware/auth_middleware.go b/backend/internal/middleware/auth_middleware.go index 889ccdff..16a89fc7 100644 --- a/backend/internal/middleware/auth_middleware.go +++ b/backend/internal/middleware/auth_middleware.go @@ -74,11 +74,12 @@ func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware { func (m *AuthMiddleware) Add() gin.HandlerFunc { return func(c *gin.Context) { - userID, isAdmin, authenticationMethod, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired) + userID, isAdmin, authenticationMethod, authenticationTime, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired) if err == nil { c.Set("userID", userID) c.Set("userIsAdmin", isAdmin) c.Set("authenticationMethod", authenticationMethod) + c.Set("authenticationTime", authenticationTime) if c.IsAborted() { return } diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go index d6ae14ef..4a1cc85b 100644 --- a/backend/internal/middleware/cors.go +++ b/backend/internal/middleware/cors.go @@ -43,7 +43,7 @@ func isCorsPath(path string) bool { switch path { case "/api/oidc/token", "/api/oidc/userinfo", - "/oidc/end-session", + "/api/oidc/end-session", "/api/oidc/introspect", "/.well-known/jwks.json", "/.well-known/openid-configuration": diff --git a/backend/internal/middleware/csp_middleware.go b/backend/internal/middleware/csp_middleware.go index ae64528e..2b6f9f88 100644 --- a/backend/internal/middleware/csp_middleware.go +++ b/backend/internal/middleware/csp_middleware.go @@ -1,11 +1,8 @@ package middleware import ( - "crypto/rand" - "encoding/base64" - "strings" - "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/utils" ) // CspMiddleware sets a Content Security Policy header and, when possible, @@ -16,19 +13,14 @@ func NewCspMiddleware() *CspMiddleware { return &CspMiddleware{} } // GetCSPNonce returns the CSP nonce generated for this request, if any. func GetCSPNonce(c *gin.Context) string { - if v, ok := c.Get("csp_nonce"); ok { - if s, ok := v.(string); ok { - return s - } - } - return "" + return utils.GetCSPNonce(c) } func (m *CspMiddleware) Add() gin.HandlerFunc { return func(c *gin.Context) { // Generate a random base64 nonce for this request - nonce := generateNonce() - c.Set("csp_nonce", nonce) + nonce := utils.GenerateCSPNonce() + utils.SetCSPNonce(c, nonce) c.Writer.Header().Set("Content-Security-Policy", BuildCSP(nonce)) c.Next() @@ -36,36 +28,5 @@ func (m *CspMiddleware) Add() gin.HandlerFunc { } func BuildCSP(nonce string, formActionExtra ...string) string { - formAction := "'self'" - - if len(formActionExtra) > 0 { - b := strings.Builder{} - - for _, extra := range formActionExtra { - if extra != "" { - b.WriteByte(' ') - b.WriteString(extra) - } - } - - formAction += b.String() - } - - return "default-src 'self'; " + - "base-uri 'self'; " + - "object-src 'none'; " + - "frame-ancestors 'none'; " + - "form-action " + formAction + "; " + - "img-src * blob:;" + - "font-src 'self'; " + - "style-src 'self' 'unsafe-inline'; " + - "script-src 'self' 'nonce-" + nonce + "'" -} - -func generateNonce() string { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - return "" // if generation fails, return empty; policy will omit nonce - } - return base64.RawURLEncoding.EncodeToString(b) + return utils.BuildCSP(nonce, formActionExtra...) } diff --git a/backend/internal/middleware/jwt_auth.go b/backend/internal/middleware/jwt_auth.go index c311b537..27aba3ce 100644 --- a/backend/internal/middleware/jwt_auth.go +++ b/backend/internal/middleware/jwt_auth.go @@ -2,6 +2,7 @@ package middleware import ( "strings" + "time" "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/common" @@ -20,7 +21,7 @@ func NewJwtAuthMiddleware(jwtService *service.JwtService, userService *service.U func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc { return func(c *gin.Context) { - userID, isAdmin, authenticationMethod, err := m.Verify(c, adminRequired) + userID, isAdmin, authenticationMethod, authenticationTime, err := m.Verify(c, adminRequired) if err != nil { c.Abort() _ = c.Error(err) @@ -30,11 +31,12 @@ func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc { c.Set("userID", userID) c.Set("userIsAdmin", isAdmin) c.Set("authenticationMethod", authenticationMethod) + c.Set("authenticationTime", authenticationTime) c.Next() } } -func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, authenticationMethod string, err error) { +func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, authenticationMethod string, authenticationTime time.Time, err error) { // Extract the token from the cookie accessToken, err := c.Cookie(cookie.AccessTokenCookieName) if err != nil { @@ -42,37 +44,38 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject var ok bool _, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ") if !ok || accessToken == "" { - return "", false, "", &common.NotSignedInError{} + return "", false, "", time.Time{}, &common.NotSignedInError{} } } token, err := m.jwtService.VerifyAccessToken(accessToken) if err != nil { - return "", false, "", &common.NotSignedInError{} + return "", false, "", time.Time{}, &common.NotSignedInError{} } authenticationMethod, err = service.GetAuthenticationMethod(token) if err != nil { - return "", false, "", &common.NotSignedInError{} + return "", false, "", time.Time{}, &common.NotSignedInError{} } + authenticationTime, _ = token.IssuedAt() subject, ok := token.Subject() if !ok { _ = c.Error(&common.TokenInvalidError{}) - return "", false, "", &common.TokenInvalidError{} + return "", false, "", time.Time{}, &common.TokenInvalidError{} } user, err := m.userService.GetUser(c, subject) if err != nil { - return "", false, "", &common.NotSignedInError{} + return "", false, "", time.Time{}, &common.NotSignedInError{} } if user.Disabled { - return "", false, "", &common.UserDisabledError{} + return "", false, "", time.Time{}, &common.UserDisabledError{} } if adminRequired && !user.IsAdmin { - return "", false, "", &common.MissingPermissionError{} + return "", false, "", time.Time{}, &common.MissingPermissionError{} } - return subject, user.IsAdmin, authenticationMethod, nil + return subject, user.IsAdmin, authenticationMethod, authenticationTime, nil } diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index 165ea325..bd9ca046 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -3,14 +3,13 @@ package model import ( "database/sql/driver" "encoding/json" - "strings" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/utils" ) type UserAuthorizedOidcClient struct { - Scope string + Scope datatype.StringList LastUsedAt datatype.DateTime `sortable:"true"` UserID string `gorm:"primary_key;"` @@ -20,31 +19,6 @@ type UserAuthorizedOidcClient struct { Client OidcClient } -func (c UserAuthorizedOidcClient) Scopes() []string { - if len(c.Scope) == 0 { - return []string{} - } - - return strings.Split(c.Scope, " ") -} - -type OidcAuthorizationCode struct { - Base - - Code string - Scope string - AuthenticationMethod string - Nonce string - CodeChallenge *string - CodeChallengeMethodSha256 *bool - ExpiresAt datatype.DateTime - - UserID string - User User - - ClientID string -} - type OidcClient struct { Base @@ -76,39 +50,16 @@ func (c OidcClient) HasDarkLogo() bool { return c.DarkImageType != nil && *c.DarkImageType != "" } -type OidcRefreshToken struct { - Base - - Token string - IdTokenJti *string - ExpiresAt datatype.DateTime - Scope string - AuthenticationMethod string - - UserID string - User User - - ClientID string - Client OidcClient -} - -func (c OidcRefreshToken) Scopes() []string { - if len(c.Scope) == 0 { - return []string{} - } - - return strings.Split(c.Scope, " ") -} - type OidcClientCredentials struct { //nolint:recvcheck FederatedIdentities []OidcClientFederatedIdentity `json:"federatedIdentities,omitempty"` } type OidcClientFederatedIdentity struct { - Issuer string `json:"issuer"` - Subject string `json:"subject,omitempty"` - Audience string `json:"audience,omitempty"` - JWKS string `json:"jwks,omitempty"` // URL of the JWKS + Issuer string `json:"issuer"` + Subject string `json:"subject,omitempty"` + Audience string `json:"audience,omitempty"` + JWKS string `json:"jwks,omitempty"` // URL of the JWKS + ReplayProtection bool `json:"replayProtection,omitempty"` } func (occ OidcClientCredentials) FederatedIdentityForIssuer(issuer string) (OidcClientFederatedIdentity, bool) { @@ -142,48 +93,3 @@ func (cu *UrlList) Scan(value any) error { func (cu UrlList) Value() (driver.Value, error) { return json.Marshal(cu) } - -type OidcDeviceCode struct { - Base - DeviceCode string - UserCode string - Scope string - AuthenticationMethod string - Nonce string - ExpiresAt datatype.DateTime - IsAuthorized bool - - UserID *string - User User - ClientID string - Client OidcClient -} - -type OidcPushedAuthorizationRequest struct { - Base - - RequestURI string - ClientID string - Parameters OidcAuthorizationRequestParameters - ExpiresAt datatype.DateTime -} - -type OidcAuthorizationRequestParameters struct { //nolint:recvcheck - Scope string `json:"scope,omitempty"` - RedirectURI string `json:"redirect_uri,omitempty"` - State string `json:"state,omitempty"` - Nonce string `json:"nonce,omitempty"` - CodeChallenge string `json:"code_challenge,omitempty"` - CodeChallengeMethod string `json:"code_challenge_method,omitempty"` - ResponseType string `json:"response_type,omitempty"` - Prompt string `json:"prompt,omitempty"` - ResponseMode string `json:"response_mode,omitempty"` -} - -func (p *OidcAuthorizationRequestParameters) Scan(value any) error { - return utils.UnmarshalJSONFromDatabase(p, value) -} - -func (p OidcAuthorizationRequestParameters) Value() (driver.Value, error) { - return json.Marshal(p) -} diff --git a/backend/internal/model/types/string_list.go b/backend/internal/model/types/string_list.go new file mode 100644 index 00000000..aae65b19 --- /dev/null +++ b/backend/internal/model/types/string_list.go @@ -0,0 +1,18 @@ +package datatype + +import ( + "database/sql/driver" + "encoding/json" + + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +type StringList []string //nolint:recvcheck + +func (s *StringList) Scan(value any) error { + return utils.UnmarshalJSONFromDatabase(s, value) +} + +func (s StringList) Value() (driver.Value, error) { + return json.Marshal(s) +} diff --git a/backend/internal/oidc/authorization_handler.go b/backend/internal/oidc/authorization_handler.go new file mode 100644 index 00000000..a95e043f --- /dev/null +++ b/backend/internal/oidc/authorization_handler.go @@ -0,0 +1,156 @@ +package oidc + +import ( + "log/slog" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/ory/fosite" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/utils" + "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" +) + +const parRequestURIPrefix = "urn:ietf:params:oauth:request_uri:" + +type authorizationHandler struct { + provider fosite.OAuth2Provider + authorizationService *authorizationService + baseURL string +} + +func newAuthorizationHandler( + provider fosite.OAuth2Provider, + authorizationService *authorizationService, + baseURL string, +) *authorizationHandler { + return &authorizationHandler{ + provider: provider, + authorizationService: authorizationService, + baseURL: baseURL, + } +} + +func (h *authorizationHandler) authorize(c *gin.Context) { + ctx := c.Request.Context() + userID := c.GetString("userID") + authenticationMethod := c.GetString("authenticationMethod") + authenticationTime, _ := c.Get("authenticationTime") + typedAuthenticationTime, _ := authenticationTime.(time.Time) + reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName) + + // A request that resumes an interaction only carries the interaction ID; the original + // parameters are restored from the stored session so they never travel through the + // front channel. + interactionID := c.Query("interaction") + if interactionID != "" { + query, err := h.authorizationService.interactionRequestQuery(ctx, interactionID) + if err != nil { + slog.WarnContext(ctx, "Failed to restore authorize request from interaction session", "error", err.Error()) + h.provider.WriteAuthorizeError(ctx, c.Writer, fosite.NewAuthorizeRequest(), err) + return + } + c.Request.URL.RawQuery = query.Encode() + } + + // Treat the request as a pushed authorization request only when the request_uri carries the + // PAR prefix. Without this, a client required to use PAR could bypass that requirement by + // sending an arbitrary (non-prefixed) request_uri, which fosite silently ignores. + hasPushedAuthorizationRequest := strings.HasPrefix(c.Query("request_uri"), parRequestURIPrefix) + + ar, err := h.provider.NewAuthorizeRequest(ctx, c.Request) + if err != nil { + slog.ErrorContext(ctx, "Failed to create authorize request", "error", err.Error()) + h.provider.WriteAuthorizeError(ctx, c.Writer, ar, err) + return + } + + authorization, err := h.authorizationService.authorize(ctx, authorizeInput{ + userID: userID, + authenticationMethod: authenticationMethod, + authenticationTime: typedAuthenticationTime, + requester: ar, + hasPushedAuthorizationRequest: hasPushedAuthorizationRequest, + reauthenticationToken: reauthenticationToken, + interactionID: interactionID, + requestParams: authorizeRequestParams(ar), + meta: requestMetaFromGin(c), + }) + if err != nil { + slog.ErrorContext(ctx, "Failed to authorize request", "error", err.Error()) + h.provider.WriteAuthorizeError(ctx, c.Writer, ar, err) + return + } + + if authorization.RequiresInteraction { + c.Redirect(http.StatusFound, "/interaction?interaction="+authorization.InteractionID) + return + } + + response, err := h.provider.NewAuthorizeResponse(ctx, ar, authorization.Session) + if err != nil { + slog.ErrorContext(ctx, "Failed to create authorize response", "error", err.Error()) + h.provider.WriteAuthorizeError(ctx, c.Writer, ar, err) + return + } + + response.AddParameter("iss", h.baseURL) + if ar.GetResponseMode() == fosite.ResponseModeFormPost && ar.GetRedirectURI() != nil { + c.Header("Content-Security-Policy", utils.BuildCSP(utils.GetCSPNonce(c), ar.GetRedirectURI().String())) + } + h.provider.WriteAuthorizeResponse(ctx, c.Writer, ar, response) +} + +func requestMetaFromGin(c *gin.Context) requestMeta { + return requestMeta{ + IPAddress: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + } +} + +func authorizeRequestParams(requester fosite.AuthorizeRequester) map[string]string { + params := make(map[string]string) + for key, values := range requester.GetRequestForm() { + if len(values) == 0 || key == "request_uri" || key == "interaction" { + continue + } + params[key] = values[0] + } + + return params +} + +func (h *authorizationHandler) getInteractionSession(c *gin.Context) { + interactionID := c.Param("id") + + interactionSession, err := h.authorizationService.getInteractionSession(c.Request.Context(), interactionID) + if err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, interactionSession) +} + +func (h *authorizationHandler) completeInteraction(c *gin.Context) { + interactionID := c.Param("id") + authenticationTime, _ := c.Get("authenticationTime") + typedAuthenticationTime, _ := authenticationTime.(time.Time) + + var request completeInteractionRequest + if err := c.ShouldBindJSON(&request); err != nil { + _ = c.Error(&common.ValidationError{Message: "invalid interaction request"}) + return + } + + reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName) + response, err := h.authorizationService.completeInteractionStep(c.Request.Context(), interactionID, c.GetString("userID"), request.Step, reauthenticationToken, typedAuthenticationTime, requestMetaFromGin(c)) + if err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, response) +} diff --git a/backend/internal/oidc/authorization_service.go b/backend/internal/oidc/authorization_service.go new file mode 100644 index 00000000..93590d6d --- /dev/null +++ b/backend/internal/oidc/authorization_service.go @@ -0,0 +1,690 @@ +package oidc + +import ( + "context" + "errors" + "net/url" + "slices" + "strconv" + "strings" + "time" + + "github.com/ory/fosite" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/utils" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func newAuthorizationService(db *gorm.DB, interactionSessionService *interactionSessionService, claimsService *ClaimsService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger) *authorizationService { + return &authorizationService{ + db: db, + interactionSessionService: interactionSessionService, + claimsService: claimsService, + reauth: reauth, + auditLog: auditLog, + } +} + +type authorizationService struct { + db *gorm.DB + interactionSessionService *interactionSessionService + claimsService *ClaimsService + reauth ReauthenticationTokenConsumer + auditLog AuditLogger +} + +type requestMeta struct { + IPAddress string + UserAgent string +} + +type authorizationResult struct { + RequiresInteraction bool + InteractionID string + Session *Session +} + +type promptValues []string + +func newPromptValues(prompt string) promptValues { + return strings.Fields(prompt) +} + +func (p promptValues) has(value string) bool { + return slices.Contains(p, value) +} + +// authorizeInput is the authorization request as provided by the handler. +type authorizeInput struct { + userID string + authenticationMethod string + authenticationTime time.Time + requester fosite.AuthorizeRequester + hasPushedAuthorizationRequest bool + reauthenticationToken string + interactionID string + requestParams map[string]string + meta requestMeta +} + +// authorizeRequest is the input enriched with everything the service derives from it. +type authorizeRequest struct { + authorizeInput + + client Client + prompt promptValues + interactionSession *InteractionSession + now time.Time +} + +func (s *authorizationService) authorize(ctx context.Context, input authorizeInput) (authorizationResult, error) { + client := input.requester.GetClient().(Client) + prompt := newPromptValues(input.requester.GetRequestForm().Get("prompt")) + + if err := validateClientPKCERequirement(client, input.requester); err != nil { + return authorizationResult{}, err + } + + interactionSession, err := s.boundInteractionSession(ctx, input.interactionID, input.userID, client, input.requester) + if err != nil { + return authorizationResult{}, err + } + + // Reject authorization requests that require PAR when the request is not a resumed interaction and doesn't have a valid PAR. + if client.RequiresPushedAuthorizationRequests && !input.hasPushedAuthorizationRequest && interactionSession == nil { + return authorizationResult{}, &common.OidcPARRequiredError{} + } + + if input.userID == "" { + if prompt.has("none") { + return authorizationResult{}, fosite.ErrLoginRequired + } + + interactionSession, err := s.createInteractionSession(ctx, input.requester, input.requestParams, "", interactionRequirements{ + AuthenticationRequired: true, + ReauthenticationRequired: prompt.has("login") || client.RequiresReauthentication, + AccountSelectionRequired: prompt.has("select_account"), + ConsentRequired: prompt.has("consent"), + }) + if err != nil { + return authorizationResult{}, err + } + + return authorizationResult{RequiresInteraction: true, InteractionID: interactionSession.ID}, nil + } + + req := authorizeRequest{ + authorizeInput: input, + client: client, + prompt: prompt, + interactionSession: interactionSession, + now: time.Now().UTC(), + } + + var result authorizationResult + err = withTx(ctx, s.db, func(ctx context.Context) error { + var err error + result, err = s.authorizeAuthenticated(ctx, req) + return err + }) + if err != nil { + return authorizationResult{}, err + } + + if result.Session == nil { + return result, nil + } + + if err := s.claimsService.applyIDTokenClaims(ctx, result.Session, input.requester.GetGrantedScopes()); err != nil { + return authorizationResult{}, err + } + + return result, nil +} + +// authorizeAuthenticated either reports the interaction the user still has to complete or grants the request. +func (s *authorizationService) authorizeAuthenticated(ctx context.Context, req authorizeRequest) (authorizationResult, error) { + var user model.User + err := dbFromContext(ctx, s.db). + Preload("UserGroups"). + First(&user, "id = ?", req.userID). + Error + if err != nil { + return authorizationResult{}, err + } + + if !IsUserGroupAllowedToAuthorize(user, req.client.OidcClient) { + return authorizationResult{}, fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.") + } + + interactionSession := req.interactionSession + if interactionSession != nil && interactionSession.UserID != nil && *interactionSession.UserID != req.userID { + if err := s.switchInteractionSessionUser(ctx, interactionSession, req.userID, req.authenticationTime); err != nil { + return authorizationResult{}, err + } + if err := s.interactionSessionService.update(ctx, *interactionSession); err != nil { + return authorizationResult{}, err + } + } + + requirements, authenticationTime, err := s.resolveRequirements(ctx, req, interactionSession) + if err != nil { + return authorizationResult{}, err + } + + if requirements.any() { + if interactionSession != nil { + return authorizationResult{RequiresInteraction: true, InteractionID: interactionSession.ID}, nil + } + + created, err := s.createInteractionSession(ctx, req.requester, req.requestParams, req.userID, requirements) + if err != nil { + return authorizationResult{}, err + } + + return authorizationResult{RequiresInteraction: true, InteractionID: created.ID}, nil + } + + if interactionSession != nil { + err := s.interactionSessionService.delete(ctx, interactionSession.ID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return authorizationResult{}, fosite.ErrInvalidRequest.WithHint("The interaction session has already been used.") + } + if err != nil { + return authorizationResult{}, err + } + } + + hasAlreadyAuthorizedClient, err := s.consent(ctx, req.userID, req.client.GetID(), req.requester.GetRequestedScopes()) + if err != nil { + return authorizationResult{}, err + } + + session := s.buildAuthorizedSession(req, interactionSession, authenticationTime) + + for _, scope := range req.requester.GetRequestedScopes() { + req.requester.GrantScope(scope) + } + + authorizationEvent := model.AuditLogEventClientAuthorization + if !hasAlreadyAuthorizedClient { + authorizationEvent = model.AuditLogEventNewClientAuthorization + } + if s.auditLog != nil { + s.auditLog.Create(ctx, authorizationEvent, req.meta.IPAddress, req.meta.UserAgent, req.userID, model.AuditLogData{"clientName": req.client.Name}, dbFromContext(ctx, s.db)) + } + + return authorizationResult{Session: session}, nil +} + +// resolveRequirements determines the interaction steps still required; the requirements +// of a resumed interaction session win over the ones derived from the request. +func (s *authorizationService) resolveRequirements(ctx context.Context, req authorizeRequest, interactionSession *InteractionSession) (interactionRequirements, time.Time, error) { + authenticationTime := req.authenticationTime + + hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, req.client.GetID(), req.userID, req.requester.GetRequestedScopes()) + if err != nil { + return interactionRequirements{}, authenticationTime, err + } + + maxAgeReauthenticationRequired, err := requiresReauthenticationForMaxAge(req.requester.GetRequestForm().Get("max_age"), authenticationTime, req.now) + if err != nil { + return interactionRequirements{}, authenticationTime, err + } + + requirements := interactionRequirements{ + ConsentRequired: !hasAlreadyAuthorizedClient || req.prompt.has("consent"), + ReauthenticationRequired: req.prompt.has("login") || req.client.RequiresReauthentication || maxAgeReauthenticationRequired, + AccountSelectionRequired: req.prompt.has("select_account"), + AuthenticationRequired: false, + } + + if interactionSession != nil { + requirements = interactionRequirements{ + ConsentRequired: interactionSession.ConsentRequired, + ReauthenticationRequired: interactionSession.ReauthenticationRequired, + AccountSelectionRequired: interactionSession.AccountSelectionRequired, + AuthenticationRequired: interactionSession.AuthenticationRequired, + } + if interactionSession.ReauthenticatedAt != nil { + authenticationTime = interactionSession.ReauthenticatedAt.UTC() + } + } + + if req.prompt.has("none") && requirements.ConsentRequired { + return interactionRequirements{}, authenticationTime, fosite.ErrConsentRequired + } + if req.prompt.has("none") && requirements.ReauthenticationRequired { + return interactionRequirements{}, authenticationTime, fosite.ErrLoginRequired + } + + if requirements.ReauthenticationRequired && req.reauthenticationToken != "" && s.reauth != nil { + reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), req.reauthenticationToken, req.userID) + if err == nil { + requirements.ReauthenticationRequired = false + authenticationTime = reauthenticatedAt + } + } + + return requirements, authenticationTime, nil +} + +func (s *authorizationService) buildAuthorizedSession(req authorizeRequest, interactionSession *InteractionSession, authenticationTime time.Time) *Session { + if authenticationTime.IsZero() { + authenticationTime = req.now + } + requestedAt := req.requester.GetRequestedAt() + if interactionSession != nil && !interactionSession.RequestedAt.ToTime().IsZero() { + requestedAt = interactionSession.RequestedAt.UTC() + } + if requestedAt.IsZero() { + requestedAt = req.now + } + + return NewAuthenticatedSession(req.userID, req.authenticationMethod, authenticationTime, requestedAt) +} + +// interactionRequestQuery returns the authorize parameters stored for the interaction +// session, so the handler can rebuild the re-entry request server-side. +func (s *authorizationService) interactionRequestQuery(ctx context.Context, interactionID string) (query url.Values, err error) { + interactionSession, err := s.interactionSessionService.get(ctx, interactionID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fosite.ErrInvalidRequest.WithHint("The interaction session is invalid or has expired.") + } + if err != nil { + return nil, err + } + + query = url.Values{} + for key, value := range interactionSession.Parameters { + if value != "" { + query.Set(key, value) + } + } + query.Set("interaction", interactionSession.ID) + + return query, nil +} + +// boundInteractionSession loads the referenced interaction session and validates the request against it. +func (s *authorizationService) boundInteractionSession(ctx context.Context, interactionID string, userID string, client Client, requester fosite.AuthorizeRequester) (*InteractionSession, error) { + if interactionID == "" { + return nil, nil + } + + session, err := s.interactionSessionService.get(ctx, interactionID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fosite.ErrInvalidRequest.WithHint("The interaction session is invalid or has expired.") + } + if err != nil { + return nil, err + } + + if err := validateInteractionSessionBinding(session, userID, client, requester); err != nil { + return nil, err + } + + return &session, nil +} + +// validateInteractionSessionBinding ensures a request matches the interaction session +// it resumes: the requirement flags and the PAR exemption come from the session, so a +// tampered request (extra scopes, another client's interaction) must not inherit them. +func validateInteractionSessionBinding(interactionSession InteractionSession, userID string, client Client, requester fosite.AuthorizeRequester) error { + if interactionSession.ClientID != client.GetID() { + return fosite.ErrInvalidRequest.WithHint("The interaction session does not belong to this client.") + } + + if interactionSession.UserID != nil { + if userID == "" { + return fosite.ErrLoginRequired + } + } + + for _, scope := range requester.GetRequestedScopes() { + if !slices.Contains(interactionSession.Scopes, scope) { + return fosite.ErrInvalidRequest.WithHint("The requested scopes exceed the scopes of the interaction session.") + } + } + + return nil +} + +type interactionRequirements struct { + ConsentRequired bool + ReauthenticationRequired bool + AuthenticationRequired bool + AccountSelectionRequired bool +} + +func (r interactionRequirements) any() bool { + return r.ConsentRequired || r.ReauthenticationRequired || r.AuthenticationRequired || r.AccountSelectionRequired +} + +func (s *authorizationService) createInteractionSession(ctx context.Context, requester fosite.AuthorizeRequester, requestParams map[string]string, userID string, requirements interactionRequirements) (InteractionSession, error) { + parameters := make(map[string]string, len(requestParams)) + for key, value := range requestParams { + parameters[key] = value + } + + return s.interactionSessionService.create(ctx, InteractionSession{ + Base: model.Base{ + ID: requester.GetID(), + }, + Scopes: datatype.StringList(requester.GetRequestedScopes()), + ClientID: requester.GetClient().GetID(), + UserID: utils.PtrOrNil(userID), + ConsentRequired: requirements.ConsentRequired, + ReauthenticationRequired: requirements.ReauthenticationRequired, + AuthenticationRequired: requirements.AuthenticationRequired, + AccountSelectionRequired: requirements.AccountSelectionRequired, + RequestedAt: datatype.DateTime(requester.GetRequestedAt()), + Parameters: parameters, + }) +} + +func bindInteractionSessionUser(interactionSession *InteractionSession, userID string) error { + if userID == "" { + return fosite.ErrLoginRequired + } + if interactionSession.UserID != nil && *interactionSession.UserID != userID { + return fosite.ErrInvalidRequest.WithHint("The interaction session belongs to another user.") + } + if interactionSession.UserID == nil { + interactionSession.UserID = &userID + } + + return nil +} + +func (s *authorizationService) switchInteractionSessionUser(ctx context.Context, interactionSession *InteractionSession, userID string, authenticationTime time.Time) error { + if userID == "" { + return fosite.ErrLoginRequired + } + interactionSession.UserID = &userID + + requirements, err := s.interactionRequirementsForUser(ctx, userID, interactionSession, authenticationTime) + if err != nil { + return err + } + + interactionSession.AuthenticationRequired = false + interactionSession.ConsentRequired = requirements.ConsentRequired + interactionSession.ReauthenticationRequired = requirements.ReauthenticationRequired + interactionSession.ReauthenticatedAt = nil + + return nil +} + +func (s *authorizationService) getInteractionSession(ctx context.Context, interactionSessionID string) (interactionSessionForUser, error) { + interactionSession, err := s.interactionSessionService.get(ctx, interactionSessionID) + if err != nil { + return interactionSessionForUser{}, err + } + + return newInteractionSessionForUser(interactionSession) +} + +func (s *authorizationService) completeInteractionStep(ctx context.Context, interactionSessionID, userID string, step interactionStep, reauthenticationToken string, authenticationTime time.Time, meta requestMeta) (completeInteractionResponse, error) { + var interactionSession InteractionSession + var response completeInteractionResponse + err := withTx(ctx, s.db, func(ctx context.Context) error { + var err error + interactionSession, err = s.interactionSessionService.get(ctx, interactionSessionID) + if err != nil { + return err + } + + if interactionSession.UserID != nil && *interactionSession.UserID != userID { + if err := s.switchInteractionSessionUser(ctx, &interactionSession, userID, authenticationTime); err != nil { + return err + } + } + + if userID == "" { + return fosite.ErrLoginRequired + } + + requiredSteps := requiredInteractionSteps(interactionSession) + if len(requiredSteps) == 0 { + response = completeInteractionResponse{RedirectURL: authorizeRedirectURL(interactionSession.ID)} + return nil + } + + if requiredSteps[0] != step { + return &common.ValidationError{Message: "expected interaction step " + string(requiredSteps[0]) + " but got " + string(step)} + } + + if err := s.applyInteractionStep(ctx, &interactionSession, userID, step, reauthenticationToken, authenticationTime, meta); err != nil { + return err + } + + return s.interactionSessionService.update(ctx, interactionSession) + }) + if err != nil { + return completeInteractionResponse{}, err + } + if response.RedirectURL != "" { + return response, nil + } + + if !hasRemainingInteractionSteps(interactionSession) { + return completeInteractionResponse{RedirectURL: authorizeRedirectURL(interactionSession.ID)}, nil + } + + interaction, err := newInteractionSessionForUser(interactionSession) + if err != nil { + return completeInteractionResponse{}, err + } + + return completeInteractionResponse{Interaction: &interaction}, nil +} + +func (s *authorizationService) applyInteractionStep(ctx context.Context, interactionSession *InteractionSession, userID string, step interactionStep, reauthenticationToken string, authenticationTime time.Time, meta requestMeta) error { + switch step { + case interactionStepAuthenticate: + if err := bindInteractionSessionUser(interactionSession, userID); err != nil { + return err + } + interactionSession.AuthenticationRequired = false + return s.populatePostAuthenticationRequirements(ctx, userID, interactionSession, authenticationTime) + case interactionStepSelectAccount: + if err := s.switchInteractionSessionUser(ctx, interactionSession, userID, authenticationTime); err != nil { + return err + } + interactionSession.AccountSelectionRequired = false + return nil + case interactionStepReauthenticate: + return s.completeReauthenticationStep(ctx, interactionSession, userID, reauthenticationToken) + case interactionStepConsent: + return s.completeConsentStep(ctx, interactionSession, userID, meta) + default: + return &common.ValidationError{Message: "unknown interaction step " + string(step)} + } +} + +func (s *authorizationService) completeReauthenticationStep(ctx context.Context, interactionSession *InteractionSession, userID, reauthenticationToken string) error { + if err := bindInteractionSessionUser(interactionSession, userID); err != nil { + return err + } + if reauthenticationToken == "" { + return &common.ValidationError{Message: "reauthentication token is required"} + } + reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID) + if err != nil { + return err + } + + interactionSession.ReauthenticationRequired = false + interactionSession.ReauthenticatedAt = new(datatype.DateTime(reauthenticatedAt)) + return nil +} + +func (s *authorizationService) completeConsentStep(ctx context.Context, interactionSession *InteractionSession, userID string, meta requestMeta) error { + if err := bindInteractionSessionUser(interactionSession, userID); err != nil { + return err + } + hasAlreadyAuthorizedClient, err := s.consent(ctx, userID, interactionSession.ClientID, interactionSession.Scopes) + if err != nil { + return err + } + if !hasAlreadyAuthorizedClient && s.auditLog != nil { + s.auditLog.Create(ctx, model.AuditLogEventNewClientAuthorization, meta.IPAddress, meta.UserAgent, userID, model.AuditLogData{"clientName": interactionSession.Client.Name}, dbFromContext(ctx, s.db)) + } + interactionSession.ConsentRequired = false + return nil +} + +func (s *authorizationService) populatePostAuthenticationRequirements(ctx context.Context, userID string, interactionSession *InteractionSession, authenticationTime time.Time) error { + if userID == "" { + return errors.New("user is required to complete authentication") + } + + requirements, err := s.interactionRequirementsForUser(ctx, userID, interactionSession, authenticationTime) + if err != nil { + return err + } + + interactionSession.ConsentRequired = interactionSession.ConsentRequired || requirements.ConsentRequired + interactionSession.AccountSelectionRequired = interactionSession.AccountSelectionRequired || requirements.AccountSelectionRequired + interactionSession.ReauthenticationRequired = interactionSession.ReauthenticationRequired || requirements.ReauthenticationRequired + + return nil +} + +func (s *authorizationService) interactionRequirementsForUser(ctx context.Context, userID string, interactionSession *InteractionSession, authenticationTime time.Time) (interactionRequirements, error) { + prompt := newPromptValues(interactionSession.Parameters["prompt"]) + hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, interactionSession.ClientID, userID, interactionSession.Scopes) + if err != nil { + return interactionRequirements{}, err + } + + maxAgeReauthenticationRequired, err := requiresReauthenticationForMaxAge(interactionSession.Parameters["max_age"], authenticationTime, time.Now().UTC()) + if err != nil { + return interactionRequirements{}, err + } + + return interactionRequirements{ + ConsentRequired: !hasAlreadyAuthorizedClient || prompt.has("consent"), + ReauthenticationRequired: prompt.has("login") || interactionSession.Client.RequiresReauthentication || maxAgeReauthenticationRequired, + AccountSelectionRequired: prompt.has("select_account"), + AuthenticationRequired: false, + }, nil +} + +// authorizeRedirectURL only references the interaction session; the authorize endpoint +// restores the request parameters from it server-side. +func authorizeRedirectURL(interactionSessionID string) string { + return "/authorize?interaction=" + url.QueryEscape(interactionSessionID) +} + +// validateClientPKCERequirement enforces the per-client PkceEnabled flag. fosite only +// enforces PKCE for public clients (EnforcePKCEForPublicClients). +func validateClientPKCERequirement(client Client, requester fosite.AuthorizeRequester) error { + if !client.PkceEnabled { + return nil + } + if requester.GetRequestForm().Get("code_challenge") == "" { + return fosite.ErrInvalidRequest.WithHint("This client requires PKCE, but the 'code_challenge' parameter is missing.") + } + return nil +} + +func requiresReauthenticationForMaxAge(maxAgeRaw string, authenticationTime time.Time, now time.Time) (bool, error) { + if maxAgeRaw == "" { + return false, nil + } + + maxAge, err := strconv.ParseInt(maxAgeRaw, 10, 64) + if err != nil || maxAge < 0 { + return false, fosite.ErrInvalidRequest.WithHint("Parameter 'max_age' must be a non-negative integer.") + } + + if authenticationTime.IsZero() { + return true, nil + } + + return !now.Before(authenticationTime.UTC().Add(time.Duration(maxAge) * time.Second)), nil +} + +func (s *authorizationService) consent(ctx context.Context, userID string, clientID string, scope []string) (hasAlreadyAuthorizedClient bool, err error) { + db := dbFromContext(ctx, s.db) + + hasAlreadyAuthorizedClient, err = s.hasAuthorizedClient(ctx, clientID, userID, scope) + if err != nil { + return false, err + } + + if hasAlreadyAuthorizedClient { + err = db. + Model(&model.UserAuthorizedOidcClient{}). + Where("user_id = ? AND client_id = ?", userID, clientID). + Update("last_used_at", datatype.DateTime(time.Now())). + Error + + if err != nil { + return hasAlreadyAuthorizedClient, err + } + + return hasAlreadyAuthorizedClient, nil + } + + userAuthorizedClient := model.UserAuthorizedOidcClient{ + UserID: userID, + ClientID: clientID, + Scope: scope, + LastUsedAt: datatype.DateTime(time.Now()), + } + + err = db. + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"scope"}), + }). + Create(&userAuthorizedClient). + Error + + return hasAlreadyAuthorizedClient, err +} + +func (s *authorizationService) hasAuthorizedClient(ctx context.Context, clientID, userID string, scope []string) (bool, error) { + var userAuthorizedOidcClient model.UserAuthorizedOidcClient + err := dbFromContext(ctx, s.db). + First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID). + Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + + authorizedScopes := userAuthorizedOidcClient.Scope + for _, requestedScope := range scope { + if !slices.Contains(authorizedScopes, requestedScope) { + return false, nil + } + } + + return true, nil +} + +// IsUserGroupAllowedToAuthorize reports whether the user may use the group-restricted client. +func IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool { + if !client.IsGroupRestricted { + return true + } + + for _, userGroup := range client.AllowedUserGroups { + for _, userGroupUser := range user.UserGroups { + if userGroup.ID == userGroupUser.ID { + return true + } + } + } + + return false +} diff --git a/backend/internal/oidc/authorization_service_test.go b/backend/internal/oidc/authorization_service_test.go new file mode 100644 index 00000000..15659015 --- /dev/null +++ b/backend/internal/oidc/authorization_service_test.go @@ -0,0 +1,918 @@ +package oidc + +import ( + "context" + "net/url" + "testing" + "time" + + "github.com/ory/fosite" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" + "gorm.io/gorm" +) + +type fakeAuditLogger struct { + events []model.AuditLogEvent + data []model.AuditLogData +} + +func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, _, _, _ string, data model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) { + f.events = append(f.events, event) + f.data = append(f.data, data) + return model.AuditLog{}, true +} + +func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + auditLogger := &fakeAuditLogger{} + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger) + + const ( + userID = "test-user" + clientID = "test-client" + ) + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{ + UserID: userID, + ClientID: clientID, + Scope: datatype.StringList{"openid"}, + }).Error) + + authorization, err := service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: time.Now().UTC(), + requester: newTestAuthorizeRequester("audit-request", clientID, ""), + meta: requestMeta{IPAddress: "203.0.113.1", UserAgent: "test-agent"}, + }) + require.NoError(t, err) + require.False(t, authorization.RequiresInteraction) + + require.Equal(t, []model.AuditLogEvent{model.AuditLogEventClientAuthorization}, auditLogger.events) + require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0]) +} + +func TestAuthorizationServiceConsentStepLogsNewClientAuthorization(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + auditLogger := &fakeAuditLogger{} + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger) + + const ( + userID = "test-user" + clientID = "test-client" + interactionID = "test-interaction" + ) + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&InteractionSession{ + Base: model.Base{ID: interactionID}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + ConsentRequired: true, + RequestedAt: datatype.DateTime(time.Now().UTC()), + Parameters: map[string]string{}, + }).Error) + + response, err := service.completeInteractionStep(t.Context(), interactionID, userID, interactionStepConsent, "", time.Now().UTC(), requestMeta{}) + require.NoError(t, err) + require.NotEmpty(t, response.RedirectURL) + + require.Equal(t, []model.AuditLogEvent{model.AuditLogEventNewClientAuthorization}, auditLogger.events) + require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0]) +} + +func TestAuthorizationServiceAuthorizeConsumesInteractionSession(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + clientID = "test-client" + interactionID = "test-interaction" + ) + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{ + UserID: userID, + ClientID: clientID, + Scope: datatype.StringList{"openid"}, + }).Error) + require.NoError(t, db.Create(&InteractionSession{ + Base: model.Base{ID: interactionID}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + RequestedAt: datatype.DateTime(time.Now().UTC()), + Parameters: map[string]string{}, + }).Error) + + authorize := func() (authorizationResult, error) { + return service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: time.Now().UTC(), + requester: newTestAuthorizeRequester("consume-request", clientID, ""), + interactionID: interactionID, + }) + } + + authorization, err := authorize() + require.NoError(t, err) + require.False(t, authorization.RequiresInteraction) + + // The interaction session is consumed together with the grant and must be single-use + var count int64 + require.NoError(t, db.Model(&InteractionSession{}).Where("id = ?", interactionID).Count(&count).Error) + require.Zero(t, count) + + _, err = authorize() + require.ErrorIs(t, err, fosite.ErrInvalidRequest) +} + +func TestInteractionSessionServiceGetRejectsExpiredSession(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newInteractionSessionService(db) + + const ( + clientID = "test-client" + interactionID = "test-interaction" + ) + + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&InteractionSession{ + Base: model.Base{ID: interactionID}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + RequestedAt: datatype.DateTime(time.Now().UTC()), + Parameters: map[string]string{}, + }).Error) + + _, err := service.get(t.Context(), interactionID) + require.NoError(t, err) + + // Backdate the session past its lifetime; BeforeCreate stamps CreatedAt, so update directly + expiredCreatedAt := datatype.DateTime(time.Now().Add(-interactionSessionLifetime - time.Minute)) + require.NoError(t, db.Model(&InteractionSession{}).Where("id = ?", interactionID).Update("created_at", expiredCreatedAt).Error) + + _, err = service.get(t.Context(), interactionID) + require.ErrorIs(t, err, gorm.ErrRecordNotFound) +} + +func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + clientID = "test-client" + ) + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + + newInteractionSession := func(id string) { + require.NoError(t, db.Create(&InteractionSession{ + Base: model.Base{ID: id}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + RequestedAt: datatype.DateTime(time.Now().UTC()), + Parameters: map[string]string{}, + }).Error) + } + + authorize := func(interactionID string, scopes ...string) (authorizationResult, error) { + requester := newTestAuthorizeRequester("scope-binding-request", clientID, "") + requester.(*fosite.AuthorizeRequest).RequestedScope = fosite.Arguments(scopes) + return service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: time.Now().UTC(), + requester: requester, + interactionID: interactionID, + }) + } + + // Scopes matching the consented interaction session are granted + newInteractionSession("matching-interaction") + authorization, err := authorize("matching-interaction", "openid") + require.NoError(t, err) + require.False(t, authorization.RequiresInteraction) + + // Scopes added to the URL after consent must not be granted silently + newInteractionSession("escalated-interaction") + _, err = authorize("escalated-interaction", "openid", "profile", "email", "groups") + require.ErrorIs(t, err, fosite.ErrInvalidRequest) + + // The user must not have been recorded as having authorized the escalated scopes + var authorizedClient model.UserAuthorizedOidcClient + require.NoError(t, db.First(&authorizedClient, "user_id = ? AND client_id = ?", userID, clientID).Error) + require.Equal(t, datatype.StringList{"openid"}, authorizedClient.Scope) +} + +func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + clientID = "test-client" + otherClientID = "other-client" + interactionID = "test-interaction" + ) + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: otherClientID}, + Name: "Other Client", + }).Error) + require.NoError(t, db.Create(&InteractionSession{ + Base: model.Base{ID: interactionID}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + RequestedAt: datatype.DateTime(time.Now().UTC()), + Parameters: map[string]string{}, + }).Error) + + _, err := service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: time.Now().UTC(), + requester: newTestAuthorizeRequester("other-client-request", otherClientID, ""), + interactionID: interactionID, + }) + require.ErrorIs(t, err, fosite.ErrInvalidRequest) +} + +func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + otherUserID = "other-user" + clientID = "test-client" + interactionID = "test-interaction" + ) + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + Username: "test-user", + }).Error) + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: otherUserID}, + Username: "other-user", + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{ + UserID: userID, + ClientID: clientID, + Scope: datatype.StringList{"openid"}, + }).Error) + reauthenticatedAt := datatype.DateTime(time.Now().Add(-time.Minute).UTC()) + require.NoError(t, db.Create(&InteractionSession{ + Base: model.Base{ID: interactionID}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + UserID: stringPointer(userID), + ReauthenticatedAt: &reauthenticatedAt, + ReauthenticationRequired: false, + RequestedAt: datatype.DateTime(time.Now().UTC()), + Parameters: map[string]string{ + "prompt": "login", + }, + }).Error) + + authorization, err := service.authorize(t.Context(), authorizeInput{ + userID: otherUserID, + authenticationTime: time.Now().UTC(), + requester: newTestAuthorizeRequester("other-user-request", clientID, ""), + interactionID: interactionID, + }) + require.NoError(t, err) + require.True(t, authorization.RequiresInteraction) + require.Equal(t, interactionID, authorization.InteractionID) + + var interactionSession InteractionSession + require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error) + require.NotNil(t, interactionSession.UserID) + require.Equal(t, otherUserID, *interactionSession.UserID) + require.True(t, interactionSession.ConsentRequired) + require.True(t, interactionSession.ReauthenticationRequired) + require.Nil(t, interactionSession.ReauthenticatedAt) +} + +func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + clientID = "test-client" + interactionID = "test-interaction" + ) + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&InteractionSession{ + Base: model.Base{ID: interactionID}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + UserID: stringPointer(userID), + RequestedAt: datatype.DateTime(time.Now().UTC()), + Parameters: map[string]string{}, + }).Error) + + _, err := service.authorize(t.Context(), authorizeInput{ + requester: newTestAuthorizeRequester("unauthenticated-bound-request", clientID, ""), + interactionID: interactionID, + }) + require.ErrorIs(t, err, fosite.ErrLoginRequired) +} + +func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + clientID = "test-client" + interactionID = "test-interaction" + ) + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&InteractionSession{ + Base: model.Base{ID: interactionID}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + ConsentRequired: true, + RequestedAt: datatype.DateTime(time.Now().UTC()), + Parameters: map[string]string{}, + }).Error) + + response, err := service.completeInteractionStep(t.Context(), interactionID, userID, interactionStepConsent, "", time.Now().UTC(), requestMeta{}) + require.NoError(t, err) + require.NotEmpty(t, response.RedirectURL) + + var interactionSession InteractionSession + require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error) + require.NotNil(t, interactionSession.UserID) + require.Equal(t, userID, *interactionSession.UserID) +} + +func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirements(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + otherUserID = "other-user" + clientID = "test-client" + interactionID = "test-interaction" + ) + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + Username: "test-user", + }).Error) + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: otherUserID}, + Username: "other-user", + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&InteractionSession{ + Base: model.Base{ID: interactionID}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + UserID: stringPointer(userID), + AccountSelectionRequired: true, + RequestedAt: datatype.DateTime(time.Now().UTC()), + Parameters: map[string]string{ + "prompt": "select_account", + }, + }).Error) + + response, err := service.completeInteractionStep(t.Context(), interactionID, otherUserID, interactionStepSelectAccount, "", time.Now().UTC(), requestMeta{}) + require.NoError(t, err) + require.Empty(t, response.RedirectURL) + require.NotNil(t, response.Interaction) + require.Equal(t, interactionStepConsent, response.Interaction.CurrentStep) + + var interactionSession InteractionSession + require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error) + require.NotNil(t, interactionSession.UserID) + require.Equal(t, otherUserID, *interactionSession.UserID) + require.False(t, interactionSession.AccountSelectionRequired) + require.True(t, interactionSession.ConsentRequired) +} + +// TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser ensures consent is +// evaluated for the FINAL selected user. When an already-consented user starts +// prompt=select_account and a different, not-yet-consented user is selected, consent must +// still be required for that user rather than being inherited from the initiator. +func TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + initiatorID = "initiator-user" + switchedID = "switched-user" + clientID = "test-client" + ) + + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: initiatorID}, Username: "initiator"}).Error) + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: switchedID}, Username: "switched"}).Error) + require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error) + + // The initiator has previously consented to the client; the switched-to user has not. + require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{ + UserID: initiatorID, + ClientID: clientID, + Scope: datatype.StringList{"openid"}, + }).Error) + + authorization, err := service.authorize(t.Context(), authorizeInput{ + userID: initiatorID, + authenticationTime: time.Now().UTC(), + requester: newTestAuthorizeRequester("select-account-request", clientID, "select_account"), + requestParams: map[string]string{"prompt": "select_account"}, + }) + require.NoError(t, err) + require.True(t, authorization.RequiresInteraction) + + response, err := service.completeInteractionStep(t.Context(), authorization.InteractionID, switchedID, interactionStepSelectAccount, "", time.Now().UTC(), requestMeta{}) + require.NoError(t, err) + + // The flow must not be granted yet: consent is still pending for the switched-to user. + require.Empty(t, response.RedirectURL) + require.NotNil(t, response.Interaction) + require.Equal(t, interactionStepConsent, response.Interaction.CurrentStep) + + // No authorization record may have been silently created for the switched-to user. + var count int64 + require.NoError(t, db.Model(&model.UserAuthorizedOidcClient{}). + Where("user_id = ? AND client_id = ?", switchedID, clientID). + Count(&count).Error) + require.Zero(t, count) +} + +func TestValidateClientPKCERequirement(t *testing.T) { + pkceClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "c"}, PkceEnabled: true}} + plainClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "c"}}} + + withChallenge := newTestAuthorizeRequesterWithForm("with-challenge", "c", url.Values{"code_challenge": {"abc123"}}) + withoutChallenge := newTestAuthorizeRequesterWithForm("without-challenge", "c", url.Values{}) + + require.NoError(t, validateClientPKCERequirement(plainClient, withoutChallenge)) + require.NoError(t, validateClientPKCERequirement(pkceClient, withChallenge)) + + err := validateClientPKCERequirement(pkceClient, withoutChallenge) + require.ErrorIs(t, err, fosite.ErrInvalidRequest) +} + +// TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE proves the per-client PkceEnabled +// flag is honored for confidential clients (fosite only enforces PKCE for public clients). +func TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + clientID = "pkce-client" + ) + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "test-user"}).Error) + require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "PKCE Client", PkceEnabled: true}).Error) + require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: userID, ClientID: clientID, Scope: datatype.StringList{"openid"}}).Error) + + pkceClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, Name: "PKCE Client", PkceEnabled: true}} + + // Without a code_challenge the request is rejected before any interaction. + missing := newTestAuthorizeRequesterWithForm("authz-no-pkce", clientID, url.Values{}) + missing.(*fosite.AuthorizeRequest).Client = pkceClient + _, err := service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: time.Now().UTC(), + requester: missing, + }) + require.ErrorIs(t, err, fosite.ErrInvalidRequest) + + // With a code_challenge the PKCE gate passes and the (already-consented) request is granted. + withChallenge := newTestAuthorizeRequesterWithForm("authz-pkce", clientID, url.Values{"code_challenge": {"abc123"}}) + withChallenge.(*fosite.AuthorizeRequest).Client = pkceClient + result, err := service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: time.Now().UTC(), + requester: withChallenge, + }) + require.NoError(t, err) + require.False(t, result.RequiresInteraction) +} + +func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + clientID = "test-client" + interactionID = "test-interaction" + ) + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + RequiresPushedAuthorizationRequests: true, + }).Error) + require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{ + UserID: userID, + ClientID: clientID, + Scope: datatype.StringList{"openid"}, + }).Error) + + authorize := func(interactionID string, hasPushedAuthorizationRequest bool) (authorizationResult, error) { + requester := newTestAuthorizeRequester("par-request", clientID, "") + requester.(*fosite.AuthorizeRequest).Client = Client{ + OidcClient: model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + RequiresPushedAuthorizationRequests: true, + }, + } + return service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: time.Now().UTC(), + requester: requester, + hasPushedAuthorizationRequest: hasPushedAuthorizationRequest, + interactionID: interactionID, + }) + } + + // Without a pushed authorization request the client must be rejected + _, err := authorize("", false) + var parRequiredError *common.OidcPARRequiredError + require.ErrorAs(t, err, &parRequiredError) + + // Re-entry after a completed interaction carries no request_uri, but the bound + // interaction session proves the original request was PAR-validated + require.NoError(t, db.Create(&InteractionSession{ + Base: model.Base{ID: interactionID}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + RequestedAt: datatype.DateTime(time.Now().UTC()), + Parameters: map[string]string{}, + }).Error) + + authorization, err := authorize(interactionID, false) + require.NoError(t, err) + require.False(t, authorization.RequiresInteraction) + + // An unknown interaction ID must not satisfy the PAR requirement + _, err = authorize("nonexistent", false) + require.ErrorIs(t, err, fosite.ErrInvalidRequest) +} + +func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + clientID = "test-client" + interactionID = "test-interaction" + ) + + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&InteractionSession{ + Base: model.Base{ID: interactionID}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + RequestedAt: datatype.DateTime(time.Now().UTC()), + Parameters: map[string]string{ + "client_id": clientID, + "response_type": "code", + "scope": "openid", + "redirect_uri": "https://client.example/callback", + "state": "test-state", + }, + }).Error) + + query, err := service.interactionRequestQuery(t.Context(), interactionID) + require.NoError(t, err) + require.Equal(t, clientID, query.Get("client_id")) + require.Equal(t, "code", query.Get("response_type")) + require.Equal(t, "openid", query.Get("scope")) + require.Equal(t, "https://client.example/callback", query.Get("redirect_uri")) + require.Equal(t, "test-state", query.Get("state")) + require.Equal(t, interactionID, query.Get("interaction")) + + _, err = service.interactionRequestQuery(t.Context(), "nonexistent") + require.ErrorIs(t, err, fosite.ErrInvalidRequest) +} + +func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + clientID = "test-client" + ) + loginTime := time.Now().Add(-10 * time.Minute).UTC().Truncate(time.Second) + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{ + UserID: userID, + ClientID: clientID, + Scope: datatype.StringList{"openid"}, + }).Error) + + firstAuthorization, err := service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: loginTime, + requester: newTestAuthorizeRequester("first-request", clientID, ""), + }) + require.NoError(t, err) + require.False(t, firstAuthorization.RequiresInteraction) + require.Equal(t, loginTime, firstAuthorization.Session.IDTokenClaims().AuthTime) + + secondAuthorization, err := service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: loginTime, + requester: newTestAuthorizeRequester("second-request", clientID, "none"), + }) + require.NoError(t, err) + require.False(t, secondAuthorization.RequiresInteraction) + require.Equal(t, loginTime, secondAuthorization.Session.IDTokenClaims().AuthTime) +} + +func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + clientID = "test-client" + ) + loginTime := time.Now().Add(-2 * time.Minute).UTC() + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{ + UserID: userID, + ClientID: clientID, + Scope: datatype.StringList{"openid"}, + }).Error) + + authorization, err := service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: loginTime, + requester: newTestAuthorizeRequesterWithForm("max-age-request", clientID, url.Values{"max_age": {"1"}}), + requestParams: map[string]string{"max_age": "1"}, + }) + require.NoError(t, err) + require.True(t, authorization.RequiresInteraction) + + interaction, err := service.getInteractionSession(t.Context(), authorization.InteractionID) + require.NoError(t, err) + require.Equal(t, interactionStepReauthenticate, interaction.CurrentStep) +} + +func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + clientID = "test-client" + interactionID = "test-interaction" + ) + loginTime := time.Now().Add(-2 * time.Minute).UTC() + reauthenticatedAt := time.Now().Add(-1 * time.Second).UTC().Truncate(time.Second) + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{ + UserID: userID, + ClientID: clientID, + Scope: datatype.StringList{"openid"}, + }).Error) + reauthenticatedAtValue := datatype.DateTime(reauthenticatedAt) + require.NoError(t, db.Create(&InteractionSession{ + Base: model.Base{ID: interactionID}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + ReauthenticationRequired: false, + ReauthenticatedAt: &reauthenticatedAtValue, + Parameters: map[string]string{ + "max_age": "1", + }, + }).Error) + + authorization, err := service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: loginTime, + requester: newTestAuthorizeRequesterWithForm("final-request", clientID, url.Values{"max_age": {"1"}}), + interactionID: interactionID, + requestParams: map[string]string{"max_age": "1"}, + }) + require.NoError(t, err) + require.False(t, authorization.RequiresInteraction) + require.Equal(t, reauthenticatedAt, authorization.Session.IDTokenClaims().AuthTime) +} + +func TestAuthorizationServiceAuthorizeUsesOriginalInteractionRequestTime(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + clientID = "test-client" + interactionID = "test-interaction" + ) + originalRequestedAt := time.Now().Add(-10 * time.Second).UTC().Truncate(time.Second) + reauthenticatedAt := originalRequestedAt.Add(5 * time.Second) + continuationRequestedAt := reauthenticatedAt.Add(5 * time.Second) + + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{ + UserID: userID, + ClientID: clientID, + Scope: datatype.StringList{"openid"}, + }).Error) + reauthenticatedAtValue := datatype.DateTime(reauthenticatedAt) + require.NoError(t, db.Create(&InteractionSession{ + Base: model.Base{ID: interactionID}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + ReauthenticationRequired: false, + RequestedAt: datatype.DateTime(originalRequestedAt), + ReauthenticatedAt: &reauthenticatedAtValue, + Parameters: map[string]string{ + "prompt": "login", + }, + }).Error) + + requester := newTestAuthorizeRequesterWithForm( + "final-request", + clientID, + url.Values{"prompt": {"login"}}, + ) + requester.(*fosite.AuthorizeRequest).RequestedAt = continuationRequestedAt + + authorization, err := service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: originalRequestedAt.Add(-time.Minute), + requester: requester, + interactionID: interactionID, + requestParams: map[string]string{"prompt": "login"}, + }) + require.NoError(t, err) + require.False(t, authorization.RequiresInteraction) + require.Equal(t, originalRequestedAt, authorization.Session.IDTokenClaims().RequestedAt) + require.Equal(t, reauthenticatedAt, authorization.Session.IDTokenClaims().AuthTime) + require.False(t, authorization.Session.IDTokenClaims().AuthTime.Before(authorization.Session.IDTokenClaims().RequestedAt)) +} + +func TestInteractionSessionServiceSavePersistsParameters(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newInteractionSessionService(db) + + const ( + clientID = "test-client" + interactionID = "test-interaction" + ) + + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + + interactionSession, err := service.create(t.Context(), InteractionSession{ + Base: model.Base{ID: interactionID}, + Scopes: datatype.StringList{"openid"}, + ClientID: clientID, + ReauthenticationRequired: true, + RequestedAt: datatype.DateTime(time.Now().UTC()), + Parameters: map[string]string{ + "max_age": "1", + }, + }) + require.NoError(t, err) + + reauthenticatedAt := datatype.DateTime(time.Now().UTC().Truncate(time.Second)) + interactionSession.ReauthenticationRequired = false + interactionSession.ReauthenticatedAt = &reauthenticatedAt + require.NoError(t, service.update(t.Context(), interactionSession)) + + storedInteractionSession, err := service.get(t.Context(), interactionID) + require.NoError(t, err) + require.False(t, storedInteractionSession.ReauthenticationRequired) + require.Equal(t, "1", storedInteractionSession.Parameters["max_age"]) + require.NotNil(t, storedInteractionSession.ReauthenticatedAt) + require.Equal(t, reauthenticatedAt.UTC(), storedInteractionSession.ReauthenticatedAt.UTC()) +} + +func newTestAuthorizeRequester(requestID, clientID, prompt string) fosite.AuthorizeRequester { + form := url.Values{} + if prompt != "" { + form.Set("prompt", prompt) + } + return newTestAuthorizeRequesterWithForm(requestID, clientID, form) +} + +func newTestAuthorizeRequesterWithForm(requestID, clientID string, form url.Values) fosite.AuthorizeRequester { + requester := fosite.NewAuthorizeRequest() + requester.ID = requestID + requester.RequestedAt = time.Now().UTC() + requester.Client = Client{ + OidcClient: model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }, + } + requester.RequestedScope = fosite.Arguments{"openid"} + requester.ResponseTypes = fosite.Arguments{"code"} + requester.RedirectURI = &url.URL{Scheme: "https", Host: "client.example", Path: "/callback"} + requester.Form = form + return requester +} + +func stringPointer(value string) *string { + return &value +} diff --git a/backend/internal/oidc/claims_service.go b/backend/internal/oidc/claims_service.go new file mode 100644 index 00000000..ef8f2fd7 --- /dev/null +++ b/backend/internal/oidc/claims_service.go @@ -0,0 +1,166 @@ +package oidc + +import ( + "context" + "encoding/json" + "errors" + "slices" + + "github.com/ory/fosite" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/model" + "gorm.io/gorm" +) + +const ( + idTokenType = "id-token" +) + +type ClaimsService struct { + db *gorm.DB + customClaims CustomClaimSource + baseURL string + signer TokenSigner +} + +func newClaimsService(db *gorm.DB, customClaims CustomClaimSource, baseURL string, signer TokenSigner) *ClaimsService { + return &ClaimsService{ + db: db, + customClaims: customClaims, + baseURL: baseURL, + signer: signer, + } +} + +// ValidateUserAccess re-checks, at token-issuance time, that the user behind a grant is +// still allowed to obtain tokens for the client. +func (s *ClaimsService) ValidateUserAccess(ctx context.Context, userID string, client Client) error { + // Grants without a resource owner (e.g. client_credentials) carry an empty subject + // and have no user to validate. + if userID == "" { + return nil + } + + var user model.User + err := dbFromContext(ctx, s.db). + Preload("UserGroups"). + First(&user, "id = ?", userID). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return fosite.ErrInvalidGrant.WithHint("The user account no longer exists.") + } + if err != nil { + return err + } + + if user.Disabled { + return fosite.ErrInvalidGrant.WithHint("The user account is disabled.") + } + + if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) { + return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.") + } + + return nil +} + +// applyIDTokenClaims applies the claims of a user to the ID token claims in the session based on the requested scopes. +func (s *ClaimsService) applyIDTokenClaims(ctx context.Context, session *Session, scopes fosite.Arguments) error { + userID := session.Subject + if userID == "" { + return nil + } + + claims, err := s.GetUserClaims(ctx, userID, scopes) + if err != nil { + return err + } + + // Record the signing algorithm on the ID token header so fosite derives the at_hash/ + // c_hash digest from it (e.g. RS384 -> SHA-384, ES512 -> SHA-512). Without this the + // header is empty and fosite defaults to SHA-256, producing wrong hashes whenever the + // signing key is not a 256-bit algorithm. ToMap() strips "alg" before signing, so this + // never overrides the real JWS header. The signer is always wired in production; it is + // only nil in unit tests that do not assert hash correctness. + if s.signer != nil { + alg, err := s.signer.GetKeyAlg() + if err != nil { + return err + } + session.IDTokenHeaders().Add("alg", alg.String()) + } + + applyUserClaimsToIDToken(session, userID, claims) + return nil +} + +func applyUserClaimsToIDToken(session *Session, userID string, claims map[string]any) { + idTokenClaims := session.IDTokenClaims() + idTokenClaims.Subject = userID + idTokenClaims.Extra = claims + idTokenClaims.Extra[common.TokenTypeClaim] = idTokenType + if session.AuthenticationMethod != "" { + idTokenClaims.AuthenticationMethodsReferences = []string{session.AuthenticationMethod} + } +} + +// GetUserClaims retrieves the claims for a user based on the requested scopes. It includes standard claims +// like "sub" and "email" as well as any custom claims defined for the user or their groups. +func (s *ClaimsService) GetUserClaims(ctx context.Context, userID string, scopes []string) (map[string]any, error) { + db := dbFromContext(ctx, s.db) + + var user model.User + err := db. + Preload("UserGroups"). + First(&user, "id = ?", userID). + Error + if err != nil { + return nil, err + } + + claims := make(map[string]any, 10) + + if slices.Contains(scopes, "profile") { + customClaims, err := s.customClaims.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, db) + if err != nil { + return nil, err + } + + for _, customClaim := range customClaims { + // A custom claim value can be a JSON document or a plain string + var jsonValue any + if err := json.Unmarshal([]byte(customClaim.Value), &jsonValue); err == nil { + claims[customClaim.Key] = jsonValue + } else { + claims[customClaim.Key] = customClaim.Value + } + } + + claims["given_name"] = user.FirstName + claims["family_name"] = user.LastName + claims["name"] = user.FullName() + claims["display_name"] = user.DisplayName + claims["preferred_username"] = user.Username + claims["picture"] = s.baseURL + "/api/users/" + user.ID + "/profile-picture.png" + } + + claims["sub"] = user.ID + + // Only release the email claims when the user actually has an email. Emitting + // email_verified alongside a null/absent email (OIDC Core §5.1) is malformed and can + // mislead relying parties that key trust decisions on email_verified. + if slices.Contains(scopes, "email") && user.Email != nil && *user.Email != "" { + claims["email"] = *user.Email + claims["email_verified"] = user.EmailVerified + } + + if slices.Contains(scopes, "groups") { + userGroups := make([]string, len(user.UserGroups)) + for i, group := range user.UserGroups { + userGroups[i] = group.Name + } + claims["groups"] = userGroups + } + + return claims, nil +} diff --git a/backend/internal/oidc/claims_service_test.go b/backend/internal/oidc/claims_service_test.go new file mode 100644 index 00000000..418fa630 --- /dev/null +++ b/backend/internal/oidc/claims_service_test.go @@ -0,0 +1,173 @@ +package oidc + +import ( + "context" + "testing" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/ory/fosite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +// TestClaimsServiceValidateUserAccess covers the per-grant re-validation that the token +// endpoint performs on every grant (notably refresh_token, which fosite replays without +// reloading the user). A disabled user, a user removed from a group-restricted client, or +// a deleted user must be rejected so they cannot keep minting tokens from a still-valid +// refresh token. +func TestClaimsServiceValidateUserAccess(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + claimsService := newClaimsService(db, nil, "", nil) + + group := model.UserGroup{Base: model.Base{ID: "group-allowed"}, Name: "allowed", FriendlyName: "Allowed"} + require.NoError(t, db.Create(&group).Error) + + enabledUser := model.User{Base: model.Base{ID: "user-enabled"}, Username: "enabled"} + require.NoError(t, db.Create(&enabledUser).Error) + require.NoError(t, db.Model(&enabledUser).Association("UserGroups").Append(&group)) + + disabledUser := model.User{Base: model.Base{ID: "user-disabled"}, Username: "disabled", Disabled: true} + require.NoError(t, db.Create(&disabledUser).Error) + + outsiderUser := model.User{Base: model.Base{ID: "user-outsider"}, Username: "outsider"} + require.NoError(t, db.Create(&outsiderUser).Error) + + openClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "client-open"}, Name: "Open"}} + restrictedClient := Client{OidcClient: model.OidcClient{ + Base: model.Base{ID: "client-restricted"}, + Name: "Restricted", + IsGroupRestricted: true, + AllowedUserGroups: []model.UserGroup{group}, + }} + + t.Run("empty subject is allowed (client_credentials)", func(t *testing.T) { + require.NoError(t, claimsService.ValidateUserAccess(t.Context(), "", openClient)) + }) + + t.Run("enabled user is allowed", func(t *testing.T) { + require.NoError(t, claimsService.ValidateUserAccess(t.Context(), enabledUser.ID, openClient)) + }) + + t.Run("disabled user is rejected with invalid_grant", func(t *testing.T) { + err := claimsService.ValidateUserAccess(t.Context(), disabledUser.ID, openClient) + require.ErrorIs(t, err, fosite.ErrInvalidGrant) + }) + + t.Run("user in an allowed group may use a group-restricted client", func(t *testing.T) { + require.NoError(t, claimsService.ValidateUserAccess(t.Context(), enabledUser.ID, restrictedClient)) + }) + + t.Run("user outside the allowed groups is rejected with access_denied", func(t *testing.T) { + err := claimsService.ValidateUserAccess(t.Context(), outsiderUser.ID, restrictedClient) + require.ErrorIs(t, err, fosite.ErrAccessDenied) + }) + + t.Run("deleted user is rejected with invalid_grant", func(t *testing.T) { + err := claimsService.ValidateUserAccess(t.Context(), "does-not-exist", openClient) + require.ErrorIs(t, err, fosite.ErrInvalidGrant) + }) +} + +type fakeCustomClaimSource struct { + claims []model.CustomClaim +} + +func (f fakeCustomClaimSource) GetCustomClaimsForUserWithUserGroups(_ context.Context, _ string, _ *gorm.DB) ([]model.CustomClaim, error) { + return f.claims, nil +} + +// TestClaimsServiceGetUserClaims pins the scope-to-claims mapping that powers both the ID +// token and the userinfo endpoint: each OIDC scope must only release its own claims, "sub" +// is always present, and custom claims are emitted as parsed JSON when the stored value is +// valid JSON and as a raw string otherwise. +func TestClaimsServiceGetUserClaims(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + const ( + baseURL = "https://id.example.com" + userID = "user-1" + ) + + customClaims := fakeCustomClaimSource{claims: []model.CustomClaim{ + {Key: "department", Value: "engineering"}, // plain string + {Key: "roles", Value: `["admin","dev"]`}, // JSON document + }} + service := newClaimsService(db, customClaims, baseURL, nil) + + group := model.UserGroup{Base: model.Base{ID: "group-1"}, Name: "developers", FriendlyName: "Developers"} + require.NoError(t, db.Create(&group).Error) + + user := model.User{ + Base: model.Base{ID: userID}, + Username: "tim", + FirstName: "Tim", + LastName: "Cook", + DisplayName: "Tim Cook", + Email: stringPointer("tim@example.com"), + EmailVerified: true, + } + require.NoError(t, db.Create(&user).Error) + require.NoError(t, db.Model(&user).Association("UserGroups").Append(&group)) + + t.Run("openid only releases sub", func(t *testing.T) { + claims, err := service.GetUserClaims(t.Context(), userID, []string{"openid"}) + require.NoError(t, err) + require.Equal(t, map[string]any{"sub": userID}, claims) + }) + + t.Run("email scope releases email claims", func(t *testing.T) { + claims, err := service.GetUserClaims(t.Context(), userID, []string{"openid", "email"}) + require.NoError(t, err) + require.Equal(t, userID, claims["sub"]) + require.Equal(t, "tim@example.com", claims["email"]) + require.Equal(t, true, claims["email_verified"]) + require.NotContains(t, claims, "given_name") + require.NotContains(t, claims, "groups") + }) + + t.Run("groups scope releases group names", func(t *testing.T) { + claims, err := service.GetUserClaims(t.Context(), userID, []string{"groups"}) + require.NoError(t, err) + require.Equal(t, []string{"developers"}, claims["groups"]) + }) + + t.Run("profile scope releases profile and custom claims", func(t *testing.T) { + claims, err := service.GetUserClaims(t.Context(), userID, []string{"profile"}) + require.NoError(t, err) + require.Equal(t, "Tim", claims["given_name"]) + require.Equal(t, "Cook", claims["family_name"]) + require.Equal(t, "Tim Cook", claims["name"]) + require.Equal(t, "Tim Cook", claims["display_name"]) + require.Equal(t, "tim", claims["preferred_username"]) + require.Equal(t, baseURL+"/api/users/"+userID+"/profile-picture.png", claims["picture"]) + + // Custom claims: plain string stays a string, JSON document is decoded. + require.Equal(t, "engineering", claims["department"]) + require.Equal(t, []any{"admin", "dev"}, claims["roles"]) + + // Profile must not leak email when the email scope was not requested. + require.NotContains(t, claims, "email") + }) +} + +// TestClaimsServiceAppliesSigningAlgToIDTokenHeader verifies the ID token header carries the +// signing algorithm so fosite derives the at_hash/c_hash digest from it (e.g. RS384 -> +// SHA-384, ES512 -> SHA-512) instead of always defaulting to SHA-256. +func TestClaimsServiceAppliesSigningAlgToIDTokenHeader(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: "alg-user"}, Username: "alg"}).Error) + + for _, alg := range []jwa.SignatureAlgorithm{jwa.RS256(), jwa.RS384(), jwa.ES512()} { + t.Run(alg.String(), func(t *testing.T) { + service := newClaimsService(db, nil, "", algTestSigner{alg: alg}) + + session := NewEmptySession() + session.Subject = "alg-user" + + require.NoError(t, service.applyIDTokenClaims(t.Context(), session, fosite.Arguments{"openid"})) + require.Equal(t, alg.String(), session.IDTokenHeaders().Get("alg")) + }) + } +} diff --git a/backend/internal/oidc/cleanup.go b/backend/internal/oidc/cleanup.go new file mode 100644 index 00000000..064a6429 --- /dev/null +++ b/backend/internal/oidc/cleanup.go @@ -0,0 +1,37 @@ +package oidc + +import ( + "context" + "time" + + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "gorm.io/gorm" +) + +// CleanupExpiredOAuth2Sessions deletes OAuth2 sessions whose tokens or codes have +// expired. +// +// Invalidated-but-unexpired rows are intentionally KEPT until their original expiry: fosite relies on +// finding the inactive row to detect a reuse and revoke the affected token family. +func CleanupExpiredOAuth2Sessions(ctx context.Context, db *gorm.DB) (int64, error) { + st := db. + WithContext(ctx). + Delete(&OAuth2Session{}, "expires_at < ?", datatype.DateTime(time.Now())) + return st.RowsAffected, st.Error +} + +// CleanupExpiredClientAssertionJTIs deletes expired JWT IDs used for client assertion replay protection. +func CleanupExpiredClientAssertionJTIs(ctx context.Context, db *gorm.DB) (int64, error) { + st := db. + WithContext(ctx). + Delete(&clientAssertionJTI{}, "expires_at < ?", datatype.DateTime(time.Now())) + return st.RowsAffected, st.Error +} + +// CleanupAbandonedInteractionSessions removes interaction sessions that were never completed. +func CleanupAbandonedInteractionSessions(ctx context.Context, db *gorm.DB) (int64, error) { + st := db. + WithContext(ctx). + Delete(&InteractionSession{}, "created_at < ?", datatype.DateTime(time.Now().Add(-interactionSessionLifetime))) + return st.RowsAffected, st.Error +} diff --git a/backend/internal/oidc/cleanup_test.go b/backend/internal/oidc/cleanup_test.go new file mode 100644 index 00000000..1e367907 --- /dev/null +++ b/backend/internal/oidc/cleanup_test.go @@ -0,0 +1,40 @@ +package oidc + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +// TestCleanupExpiredOAuth2SessionsKeepsInvalidatedButUnexpiredSessions verifies that +// rotated/consumed (active=false) sessions are kept until their original expiry, so fosite +// can still detect refresh-token reuse and revoke the affected token family. Only rows +// past their expiry are removed. +func TestCleanupExpiredOAuth2SessionsKeepsInvalidatedButUnexpiredSessions(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + + past := datatype.DateTime(time.Now().Add(-time.Hour)) + future := datatype.DateTime(time.Now().Add(time.Hour)) + + rows := []OAuth2Session{ + {Base: model.Base{ID: "expired"}, Kind: "access_token", Key: "k-expired", RequestID: "r1", Active: true, RequestData: "{}", ExpiresAt: &past}, + {Base: model.Base{ID: "rotated"}, Kind: "refresh_token", Key: "k-rotated", RequestID: "r2", Active: false, RequestData: "{}", ExpiresAt: &future}, + {Base: model.Base{ID: "active"}, Kind: "refresh_token", Key: "k-active", RequestID: "r3", Active: true, RequestData: "{}", ExpiresAt: &future}, + } + for i := range rows { + require.NoError(t, db.Create(&rows[i]).Error) + } + + deleted, err := CleanupExpiredOAuth2Sessions(t.Context(), db) + require.NoError(t, err) + require.Equal(t, int64(1), deleted) + + var remaining []string + require.NoError(t, db.Model(&OAuth2Session{}).Pluck("id", &remaining).Error) + require.ElementsMatch(t, []string{"active", "rotated"}, remaining) +} diff --git a/backend/internal/oidc/client.go b/backend/internal/oidc/client.go new file mode 100644 index 00000000..3623e238 --- /dev/null +++ b/backend/internal/oidc/client.go @@ -0,0 +1,61 @@ +package oidc + +import ( + "github.com/ory/fosite" + "github.com/pocket-id/pocket-id/backend/internal/model" +) + +var _ fosite.Client = (*Client)(nil) +var _ fosite.ResponseModeClient = (*Client)(nil) + +type Client struct { + model.OidcClient +} + +func (c Client) GetID() string { + return c.ID +} + +func (c Client) GetHashedSecret() []byte { + return []byte(c.Secret) +} + +func (c Client) GetRedirectURIs() []string { + return c.CallbackURLs +} + +func (c Client) GetGrantTypes() fosite.Arguments { + grantTypes := fosite.Arguments{ + string(fosite.GrantTypeAuthorizationCode), + string(fosite.GrantTypeRefreshToken), + string(fosite.GrantTypeDeviceCode), + } + if !c.IsPublic() { + grantTypes = append(grantTypes, string(fosite.GrantTypeClientCredentials)) + } + return grantTypes +} + +func (c Client) GetResponseTypes() fosite.Arguments { + return fosite.Arguments{"code"} +} + +func (c Client) GetScopes() fosite.Arguments { + return fosite.Arguments{"openid", "profile", "email", "groups"} +} + +func (c Client) IsPublic() bool { + return c.OidcClient.IsPublic +} + +func (c Client) GetAudience() fosite.Arguments { + return fosite.Arguments{c.ID} +} + +func (c Client) GetResponseModes() []fosite.ResponseModeType { + return []fosite.ResponseModeType{ + fosite.ResponseModeQuery, + fosite.ResponseModeFragment, + fosite.ResponseModeFormPost, + } +} diff --git a/backend/internal/oidc/device_handler.go b/backend/internal/oidc/device_handler.go new file mode 100644 index 00000000..38ac6620 --- /dev/null +++ b/backend/internal/oidc/device_handler.go @@ -0,0 +1,87 @@ +package oidc + +import ( + "errors" + "log/slog" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/ory/fosite" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" +) + +type deviceHandler struct { + provider fosite.OAuth2Provider + deviceService *deviceService +} + +func newDeviceHandler(provider fosite.OAuth2Provider, deviceService *deviceService) *deviceHandler { + return &deviceHandler{ + provider: provider, + deviceService: deviceService, + } +} + +func (h *deviceHandler) authorizeDevice(c *gin.Context) { + ctx := c.Request.Context() + + response, request, err := h.deviceService.createDeviceAuthorization(ctx, c.Request) + if err != nil { + slog.ErrorContext(ctx, "Failed to create device authorization", "error", err) + h.provider.WriteAccessError(ctx, c.Writer, request, err) + return + } + + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusOK, response) +} + +func (h *deviceHandler) verifyDeviceCode(c *gin.Context) { + authenticationTime, _ := c.Get("authenticationTime") + typedAuthenticationTime, _ := authenticationTime.(time.Time) + reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName) + + userCode := c.Query("code") + if userCode == "" { + _ = c.Error(&common.ValidationError{Message: "code is required"}) + return + } + + err := h.deviceService.acceptDeviceCode( + c.Request.Context(), + userCode, + c.GetString("userID"), + c.GetString("authenticationMethod"), + typedAuthenticationTime, + reauthenticationToken, + requestMetaFromGin(c), + ) + if err != nil { + if errors.Is(err, fosite.ErrAccessDenied) { + c.JSON(http.StatusForbidden, gin.H{"error": "You're not allowed to access this service."}) + return + } + _ = c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} + +func (h *deviceHandler) deviceCodeInfo(c *gin.Context) { + userCode := c.Query("code") + if userCode == "" { + _ = c.Error(&common.ValidationError{Message: "code is required"}) + return + } + + deviceCodeInfo, err := h.deviceService.getDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID")) + if err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, deviceCodeInfo) +} diff --git a/backend/internal/oidc/device_service.go b/backend/internal/oidc/device_service.go new file mode 100644 index 00000000..29661203 --- /dev/null +++ b/backend/internal/oidc/device_service.go @@ -0,0 +1,200 @@ +package oidc + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/ory/fosite" + "github.com/ory/fosite/handler/rfc8628" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/model" + "gorm.io/gorm" +) + +type deviceService struct { + provider fosite.OAuth2Provider + store *Store + userCodeStrategy rfc8628.UserCodeStrategy + authorizationService *authorizationService + claimsService *ClaimsService + auditLog AuditLogger + db *gorm.DB +} + +func newDeviceService( + provider fosite.OAuth2Provider, + store *Store, + userCodeStrategy rfc8628.UserCodeStrategy, + authorizationService *authorizationService, + claimsService *ClaimsService, + auditLog AuditLogger, + db *gorm.DB, +) *deviceService { + return &deviceService{ + provider: provider, + store: store, + userCodeStrategy: userCodeStrategy, + authorizationService: authorizationService, + claimsService: claimsService, + auditLog: auditLog, + db: db, + } +} + +func (s *deviceService) createDeviceAuthorization(ctx context.Context, req *http.Request) (*dto.OidcDeviceAuthorizationResponseDto, fosite.Requester, error) { + request, err := s.provider.NewDeviceRequest(ctx, req) + if err != nil { + return nil, request, err + } + + session := NewEmptySession() + response, err := s.provider.NewDeviceResponse(ctx, request, session) + if err != nil { + return nil, request, err + } + + return &dto.OidcDeviceAuthorizationResponseDto{ + DeviceCode: response.GetDeviceCode(), + UserCode: response.GetUserCode(), + VerificationURI: response.GetVerificationURI(), + VerificationURIComplete: response.GetVerificationURIComplete(), + ExpiresIn: int(response.GetExpiresIn()), + Interval: response.GetInterval(), + }, request, nil +} + +func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID, authenticationMethod string, authenticationTime time.Time, reauthenticationToken string, meta requestMeta) error { + request, userCodeSignature, err := s.deviceRequestFromUserCode(ctx, userCode) + if err != nil { + return err + } + + // A user code may be approved only once. Rejecting an already-approved code prevents a second + // logged-in user from rebinding a pending device authorization to themselves before the device + // polls for its token. + if request.GetUserCodeState() != fosite.UserCodeUnused { + return &common.OidcInvalidDeviceCodeError{} + } + + client := request.GetClient().(Client) + var user model.User + if err = s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil { + return err + } + if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) { + return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.") + } + + for _, scope := range request.GetRequestedScopes() { + request.GrantScope(scope) + } + for _, audience := range request.GetRequestedAudience() { + request.GrantAudience(audience) + } + + return withTx(ctx, s.db, func(ctx context.Context) error { + if client.RequiresReauthentication { + if reauthenticationToken == "" || s.authorizationService == nil || s.authorizationService.reauth == nil { + return &common.ReauthenticationRequiredError{} + } + + reauthenticatedAt, err := s.authorizationService.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID) + if err != nil { + return err + } + authenticationTime = reauthenticatedAt + } + if authenticationTime.IsZero() { + authenticationTime = time.Now().UTC() + } + + session := NewAuthenticatedSession(userID, authenticationMethod, authenticationTime, request.GetRequestedAt()) + + if err = s.claimsService.applyIDTokenClaims(ctx, session, request.GetGrantedScopes()); err != nil { + return err + } + request.SetSession(session) + + hasAlreadyAuthorizedClient, err := s.authorizationService.consent(ctx, userID, client.GetID(), request.GetRequestedScopes()) + if err != nil { + return err + } + + event := model.AuditLogEventDeviceCodeAuthorization + if !hasAlreadyAuthorizedClient { + event = model.AuditLogEventNewDeviceCodeAuthorization + } + s.auditLog.Create(ctx, event, meta.IPAddress, meta.UserAgent, userID, model.AuditLogData{"clientName": client.Name}, dbFromContext(ctx, s.db)) + + deviceCodeSignature, err := s.store.AcceptDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature, request) + if err != nil { + return err + } + + if request.GetGrantedScopes().Has("openid") { + if err := s.store.CreateOpenIDConnectSession(ctx, deviceCodeSignature, request); err != nil { + return err + } + } + + return nil + }) +} + +func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID string) (*dto.DeviceCodeInfoDto, error) { + request, _, err := s.deviceRequestFromUserCode(ctx, userCode) + if err != nil { + return nil, err + } + + client := request.GetClient().(Client) + authorizationRequired := true + if userID != "" { + hasAuthorizedClient, err := s.authorizationService.hasAuthorizedClient(ctx, client.GetID(), userID, request.GetRequestedScopes()) + if err != nil { + return nil, err + } + authorizationRequired = !hasAuthorizedClient + } + + return &dto.DeviceCodeInfoDto{ + Client: dto.OidcClientMetaDataDto{ + ID: client.ID, + Name: client.Name, + HasLogo: client.HasLogo(), + HasDarkLogo: client.HasDarkLogo(), + LaunchURL: client.LaunchURL, + RequiresReauthentication: client.RequiresReauthentication, + }, + Scope: request.GetRequestedScopes(), + AuthorizationRequired: authorizationRequired, + ReauthenticationRequired: client.RequiresReauthentication, + }, nil +} + +func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode string) (fosite.DeviceRequester, string, error) { + userCodeSignature, err := s.userCodeStrategy.UserCodeSignature(ctx, userCode) + if err != nil { + return nil, "", err + } + + request, err := s.store.GetDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature) + if errors.Is(err, fosite.ErrNotFound) { + return nil, "", &common.OidcInvalidDeviceCodeError{} + } + if err != nil { + return nil, "", err + } + + if err = s.userCodeStrategy.ValidateUserCode(ctx, request, userCode); err != nil { + if errors.Is(err, fosite.ErrDeviceExpiredToken) { + return nil, "", &common.OidcDeviceCodeExpiredError{} + } + return nil, "", err + } + + return request, userCodeSignature, nil +} diff --git a/backend/internal/oidc/device_service_test.go b/backend/internal/oidc/device_service_test.go new file mode 100644 index 00000000..989301c3 --- /dev/null +++ b/backend/internal/oidc/device_service_test.go @@ -0,0 +1,122 @@ +package oidc + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type fakeReauthenticationConsumer struct { + token string + userID string + reauthenticatedAt time.Time + calls int +} + +func (f *fakeReauthenticationConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, userID string) (time.Time, error) { + f.calls++ + if token != f.token || userID != f.userID { + return time.Time{}, &common.ReauthenticationRequiredError{} + } + + return f.reauthenticatedAt, nil +} + +func TestDeviceServiceAcceptRequiresReauthenticationTokenWhenClientRequiresIt(t *testing.T) { + const ( + userID = "test-user" + clientID = "test-client" + ) + reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential + token: "valid-reauth-token", + userID: userID, + reauthenticatedAt: time.Now().UTC().Truncate(time.Second), + } + service, _, _, userCode, _ := newTestDeviceServiceWithCode(t, clientID, userID, true, reauth) + + err := service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().UTC(), "", requestMeta{}) + require.ErrorAs(t, err, new(*common.ReauthenticationRequiredError)) + require.Zero(t, reauth.calls) + + info, err := service.getDeviceCodeInfo(t.Context(), userCode, userID) + require.NoError(t, err) + require.True(t, info.ReauthenticationRequired) + + err = service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().UTC(), reauth.token, requestMeta{}) + require.NoError(t, err) + require.Equal(t, 1, reauth.calls) +} + +func TestDeviceServiceAcceptUsesReauthenticationTimeForDeviceSession(t *testing.T) { + const ( + userID = "test-user" + clientID = "test-client" + ) + reauthenticatedAt := time.Now().Add(-30 * time.Second).UTC().Truncate(time.Second) + reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential + token: "valid-reauth-token", + userID: userID, + reauthenticatedAt: reauthenticatedAt, + } + service, store, provider, userCode, deviceCode := newTestDeviceServiceWithCode(t, clientID, userID, true, reauth) + + err := service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().Add(-time.Hour).UTC(), reauth.token, requestMeta{}) + require.NoError(t, err) + + deviceCodeSignature, err := provider.deviceStrategy.DeviceCodeSignature(t.Context(), deviceCode) + require.NoError(t, err) + acceptedRequest, err := store.GetDeviceCodeSession(t.Context(), deviceCodeSignature, NewEmptySession()) + require.NoError(t, err) + session := acceptedRequest.GetSession().(*Session) + require.Equal(t, reauthenticatedAt, session.IDTokenClaims().AuthTime) +} + +func newTestDeviceServiceWithCode(t *testing.T, clientID, userID string, requiresReauthentication bool, reauth ReauthenticationTokenConsumer) (*deviceService, *Store, *oidcProvider, string, string) { + t.Helper() + + db := testutils.NewDatabaseForTest(t) + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + IsPublic: true, + RequiresReauthentication: requiresReauthentication, + }).Error) + + store := NewStore(db) + signerKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + provider, err := newProvider(store, nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + BaseURL: "https://issuer.example.com", + TokenBaseURL: "https://issuer.example.com", + Secret: "test-secret", + }) + require.NoError(t, err) + + claimsService := newClaimsService(db, nil, "", nil) + authorizationService := newAuthorizationService(db, newInteractionSessionService(db), claimsService, reauth, &fakeAuditLogger{}) + service := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, &fakeAuditLogger{}, db) + + form := url.Values{ + "client_id": {clientID}, + "scope": {"openid"}, + } + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/device/authorize", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, _, err := service.createDeviceAuthorization(t.Context(), req) + require.NoError(t, err) + + return service, store, provider, response.UserCode, response.DeviceCode +} diff --git a/backend/internal/oidc/end_session_handler.go b/backend/internal/oidc/end_session_handler.go new file mode 100644 index 00000000..185378a7 --- /dev/null +++ b/backend/internal/oidc/end_session_handler.go @@ -0,0 +1,58 @@ +package oidc + +import ( + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" +) + +type endSessionHandler struct { + endSessionService *endSessionService + baseURL string +} + +func newEndSessionHandler(endSessionService *endSessionService, baseURL string) *endSessionHandler { + return &endSessionHandler{ + endSessionService: endSessionService, + baseURL: baseURL, + } +} + +func (h *endSessionHandler) endSession(c *gin.Context) { + input, err := bindEndSessionRequest(c) + if err != nil { + _ = c.Error(err) + return + } + + callbackURL, err := h.endSessionService.endSession(c.Request.Context(), input, c.GetString("userID")) + if err != nil { + slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err) + c.Redirect(http.StatusFound, h.baseURL+"/logout") + return + } + + cookie.AddAccessTokenCookie(c, 0, "") + if callbackURL == "" { + c.Redirect(http.StatusFound, h.baseURL+"/logout") + return + } + + c.Redirect(http.StatusFound, appendStateToURL(callbackURL, input.State)) +} + +func bindEndSessionRequest(c *gin.Context) (dto.OidcLogoutDto, error) { + var input dto.OidcLogoutDto + + switch c.Request.Method { + case http.MethodGet: + return input, c.ShouldBindQuery(&input) + case http.MethodPost: + return input, c.ShouldBind(&input) + default: + return input, nil + } +} diff --git a/backend/internal/oidc/end_session_service.go b/backend/internal/oidc/end_session_service.go new file mode 100644 index 00000000..7dde1844 --- /dev/null +++ b/backend/internal/oidc/end_session_service.go @@ -0,0 +1,153 @@ +package oidc + +import ( + "context" + "errors" + "net/url" + "time" + + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/model" + "github.com/pocket-id/pocket-id/backend/internal/utils" + "gorm.io/gorm" +) + +type endSessionService struct { + db *gorm.DB + store *Store + signer TokenSigner + baseURL string +} + +func newEndSessionService(db *gorm.DB, store *Store, signer TokenSigner, baseURL string) *endSessionService { + return &endSessionService{ + db: db, + store: store, + signer: signer, + baseURL: baseURL, + } +} + +// endSession revokes the sessions belonging to the ID token hint and returns the +// client's post-logout callback URL (empty if none is configured). +func (s *endSessionService) endSession(ctx context.Context, input dto.OidcLogoutDto, userID string) (string, error) { + if input.IdTokenHint == "" { + return "", &common.TokenInvalidError{} + } + + token, err := s.verifyIDTokenHint(input.IdTokenHint) + if err != nil { + return "", &common.TokenInvalidError{} + } + + clientIDs, ok := token.Audience() + if !ok || len(clientIDs) == 0 { + return "", &common.TokenInvalidError{} + } + clientID := clientIDs[0] + if input.ClientId != "" && clientID != input.ClientId { + return "", &common.OidcClientIdNotMatchingError{} + } + + subject, ok := token.Subject() + if !ok || subject == "" { + return "", &common.TokenInvalidError{} + } + if userID != "" && subject != userID { + return "", &common.TokenInvalidError{} + } + userID = subject + + idTokenJTI, ok := token.JwtID() + if !ok { + return "", &common.TokenInvalidError{} + } + + var callbackURL string + err = withTx(ctx, s.db, func(ctx context.Context) error { + var authorizedClient model.UserAuthorizedOidcClient + err := dbFromContext(ctx, s.db). + Preload("Client"). + First(&authorizedClient, "client_id = ? AND user_id = ?", clientID, userID). + Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.OidcMissingAuthorizationError{} + } + return err + } + + callbackURL, err = logoutCallbackURL(&authorizedClient.Client, input.PostLogoutRedirectUri) + if err != nil { + return err + } + + return s.store.RevokeSessionsByIDTokenHint(ctx, userID, clientID, idTokenJTI) + }) + if err != nil { + return "", err + } + + return callbackURL, nil +} + +func (s *endSessionService) verifyIDTokenHint(tokenString string) (jwt.Token, error) { + alg, err := s.signer.GetKeyAlg() + if err != nil { + return nil, err + } + + token, err := jwt.ParseString( + tokenString, + jwt.WithValidate(true), + jwt.WithKey(alg, s.signer.GetPrivateKey()), + jwt.WithAcceptableSkew(time.Minute), + jwt.WithResetValidators(true), + jwt.WithIssuer(s.baseURL), + jwt.WithValidator(jwt.IsIssuedAtValid()), + jwt.WithValidator(jwt.IsNbfValid()), + ) + if err != nil { + return nil, err + } + + // id_token_hint must be an ID token, never an access token (both are signed with the same + // key). An expired ID token is still accepted here, as required by OIDC RP-Initiated Logout. + var tokenType string + if err := token.Get(common.TokenTypeClaim, &tokenType); err != nil || tokenType != idTokenType { + return nil, &common.TokenInvalidError{} + } + + return token, nil +} + +func logoutCallbackURL(client *model.OidcClient, inputLogoutCallbackURL string) (string, error) { + if len(client.LogoutCallbackURLs) == 0 { + return "", nil + } + if inputLogoutCallbackURL == "" { + return client.LogoutCallbackURLs[0], nil + } + + matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL) + if err != nil || matched == "" { + return "", &common.OidcInvalidCallbackURLError{} + } + + return matched, nil +} + +func appendStateToURL(callbackURL string, state string) string { + parsed, err := url.Parse(callbackURL) + if err != nil { + return callbackURL + } + if state != "" { + q := parsed.Query() + q.Set("state", state) + parsed.RawQuery = q.Encode() + } + return parsed.String() +} diff --git a/backend/internal/oidc/end_session_service_test.go b/backend/internal/oidc/end_session_service_test.go new file mode 100644 index 00000000..3342fe8b --- /dev/null +++ b/backend/internal/oidc/end_session_service_test.go @@ -0,0 +1,307 @@ +package oidc + +import ( + "crypto/rand" + "crypto/rsa" + "net/url" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +// TestLogoutCallbackURL locks the post-logout redirect resolution that replaced the +// deleted callback_url_util helper. An attacker-supplied post_logout_redirect_uri must +// only be honored when it exactly matches one the client registered, otherwise logout +// would be an open redirect. +func TestLogoutCallbackURL(t *testing.T) { + noURLs := &model.OidcClient{Base: model.Base{ID: "c"}} + withURLs := &model.OidcClient{Base: model.Base{ID: "c"}, LogoutCallbackURLs: model.UrlList{ + "https://app.example/logout", + "https://app.example/logout2", + "https://*.example/logout", + }} + + t.Run("no configured logout URLs yields no callback", func(t *testing.T) { + got, err := logoutCallbackURL(noURLs, "") + require.NoError(t, err) + require.Empty(t, got) + }) + + t.Run("empty input falls back to first registered URL", func(t *testing.T) { + got, err := logoutCallbackURL(withURLs, "") + require.NoError(t, err) + require.Equal(t, "https://app.example/logout", got) + }) + + t.Run("exact match is honored", func(t *testing.T) { + got, err := logoutCallbackURL(withURLs, "https://app.example/logout2") + require.NoError(t, err) + require.Equal(t, "https://app.example/logout2", got) + }) + + t.Run("wildcard match is honored with the requested URL", func(t *testing.T) { + got, err := logoutCallbackURL(withURLs, "https://tenant.example/logout") + require.NoError(t, err) + require.Equal(t, "https://tenant.example/logout", got) + }) + + t.Run("unregistered URL is rejected (no open redirect)", func(t *testing.T) { + _, err := logoutCallbackURL(withURLs, "https://evil.example/steal") + var target *common.OidcInvalidCallbackURLError + require.ErrorAs(t, err, &target) + }) +} + +func TestAppendStateToURL(t *testing.T) { + require.Equal(t, "https://app.example/cb", appendStateToURL("https://app.example/cb", "")) + require.Equal(t, "https://app.example/cb?state=xyz", appendStateToURL("https://app.example/cb", "xyz")) + + got := appendStateToURL("https://app.example/cb?foo=bar", "xyz") + parsed, err := url.Parse(got) + require.NoError(t, err) + require.Equal(t, "bar", parsed.Query().Get("foo")) + require.Equal(t, "xyz", parsed.Query().Get("state")) +} + +// TestEndSessionService exercises the RP-initiated logout validation and the session +// revocation it triggers. The ID token hint must be a valid, first-party token whose +// subject identifies the logged-out user and whose audience matches the client; only then +// is the client's registered post-logout URL returned and the user/client sessions revoked. +func TestEndSessionService(t *testing.T) { + const ( + baseURL = "https://issuer.example.com" + userID = "user-1" + clientID = "client-1" + jti = "id-token-jti" + ) + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + signer := testTokenSigner{key: key} + + type tokenOptions struct { + issuer string + subject string + audience string + jti string + omitSubject bool + omitJTI bool + omitAud bool + omitType bool + } + signToken := func(t *testing.T, opts tokenOptions) string { + t.Helper() + builder := jwt.NewBuilder().IssuedAt(time.Now()) + if opts.issuer != "" { + builder = builder.Issuer(opts.issuer) + } + if !opts.omitSubject { + builder = builder.Subject(opts.subject) + } + if !opts.omitAud { + builder = builder.Audience([]string{opts.audience}) + } + if !opts.omitJTI { + builder = builder.JwtID(opts.jti) + } + if !opts.omitType { + builder = builder.Claim(common.TokenTypeClaim, idTokenType) + } + token, err := builder.Build() + require.NoError(t, err) + signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), key)) + require.NoError(t, err) + return string(signed) + } + + newService := func(t *testing.T) (*endSessionService, *Store) { + t.Helper() + db := testutils.NewDatabaseForTest(t) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + LogoutCallbackURLs: model.UrlList{"https://app.example/logout"}, + }).Error) + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim"}).Error) + require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: userID, ClientID: clientID}).Error) + store := NewStore(db) + return newEndSessionService(db, store, signer, baseURL), store + } + + validToken := tokenOptions{issuer: baseURL, subject: userID, audience: clientID, jti: jti} + + t.Run("missing id_token_hint is rejected", func(t *testing.T) { + service, _ := newService(t) + _, err := service.endSession(t.Context(), dto.OidcLogoutDto{}, userID) + var target *common.TokenInvalidError + require.ErrorAs(t, err, &target) + }) + + t.Run("malformed id_token_hint is rejected", func(t *testing.T) { + service, _ := newService(t) + _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: "not-a-jwt"}, userID) + var target *common.TokenInvalidError + require.ErrorAs(t, err, &target) + }) + + t.Run("token signed by a foreign key is rejected", func(t *testing.T) { + service, _ := newService(t) + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + token, err := jwt.NewBuilder(). + Issuer(baseURL).Subject(userID).Audience([]string{clientID}).JwtID(jti).IssuedAt(time.Now()).Build() + require.NoError(t, err) + signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), otherKey)) + require.NoError(t, err) + + _, err = service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: string(signed)}, userID) + var target *common.TokenInvalidError + require.ErrorAs(t, err, &target) + }) + + t.Run("non-ID token (missing type claim) is rejected as id_token_hint", func(t *testing.T) { + // A first-party JWT access token of the same user/client (signed with the same key) must + // not be accepted as an id_token_hint; only genuine ID tokens carry the type claim. + service, _ := newService(t) + token := signToken(t, tokenOptions{issuer: baseURL, subject: userID, audience: clientID, jti: jti, omitType: true}) + _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID) + var target *common.TokenInvalidError + require.ErrorAs(t, err, &target) + }) + + t.Run("subject not matching the logged-in user is rejected", func(t *testing.T) { + service, _ := newService(t) + token := signToken(t, tokenOptions{issuer: baseURL, subject: "someone-else", audience: clientID, jti: jti}) + _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID) + var target *common.TokenInvalidError + require.ErrorAs(t, err, &target) + }) + + t.Run("client_id parameter not matching the token audience is rejected", func(t *testing.T) { + service, _ := newService(t) + token := signToken(t, validToken) + _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token, ClientId: "different-client"}, userID) + var target *common.OidcClientIdNotMatchingError + require.ErrorAs(t, err, &target) + }) + + t.Run("user that never authorized the client is rejected", func(t *testing.T) { + service, _ := newService(t) + // A valid token for a user that has no authorization record for the client. + token := signToken(t, tokenOptions{issuer: baseURL, subject: "ghost-user", audience: clientID, jti: jti}) + _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, "ghost-user") + var target *common.OidcMissingAuthorizationError + require.ErrorAs(t, err, &target) + }) + + t.Run("unregistered post_logout_redirect_uri is rejected", func(t *testing.T) { + service, _ := newService(t) + token := signToken(t, validToken) + _, err := service.endSession(t.Context(), dto.OidcLogoutDto{ + IdTokenHint: token, + PostLogoutRedirectUri: "https://evil.example/steal", + }, userID) + var target *common.OidcInvalidCallbackURLError + require.ErrorAs(t, err, &target) + }) + + t.Run("valid logout returns the callback URL and revokes the sessions", func(t *testing.T) { + service, store := newService(t) + + // Seed an active refresh/access token pair tied to this user, client and ID token. + require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-sig", "at-sig", newTestRequester("logout-req", clientID, userID, jti))) + require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-sig", newTestRequester("logout-req", clientID, userID, jti))) + + token := signToken(t, validToken) + callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID) + require.NoError(t, err) + require.Equal(t, "https://app.example/logout", callback) + + var refresh OAuth2Session + require.NoError(t, service.db.First(&refresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-sig").Error) + require.False(t, refresh.Active, "refresh token must be revoked on logout") + + var accessCount int64 + require.NoError(t, service.db.Model(&OAuth2Session{}). + Where("kind = ? AND key = ?", sessionKindAccessToken, "at-sig"). + Count(&accessCount).Error) + require.Zero(t, accessCount, "access token must be deleted on logout") + }) + + t.Run("valid logout revokes only the session matching the id_token_hint", func(t *testing.T) { + service, store := newService(t) + + require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-matching", "at-matching", newTestRequester("matching-req", clientID, userID, jti))) + require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-matching", newTestRequester("matching-req", clientID, userID, jti))) + require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-other", "at-other", newTestRequester("other-req", clientID, userID, "other-id-token-jti"))) + require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-other", newTestRequester("other-req", clientID, userID, "other-id-token-jti"))) + + token := signToken(t, validToken) + callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID) + require.NoError(t, err) + require.Equal(t, "https://app.example/logout", callback) + + var matchingRefresh OAuth2Session + require.NoError(t, service.db.First(&matchingRefresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-matching").Error) + require.False(t, matchingRefresh.Active, "refresh token matching id_token_hint must be revoked on logout") + + var otherRefresh OAuth2Session + require.NoError(t, service.db.First(&otherRefresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-other").Error) + require.True(t, otherRefresh.Active, "unrelated refresh token for the same user/client must remain active") + + var matchingAccessCount int64 + require.NoError(t, service.db.Model(&OAuth2Session{}). + Where("kind = ? AND key = ?", sessionKindAccessToken, "at-matching"). + Count(&matchingAccessCount).Error) + require.Zero(t, matchingAccessCount, "access token matching id_token_hint must be deleted on logout") + + var otherAccessCount int64 + require.NoError(t, service.db.Model(&OAuth2Session{}). + Where("kind = ? AND key = ?", sessionKindAccessToken, "at-other"). + Count(&otherAccessCount).Error) + require.Equal(t, int64(1), otherAccessCount, "unrelated access token for the same user/client must remain active") + }) + + t.Run("valid logout without UI session derives the user from id_token_hint", func(t *testing.T) { + service, store := newService(t) + + // The OP browser session is absent, but the ID token hint still identifies the + // End-User and RP session that should be logged out. + require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-no-session", "at-no-session", newTestRequester("logout-req", clientID, userID, jti))) + require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-no-session", newTestRequester("logout-req", clientID, userID, jti))) + + token := signToken(t, validToken) + callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, "") + require.NoError(t, err) + require.Equal(t, "https://app.example/logout", callback) + + var refresh OAuth2Session + require.NoError(t, service.db.First(&refresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-no-session").Error) + require.False(t, refresh.Active, "refresh token must be revoked on logout") + + var accessCount int64 + require.NoError(t, service.db.Model(&OAuth2Session{}). + Where("kind = ? AND key = ?", sessionKindAccessToken, "at-no-session"). + Count(&accessCount).Error) + require.Zero(t, accessCount, "access token must be deleted on logout") + }) + + t.Run("valid logout honors a registered post_logout_redirect_uri", func(t *testing.T) { + service, _ := newService(t) + token := signToken(t, validToken) + callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{ + IdTokenHint: token, + PostLogoutRedirectUri: "https://app.example/logout", + }, userID) + require.NoError(t, err) + require.Equal(t, "https://app.example/logout", callback) + }) +} diff --git a/backend/internal/oidc/federated_client_auth.go b/backend/internal/oidc/federated_client_auth.go new file mode 100644 index 00000000..22d5e05b --- /dev/null +++ b/backend/internal/oidc/federated_client_auth.go @@ -0,0 +1,228 @@ +package oidc + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/lestrrat-go/httprc/v3" + "github.com/lestrrat-go/httprc/v3/errsink" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/ory/fosite" +) + +const clientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec + +var errNoFederatedClientAssertion = errors.New("no federated client assertion") + +// federatedClientStore is the subset of the store the federated authenticator needs. +type federatedClientStore interface { + GetClient(ctx context.Context, id string) (fosite.Client, error) + ClientAssertionJWTValid(ctx context.Context, jti string) error + SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error +} + +// federatedClientAuthenticator authenticates clients via JWT bearer assertions issued +// by a federated identity provider configured per client. +type federatedClientAuthenticator struct { + clients federatedClientStore + httpClient *http.Client + jwksCache *jwk.Cache + defaultAudience string +} + +func newFederatedClientAuthenticator(ctx context.Context, clients federatedClientStore, httpClient *http.Client, defaultAudience string) (*federatedClientAuthenticator, error) { + authenticator := &federatedClientAuthenticator{ + clients: clients, + httpClient: httpClient, + defaultAudience: defaultAudience, + } + + jwksCache, err := authenticator.getJWKCache(ctx) + if err != nil { + return nil, err + } + authenticator.jwksCache = jwksCache + + return authenticator, nil +} + +func (a *federatedClientAuthenticator) getJWKCache(ctx context.Context) (*jwk.Cache, error) { + // We need to create a custom HTTP client to set a timeout. + client := a.httpClient + if client == nil { + client = &http.Client{ + Timeout: 10 * time.Second, + } + + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + // Indicates a development-time error + panic("Default transport is not of type *http.Transport") + } + transport := defaultTransport.Clone() + transport.TLSClientConfig.MinVersion = tls.VersionTLS12 + client.Transport = transport + } + + return jwk.NewCache(ctx, + httprc.NewClient( + httprc.WithErrorSink(errsink.NewSlog(slog.Default())), + httprc.WithHTTPClient(client), + ), + ) +} + +// newClientAuthenticationStrategy accepts federated client assertions before falling +// back to fosite's default client authentication. +func newClientAuthenticationStrategy(authenticator *federatedClientAuthenticator, provider *fosite.Fosite) fosite.ClientAuthenticationStrategy { + return func(ctx context.Context, r *http.Request, form url.Values) (fosite.Client, error) { + client, err := authenticator.authenticateForm(ctx, form) + if err == nil { + return client, nil + } + if !errors.Is(err, errNoFederatedClientAssertion) { + return nil, err + } + + return provider.DefaultClientAuthenticationStrategy(ctx, r, form) + } +} + +// authenticateForm returns errNoFederatedClientAssertion when the form carries no +// federated assertion, so the caller can fall back to other authentication methods. +func (a *federatedClientAuthenticator) authenticateForm(ctx context.Context, form url.Values) (fosite.Client, error) { + if form.Get("client_assertion_type") != clientAssertionTypeJWTBearer || form.Get("client_assertion") == "" { + return nil, errNoFederatedClientAssertion + } + + return a.authenticateAssertion(ctx, form.Get("client_assertion"), form.Get("client_id")) +} + +// authenticateAssertion validates the assertion JWT against the client's configured +// federated identity. An empty clientID falls back to the assertion's subject. +func (a *federatedClientAuthenticator) authenticateAssertion(ctx context.Context, assertion string, clientID string) (fosite.Client, error) { + rawAssertion := []byte(assertion) + insecureToken, err := jwt.ParseInsecure(rawAssertion) + if err != nil { + return nil, fosite.ErrInvalidClient.WithHint("Invalid client assertion.").WithWrap(err) + } + + issuer, _ := insecureToken.Issuer() + if issuer == "" { + return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing issuer.") + } + + if clientID == "" { + clientID, _ = insecureToken.Subject() + } + if clientID == "" { + return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing subject.") + } + + client, err := a.clients.GetClient(ctx, clientID) + if err != nil { + return nil, fosite.ErrInvalidClient.WithWrap(err) + } + + oidcClient, ok := client.(Client) + if !ok { + return nil, errNoFederatedClientAssertion + } + + federatedIdentity, ok := oidcClient.Credentials.FederatedIdentityForIssuer(issuer) + if !ok { + return nil, errNoFederatedClientAssertion + } + + jwksURL := federatedIdentity.JWKS + if jwksURL == "" { + jwksURL = strings.TrimRight(issuer, "/") + "/.well-known/jwks.json" + } + + jwks, err := a.fetchJWKSet(ctx, jwksURL) + if err != nil { + return nil, fosite.ErrInvalidClient.WithHint("Unable to fetch client assertion JWKS.").WithWrap(err) + } + + audience := federatedIdentity.Audience + if audience == "" { + audience = a.defaultAudience + } + subject := federatedIdentity.Subject + if subject == "" { + subject = client.GetID() + } + + parsed, err := jwt.Parse(rawAssertion, + jwt.WithValidate(true), + jwt.WithAcceptableSkew(30*time.Second), + jwt.WithRequiredClaim(jwt.ExpirationKey), + jwt.WithIssuer(issuer), + jwt.WithSubject(subject), + jwt.WithAudience(audience), + jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)), + ) + if err != nil { + return nil, fosite.ErrInvalidClient.WithHint("Invalid client assertion.").WithWrap(err) + } + + if federatedIdentity.ReplayProtection { + jti, ok := parsed.JwtID() + if !ok || jti == "" { + return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing jti claim, which is required for replay protection.") + } + + // Check if the jti has been used before + if err := a.clients.ClientAssertionJWTValid(ctx, jti); err != nil { + return nil, fosite.ErrInvalidClient.WithHint("Client assertion has already been used.").WithWrap(err) + } + // Store the jti to prevent future reuse + exp, _ := parsed.Expiration() + if err := a.clients.SetClientAssertionJWT(ctx, jti, exp); err != nil { + return nil, fosite.ErrInvalidClient.WithWrap(err) + } + + } + + return client, nil +} + +func (a *federatedClientAuthenticator) fetchJWKSet(ctx context.Context, jwksURL string) (jwk.Set, error) { + if !a.jwksCache.IsRegistered(ctx, jwksURL) { + // We set a timeout because otherwise Register will keep trying in case of errors + registerCtx, registerCancel := context.WithTimeout(ctx, 15*time.Second) + defer registerCancel() + + registerOptions := []jwk.RegisterOption{ + jwk.WithMaxInterval(24 * time.Hour), + jwk.WithMinInterval(15 * time.Minute), + jwk.WithWaitReady(true), + } + if a.httpClient != nil { + registerOptions = append(registerOptions, jwk.WithHTTPClient(a.httpClient)) + } + + // We need to register the URL + err := a.jwksCache.Register(registerCtx, jwksURL, registerOptions...) + // In case of race conditions (two goroutines calling jwkCache.Register at the same time), it's possible we can get a conflict anyways, so we ignore that error + if err != nil && !errors.Is(err, httprc.ErrResourceAlreadyExists()) { + return nil, fmt.Errorf("failed to register JWK set: %w", err) + } + } + + jwks, err := a.jwksCache.CachedSet(jwksURL) + if err != nil { + return nil, fmt.Errorf("failed to get cached JWK set: %w", err) + } + + return jwks, nil +} diff --git a/backend/internal/oidc/federated_client_auth_test.go b/backend/internal/oidc/federated_client_auth_test.go new file mode 100644 index 00000000..9ad529d7 --- /dev/null +++ b/backend/internal/oidc/federated_client_auth_test.go @@ -0,0 +1,310 @@ +package oidc + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/ory/fosite" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/model" + jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk" +) + +type fakeFederatedStore struct { + client fosite.Client + jtis map[string]time.Time +} + +func (f *fakeFederatedStore) GetClient(_ context.Context, id string) (fosite.Client, error) { + if f.client == nil || f.client.GetID() != id { + return nil, fosite.ErrNotFound + } + return f.client, nil +} + +func (f *fakeFederatedStore) ClientAssertionJWTValid(_ context.Context, jti string) error { + if exp, ok := f.jtis[jti]; ok && exp.After(time.Now()) { + return fosite.ErrJTIKnown + } + return nil +} + +func (f *fakeFederatedStore) SetClientAssertionJWT(_ context.Context, jti string, exp time.Time) error { + f.jtis[jti] = exp + return nil +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func newJWKSetHTTPClient(t *testing.T, set jwk.Set) *http.Client { + t.Helper() + + client, _ := newCountingJWKSetHTTPClient(t, set, false) + return client +} + +func newCountingJWKSetHTTPClient(t *testing.T, set jwk.Set, failAfterFirst bool) (*http.Client, *atomic.Int64) { + t.Helper() + + raw, err := json.Marshal(set) + require.NoError(t, err) + + var requests atomic.Int64 + return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if failAfterFirst && requests.Load() > 0 { + return nil, errors.New("jwks endpoint unavailable") + } + requests.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(string(raw))), + Request: req, + }, nil + })}, &requests +} + +// TestFederatedClientAuthenticatorAssertionValidation covers federated client assertions +// issued by providers that may cache and reuse tokens until their exp claim. +func TestFederatedClientAuthenticatorAssertionValidation(t *testing.T) { + signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "") + require.NoError(t, err) + signingAlg, ok := signingKey.Algorithm() + require.True(t, ok) + + publicKey, err := signingKey.PublicKey() + require.NoError(t, err) + jwks := jwk.NewSet() + require.NoError(t, jwks.AddKey(publicKey)) + + const ( + issuer = "https://idp.example.com" + clientID = "federated-client" + audience = "https://pocket-id.example.com" + jwksURL = "https://idp.example.com/jwks.json" + ) + + newAuthenticator := func(t *testing.T, replayProtection bool) (*federatedClientAuthenticator, *fakeFederatedStore) { + t.Helper() + store := &fakeFederatedStore{ + client: Client{OidcClient: model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Federated Client", + Credentials: model.OidcClientCredentials{ + FederatedIdentities: []model.OidcClientFederatedIdentity{{ + Issuer: issuer, + JWKS: jwksURL, + ReplayProtection: replayProtection, + }}, + }, + }}, + jtis: map[string]time.Time{}, + } + authenticator, err := newFederatedClientAuthenticator(t.Context(), store, newJWKSetHTTPClient(t, jwks), audience) + require.NoError(t, err) + return authenticator, store + } + + signAssertion := func(t *testing.T, mutate func(b *jwt.Builder) *jwt.Builder) string { + t.Helper() + builder := jwt.NewBuilder(). + Issuer(issuer). + Subject(clientID). + Audience([]string{audience}). + IssuedAt(time.Now()) + token, err := mutate(builder).Build() + require.NoError(t, err) + signed, err := jwt.Sign(token, jwt.WithKey(signingAlg, signingKey)) + require.NoError(t, err) + return string(signed) + } + + t.Run("valid assertion with jti authenticates once when replay protection is enabled", func(t *testing.T) { + authenticator, _ := newAuthenticator(t, true) + assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder { + return b.JwtID("replay-protected-token").Expiration(time.Now().Add(5 * time.Minute)) + }) + + client, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID) + require.NoError(t, err) + require.Equal(t, clientID, client.GetID()) + + _, err = authenticator.authenticateAssertion(t.Context(), assertion, clientID) + require.ErrorIs(t, err, fosite.ErrInvalidClient) + }) + + t.Run("valid assertion with jti can be reused when replay protection is disabled", func(t *testing.T) { + authenticator, store := newAuthenticator(t, false) + assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder { + return b.JwtID("cached-provider-token").Expiration(time.Now().Add(5 * time.Minute)) + }) + + client, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID) + require.NoError(t, err) + require.Equal(t, clientID, client.GetID()) + + client, err = authenticator.authenticateAssertion(t.Context(), assertion, clientID) + require.NoError(t, err) + require.Equal(t, clientID, client.GetID()) + require.Empty(t, store.jtis) + }) + + t.Run("assertion without exp is rejected", func(t *testing.T) { + authenticator, _ := newAuthenticator(t, true) + assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder { + return b.JwtID("jti-no-exp") + }) + _, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID) + require.ErrorIs(t, err, fosite.ErrInvalidClient) + }) + + t.Run("assertion without jti is rejected when replay protection is enabled", func(t *testing.T) { + authenticator, _ := newAuthenticator(t, true) + assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder { + return b.Expiration(time.Now().Add(5 * time.Minute)) + }) + _, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID) + require.ErrorIs(t, err, fosite.ErrInvalidClient) + }) + + t.Run("assertion without jti authenticates when replay protection is disabled", func(t *testing.T) { + authenticator, store := newAuthenticator(t, false) + assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder { + return b.Expiration(time.Now().Add(5 * time.Minute)) + }) + client, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID) + require.NoError(t, err) + require.Equal(t, clientID, client.GetID()) + require.Empty(t, store.jtis) + }) +} + +func TestFederatedClientAuthenticatorAllowsConfiguredSubjectDifferentFromClientID(t *testing.T) { + signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "") + require.NoError(t, err) + signingAlg, ok := signingKey.Algorithm() + require.True(t, ok) + + publicKey, err := signingKey.PublicKey() + require.NoError(t, err) + jwks := jwk.NewSet() + require.NoError(t, jwks.AddKey(publicKey)) + + const ( + issuer = "https://idp.example.com" + clientID = "pocket-id-client" + subject = "external-workload-subject" + audience = "api://pocket-id" + jwksURL = "https://idp.example.com/jwks.json" + ) + + store := &fakeFederatedStore{ + client: Client{OidcClient: model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Federated Client", + Credentials: model.OidcClientCredentials{ + FederatedIdentities: []model.OidcClientFederatedIdentity{{ + Issuer: issuer, + Subject: subject, + Audience: audience, + JWKS: jwksURL, + }}, + }, + }}, + jtis: map[string]time.Time{}, + } + authenticator, err := newFederatedClientAuthenticator(t.Context(), store, newJWKSetHTTPClient(t, jwks), "unused-default-audience") + require.NoError(t, err) + + token, err := jwt.NewBuilder(). + Issuer(issuer). + Subject(subject). + Audience([]string{audience}). + IssuedAt(time.Now()). + Expiration(time.Now().Add(5 * time.Minute)). + JwtID("jti-custom-subject"). + Build() + require.NoError(t, err) + assertion, err := jwt.Sign(token, jwt.WithKey(signingAlg, signingKey)) + require.NoError(t, err) + + client, err := authenticator.authenticateAssertion(t.Context(), string(assertion), clientID) + require.NoError(t, err) + require.Equal(t, clientID, client.GetID()) +} + +func TestFederatedClientAuthenticatorCachesJWKS(t *testing.T) { + signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "") + require.NoError(t, err) + signingAlg, ok := signingKey.Algorithm() + require.True(t, ok) + + publicKey, err := signingKey.PublicKey() + require.NoError(t, err) + jwks := jwk.NewSet() + require.NoError(t, jwks.AddKey(publicKey)) + + const ( + issuer = "https://idp.example.com" + clientID = "federated-client" + audience = "https://pocket-id.example.com" + jwksURL = "https://idp.example.com/jwks.json" + ) + + httpClient, requests := newCountingJWKSetHTTPClient(t, jwks, true) + store := &fakeFederatedStore{ + client: Client{OidcClient: model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Federated Client", + Credentials: model.OidcClientCredentials{ + FederatedIdentities: []model.OidcClientFederatedIdentity{{ + Issuer: issuer, + JWKS: jwksURL, + }}, + }, + }}, + jtis: map[string]time.Time{}, + } + authenticator, err := newFederatedClientAuthenticator(t.Context(), store, httpClient, audience) + require.NoError(t, err) + + signAssertion := func(t *testing.T, jti string) string { + t.Helper() + token, err := jwt.NewBuilder(). + Issuer(issuer). + Subject(clientID). + Audience([]string{audience}). + IssuedAt(time.Now()). + Expiration(time.Now().Add(5 * time.Minute)). + JwtID(jti). + Build() + require.NoError(t, err) + signed, err := jwt.Sign(token, jwt.WithKey(signingAlg, signingKey)) + require.NoError(t, err) + return string(signed) + } + + client, err := authenticator.authenticateAssertion(t.Context(), signAssertion(t, "jti-cache-1"), clientID) + require.NoError(t, err) + require.Equal(t, clientID, client.GetID()) + + client, err = authenticator.authenticateAssertion(t.Context(), signAssertion(t, "jti-cache-2"), clientID) + require.NoError(t, err) + require.Equal(t, clientID, client.GetID()) + require.EqualValues(t, 1, requests.Load()) +} diff --git a/backend/internal/oidc/interaction_session_dto.go b/backend/internal/oidc/interaction_session_dto.go new file mode 100644 index 00000000..0d685cf9 --- /dev/null +++ b/backend/internal/oidc/interaction_session_dto.go @@ -0,0 +1,70 @@ +package oidc + +import ( + "github.com/pocket-id/pocket-id/backend/internal/dto" +) + +type interactionStep string + +const ( + interactionStepAuthenticate interactionStep = "authenticate" + interactionStepSelectAccount interactionStep = "select_account" + interactionStepReauthenticate interactionStep = "reauthenticate" + interactionStepConsent interactionStep = "consent" +) + +type interactionSessionForUser struct { + ID string `json:"id"` + Scopes []string `json:"scopes"` + Client dto.OidcClientMetaDataDto `json:"client"` + CurrentStep interactionStep `json:"currentStep,omitempty"` + RequiredSteps []interactionStep `json:"requiredSteps"` +} + +type completeInteractionRequest struct { + Step interactionStep `json:"step"` +} + +type completeInteractionResponse struct { + Interaction *interactionSessionForUser `json:"interaction,omitempty"` + RedirectURL string `json:"redirectUrl,omitempty"` +} + +func newInteractionSessionForUser(interactionSession InteractionSession) (interactionSessionForUser, error) { + var client dto.OidcClientMetaDataDto + if err := dto.MapStruct(interactionSession.Client, &client); err != nil { + return interactionSessionForUser{}, err + } + + requiredSteps := requiredInteractionSteps(interactionSession) + var currentStep interactionStep + if len(requiredSteps) > 0 { + currentStep = requiredSteps[0] + } + + return interactionSessionForUser{ + ID: interactionSession.ID, + Scopes: interactionSession.Scopes, + Client: client, + CurrentStep: currentStep, + RequiredSteps: requiredSteps, + }, nil +} + +func requiredInteractionSteps(interactionSession InteractionSession) []interactionStep { + steps := make([]interactionStep, 0, 4) + if interactionSession.AuthenticationRequired { + steps = append(steps, interactionStepAuthenticate) + } + if interactionSession.AccountSelectionRequired { + steps = append(steps, interactionStepSelectAccount) + } + if interactionSession.ReauthenticationRequired { + steps = append(steps, interactionStepReauthenticate) + } + if interactionSession.ConsentRequired { + steps = append(steps, interactionStepConsent) + } + + return steps +} diff --git a/backend/internal/oidc/interaction_session_service.go b/backend/internal/oidc/interaction_session_service.go new file mode 100644 index 00000000..a7d28b85 --- /dev/null +++ b/backend/internal/oidc/interaction_session_service.go @@ -0,0 +1,85 @@ +package oidc + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +// interactionSessionLifetime is how long a pending interaction stays valid before the +// user has to restart the authorization flow. +const interactionSessionLifetime = time.Hour + +type interactionSessionService struct { + db *gorm.DB +} + +func newInteractionSessionService(db *gorm.DB) *interactionSessionService { + return &interactionSessionService{ + db: db, + } +} + +func (s *interactionSessionService) create(ctx context.Context, interactionSession InteractionSession) (InteractionSession, error) { + err := dbFromContext(ctx, s.db). + Create(&interactionSession). + Error + if err != nil { + return InteractionSession{}, err + } + + return interactionSession, nil +} + +func (s *interactionSessionService) get(ctx context.Context, id string) (InteractionSession, error) { + var interactionSession InteractionSession + err := dbFromContext(ctx, s.db). + Preload("Client"). + First(&interactionSession, "id = ?", id). + Error + if err != nil { + return InteractionSession{}, err + } + + if time.Since(interactionSession.CreatedAt.ToTime()) > interactionSessionLifetime { + return InteractionSession{}, gorm.ErrRecordNotFound + } + + return interactionSession, nil +} + +func (s *interactionSessionService) update(ctx context.Context, interactionSession InteractionSession) error { + return dbFromContext(ctx, s.db). + Model(&InteractionSession{}). + Where("id = ?", interactionSession.ID). + Updates(map[string]any{ + "authentication_required": interactionSession.AuthenticationRequired, + "account_selection_required": interactionSession.AccountSelectionRequired, + "reauthentication_required": interactionSession.ReauthenticationRequired, + "consent_required": interactionSession.ConsentRequired, + "user_id": interactionSession.UserID, + "reauthenticated_at": interactionSession.ReauthenticatedAt, + "parameters": interactionSession.Parameters, + }). + Error +} + +func (s *interactionSessionService) delete(ctx context.Context, id string) error { + tx := dbFromContext(ctx, s.db).Where("id = ?", id).Delete(&InteractionSession{}) + if tx.Error != nil { + return tx.Error + } + if tx.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + + return nil +} + +func hasRemainingInteractionSteps(interactionSession InteractionSession) bool { + return interactionSession.AuthenticationRequired || + interactionSession.AccountSelectionRequired || + interactionSession.ReauthenticationRequired || + interactionSession.ConsentRequired +} diff --git a/backend/internal/oidc/introspection_handler.go b/backend/internal/oidc/introspection_handler.go new file mode 100644 index 00000000..3f1e4eff --- /dev/null +++ b/backend/internal/oidc/introspection_handler.go @@ -0,0 +1,115 @@ +package oidc + +import ( + "context" + "log/slog" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + "github.com/ory/fosite" +) + +type introspectionHandler struct { + provider fosite.OAuth2Provider + authenticator *federatedClientAuthenticator + baseURL string +} + +func newIntrospectionHandler(provider fosite.OAuth2Provider, authenticator *federatedClientAuthenticator, baseURL string) *introspectionHandler { + return &introspectionHandler{ + provider: provider, + authenticator: authenticator, + baseURL: baseURL, + } +} + +// introspectToken godoc +// @Summary Introspect OIDC tokens +// @Description Pass a token to verify if it is considered valid. +// @Tags OIDC +// @Produce json +// @Param token formData string true "The token to be introspected." +// @Success 200 {object} object "Response with the introspection result." +// @Router /api/oidc/introspect [post] +func (h *introspectionHandler) introspectToken(c *gin.Context) { + ctx := c.Request.Context() + + if h.tryFederatedClientAssertionIntrospection(c) { + return + } + + response, err := h.provider.NewIntrospectionRequest(ctx, c.Request, NewEmptySession()) + if err != nil { + slog.ErrorContext(ctx, "Failed to create introspection request", "error", err) + h.provider.WriteIntrospectionError(ctx, c.Writer, err) + return + } + + // A client may only introspect its own tokens. If it was issued to another client, report it as + // inactive instead of leaking its existence or contents. + callerClientID, err := h.callerClientID(ctx, c) + if err != nil || callerClientID == "" || response.GetAccessRequester().GetClient().GetID() != callerClientID { + h.provider.WriteIntrospectionResponse(ctx, c.Writer, &fosite.IntrospectionResponse{Active: false}) + return + } + + h.provider.WriteIntrospectionResponse(ctx, c.Writer, response) +} + +// tryFederatedClientAssertionIntrospection handles introspection requests authenticated +// with a federated client assertion passed as bearer token instead of client credentials. +func (h *introspectionHandler) tryFederatedClientAssertionIntrospection(c *gin.Context) bool { + ctx := c.Request.Context() + assertion := fosite.AccessTokenFromRequest(c.Request) + clientID := c.PostForm("client_id") + if assertion == "" || clientID == "" { + return false + } + + client, err := h.authenticator.authenticateAssertion(ctx, assertion, clientID) + if err != nil { + h.provider.WriteIntrospectionError(ctx, c.Writer, fosite.ErrRequestUnauthorized.WithWrap(err)) + return true + } + + tokenUse, accessRequester, err := h.provider.IntrospectToken(ctx, c.PostForm("token"), fosite.TokenUse(c.PostForm("token_type_hint")), NewEmptySession(), strings.Fields(c.PostForm("scope"))...) + if err != nil { + h.provider.WriteIntrospectionError(ctx, c.Writer, fosite.ErrInactiveToken.WithWrap(err)) + return true + } + + response := &fosite.IntrospectionResponse{ + Active: true, + AccessRequester: accessRequester, + TokenUse: tokenUse, + } + if tokenUse == fosite.AccessToken { + response.AccessTokenType = fosite.BearerAccessToken + } + + if accessRequester.GetClient().GetID() != client.GetID() { + h.provider.WriteIntrospectionResponse(ctx, c.Writer, &fosite.IntrospectionResponse{Active: false}) + return true + } + + h.provider.WriteIntrospectionResponse(ctx, c.Writer, response) + return true +} + +// callerClientID resolves the client that authenticated this introspection request. +func (h *introspectionHandler) callerClientID(ctx context.Context, c *gin.Context) (string, error) { + if bearer := fosite.AccessTokenFromRequest(c.Request); bearer != "" { + _, accessRequester, err := h.provider.IntrospectToken(ctx, bearer, fosite.AccessToken, NewEmptySession()) + if err != nil { + return "", err + } + return accessRequester.GetClient().GetID(), nil + } + + if id, _, ok := c.Request.BasicAuth(); ok { + return url.QueryUnescape(id) + } + + return "", nil +} diff --git a/backend/internal/oidc/introspection_handler_test.go b/backend/internal/oidc/introspection_handler_test.go new file mode 100644 index 00000000..c12c70f9 --- /dev/null +++ b/backend/internal/oidc/introspection_handler_test.go @@ -0,0 +1,207 @@ +package oidc + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/ory/fosite" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/model" + jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +// TestIntrospectionHandlerBindsTokenToCallerClient verifies that an RFC 6750 bearer-auth +// caller (which omits client_id) can only introspect its own client's tokens. A client +// must not be able to introspect another client's token — doing so would leak the token's +// validity and the user PII it carries. +func TestIntrospectionHandlerBindsTokenToCallerClient(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := testutils.NewDatabaseForTest(t) + require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-a"}, Name: "Client A"}).Error) + require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-b"}, Name: "Client B"}).Error) + + signerKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + BaseURL: "https://issuer.example.com", + TokenBaseURL: "https://issuer.example.com", + Secret: "test-secret", + }) + require.NoError(t, err) + + issueAccessToken := func(t *testing.T, requestID, clientID, subject string) string { + t.Helper() + session := NewEmptySession() + session.Subject = subject + session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour)) + + request := fosite.NewAccessRequest(session) + request.ID = requestID + request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}} + request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)} + request.RequestedScope = fosite.Arguments{"openid"} + request.GrantedScope = fosite.Arguments{"openid"} + request.RequestedAudience = fosite.Arguments{clientID} + request.GrantedAudience = fosite.Arguments{clientID} + + response, err := provider.NewAccessResponse(t.Context(), request) + require.NoError(t, err) + return response.GetAccessToken() + } + + clientAToken := issueAccessToken(t, "req-a", "client-a", "user-a") + clientBToken := issueAccessToken(t, "req-b", "client-b", "user-b") + clientBOtherToken := issueAccessToken(t, "req-b-2", "client-b", "user-b") + + handler := newIntrospectionHandler(provider, nil, "https://issuer.example.com") + + introspect := func(t *testing.T, bearer, token string) map[string]any { + t.Helper() + body := url.Values{"token": {token}}.Encode() + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/introspect", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Bearer "+bearer) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + handler.introspectToken(c) + + var out map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + return out + } + + // A client using its own token as bearer must not learn about another client's token. + cross := introspect(t, clientBToken, clientAToken) + require.Equal(t, false, cross["active"]) + + // A client may introspect another of its own tokens. + same := introspect(t, clientBToken, clientBOtherToken) + require.Equal(t, true, same["active"]) +} + +func TestIntrospectionHandlerAllowsReusedFederatedClientAssertion(t *testing.T) { + gin.SetMode(gin.TestMode) + + const ( + baseURL = "https://issuer.example.com" + issuer = "https://idp.example.com" + clientID = "federated-client" + subject = "external-workload" + audience = "api://pocket-id" + jwksURL = "https://idp.example.com/jwks.json" + ) + + signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "") + require.NoError(t, err) + signingAlg, ok := signingKey.Algorithm() + require.True(t, ok) + + publicKey, err := signingKey.PublicKey() + require.NoError(t, err) + jwks := jwk.NewSet() + require.NoError(t, jwks.AddKey(publicKey)) + + db := testutils.NewDatabaseForTest(t) + replayProtection := false + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Federated Client", + Credentials: model.OidcClientCredentials{ + FederatedIdentities: []model.OidcClientFederatedIdentity{{ + Issuer: issuer, + Subject: subject, + Audience: audience, + JWKS: jwksURL, + ReplayProtection: replayProtection, + }}, + }, + }).Error) + + store := NewStore(db) + authenticator, err := newFederatedClientAuthenticator(t.Context(), store, newJWKSetHTTPClient(t, jwks), baseURL) + require.NoError(t, err) + + signerKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + provider, err := newProvider(store, authenticator, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + BaseURL: baseURL, + TokenBaseURL: baseURL, + Secret: "test-secret", + }) + require.NoError(t, err) + + session := NewEmptySession() + session.Subject = "user-a" + session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour)) + + accessRequest := fosite.NewAccessRequest(session) + accessRequest.ID = "replay-test-request" + accessRequest.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}} + accessRequest.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)} + accessRequest.RequestedScope = fosite.Arguments{"openid"} + accessRequest.GrantedScope = fosite.Arguments{"openid"} + accessRequest.RequestedAudience = fosite.Arguments{clientID} + accessRequest.GrantedAudience = fosite.Arguments{clientID} + + accessResponse, err := provider.NewAccessResponse(t.Context(), accessRequest) + require.NoError(t, err) + + assertionToken, err := jwt.NewBuilder(). + Issuer(issuer). + Subject(subject). + Audience([]string{audience}). + IssuedAt(time.Now()). + Expiration(time.Now().Add(5 * time.Minute)). + JwtID("cached-provider-token"). + Build() + require.NoError(t, err) + signedAssertion, err := jwt.Sign(assertionToken, jwt.WithKey(signingAlg, signingKey)) + require.NoError(t, err) + + handler := newIntrospectionHandler(provider, authenticator, baseURL) + introspect := func(t *testing.T) (int, map[string]any) { + t.Helper() + body := url.Values{ + "client_id": {clientID}, + "token": {accessResponse.GetAccessToken()}, + }.Encode() + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/introspect", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Bearer "+string(signedAssertion)) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + handler.introspectToken(c) + + var out map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + return rec.Code, out + } + + status, body := introspect(t) + require.Equal(t, http.StatusOK, status) + require.Equal(t, true, body["active"]) + + status, body = introspect(t) + require.Equal(t, http.StatusOK, status) + require.Equal(t, true, body["active"]) +} diff --git a/backend/internal/oidc/models.go b/backend/internal/oidc/models.go new file mode 100644 index 00000000..b7ff9f09 --- /dev/null +++ b/backend/internal/oidc/models.go @@ -0,0 +1,75 @@ +package oidc + +import ( + "database/sql/driver" + "encoding/json" + + "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +// OAuth2Session is a fosite requester persisted by the Store. A single table holds +// all session kinds (authorization codes, tokens, PKCE, PAR, device codes, ...). +type OAuth2Session struct { + model.Base + + Kind string + Key string + RequestID string + AccessTokenSignature string + Active bool + RequestData string + ExpiresAt *datatype.DateTime +} + +func (OAuth2Session) TableName() string { + return "oauth2_sessions" +} + +// clientAssertionJTI stores consumed client assertion JWT IDs for replay protection. +type clientAssertionJTI struct { + model.Base + + JTI string `gorm:"not null;uniqueIndex"` + ExpiresAt datatype.DateTime `gorm:"not null;index"` +} + +func (clientAssertionJTI) TableName() string { + return "oauth2_jtis" +} + +// InteractionSession tracks the user-facing steps (authentication, account selection, +// reauthentication, consent) that must be completed before an authorization request +// can be granted. +type InteractionSession struct { + model.Base + + Scopes datatype.StringList + + ClientID string + Client model.OidcClient + + UserID *string + User model.User + + ConsentRequired bool + ReauthenticationRequired bool + AuthenticationRequired bool + AccountSelectionRequired bool + + RequestedAt datatype.DateTime + ReauthenticatedAt *datatype.DateTime + + Parameters InteractionSessionParameters +} + +type InteractionSessionParameters map[string]string //nolint:recvcheck + +func (p *InteractionSessionParameters) Scan(value any) error { + return utils.UnmarshalJSONFromDatabase(p, value) +} + +func (p InteractionSessionParameters) Value() (driver.Value, error) { + return json.Marshal(p) +} diff --git a/backend/internal/oidc/module.go b/backend/internal/oidc/module.go new file mode 100644 index 00000000..00b21b8d --- /dev/null +++ b/backend/internal/oidc/module.go @@ -0,0 +1,121 @@ +package oidc + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/pocket-id/pocket-id/backend/internal/model" + "gorm.io/gorm" +) + +type Config struct { + BaseURL string + TokenBaseURL string + Secret string +} + +type TokenSigner interface { + GetPrivateKey() any + GetKeyAlg() (jwa.KeyAlgorithm, error) + GetKeyID() (string, bool) +} + +type CustomClaimSource interface { + GetCustomClaimsForUserWithUserGroups(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomClaim, error) +} + +type ReauthenticationTokenConsumer interface { + ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error) +} + +type AuditLogger interface { + Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool) +} + +type Dependencies struct { + DB *gorm.DB + Config Config + HTTPClient *http.Client + + Signer TokenSigner + CustomClaims CustomClaimSource + Reauth ReauthenticationTokenConsumer + AuditLog AuditLogger +} + +type Module struct { + Preview *ClientPreviewBuilder + + config Config + store *Store + + authorizationHandler *authorizationHandler + tokenHandler *tokenHandler + userInfoHandler *userInfoHandler + parHandler *parHandler + introspectionHandler *introspectionHandler + endSessionHandler *endSessionHandler + deviceHandler *deviceHandler +} + +func New(ctx context.Context, deps Dependencies) (*Module, error) { + store := NewStore(deps.DB) + authenticator, err := newFederatedClientAuthenticator(ctx, store, deps.HTTPClient, deps.Config.BaseURL) + if err != nil { + return nil, fmt.Errorf("failed to create federated client authenticator: %w", err) + } + provider, err := newProvider(store, authenticator, deps.Signer, deps.Config) + if err != nil { + return nil, fmt.Errorf("failed to create OAuth2 provider: %w", err) + } + + claimsService := newClaimsService(deps.DB, deps.CustomClaims, deps.Config.BaseURL, deps.Signer) + previewBuilder := newClientPreviewBuilder(claimsService, provider.tokenStrategies) + interactionSessionService := newInteractionSessionService(deps.DB) + authorizationService := newAuthorizationService(deps.DB, interactionSessionService, claimsService, deps.Reauth, deps.AuditLog) + deviceService := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, deps.AuditLog, deps.DB) + endSessionService := newEndSessionService(deps.DB, store, deps.Signer, deps.Config.BaseURL) + + return &Module{ + Preview: previewBuilder, + + config: deps.Config, + store: store, + + authorizationHandler: newAuthorizationHandler(provider, authorizationService, deps.Config.BaseURL), + tokenHandler: newTokenHandler(provider, claimsService), + userInfoHandler: newUserInfoHandler(provider, claimsService), + parHandler: newPARHandler(provider), + introspectionHandler: newIntrospectionHandler(provider, authenticator, deps.Config.BaseURL), + endSessionHandler: newEndSessionHandler(endSessionService, deps.Config.BaseURL), + deviceHandler: newDeviceHandler(provider, deviceService), + }, nil +} + +func (m *Module) RegisterRoutes(rootGroup *gin.RouterGroup, apiGroup *gin.RouterGroup, optionalBrowserAuth gin.HandlerFunc, browserAuth gin.HandlerFunc) { + rootGroup.GET("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize) + rootGroup.POST("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize) + + apiGroup.GET("/oidc/interactions/:id", m.authorizationHandler.getInteractionSession) + apiGroup.POST("/oidc/interactions/:id/complete", browserAuth, m.authorizationHandler.completeInteraction) + + apiGroup.POST("/oidc/par", m.parHandler.pushedAuthorizationRequest) + + apiGroup.POST("/oidc/token", m.tokenHandler.token) + + apiGroup.GET("/oidc/userinfo", m.userInfoHandler.userInfo) + apiGroup.POST("/oidc/userinfo", m.userInfoHandler.userInfo) + + apiGroup.POST("/oidc/introspect", m.introspectionHandler.introspectToken) + + apiGroup.GET("/oidc/end-session", optionalBrowserAuth, m.endSessionHandler.endSession) + apiGroup.POST("/oidc/end-session", optionalBrowserAuth, m.endSessionHandler.endSession) + + apiGroup.POST("/oidc/device/authorize", m.deviceHandler.authorizeDevice) + apiGroup.POST("/oidc/device/verify", browserAuth, m.deviceHandler.verifyDeviceCode) + apiGroup.GET("/oidc/device/info", browserAuth, m.deviceHandler.deviceCodeInfo) +} diff --git a/backend/internal/oidc/par_handler.go b/backend/internal/oidc/par_handler.go new file mode 100644 index 00000000..adf37d10 --- /dev/null +++ b/backend/internal/oidc/par_handler.go @@ -0,0 +1,38 @@ +package oidc + +import ( + "log/slog" + + "github.com/gin-gonic/gin" + "github.com/ory/fosite" +) + +type parHandler struct { + provider fosite.OAuth2Provider +} + +func newPARHandler(provider fosite.OAuth2Provider) *parHandler { + return &parHandler{ + provider: provider, + } +} + +func (h *parHandler) pushedAuthorizationRequest(c *gin.Context) { + ctx := c.Request.Context() + + ar, err := h.provider.NewPushedAuthorizeRequest(ctx, c.Request) + if err != nil { + slog.ErrorContext(ctx, "Failed to create pushed authorize request", "error", err) + h.provider.WritePushedAuthorizeError(ctx, c.Writer, ar, err) + return + } + + response, err := h.provider.NewPushedAuthorizeResponse(ctx, ar, NewEmptySession()) + if err != nil { + slog.ErrorContext(ctx, "Failed to create pushed authorize response", "error", err) + h.provider.WritePushedAuthorizeError(ctx, c.Writer, ar, err) + return + } + + h.provider.WritePushedAuthorizeResponse(ctx, c.Writer, ar, response) +} diff --git a/backend/internal/oidc/preview.go b/backend/internal/oidc/preview.go new file mode 100644 index 00000000..50e0114c --- /dev/null +++ b/backend/internal/oidc/preview.go @@ -0,0 +1,122 @@ +package oidc + +import ( + "context" + "fmt" + "net/url" + "time" + + jwxjwt "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/ory/fosite" + + "github.com/pocket-id/pocket-id/backend/internal/model" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +type ClientPreview struct { + IDToken map[string]any + AccessToken map[string]any + UserInfo map[string]any +} + +type ClientPreviewBuilder struct { + claimsService *ClaimsService + strategies tokenStrategies +} + +func newClientPreviewBuilder(claimsService *ClaimsService, strategies tokenStrategies) *ClientPreviewBuilder { + return &ClientPreviewBuilder{ + claimsService: claimsService, + strategies: strategies, + } +} + +func (b *ClientPreviewBuilder) BuildClientPreview(ctx context.Context, client model.OidcClient, userID string, scopes []string, authenticationMethod string) (*ClientPreview, error) { + scopeArgs, err := b.validatedScopes(ctx, client, scopes) + if err != nil { + return nil, err + } + + userInfo, err := b.claimsService.GetUserClaims(ctx, userID, scopeArgs) + if err != nil { + return nil, err + } + + request := b.newPreviewRequest(ctx, client, userID, scopeArgs, authenticationMethod) + session := request.GetSession().(*Session) + applyUserClaimsToIDToken(session, userID, userInfo) + + idToken, err := b.strategies.idToken.GenerateIDToken(ctx, b.strategies.config.GetIDTokenLifespan(ctx), request) + if err != nil { + return nil, fmt.Errorf("failed to generate preview ID token: %w", err) + } + + accessToken, _, err := b.strategies.accessToken.GenerateAccessToken(ctx, request) + if err != nil { + return nil, fmt.Errorf("failed to generate preview access token: %w", err) + } + + idTokenPayload, err := claimsFromJWTString(idToken) + if err != nil { + return nil, fmt.Errorf("failed to decode preview ID token: %w", err) + } + + accessTokenPayload, err := claimsFromJWTString(accessToken) + if err != nil { + return nil, fmt.Errorf("failed to decode preview access token: %w", err) + } + + return &ClientPreview{ + IDToken: idTokenPayload, + AccessToken: accessTokenPayload, + UserInfo: userInfo, + }, nil +} + +func (b *ClientPreviewBuilder) validatedScopes(ctx context.Context, client model.OidcClient, scopes []string) (fosite.Arguments, error) { + scopeStrategy := b.strategies.config.GetScopeStrategy(ctx) + clientScopes := Client{OidcClient: client}.GetScopes() + + scopeArgs := make(fosite.Arguments, 0, len(scopes)) + for _, scope := range fosite.RemoveEmpty(scopes) { + if !scopeStrategy(clientScopes, scope) { + return nil, fosite.ErrInvalidScope.WithHintf("The OAuth 2.0 Client is not allowed to request scope '%s'.", scope) + } + if !scopeArgs.Has(scope) { + scopeArgs = append(scopeArgs, scope) + } + } + + return scopeArgs, nil +} + +func (b *ClientPreviewBuilder) newPreviewRequest(ctx context.Context, client model.OidcClient, userID string, scopes fosite.Arguments, authenticationMethod string) *fosite.Request { + now := time.Now().UTC() + session := NewAuthenticatedSession(userID, authenticationMethod, now, now) + session.SetExpiresAt(fosite.AccessToken, now.Add(b.strategies.config.GetAccessTokenLifespan(ctx))) + + request := fosite.NewRequest() + request.RequestedAt = now + request.Client = Client{OidcClient: client} + request.RequestedScope = scopes + request.GrantedScope = scopes + request.RequestedAudience = fosite.Arguments{client.ID} + request.GrantedAudience = fosite.Arguments{client.ID} + request.Form = url.Values{} + request.Session = session + + return request +} + +func claimsFromJWTString(tokenString string) (map[string]any, error) { + token, err := jwxjwt.ParseString( + tokenString, + jwxjwt.WithValidate(false), + jwxjwt.WithVerify(false), + ) + if err != nil { + return nil, err + } + + return utils.GetClaimsFromToken(token) +} diff --git a/backend/internal/oidc/preview_test.go b/backend/internal/oidc/preview_test.go new file mode 100644 index 00000000..4ba9737e --- /dev/null +++ b/backend/internal/oidc/preview_test.go @@ -0,0 +1,102 @@ +package oidc + +import ( + "crypto/rand" + "crypto/rsa" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + signerKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + BaseURL: "https://issuer.example.com", + TokenBaseURL: "https://issuer.example.com", + Secret: "test-secret", + }) + require.NoError(t, err) + + builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil), provider.tokenStrategies) + + const ( + userID = "test-user" + clientID = "test-client" + ) + email := "user@example.com" + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + Username: "test-user", + Email: &email, + EmailVerified: true, + }).Error) + + preview, err := builder.BuildClientPreview(t.Context(), model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }, userID, []string{"openid", "email"}, "phr") + require.NoError(t, err) + + require.Equal(t, "https://issuer.example.com", preview.AccessToken["iss"]) + require.ElementsMatch(t, []string{"openid", "email"}, stringSliceClaim(t, preview.AccessToken["scp"])) + require.ElementsMatch(t, []string{clientID}, stringSliceClaim(t, preview.AccessToken["aud"])) + require.NotContains(t, preview.AccessToken, "type") + + require.Equal(t, userID, preview.IDToken["sub"]) + // ID tokens carry the "type" marker (so the end-session endpoint can reject access tokens + // passed as id_token_hint) and the amr from the authentication method. + require.Equal(t, idTokenType, preview.IDToken["type"]) + require.ElementsMatch(t, []string{"phr"}, stringSliceClaim(t, preview.IDToken["amr"])) + + require.Equal(t, email, preview.UserInfo["email"]) + require.Equal(t, true, preview.UserInfo["email_verified"]) +} + +func TestClientPreviewBuilderRejectsInvalidScope(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + signerKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + BaseURL: "https://issuer.example.com", + TokenBaseURL: "https://issuer.example.com", + Secret: "test-secret", + }) + require.NoError(t, err) + + builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil), provider.tokenStrategies) + _, err = builder.BuildClientPreview(t.Context(), model.OidcClient{ + Base: model.Base{ID: "test-client"}, + Name: "Test Client", + }, "test-user", []string{"openid", "unknown"}, "") + require.Error(t, err) + require.ErrorContains(t, err, "invalid_scope") +} + +func stringSliceClaim(t *testing.T, value any) []string { + t.Helper() + + switch typed := value.(type) { + case []string: + return typed + case []any: + values := make([]string, 0, len(typed)) + for _, item := range typed { + value, ok := item.(string) + require.Truef(t, ok, "expected string claim item, got %T", item) + values = append(values, value) + } + return values + case string: + return []string{typed} + default: + require.Failf(t, "unexpected claim type", "expected string slice claim, got %T", value) + return nil + } +} diff --git a/backend/internal/oidc/provider.go b/backend/internal/oidc/provider.go new file mode 100644 index 00000000..8d7e70fa --- /dev/null +++ b/backend/internal/oidc/provider.go @@ -0,0 +1,137 @@ +package oidc + +import ( + "context" + "crypto/sha256" + "io" + "log/slog" + "net/url" + "time" + + "github.com/ory/fosite" + "github.com/ory/fosite/compose" + fositeoauth2 "github.com/ory/fosite/handler/oauth2" + "github.com/ory/fosite/handler/openid" + "github.com/ory/fosite/handler/rfc8628" + "github.com/pocket-id/pocket-id/backend/internal/utils" + "golang.org/x/crypto/hkdf" +) + +type oidcProvider struct { + fosite.OAuth2Provider + deviceStrategy *rfc8628.DefaultDeviceStrategy + tokenStrategies +} + +type tokenStrategies struct { + accessToken fositeoauth2.AccessTokenStrategy + idToken openid.OpenIDConnectTokenStrategy + config *fosite.Config +} + +func newProvider(store *Store, authenticator *federatedClientAuthenticator, signer TokenSigner, config Config) (*oidcProvider, error) { + secret, err := DeriveGlobalSecret(config.Secret) + if err != nil { + return nil, err + } + + var fositeConfig = &fosite.Config{ + RefreshTokenLifespan: 30 * 24 * time.Hour, + DeviceAndUserCodeLifespan: 15 * time.Minute, + DeviceAuthTokenPollingInterval: 5 * time.Second, + DeviceVerificationURL: config.BaseURL + "/device", + PushedAuthorizeContextLifespan: 90 * time.Second, + IDTokenIssuer: config.BaseURL, + AccessTokenIssuer: config.BaseURL, + TokenURL: config.TokenBaseURL + "/api/oidc/token", + ScopeStrategy: fosite.ExactScopeStrategy, + AudienceMatchingStrategy: fosite.ExactAudienceMatchingStrategy, + RedirectURIMatcher: matchRedirectURI, + EnforcePKCEForPublicClients: true, + EnablePKCEPlainChallengeMethod: true, + RefreshTokenScopes: []string{}, + GlobalSecret: secret, + } + + keyGetter := func(context.Context) (interface{}, error) { + return SigningKeyFromSigner(signer) + } + sig := newJWTSigner(keyGetter) + coreStrategy := compose.NewOAuth2HMACStrategy(fositeConfig) + deviceStrategy := compose.NewDeviceStrategy(fositeConfig) + accessTokenStrategy := &fositeoauth2.DefaultJWTStrategy{ + Signer: sig, + HMACSHAStrategy: coreStrategy, + Config: fositeConfig, + } + idTokenStrategy := &openid.DefaultStrategy{ + Signer: sig, + Config: fositeConfig, + } + provider := compose.Compose( + fositeConfig, + store, + &compose.CommonStrategy{ + CoreStrategy: accessTokenStrategy, + RFC8628CodeStrategy: deviceStrategy, + OpenIDConnectTokenStrategy: idTokenStrategy, + Signer: sig, + }, + compose.OAuth2AuthorizeExplicitFactory, + compose.OAuth2ClientCredentialsGrantFactory, + compose.OAuth2RefreshTokenGrantFactory, + compose.RFC8628DeviceFactory, + compose.RFC8628DeviceAuthorizationTokenFactory, + compose.OpenIDConnectExplicitFactory, + compose.OpenIDConnectRefreshFactory, + compose.OpenIDConnectDeviceFactory, + compose.OAuth2TokenIntrospectionFactory, + compose.OAuth2PKCEFactory, + compose.PushedAuthorizeHandlerFactory, + ).(*fosite.Fosite) + + fositeConfig.ClientAuthenticationStrategy = newClientAuthenticationStrategy(authenticator, provider) + return &oidcProvider{ + OAuth2Provider: provider, + deviceStrategy: deviceStrategy, + tokenStrategies: tokenStrategies{ + accessToken: accessTokenStrategy, + idToken: idTokenStrategy, + config: fositeConfig, + }, + }, nil +} + +func matchRedirectURI(rawurl string, client fosite.Client) (*url.URL, error) { + redirectURI, err := fosite.MatchRedirectURIWithClientRedirectURIs(rawurl, client) + if err == nil || rawurl == "" { + return redirectURI, err + } + + invalidRedirectErr := err + matchedURL, matchErr := utils.GetCallbackURLFromList(client.GetRedirectURIs(), rawurl) + if matchErr != nil || matchedURL == "" { + slog.Debug("Redirect URI does not match any of the registered callback URLs", "rawurl", rawurl, "client_id", client.GetID(), "error", matchErr) + return nil, invalidRedirectErr + } + + redirectURI, err = url.Parse(matchedURL) + if err != nil || !fosite.IsValidRedirectURI(redirectURI) { + slog.Debug("Matched callback URL is invalid", "matchedURL", matchedURL, "client_id", client.GetID(), "error", err) + return nil, invalidRedirectErr + } + + return redirectURI, nil +} + +// DeriveGlobalSecret derives a 32-byte secret from the provided secret. +func DeriveGlobalSecret(secret string) ([]byte, error) { + const info = "pocketid/fosite_global_secret" + r := hkdf.New(sha256.New, []byte(secret), nil, []byte(info)) + + key := make([]byte, 32) + if _, err := io.ReadFull(r, key); err != nil { + return nil, err + } + return key, nil +} diff --git a/backend/internal/oidc/provider_test.go b/backend/internal/oidc/provider_test.go new file mode 100644 index 00000000..8d9b5a03 --- /dev/null +++ b/backend/internal/oidc/provider_test.go @@ -0,0 +1,272 @@ +package oidc + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/ory/fosite" + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" + "github.com/stretchr/testify/require" +) + +type testTokenSigner struct { + key *rsa.PrivateKey +} + +func (s testTokenSigner) GetPrivateKey() any { + return s.key +} + +func (s testTokenSigner) GetKeyAlg() (jwa.KeyAlgorithm, error) { + return jwa.RS256(), nil +} + +func (s testTokenSigner) GetKeyID() (string, bool) { + return "test-key-id", true +} + +func TestProviderIssuesJWTAccessTokens(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + signerKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + BaseURL: "https://issuer.example.com", + TokenBaseURL: "https://issuer.example.com", + Secret: "test-secret", + }) + require.NoError(t, err) + + session := NewEmptySession() + session.Subject = "test-user" + session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour)) + + request := fosite.NewAccessRequest(session) + request.ID = "test-request" + request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: "test-client"}}} + request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)} + request.RequestedScope = fosite.Arguments{"openid"} + request.GrantedScope = fosite.Arguments{"openid"} + request.RequestedAudience = fosite.Arguments{"test-client"} + request.GrantedAudience = fosite.Arguments{"test-client"} + + response, err := provider.NewAccessResponse(t.Context(), request) + require.NoError(t, err) + require.Len(t, strings.Split(response.GetAccessToken(), "."), 3) + + // The issued JWT must carry a `kid` header matching the signing key so RPs can + // select the verification key from the published JWKS (esp. after key rotation). + header := decodeJWTPart(t, response.GetAccessToken(), 0) + require.Equal(t, "test-key-id", header["kid"]) +} + +func TestProviderAcceptsWildcardRedirectURI(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + signerKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: "test-client"}, + Name: "Test Client", + CallbackURLs: model.UrlList{"https://*.example.com/callback"}, + }).Error) + + provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + BaseURL: "https://issuer.example.com", + TokenBaseURL: "https://issuer.example.com", + Secret: "test-secret", + }) + require.NoError(t, err) + + const requestedRedirectURI = "https://tenant.example.com/callback" + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/api/oidc/authorize?client_id=test-client&response_type=code&scope=openid&state=state-with-enough-entropy&redirect_uri="+requestedRedirectURI, + nil, + ) + + ar, err := provider.NewAuthorizeRequest(req.Context(), req) + require.NoError(t, err) + require.Equal(t, requestedRedirectURI, ar.GetRedirectURI().String()) + require.True(t, ar.IsRedirectURIValid()) + require.NotContains(t, ar.GetClient().GetRedirectURIs(), requestedRedirectURI) +} + +func TestProviderAcceptsPushedAuthorizationWildcardRedirectURI(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + signerKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: "test-client"}, + Name: "Test Client", + CallbackURLs: model.UrlList{"https://*.example.com/callback"}, + IsPublic: true, + }).Error) + + provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + BaseURL: "https://issuer.example.com", + TokenBaseURL: "https://issuer.example.com", + Secret: "test-secret", + }) + require.NoError(t, err) + + const requestedRedirectURI = "https://tenant.example.com/callback" + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/api/oidc/par?client_id=test-client&response_type=code&scope=openid&state=state-with-enough-entropy&redirect_uri="+requestedRedirectURI, + nil, + ) + + ar, err := provider.NewPushedAuthorizeRequest(req.Context(), req) + require.NoError(t, err) + require.Equal(t, requestedRedirectURI, ar.GetRedirectURI().String()) + require.True(t, ar.IsRedirectURIValid()) + require.NotContains(t, ar.GetClient().GetRedirectURIs(), requestedRedirectURI) +} + +func TestProviderRejectsUnmatchedWildcardRedirectURI(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + signerKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: "test-client"}, + Name: "Test Client", + CallbackURLs: model.UrlList{"https://*.example.com/callback"}, + }).Error) + + provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + BaseURL: "https://issuer.example.com", + TokenBaseURL: "https://issuer.example.com", + Secret: "test-secret", + }) + require.NoError(t, err) + + const requestedRedirectURI = "https://evil.example.net/callback" + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/api/oidc/authorize?client_id=test-client&response_type=code&scope=openid&state=state-with-enough-entropy&redirect_uri="+requestedRedirectURI, + nil, + ) + + _, err = provider.NewAuthorizeRequest(req.Context(), req) + require.ErrorIs(t, err, fosite.ErrInvalidRequest) +} + +// decodeJWTPart base64url-decodes the header (index 0) or claims (index 1) segment of a +// JWT without verifying the signature, for assertions in tests. +func decodeJWTPart(t *testing.T, token string, index int) map[string]any { + t.Helper() + parts := strings.Split(token, ".") + require.Len(t, parts, 3) + raw, err := base64.RawURLEncoding.DecodeString(parts[index]) + require.NoError(t, err) + var out map[string]any + require.NoError(t, json.Unmarshal(raw, &out)) + return out +} + +type algTestSigner struct { + key any + alg jwa.KeyAlgorithm +} + +func (s algTestSigner) GetPrivateKey() any { return s.key } +func (s algTestSigner) GetKeyAlg() (jwa.KeyAlgorithm, error) { return s.alg, nil } +func (s algTestSigner) GetKeyID() (string, bool) { return "test-key-id", true } + +// TestProviderIssuesAndValidatesTokensForSupportedAlgorithms guards against the +// regression where fosite's DefaultSigner derived the JWT algorithm from the Go key type +// alone: it broke EdDSA/ES384/ES512 token issuance entirely and silently downgraded +// RS384/RS512 to RS256. The provider must both ISSUE (NewAccessResponse) and VALIDATE +// (IntrospectToken -> Signer.Decode, the path used by introspection and userinfo) tokens +// for every signing algorithm Pocket ID supports. +func TestProviderIssuesAndValidatesTokensForSupportedAlgorithms(t *testing.T) { + cases := []struct { + name string + alg jwa.KeyAlgorithm + gen func(t *testing.T) any + }{ + {"RS256", jwa.RS256(), generateRSATestKey}, + {"RS384", jwa.RS384(), generateRSATestKey}, + {"RS512", jwa.RS512(), generateRSATestKey}, + {"ES256", jwa.ES256(), func(t *testing.T) any { return generateECTestKey(t, elliptic.P256()) }}, + {"ES384", jwa.ES384(), func(t *testing.T) any { return generateECTestKey(t, elliptic.P384()) }}, + {"ES512", jwa.ES512(), func(t *testing.T) any { return generateECTestKey(t, elliptic.P521()) }}, + {"EdDSA", jwa.EdDSA(), generateEd25519TestKey}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "test-client"}, Name: "Test Client"}).Error) + + provider, err := newProvider(NewStore(db), nil, algTestSigner{key: tc.gen(t), alg: tc.alg}, Config{ //nolint:gosec // static test-only provider secret + BaseURL: "https://issuer.example.com", + TokenBaseURL: "https://issuer.example.com", + Secret: "test-secret", + }) + require.NoError(t, err) + + session := NewEmptySession() + session.Subject = "test-user" + session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour)) + + request := fosite.NewAccessRequest(session) + request.ID = "test-request-" + tc.name + request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: "test-client"}}} + request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)} + request.RequestedScope = fosite.Arguments{"openid"} + request.GrantedScope = fosite.Arguments{"openid"} + request.RequestedAudience = fosite.Arguments{"test-client"} + request.GrantedAudience = fosite.Arguments{"test-client"} + + response, err := provider.NewAccessResponse(t.Context(), request) + require.NoError(t, err) + + accessToken := response.GetAccessToken() + require.Len(t, strings.Split(accessToken, "."), 3) + header := decodeJWTPart(t, accessToken, 0) + require.Equal(t, tc.alg.String(), header["alg"]) + + tokenUse, introspected, err := provider.IntrospectToken(t.Context(), accessToken, fosite.AccessToken, NewEmptySession()) + require.NoError(t, err) + require.Equal(t, fosite.AccessToken, tokenUse) + require.Equal(t, "test-client", introspected.GetClient().GetID()) + }) + } +} + +func generateRSATestKey(t *testing.T) any { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + return key +} + +func generateECTestKey(t *testing.T, curve elliptic.Curve) any { + t.Helper() + key, err := ecdsa.GenerateKey(curve, rand.Reader) + require.NoError(t, err) + return key +} + +func generateEd25519TestKey(t *testing.T) any { + t.Helper() + _, key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + return key +} diff --git a/backend/internal/oidc/session.go b/backend/internal/oidc/session.go new file mode 100644 index 00000000..4c0c0e09 --- /dev/null +++ b/backend/internal/oidc/session.go @@ -0,0 +1,148 @@ +package oidc + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" + "github.com/ory/fosite" + fositeoauth2 "github.com/ory/fosite/handler/oauth2" + "github.com/ory/fosite/handler/openid" + fositejwt "github.com/ory/fosite/token/jwt" +) + +var _ openid.Session = (*Session)(nil) +var _ fositeoauth2.JWTSessionContainer = (*Session)(nil) + +type Session struct { + Claims *fositejwt.IDTokenClaims `json:"id_token_claims"` + Headers *fositejwt.Headers `json:"headers"` + JWTClaims *fositejwt.JWTClaims `json:"jwt_claims,omitempty"` + JWTHeader *fositejwt.Headers `json:"jwt_header,omitempty"` + ExpiresAt map[fosite.TokenType]time.Time `json:"expires_at,omitempty"` + Subject string `json:"subject"` + AuthenticationMethod string `json:"authentication_method,omitempty"` +} + +func NewEmptySession() *Session { + return &Session{ + Claims: &fositejwt.IDTokenClaims{ + RequestedAt: time.Now().UTC(), + Extra: map[string]interface{}{}, + }, + } +} + +func NewAuthenticatedSession(subject, authenticationMethod string, authenticationTime, requestedAt time.Time) *Session { + now := time.Now().UTC() + if authenticationTime.IsZero() { + authenticationTime = now + } + if requestedAt.IsZero() { + requestedAt = now + } + + session := NewEmptySession() + session.Subject = subject + session.AuthenticationMethod = authenticationMethod + session.Claims.Subject = subject + session.Claims.AuthTime = authenticationTime.UTC() + session.Claims.RequestedAt = requestedAt.UTC() + session.Claims.JTI = uuid.NewString() + + return session +} + +func (s *Session) SetExpiresAt(key fosite.TokenType, exp time.Time) { + if s.ExpiresAt == nil { + s.ExpiresAt = make(map[fosite.TokenType]time.Time) + } + s.ExpiresAt[key] = exp +} + +func (s *Session) GetExpiresAt(key fosite.TokenType) time.Time { + if s.ExpiresAt == nil { + s.ExpiresAt = make(map[fosite.TokenType]time.Time) + } + return s.ExpiresAt[key] +} + +func (s *Session) GetUsername() string { + return s.GetSubject() +} + +func (s *Session) GetExtraClaims() map[string]interface{} { + if s == nil || s.Claims == nil || s.Claims.Issuer == "" { + return map[string]interface{}{} + } + + return map[string]interface{}{ + "iss": s.Claims.Issuer, + } +} + +func (s *Session) GetSubject() string { + if s == nil { + return "" + } + return s.Subject +} + +func (s *Session) Clone() fosite.Session { + if s == nil { + return nil + } + + var clone Session + data, err := json.Marshal(s) + if err != nil { + return NewEmptySession() + } + if err = json.Unmarshal(data, &clone); err != nil { + return NewEmptySession() + } + return &clone +} + +func (s *Session) IDTokenClaims() *fositejwt.IDTokenClaims { + if s.Claims == nil { + s.Claims = &fositejwt.IDTokenClaims{} + } + if s.Claims.Extra == nil { + s.Claims.Extra = map[string]interface{}{} + } + return s.Claims +} + +func (s *Session) IDTokenHeaders() *fositejwt.Headers { + if s.Headers == nil { + s.Headers = &fositejwt.Headers{} + } + if s.Headers.Extra == nil { + s.Headers.Extra = map[string]interface{}{} + } + return s.Headers +} + +func (s *Session) GetJWTClaims() fositejwt.JWTClaimsContainer { + if s.JWTClaims == nil { + s.JWTClaims = &fositejwt.JWTClaims{} + } + if s.JWTClaims.Subject == "" { + s.JWTClaims.Subject = s.GetSubject() + } + if s.JWTClaims.Extra == nil { + s.JWTClaims.Extra = map[string]interface{}{} + } + return s.JWTClaims +} + +func (s *Session) GetJWTHeader() *fositejwt.Headers { + if s.JWTHeader == nil { + s.JWTHeader = &fositejwt.Headers{} + } + if s.JWTHeader.Extra == nil { + s.JWTHeader.Extra = map[string]interface{}{} + } + return s.JWTHeader +} diff --git a/backend/internal/oidc/session_test.go b/backend/internal/oidc/session_test.go new file mode 100644 index 00000000..c641de1a --- /dev/null +++ b/backend/internal/oidc/session_test.go @@ -0,0 +1,34 @@ +package oidc + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestNewAuthenticatedSession(t *testing.T) { + authenticationTime := time.Date(2026, 6, 16, 10, 0, 0, 0, time.FixedZone("CEST", 2*60*60)) + requestedAt := time.Date(2026, 6, 16, 9, 59, 0, 0, time.FixedZone("CEST", 2*60*60)) + + session := NewAuthenticatedSession("user-id", "passkey", authenticationTime, requestedAt) + + require.Equal(t, "user-id", session.Subject) + require.Equal(t, "user-id", session.Claims.Subject) + require.Equal(t, "passkey", session.AuthenticationMethod) + require.Equal(t, authenticationTime.UTC(), session.Claims.AuthTime) + require.Equal(t, requestedAt.UTC(), session.Claims.RequestedAt) + require.NotNil(t, session.Claims.Extra) +} + +func TestNewAuthenticatedSessionDefaultsTimes(t *testing.T) { + before := time.Now().UTC() + session := NewAuthenticatedSession("user-id", "passkey", time.Time{}, time.Time{}) + after := time.Now().UTC() + + require.False(t, session.Claims.AuthTime.IsZero()) + require.False(t, session.Claims.RequestedAt.IsZero()) + require.False(t, session.Claims.AuthTime.Before(before)) + require.False(t, session.Claims.AuthTime.After(after)) + require.Equal(t, session.Claims.AuthTime, session.Claims.RequestedAt) +} diff --git a/backend/internal/oidc/signer.go b/backend/internal/oidc/signer.go new file mode 100644 index 00000000..3d4d0ce6 --- /dev/null +++ b/backend/internal/oidc/signer.go @@ -0,0 +1,71 @@ +package oidc + +import ( + "context" + "errors" + + jose "github.com/go-jose/go-jose/v4" + fositejwt "github.com/ory/fosite/token/jwt" +) + +// SigningKeyFromSigner wraps the raw signing key in a *jose.JSONWebKey that carries the +// key's algorithm. +// +// fosite's DefaultSigner picks the JOSE algorithm from the Go key TYPE alone: every +// *rsa.PrivateKey is signed with RS256 and every *ecdsa.PrivateKey with ES256, while an +// ed25519.PrivateKey is not supported at all. +func SigningKeyFromSigner(signer TokenSigner) (*jose.JSONWebKey, error) { + rawKey := signer.GetPrivateKey() + if rawKey == nil { + return nil, errors.New("signing key is not available") + } + + alg, err := signer.GetKeyAlg() + if err != nil { + return nil, err + } + + signingKey := &jose.JSONWebKey{ + Key: rawKey, + Algorithm: alg.String(), + } + + if keyID, ok := signer.GetKeyID(); ok { + signingKey.KeyID = keyID + } + + return signingKey, nil +} + +type jwtSigner struct { + *fositejwt.DefaultSigner +} + +func newJWTSigner(keyGetter fositejwt.GetPrivateKeyFunc) *jwtSigner { + return &jwtSigner{ + DefaultSigner: &fositejwt.DefaultSigner{GetPrivateKey: keyGetter}, + } +} + +func (s *jwtSigner) Validate(ctx context.Context, token string) (string, error) { + if _, err := s.Decode(ctx, token); err != nil { + return "", err + } + return s.GetSignature(ctx, token) +} + +func (s *jwtSigner) Decode(ctx context.Context, token string) (*fositejwt.Token, error) { + key, err := s.GetPrivateKey(ctx) + if err != nil { + return nil, err + } + + verificationKey := key + if jsonWebKey, ok := key.(*jose.JSONWebKey); ok { + verificationKey = new(jsonWebKey.Public()) + } + + return fositejwt.Parse(token, func(*fositejwt.Token) (any, error) { + return verificationKey, nil + }) +} diff --git a/backend/internal/oidc/store.go b/backend/internal/oidc/store.go new file mode 100644 index 00000000..b6991043 --- /dev/null +++ b/backend/internal/oidc/store.go @@ -0,0 +1,818 @@ +package oidc + +import ( + "context" + "encoding/json" + "errors" + "net/url" + "time" + + "github.com/ory/fosite" + fositeoauth2 "github.com/ory/fosite/handler/oauth2" + "github.com/ory/fosite/handler/openid" + "github.com/ory/fosite/handler/pkce" + "github.com/ory/fosite/handler/rfc8628" + fositestorage "github.com/ory/fosite/storage" + "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + sessionKindAuthorizeCode = "authorize_code" + sessionKindAccessToken = "access_token" + sessionKindRefreshToken = "refresh_token" + sessionKindPKCE = "pkce" + sessionKindOpenID = "openid" + sessionKindPAR = "par" + sessionKindDeviceCode = "device_code" + sessionKindUserCode = "user_code" +) + +var ( + _ fosite.Storage = (*Store)(nil) + _ fosite.PARStorage = (*Store)(nil) + _ fositeoauth2.CoreStorage = (*Store)(nil) + _ fositeoauth2.TokenRevocationStorage = (*Store)(nil) + _ rfc8628.RFC8628CoreStorage = (*Store)(nil) + _ openid.OpenIDConnectRequestStorage = (*Store)(nil) + _ pkce.PKCERequestStorage = (*Store)(nil) + _ fositestorage.Transactional = (*Store)(nil) +) + +// NewStore creates the fosite storage. Exported for packages that need to seed or +// revoke sessions (e.g. the e2e test service). +func NewStore(db *gorm.DB) *Store { + return &Store{db: db} +} + +type Store struct { + db *gorm.DB +} + +type storedRequester struct { + Authorize bool `json:"authorize,omitempty"` + + ID string `json:"id"` + RequestedAt time.Time `json:"requested_at"` + ClientID string `json:"client_id"` + RequestedScope fosite.Arguments `json:"requested_scope,omitempty"` + GrantedScope fosite.Arguments `json:"granted_scope,omitempty"` + Form url.Values `json:"form,omitempty"` + Session *Session `json:"session,omitempty"` + RequestedAudience fosite.Arguments `json:"requested_audience,omitempty"` + GrantedAudience fosite.Arguments `json:"granted_audience,omitempty"` + Device bool `json:"device,omitempty"` + UserCodeState fosite.UserCodeState `json:"user_code_state,omitempty"` + + ResponseTypes fosite.Arguments `json:"response_types,omitempty"` + RedirectURI string `json:"redirect_uri,omitempty"` + State string `json:"state,omitempty"` + HandledResponseTypes fosite.Arguments `json:"handled_response_types,omitempty"` + ResponseMode fosite.ResponseModeType `json:"response_mode,omitempty"` + DefaultResponseMode fosite.ResponseModeType `json:"default_response_mode,omitempty"` +} + +// Satisfies fosite.Storage + +func (s *Store) GetClient(ctx context.Context, id string) (fosite.Client, error) { + var client model.OidcClient + err := s.dbFor(ctx). + Preload("AllowedUserGroups"). + First(&client, "id = ?", id). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fosite.ErrNotFound + } + if err != nil { + return nil, err + } + + return Client{OidcClient: client}, nil +} + +func (s *Store) ClientAssertionJWTValid(ctx context.Context, jti string) error { + var count int64 + err := s.dbFor(ctx). + Model(&clientAssertionJTI{}). + Where("jti = ? AND expires_at > ?", jti, datatype.DateTime(time.Now())). + Count(&count). + Error + if err != nil { + return err + } + if count > 0 { + return fosite.ErrJTIKnown + } + return nil +} + +func (s *Store) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error { + err := s.dbFor(ctx).Create(&clientAssertionJTI{ + JTI: jti, + ExpiresAt: datatype.DateTime(exp), + }).Error + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fosite.ErrJTIKnown + } + return err +} + +// Satisfies fositeoauth2.CoreStorage + +func (s *Store) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) error { + return s.upsertSession(ctx, sessionKindAuthorizeCode, code, request, "", true, fosite.AuthorizeCode) +} + +func (s *Store) GetAuthorizeCodeSession(ctx context.Context, code string, _ fosite.Session) (fosite.Requester, error) { + request, active, err := s.getRequesterSession(ctx, sessionKindAuthorizeCode, code) + if err != nil { + return nil, err + } + if !active { + return request, fosite.ErrInvalidatedAuthorizeCode + } + return request, nil +} + +func (s *Store) InvalidateAuthorizeCodeSession(ctx context.Context, code string) error { + return s.deactivateSession(ctx, sessionKindAuthorizeCode, code) +} + +func (s *Store) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) error { + return s.upsertSession(ctx, sessionKindAccessToken, signature, request, "", true, fosite.AccessToken) +} + +func (s *Store) GetAccessTokenSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) { + request, _, err := s.getRequesterSession(ctx, sessionKindAccessToken, signature) + return request, err +} + +func (s *Store) DeleteAccessTokenSession(ctx context.Context, signature string) error { + return s.deleteSession(ctx, sessionKindAccessToken, signature) +} + +func (s *Store) CreateRefreshTokenSession(ctx context.Context, signature string, accessSignature string, request fosite.Requester) error { + return s.upsertSession(ctx, sessionKindRefreshToken, signature, request, accessSignature, true, fosite.RefreshToken) +} + +func (s *Store) GetRefreshTokenSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) { + request, active, err := s.getRequesterSession(ctx, sessionKindRefreshToken, signature) + if err != nil { + return nil, err + } + if !active { + return request, fosite.ErrInactiveToken + } + return request, nil +} + +func (s *Store) DeleteRefreshTokenSession(ctx context.Context, signature string) error { + return s.deleteSession(ctx, sessionKindRefreshToken, signature) +} + +func (s *Store) RotateRefreshToken(ctx context.Context, requestID string, refreshTokenSignature string) error { + if err := s.deactivateSession(ctx, sessionKindRefreshToken, refreshTokenSignature); err != nil { + return err + } + return s.RevokeAccessToken(ctx, requestID) +} + +// Satisfies fositeoauth2.TokenRevocationStorage + +func (s *Store) RevokeRefreshToken(ctx context.Context, requestID string) error { + return s.dbFor(ctx). + Model(&OAuth2Session{}). + Where("kind = ? AND request_id = ?", sessionKindRefreshToken, requestID). + Update("active", false). + Error +} + +func (s *Store) RevokeAccessToken(ctx context.Context, requestID string) error { + return s.dbFor(ctx). + Where("kind = ? AND request_id = ?", sessionKindAccessToken, requestID). + Delete(&OAuth2Session{}). + Error +} + +func (s *Store) RevokeSessionsByIDTokenHint(ctx context.Context, userID, clientID, idTokenJTI string) error { + _, jtiMatches, err := s.findUserClientRequestIDs(ctx, userID, clientID, idTokenJTI) + if err != nil { + return err + } + + return s.revokeRequestIDs(ctx, jtiMatches) +} + +func RevokeUserClientSessions(ctx context.Context, db *gorm.DB, userID, clientID string) error { + s := NewStore(db) + requestIDs, _, err := s.findUserClientRequestIDs(ctx, userID, clientID, "") + if err != nil { + return err + } + return s.revokeRequestIDs(ctx, requestIDs) +} + +func (s *Store) findUserClientRequestIDs(ctx context.Context, userID, clientID, idTokenJTI string) (candidates []string, jtiMatches []string, err error) { + var sessions []OAuth2Session + err = s.dbFor(ctx). + Where("kind = ? AND active = ?", sessionKindRefreshToken, true). + Find(&sessions). + Error + if err != nil { + return nil, nil, err + } + + candidateRequestIDs := map[string]struct{}{} + matchingRequestIDs := map[string]struct{}{} + for _, session := range sessions { + requester, err := s.decodeRequester(ctx, session.RequestData) + if err != nil { + return nil, nil, err + } + requestSession := requester.GetSession() + if requestSession == nil || requester.GetClient().GetID() != clientID || requestSession.GetSubject() != userID { + continue + } + + // Add all sessions that match the user and client + candidateRequestIDs[session.RequestID] = struct{}{} + // Only add sessions that also match the ID token hint JTI + if storedSession, ok := requestSession.(*Session); ok && idTokenJTI != "" && storedSession.IDTokenClaims().JTI == idTokenJTI { + matchingRequestIDs[session.RequestID] = struct{}{} + } + } + + return mapKeys(candidateRequestIDs), mapKeys(matchingRequestIDs), nil +} + +func (s *Store) revokeRequestIDs(ctx context.Context, requestIDs []string) error { + if len(requestIDs) == 0 { + return nil + } + + if err := s.dbFor(ctx). + Model(&OAuth2Session{}). + Where("kind = ? AND request_id IN ?", sessionKindRefreshToken, requestIDs). + Update("active", false). + Error; err != nil { + return err + } + + return s.dbFor(ctx). + Where("kind = ? AND request_id IN ?", sessionKindAccessToken, requestIDs). + Delete(&OAuth2Session{}). + Error +} + +func mapKeys(m map[string]struct{}) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + return keys +} + +// Satisfies pkce.PKCERequestStorage + +func (s *Store) CreatePKCERequestSession(ctx context.Context, signature string, requester fosite.Requester) error { + // PKCE sessions share the authorize code lifespan so abandoned ones expire and get cleaned up + return s.upsertSession(ctx, sessionKindPKCE, signature, requester, "", true, fosite.AuthorizeCode) +} + +func (s *Store) GetPKCERequestSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) { + request, _, err := s.getRequesterSession(ctx, sessionKindPKCE, signature) + return request, err +} + +func (s *Store) DeletePKCERequestSession(ctx context.Context, signature string) error { + return s.deleteSession(ctx, sessionKindPKCE, signature) +} + +// Satisfies openid.OpenIDConnectRequestStorage + +func (s *Store) CreateOpenIDConnectSession(ctx context.Context, authorizeCode string, requester fosite.Requester) error { + return s.upsertSession(ctx, sessionKindOpenID, authorizeCode, requester, "", true, fosite.AuthorizeCode) +} + +func (s *Store) GetOpenIDConnectSession(ctx context.Context, authorizeCode string, _ fosite.Requester) (fosite.Requester, error) { + request, _, err := s.getRequesterSession(ctx, sessionKindOpenID, authorizeCode) + if errors.Is(err, fosite.ErrNotFound) { + return nil, openid.ErrNoSessionFound + } + return request, err +} + +func (s *Store) DeleteOpenIDConnectSession(ctx context.Context, authorizeCode string) error { + return s.deleteSession(ctx, sessionKindOpenID, authorizeCode) +} + +// Satisfies fosite.PARStorage + +func (s *Store) CreatePARSession(ctx context.Context, requestURI string, request fosite.AuthorizeRequester) error { + return s.upsertAuthorizeSession(ctx, sessionKindPAR, requestURI, request, true, fosite.PushedAuthorizeRequestContext) +} + +func (s *Store) GetPARSession(ctx context.Context, requestURI string) (fosite.AuthorizeRequester, error) { + session, err := s.getSession(ctx, sessionKindPAR, requestURI) + if err != nil { + return nil, err + } + if !session.Active || session.ExpiresAt == nil || session.ExpiresAt.ToTime().Before(time.Now()) { + return nil, fosite.ErrNotFound + } + + result := s.dbFor(ctx). + Model(&OAuth2Session{}). + Where("kind = ? AND key = ? AND active = ?", sessionKindPAR, requestURI, true). + Update("active", false) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, fosite.ErrNotFound + } + + return s.decodeAuthorizeRequester(ctx, session.RequestData) +} + +func (s *Store) DeletePARSession(ctx context.Context, requestURI string) error { + return s.deleteSession(ctx, sessionKindPAR, requestURI) +} + +// Satisfies rfc8628.RFC8628CoreStorage + +func (s *Store) CreateDeviceAuthSession(ctx context.Context, deviceCodeSignature, userCodeSignature string, request fosite.DeviceRequester) error { + requestData, err := s.encodeDeviceRequester(request) + if err != nil { + return err + } + + if _, err := s.getSession(ctx, sessionKindUserCode, userCodeSignature); err == nil { + return fosite.ErrExistingUserCodeSignature + } else if !errors.Is(err, fosite.ErrNotFound) { + return err + } + + expDeviceCode := expiresAt(request.GetSession(), fosite.DeviceCode) + expUserCode := expiresAt(request.GetSession(), fosite.UserCode) + if err := s.storeSession(ctx, sessionKindDeviceCode, deviceCodeSignature, request.GetID(), "", true, requestData, expDeviceCode); err != nil { + return err + } + return s.storeSession(ctx, sessionKindUserCode, userCodeSignature, request.GetID(), "", true, requestData, expUserCode) +} + +func (s *Store) GetDeviceCodeSession(ctx context.Context, signature string, _ fosite.Session) (fosite.DeviceRequester, error) { + request, active, err := s.getDeviceRequesterSession(ctx, sessionKindDeviceCode, signature) + if err != nil { + return nil, err + } + if !active { + return request, fosite.ErrInvalidatedDeviceCode + } + return request, nil +} + +func (s *Store) InvalidateDeviceCodeSession(ctx context.Context, signature string) error { + session, err := s.getSession(ctx, sessionKindDeviceCode, signature) + if err != nil { + return err + } + + // Only flip rows that are still active so two concurrent token requests for the same + // device code cannot both pass the single-use check and each mint a token set. + result := s.dbFor(ctx). + Model(&OAuth2Session{}). + Where("kind IN ? AND request_id = ? AND active = ?", []string{sessionKindDeviceCode, sessionKindUserCode}, session.RequestID, true). + Update("active", false) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fosite.ErrNotFound + } + return nil +} + +func (s *Store) GetDeviceCodeSessionByUserCodeSignature(ctx context.Context, signature string) (fosite.DeviceRequester, error) { + request, active, err := s.getDeviceRequesterSession(ctx, sessionKindUserCode, signature) + if err != nil { + return nil, err + } + if !active { + return nil, fosite.ErrNotFound + } + return request, nil +} + +func (s *Store) AcceptDeviceCodeSessionByUserCodeSignature(ctx context.Context, signature string, request fosite.DeviceRequester) (string, error) { + userCodeSession, err := s.getSession(ctx, sessionKindUserCode, signature) + if err != nil { + return "", err + } + request.SetUserCodeState(fosite.UserCodeAccepted) + + requestData, err := s.encodeDeviceRequester(request) + if err != nil { + return "", err + } + + deviceCodeSession, err := s.getSessionByRequestID(ctx, sessionKindDeviceCode, userCodeSession.RequestID) + if err != nil { + return "", err + } + + err = s.dbFor(ctx). + Model(&OAuth2Session{}). + Where("kind IN ? AND request_id = ?", []string{sessionKindDeviceCode, sessionKindUserCode}, userCodeSession.RequestID). + Updates(map[string]any{ + "request_data": requestData, + }). + Error + if err != nil { + return "", err + } + + return deviceCodeSession.Key, nil +} + +// Satisfies fositestorage.Transactional + +func (s *Store) BeginTX(ctx context.Context) (context.Context, error) { + tx := s.db.WithContext(ctx).Begin() + if tx.Error != nil { + return ctx, tx.Error + } + return contextWithTx(ctx, tx), nil +} + +func (s *Store) Commit(ctx context.Context) error { + tx, ok := ctx.Value(txContextKey{}).(*gorm.DB) + if !ok { + return nil + } + return tx.Commit().Error +} + +func (s *Store) Rollback(ctx context.Context) error { + tx, ok := ctx.Value(txContextKey{}).(*gorm.DB) + if !ok { + return nil + } + return tx.Rollback().Error +} + +func (s *Store) upsertSession(ctx context.Context, kind string, key string, requester fosite.Requester, accessTokenSignature string, active bool, expiresAtKey fosite.TokenType) error { + requestData, err := s.encodeRequester(requester) + if err != nil { + return err + } + + return s.storeSession(ctx, kind, key, requester.GetID(), accessTokenSignature, active, requestData, expiresAt(requester.GetSession(), expiresAtKey)) +} + +func (s *Store) upsertAuthorizeSession(ctx context.Context, kind string, key string, requester fosite.AuthorizeRequester, active bool, expiresAtKey fosite.TokenType) error { + requestData, err := s.encodeAuthorizeRequester(requester) + if err != nil { + return err + } + + return s.storeSession(ctx, kind, key, requester.GetID(), "", active, requestData, expiresAt(requester.GetSession(), expiresAtKey)) +} + +func (s *Store) storeSession(ctx context.Context, kind string, key string, requestID string, accessTokenSignature string, active bool, requestData string, exp *datatype.DateTime) error { + session := OAuth2Session{ + Kind: kind, + Key: key, + RequestID: requestID, + AccessTokenSignature: accessTokenSignature, + Active: active, + RequestData: requestData, + ExpiresAt: exp, + } + + return s.dbFor(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "kind"}, {Name: "key"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "request_id", + "access_token_signature", + "active", + "request_data", + "expires_at", + }), + }). + Create(&session). + Error +} + +func (s *Store) getRequesterSession(ctx context.Context, kind string, key string) (fosite.Requester, bool, error) { + session, err := s.getSession(ctx, kind, key) + if err != nil { + return nil, false, err + } + + requester, err := s.decodeRequester(ctx, session.RequestData) + if err != nil { + return nil, false, err + } + + return requester, session.Active, nil +} + +func (s *Store) getDeviceRequesterSession(ctx context.Context, kind string, key string) (fosite.DeviceRequester, bool, error) { + session, err := s.getSession(ctx, kind, key) + if err != nil { + return nil, false, err + } + + requester, err := s.decodeDeviceRequester(ctx, session.RequestData) + if err != nil { + return nil, false, err + } + + return requester, session.Active, nil +} + +func (s *Store) getSession(ctx context.Context, kind string, key string) (session OAuth2Session, err error) { + err = s.dbFor(ctx). + Where("kind = ? AND key = ?", kind, key). + First(&session). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return session, fosite.ErrNotFound + } + if err != nil { + return session, err + } + + return session, nil +} + +func (s *Store) getSessionByRequestID(ctx context.Context, kind string, requestID string) (session OAuth2Session, err error) { + err = s.dbFor(ctx). + Where("kind = ? AND request_id = ?", kind, requestID). + First(&session). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return session, fosite.ErrNotFound + } + if err != nil { + return session, err + } + + return session, nil +} + +func (s *Store) deleteSession(ctx context.Context, kind string, key string) error { + return s.dbFor(ctx). + Where("kind = ? AND key = ?", kind, key). + Delete(&OAuth2Session{}). + Error +} + +func (s *Store) deactivateSession(ctx context.Context, kind string, key string) error { + result := s.dbFor(ctx). + Model(&OAuth2Session{}). + Where("kind = ? AND key = ? AND active = ?", kind, key, true). + Update("active", false) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fosite.ErrNotFound + } + return nil +} + +func (s *Store) encodeRequester(requester fosite.Requester) (string, error) { + stored, err := s.storedRequesterFromRequester(requester) + if err != nil { + return "", err + } + data, err := json.Marshal(stored) + if err != nil { + return "", err + } + return string(data), nil +} + +func (s *Store) encodeAuthorizeRequester(requester fosite.AuthorizeRequester) (string, error) { + stored, err := s.storedRequesterFromRequester(requester) + if err != nil { + return "", err + } + + stored.Authorize = true + stored.ResponseTypes = cloneArguments(requester.GetResponseTypes()) + if redirectURI := requester.GetRedirectURI(); redirectURI != nil { + stored.RedirectURI = redirectURI.String() + } + stored.State = requester.GetState() + stored.ResponseMode = requester.GetResponseMode() + stored.DefaultResponseMode = requester.GetDefaultResponseMode() + + if ar, ok := requester.(*fosite.AuthorizeRequest); ok { + stored.HandledResponseTypes = cloneArguments(ar.HandledResponseTypes) + } + + data, err := json.Marshal(stored) + if err != nil { + return "", err + } + return string(data), nil +} + +func (s *Store) encodeDeviceRequester(requester fosite.DeviceRequester) (string, error) { + stored, err := s.storedRequesterFromRequester(requester) + if err != nil { + return "", err + } + + stored.Device = true + stored.UserCodeState = requester.GetUserCodeState() + + data, err := json.Marshal(stored) + if err != nil { + return "", err + } + return string(data), nil +} + +func (s *Store) storedRequesterFromRequester(requester fosite.Requester) (storedRequester, error) { + if requester == nil { + return storedRequester{}, fosite.ErrServerError.WithHint("requester must not be nil") + } + + return storedRequester{ + ID: requester.GetID(), + RequestedAt: requester.GetRequestedAt(), + ClientID: requester.GetClient().GetID(), + RequestedScope: cloneArguments(requester.GetRequestedScopes()), + GrantedScope: cloneArguments(requester.GetGrantedScopes()), + Form: sanitizeStoredForm(requester.GetRequestForm()), + Session: cloneSession(requester.GetSession()), + RequestedAudience: cloneArguments(requester.GetRequestedAudience()), + GrantedAudience: cloneArguments(requester.GetGrantedAudience()), + }, nil +} + +func (s *Store) decodeRequester(ctx context.Context, data string) (fosite.Requester, error) { + var stored storedRequester + if err := json.Unmarshal([]byte(data), &stored); err != nil { + return nil, err + } + if stored.Authorize { + return s.requesterFromStoredAuthorize(ctx, stored) + } + return s.requesterFromStored(ctx, stored) +} + +func (s *Store) decodeAuthorizeRequester(ctx context.Context, data string) (fosite.AuthorizeRequester, error) { + var stored storedRequester + if err := json.Unmarshal([]byte(data), &stored); err != nil { + return nil, err + } + stored.Authorize = true + return s.requesterFromStoredAuthorize(ctx, stored) +} + +func (s *Store) decodeDeviceRequester(ctx context.Context, data string) (fosite.DeviceRequester, error) { + var stored storedRequester + if err := json.Unmarshal([]byte(data), &stored); err != nil { + return nil, err + } + stored.Device = true + return s.requesterFromStoredDevice(ctx, stored) +} + +func (s *Store) requesterFromStored(ctx context.Context, stored storedRequester) (fosite.Requester, error) { + client, err := s.GetClient(ctx, stored.ClientID) + if err != nil { + return nil, err + } + + request := fosite.NewRequest() + request.ID = stored.ID + request.RequestedAt = stored.RequestedAt + request.Client = client + request.RequestedScope = cloneArguments(stored.RequestedScope) + request.GrantedScope = cloneArguments(stored.GrantedScope) + request.Form = cloneValues(stored.Form) + request.Session = stored.Session + request.RequestedAudience = cloneArguments(stored.RequestedAudience) + request.GrantedAudience = cloneArguments(stored.GrantedAudience) + return request, nil +} + +func (s *Store) requesterFromStoredDevice(ctx context.Context, stored storedRequester) (fosite.DeviceRequester, error) { + requester, err := s.requesterFromStored(ctx, stored) + if err != nil { + return nil, err + } + + base := requester.(*fosite.Request) + request := fosite.NewDeviceRequest() + request.Request = *base + request.UserCodeState = stored.UserCodeState + return request, nil +} + +func (s *Store) requesterFromStoredAuthorize(ctx context.Context, stored storedRequester) (fosite.AuthorizeRequester, error) { + requester, err := s.requesterFromStored(ctx, stored) + if err != nil { + return nil, err + } + + base := requester.(*fosite.Request) + request := fosite.NewAuthorizeRequest() + request.Request = *base + request.ResponseTypes = cloneArguments(stored.ResponseTypes) + request.State = stored.State + request.HandledResponseTypes = cloneArguments(stored.HandledResponseTypes) + request.ResponseMode = stored.ResponseMode + request.DefaultResponseMode = stored.DefaultResponseMode + + if stored.RedirectURI != "" { + redirectURI, err := url.Parse(stored.RedirectURI) + if err != nil { + return nil, err + } + request.RedirectURI = redirectURI + } + + return request, nil +} + +func cloneSession(session fosite.Session) *Session { + if session == nil { + return nil + } + + if s, ok := session.(*Session); ok { + cloned := s.Clone() + if typed, ok := cloned.(*Session); ok { + return typed + } + } + + cloned := NewEmptySession() + cloned.Subject = session.GetSubject() + for _, tokenType := range []fosite.TokenType{ + fosite.AccessToken, + fosite.RefreshToken, + fosite.AuthorizeCode, + fosite.IDToken, + fosite.PushedAuthorizeRequestContext, + fosite.DeviceCode, + fosite.UserCode, + } { + if exp := session.GetExpiresAt(tokenType); !exp.IsZero() { + cloned.SetExpiresAt(tokenType, exp) + } + } + return cloned +} + +func cloneArguments(arguments fosite.Arguments) fosite.Arguments { + if len(arguments) == 0 { + return fosite.Arguments{} + } + cloned := make(fosite.Arguments, len(arguments)) + copy(cloned, arguments) + return cloned +} + +func sanitizeStoredForm(values url.Values) url.Values { + cloned := cloneValues(values) + cloned.Del("client_secret") + cloned.Del("client_assertion") + return cloned +} + +func cloneValues(values url.Values) url.Values { + if len(values) == 0 { + return url.Values{} + } + cloned := make(url.Values, len(values)) + for key, value := range values { + cloned[key] = append([]string(nil), value...) + } + return cloned +} + +func expiresAt(session fosite.Session, tokenType fosite.TokenType) *datatype.DateTime { + if session == nil || tokenType == "" { + return nil + } + exp := session.GetExpiresAt(tokenType) + if exp.IsZero() { + return nil + } + return new(datatype.DateTime(exp)) +} + +func (s *Store) dbFor(ctx context.Context) *gorm.DB { + return dbFromContext(ctx, s.db) +} diff --git a/backend/internal/oidc/store_test.go b/backend/internal/oidc/store_test.go new file mode 100644 index 00000000..143db987 --- /dev/null +++ b/backend/internal/oidc/store_test.go @@ -0,0 +1,219 @@ +package oidc + +import ( + "net/url" + "testing" + "time" + + "github.com/ory/fosite" + fositejwt "github.com/ory/fosite/token/jwt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +// TestStoreGetPARSessionIsSingleUse verifies that a pushed-authorization request_uri can be +// consumed only once (RFC 9126 §4). The consumption (read + invalidate) must be atomic so +// two concurrent /authorize requests cannot both resolve the same request_uri; the store +// enforces this with a conditional UPDATE, so the second GetPARSession returns ErrNotFound. +func TestStoreGetPARSessionIsSingleUse(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + store := NewStore(db) + + const ( + clientID = "par-client" + requestURI = "urn:ietf:params:oauth:request_uri:single-use-test" + ) + require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "PAR Client"}).Error) + + session := NewEmptySession() + session.SetExpiresAt(fosite.PushedAuthorizeRequestContext, time.Now().UTC().Add(time.Minute)) + redirectURI, err := url.Parse("https://rp.example.com/callback") + require.NoError(t, err) + + request := &fosite.AuthorizeRequest{ + Request: fosite.Request{ + ID: "par-request", + RequestedAt: time.Now().UTC(), + Client: Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}, + RequestedScope: fosite.Arguments{"openid"}, + Form: url.Values{}, + Session: session, + }, + RedirectURI: redirectURI, + ResponseTypes: fosite.Arguments{"code"}, + State: "state-value", + } + require.NoError(t, store.CreatePARSession(t.Context(), requestURI, request)) + + // First consumption resolves the stored request. + first, err := store.GetPARSession(t.Context(), requestURI) + require.NoError(t, err) + require.NotNil(t, first) + require.Equal(t, clientID, first.GetClient().GetID()) + + // A second consumption is rejected: the request_uri is single-use. + _, err = store.GetPARSession(t.Context(), requestURI) + require.ErrorIs(t, err, fosite.ErrNotFound) +} + +// TestStoreInvalidateAuthorizeCodeSessionIsAtomic verifies that an authorization code can +// be invalidated only once (RFC 6749 §4.1.2). Invalidation is a conditional UPDATE guarded +// on active = true, so a second concurrent token request that already read the code as +// active fails closed (ErrNotFound) instead of minting a second token set from one code. +func TestStoreInvalidateAuthorizeCodeSessionIsAtomic(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + store := NewStore(db) + + const ( + clientID = "code-client" + code = "auth-code-single-use" + ) + require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Code Client"}).Error) + + session := NewEmptySession() + session.SetExpiresAt(fosite.AuthorizeCode, time.Now().UTC().Add(time.Minute)) + request := &fosite.Request{ + ID: "code-request", + RequestedAt: time.Now().UTC(), + Client: Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}, + RequestedScope: fosite.Arguments{"openid"}, + Form: url.Values{}, + Session: session, + } + require.NoError(t, store.CreateAuthorizeCodeSession(t.Context(), code, request)) + + // First invalidation wins. + require.NoError(t, store.InvalidateAuthorizeCodeSession(t.Context(), code)) + + // A second invalidation of the now-inactive code fails closed: a racing token request + // cannot proceed to issue a second set of tokens from the same code. + require.ErrorIs(t, store.InvalidateAuthorizeCodeSession(t.Context(), code), fosite.ErrNotFound) + + // Reads of the consumed code report it as invalidated so fosite triggers reuse handling. + _, err := store.GetAuthorizeCodeSession(t.Context(), code, nil) + require.ErrorIs(t, err, fosite.ErrInvalidatedAuthorizeCode) +} + +func TestStoreRevokeSessionsByIDTokenHintRevokesMatchingFositeSessions(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + store := NewStore(db) + + const ( + userID = "test-user-123" + clientID = "test-client-456" + otherClientID = "other-client-789" + idTokenJTI = "matching-id-token-jti" + otherIDTokenJTI = "other-id-token-jti" + ) + + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: otherClientID}, + Name: "Other Client", + }).Error) + + require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "matching-refresh", "matching-access", newTestRequester("matching-request", clientID, userID, idTokenJTI))) + require.NoError(t, store.CreateAccessTokenSession(t.Context(), "matching-access", newTestRequester("matching-request", clientID, userID, idTokenJTI))) + require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "same-client-different-session", "same-client-different-access", newTestRequester("same-client-request", clientID, userID, otherIDTokenJTI))) + require.NoError(t, store.CreateAccessTokenSession(t.Context(), "same-client-different-access", newTestRequester("same-client-request", clientID, userID, otherIDTokenJTI))) + require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "other-client-same-jti", "other-client-access", newTestRequester("other-client-request", otherClientID, userID, idTokenJTI))) + require.NoError(t, store.CreateAccessTokenSession(t.Context(), "other-client-access", newTestRequester("other-client-request", otherClientID, userID, idTokenJTI))) + + require.NoError(t, store.RevokeSessionsByIDTokenHint(t.Context(), userID, clientID, idTokenJTI)) + + var sessions []OAuth2Session + require.NoError(t, db.Order("key").Find(&sessions).Error) + + activeRefreshByKey := map[string]bool{} + accessKeys := map[string]bool{} + for _, session := range sessions { + switch session.Kind { + case sessionKindRefreshToken: + activeRefreshByKey[session.Key] = session.Active + case sessionKindAccessToken: + accessKeys[session.Key] = true + } + } + + assert.False(t, activeRefreshByKey["matching-refresh"]) + assert.True(t, activeRefreshByKey["same-client-different-session"]) + assert.True(t, activeRefreshByKey["other-client-same-jti"]) + assert.False(t, accessKeys["matching-access"]) + assert.True(t, accessKeys["same-client-different-access"]) + assert.True(t, accessKeys["other-client-access"]) +} + +func TestStoreRevokeSessionsByIDTokenHintSkipsSessionsWithoutMatchingJTI(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + store := NewStore(db) + + const ( + userID = "test-user-123" + clientID = "test-client-456" + otherClientID = "other-client-789" + ) + + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + }).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: otherClientID}, + Name: "Other Client", + }).Error) + + require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "same-client-refresh", "same-client-access", newTestRequester("same-client-request", clientID, userID, ""))) + require.NoError(t, store.CreateAccessTokenSession(t.Context(), "same-client-access", newTestRequester("same-client-request", clientID, userID, ""))) + require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "other-client-refresh", "other-client-access", newTestRequester("other-client-request", otherClientID, userID, ""))) + require.NoError(t, store.CreateAccessTokenSession(t.Context(), "other-client-access", newTestRequester("other-client-request", otherClientID, userID, ""))) + + require.NoError(t, store.RevokeSessionsByIDTokenHint(t.Context(), userID, clientID, "missing-from-stored-session")) + + var sessions []OAuth2Session + require.NoError(t, db.Order("key").Find(&sessions).Error) + + activeRefreshByKey := map[string]bool{} + accessKeys := map[string]bool{} + for _, session := range sessions { + switch session.Kind { + case sessionKindRefreshToken: + activeRefreshByKey[session.Key] = session.Active + case sessionKindAccessToken: + accessKeys[session.Key] = true + } + } + + assert.True(t, activeRefreshByKey["same-client-refresh"]) + assert.True(t, activeRefreshByKey["other-client-refresh"]) + assert.True(t, accessKeys["same-client-access"]) + assert.True(t, accessKeys["other-client-access"]) +} + +func newTestRequester(requestID, clientID, subject, idTokenJTI string) fosite.Requester { + session := NewEmptySession() + session.Subject = subject + session.Claims = &fositejwt.IDTokenClaims{ + JTI: idTokenJTI, + RequestedAt: time.Now().UTC(), + Extra: map[string]any{}, + } + + return &fosite.Request{ + ID: requestID, + RequestedAt: time.Now().UTC(), + Client: Client{ + OidcClient: model.OidcClient{ + Base: model.Base{ID: clientID}, + }, + }, + GrantedScope: fosite.Arguments{"openid"}, + Form: map[string][]string{}, + Session: session, + } +} diff --git a/backend/internal/oidc/token_handler.go b/backend/internal/oidc/token_handler.go new file mode 100644 index 00000000..9fddd82e --- /dev/null +++ b/backend/internal/oidc/token_handler.go @@ -0,0 +1,77 @@ +package oidc + +import ( + "log/slog" + + "github.com/gin-gonic/gin" + "github.com/ory/fosite" +) + +type tokenHandler struct { + provider fosite.OAuth2Provider + claimsService *ClaimsService +} + +func newTokenHandler(provider fosite.OAuth2Provider, claimsService *ClaimsService) *tokenHandler { + return &tokenHandler{ + provider: provider, + claimsService: claimsService, + } +} + +func (h *tokenHandler) token(c *gin.Context) { + ctx := c.Request.Context() + + // For grants that continue an existing session (authorization code, refresh token), + // fosite restores the stored session over this empty one. + session := NewEmptySession() + + accessRequest, err := h.provider.NewAccessRequest(ctx, c.Request, session) + if err != nil { + slog.ErrorContext(ctx, "Failed to create access request", "error", err) + h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err) + return + } + + requestSession, ok := accessRequest.GetSession().(*Session) + if !ok { + slog.ErrorContext(ctx, "Failed to handle token request: session must be *oidc.Session") + h.provider.WriteAccessError(ctx, c.Writer, accessRequest, fosite.ErrServerError) + return + } + + if client, ok := accessRequest.GetClient().(Client); ok { + // Re-validate the resource owner on every user-bound grant. + if err := h.claimsService.ValidateUserAccess(ctx, requestSession.Subject, client); err != nil { + slog.WarnContext(ctx, "Rejected token request: user no longer allowed to access client", "error", err.Error()) + h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err) + return + } + + // Bind every issued JWT access token to the requesting client so it always carries an aud claim. + accessRequest.GrantAudience(client.GetID()) + } + + if err := h.claimsService.applyIDTokenClaims(ctx, requestSession, accessRequest.GetGrantedScopes()); err != nil { + slog.ErrorContext(ctx, "Failed to apply ID token claims", "error", err) + h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err) + return + } + + // The client credentials grant has no resource owner, so no subject is ever set. Assign a + // stable synthetic subject so the issued JWT access token still carries a subclaim. + if requestSession.Subject == "" { + if client, ok := accessRequest.GetClient().(Client); ok && accessRequest.GetGrantTypes().Has(string(fosite.GrantTypeClientCredentials)) { + requestSession.Subject = "client-" + client.GetID() + } + } + + response, err := h.provider.NewAccessResponse(ctx, accessRequest) + if err != nil { + slog.ErrorContext(ctx, "Failed to create access response", "error", err) + h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err) + return + } + + h.provider.WriteAccessResponse(ctx, c.Writer, accessRequest, response) +} diff --git a/backend/internal/oidc/token_handler_test.go b/backend/internal/oidc/token_handler_test.go new file mode 100644 index 00000000..9355e0b1 --- /dev/null +++ b/backend/internal/oidc/token_handler_test.go @@ -0,0 +1,243 @@ +package oidc + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/ory/fosite" + "github.com/ory/fosite/compose" + fositejwt "github.com/ory/fosite/token/jwt" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +// TestTokenHandlerClientCredentialsGrant guards two regressions in one flow: +// - the resource-owner re-validation must be skipped for client_credentials (which has +// no user / empty subject), otherwise the grant is rejected with invalid_grant; and +// - every issued JWT access token must carry an `aud` claim bound to the client +// (RFC 9068 §2.2), which fosite only emits when the granted audience is non-empty. +// +// The provider-level tests bypass tokenHandler.token by calling NewAccessResponse directly, +// so this drives the real HTTP handler with confidential-client Basic auth. +func TestTokenHandlerClientCredentialsGrant(t *testing.T) { + gin.SetMode(gin.TestMode) + + const ( + baseURL = "https://issuer.example.com" + secret = "test-secret" + clientID = "cc-client" + clientPlain = "cc-secret-value" + ) + + db := testutils.NewDatabaseForTest(t) + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost) + require.NoError(t, err) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Client Credentials Client", + Secret: string(hashed), + IsPublic: false, + }).Error) + + provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: key}, Config{ + BaseURL: baseURL, + TokenBaseURL: baseURL, + Secret: secret, + }) + require.NoError(t, err) + handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil)) + + form := url.Values{"grant_type": {"client_credentials"}} + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientPlain) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + handler.token(c) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.NotEmpty(t, body["access_token"], "client_credentials must issue a token, got error: %v", body["error"]) + + claims := decodeJWTPart(t, body["access_token"].(string), 1) + require.Contains(t, jwtAudience(claims), clientID, "access token must be audience-bound to the client") +} + +// jwtAudience normalizes the `aud` claim (string or []string) into a slice. +func jwtAudience(claims map[string]any) []string { + switch aud := claims["aud"].(type) { + case string: + return []string{aud} + case []any: + out := make([]string, 0, len(aud)) + for _, a := range aud { + if s, ok := a.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +// TestTokenHandlerRefreshGrantRevalidatesUser is the regression guard for the most +// security-sensitive part of the fosite migration: fosite's refresh-token grant replays +// the stored session without reloading the user, so the token handler must re-check the +// resource owner on every refresh. Without that re-check, a user who is disabled or +// removed from a group-restricted client after the initial login keeps minting fresh +// access/ID tokens (and rotating the refresh token) until the 30-day refresh token +// expires, defeating offboarding and incident response. This drives the real refresh grant +// end to end through the HTTP handler. +func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) { + gin.SetMode(gin.TestMode) + + const ( + baseURL = "https://issuer.example.com" + secret = "test-secret" + ) + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + signer := testTokenSigner{key: key} + + // mintRefreshToken stores an active refresh-token session for the user/client pair and + // returns the opaque token. It mirrors how the e2e test service seeds refresh tokens: + // the HMAC signature is derived from the same global secret the provider uses, so the + // real refresh grant resolves it. + mintRefreshToken := func(t *testing.T, db *gorm.DB, clientID, userID string) string { + t.Helper() + globalSecret, err := DeriveGlobalSecret(secret) + require.NoError(t, err) + strategy := compose.NewOAuth2HMACStrategy(&fosite.Config{ + GlobalSecret: globalSecret, + RefreshTokenLifespan: 30 * 24 * time.Hour, + }) + token, signature, err := strategy.GenerateRefreshToken(t.Context(), nil) + require.NoError(t, err) + + now := time.Now().UTC() + session := NewEmptySession() + session.Subject = userID + session.Claims = &fositejwt.IDTokenClaims{ + Subject: userID, + RequestedAt: now, + AuthTime: now, + Extra: map[string]any{}, + } + session.SetExpiresAt(fosite.RefreshToken, now.Add(30*24*time.Hour)) + session.SetExpiresAt(fosite.AccessToken, now.Add(time.Hour)) + + request := fosite.NewRequest() + request.ID = "refresh-req-" + userID + request.RequestedAt = now + request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, IsPublic: true}} + request.RequestedScope = fosite.Arguments{"openid"} + request.GrantedScope = fosite.Arguments{"openid"} + request.RequestedAudience = fosite.Arguments{clientID} + request.GrantedAudience = fosite.Arguments{clientID} + request.Session = session + + require.NoError(t, NewStore(db).CreateRefreshTokenSession(t.Context(), signature, "", request)) + return token + } + + doRefresh := func(t *testing.T, db *gorm.DB, clientID, refreshToken string) map[string]any { + t.Helper() + provider, err := newProvider(NewStore(db), nil, signer, Config{ + BaseURL: baseURL, + TokenBaseURL: baseURL, + Secret: secret, + }) + require.NoError(t, err) + handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil)) + + form := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {refreshToken}, + "client_id": {clientID}, + } + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + handler.token(c) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + return body + } + + createClient := func(t *testing.T, db *gorm.DB, client model.OidcClient) { + t.Helper() + require.NoError(t, db.Create(&client).Error) + } + + t.Run("enabled user receives rotated tokens", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + const clientID, userID = "client-ok", "user-ok" + createClient(t, db, model.OidcClient{Base: model.Base{ID: clientID}, Name: "Client", IsPublic: true}) + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim"}).Error) + + token := mintRefreshToken(t, db, clientID, userID) + body := doRefresh(t, db, clientID, token) + + require.NotEmpty(t, body["access_token"], "expected a new access token, got error: %v", body["error"]) + require.NotEmpty(t, body["refresh_token"]) + require.NotEqual(t, token, body["refresh_token"], "refresh token must be rotated") + }) + + t.Run("disabled user is rejected on refresh", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + const clientID, userID = "client-disabled", "user-disabled" + createClient(t, db, model.OidcClient{Base: model.Base{ID: clientID}, Name: "Client", IsPublic: true}) + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim", Disabled: true}).Error) + + token := mintRefreshToken(t, db, clientID, userID) + body := doRefresh(t, db, clientID, token) + + require.Empty(t, body["access_token"]) + require.Equal(t, "invalid_grant", body["error"]) + }) + + t.Run("user removed from a group-restricted client is rejected on refresh", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + const clientID, userID = "client-restricted", "user-outsider" + group := model.UserGroup{Base: model.Base{ID: "allowed-group"}, Name: "allowed", FriendlyName: "Allowed"} + require.NoError(t, db.Create(&group).Error) + createClient(t, db, model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Restricted", + IsPublic: true, + IsGroupRestricted: true, + AllowedUserGroups: []model.UserGroup{group}, + }) + // User is not a member of the allowed group. + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "outsider"}).Error) + + token := mintRefreshToken(t, db, clientID, userID) + body := doRefresh(t, db, clientID, token) + + require.Empty(t, body["access_token"]) + require.Equal(t, "access_denied", body["error"]) + }) +} diff --git a/backend/internal/oidc/tx.go b/backend/internal/oidc/tx.go new file mode 100644 index 00000000..92aa5c70 --- /dev/null +++ b/backend/internal/oidc/tx.go @@ -0,0 +1,50 @@ +package oidc + +import ( + "context" + "errors" + "log/slog" + + "gorm.io/gorm" +) + +// Transactions are carried in the context, every database access goes through dbFromContext. +// Although storing transactions in the context is not preferred, fosite handlers don't allow passing a transaction +// so we have to do it this way. + +type txContextKey struct{} + +func contextWithTx(ctx context.Context, tx *gorm.DB) context.Context { + return context.WithValue(ctx, txContextKey{}, tx) +} + +func dbFromContext(ctx context.Context, fallback *gorm.DB) *gorm.DB { + if tx, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok { + return tx.WithContext(ctx) + } + return fallback.WithContext(ctx) +} + +// withTx runs fn inside a transaction, committing on nil error. Nested calls join the +// outer transaction. +func withTx(ctx context.Context, db *gorm.DB, fn func(ctx context.Context) error) error { + if _, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok { + return fn(ctx) + } + + tx := db.WithContext(ctx).Begin() + if tx.Error != nil { + return tx.Error + } + defer func() { + err := tx.Rollback().Error + if err != nil && !errors.Is(err, gorm.ErrInvalidTransaction) { + slog.ErrorContext(ctx, "Failed to rollback transaction", "error", err) + } + }() + + if err := fn(contextWithTx(ctx, tx)); err != nil { + return err + } + return tx.Commit().Error +} diff --git a/backend/internal/oidc/userinfo_handler.go b/backend/internal/oidc/userinfo_handler.go new file mode 100644 index 00000000..a490be9c --- /dev/null +++ b/backend/internal/oidc/userinfo_handler.go @@ -0,0 +1,66 @@ +package oidc + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/ory/fosite" +) + +type userInfoHandler struct { + provider fosite.OAuth2Provider + claimsService *ClaimsService +} + +func newUserInfoHandler(provider fosite.OAuth2Provider, claimsService *ClaimsService) *userInfoHandler { + return &userInfoHandler{ + provider: provider, + claimsService: claimsService, + } +} + +// userInfo godoc +// @Summary Get user information +// @Description Get user information based on the access token +// @Tags OIDC +// @Accept json +// @Produce json +// @Success 200 {object} object "User claims based on requested scopes" +// @Security OAuth2AccessToken +// @Router /api/oidc/userinfo [get] +func (h *userInfoHandler) userInfo(c *gin.Context) { + ctx := c.Request.Context() + tokenType, accessRequest, err := h.provider.IntrospectToken(ctx, fosite.AccessTokenFromRequest(c.Request), fosite.AccessToken, NewEmptySession()) + if err != nil { + writeUserInfoError(c, err) + return + } + if tokenType != fosite.AccessToken { + writeUserInfoError(c, fosite.ErrRequestUnauthorized.WithDescription("Only access tokens are allowed in the authorization header.")) + return + } + + session, ok := accessRequest.GetSession().(*Session) + if !ok || session.GetSubject() == "" { + writeUserInfoError(c, fosite.ErrRequestUnauthorized.WithDescription("The access token is invalid")) + return + } + + claims, err := h.claimsService.GetUserClaims(ctx, session.GetSubject(), accessRequest.GetGrantedScopes()) + if err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, claims) +} + +func writeUserInfoError(c *gin.Context, err error) { + rfcErr := fosite.ErrorToRFC6749Error(err) + if rfcErr.StatusCode() == http.StatusUnauthorized { + c.Header("WWW-Authenticate", fmt.Sprintf(`Bearer error="%s", error_description="%s"`, rfcErr.ErrorField, rfcErr.GetDescription())) + } + + c.JSON(rfcErr.StatusCode(), rfcErr) +} diff --git a/backend/internal/oidc/userinfo_handler_test.go b/backend/internal/oidc/userinfo_handler_test.go new file mode 100644 index 00000000..5335773f --- /dev/null +++ b/backend/internal/oidc/userinfo_handler_test.go @@ -0,0 +1,128 @@ +package oidc + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/ory/fosite" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +// TestUserInfoHandler covers the userinfo endpoint, which returns user PII based purely on +// a presented access token. It must reject a missing/garbage token and — importantly — a +// token that carries no resource owner (e.g. a client_credentials token), so machine +// tokens cannot be exchanged for a user's profile. A valid user access token returns the +// claims for exactly the scopes it was granted. +func TestUserInfoHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + + const ( + baseURL = "https://issuer.example.com" + userID = "user-1" + clientID = "client-1" + ) + + db := testutils.NewDatabaseForTest(t) + require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error) + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + Username: "tim", + FirstName: "Tim", + LastName: "Cook", + DisplayName: "Tim Cook", + Email: stringPointer("tim@example.com"), + EmailVerified: true, + }).Error) + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: key}, Config{ + BaseURL: baseURL, + TokenBaseURL: baseURL, + Secret: "test-secret", + }) + require.NoError(t, err) + + handler := newUserInfoHandler(provider, newClaimsService(db, nil, baseURL, nil)) + + issueAccessToken := func(t *testing.T, requestID, subject string, scopes ...string) string { + t.Helper() + session := NewEmptySession() + session.Subject = subject + session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour)) + + request := fosite.NewAccessRequest(session) + request.ID = requestID + request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}} + request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)} + request.RequestedScope = fosite.Arguments(scopes) + request.GrantedScope = fosite.Arguments(scopes) + request.RequestedAudience = fosite.Arguments{clientID} + request.GrantedAudience = fosite.Arguments{clientID} + + response, err := provider.NewAccessResponse(t.Context(), request) + require.NoError(t, err) + return response.GetAccessToken() + } + + call := func(t *testing.T, token string) (*httptest.ResponseRecorder, *gin.Context) { + t.Helper() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/oidc/userinfo", nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + handler.userInfo(c) + return rec, c + } + + t.Run("valid user access token returns the granted claims", func(t *testing.T) { + token := issueAccessToken(t, "req-valid", userID, "openid", "email") + rec, c := call(t, token) + + require.Empty(t, c.Errors) + require.Equal(t, http.StatusOK, rec.Code) + + var claims map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &claims)) + require.Equal(t, userID, claims["sub"]) + require.Equal(t, "tim@example.com", claims["email"]) + // profile was not granted, so profile claims must be absent + require.NotContains(t, claims, "given_name") + }) + + t.Run("missing access token is rejected", func(t *testing.T) { + rec, c := call(t, "") + require.Empty(t, c.Errors) + require.Equal(t, http.StatusUnauthorized, rec.Code) + require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error="request_unauthorized"`) + }) + + t.Run("garbage access token is rejected", func(t *testing.T) { + rec, c := call(t, "garbage.token.value") + require.Empty(t, c.Errors) + require.Equal(t, http.StatusUnauthorized, rec.Code) + require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error=`) + }) + + t.Run("token without a resource owner is rejected", func(t *testing.T) { + // client_credentials-style token: valid, but no subject -> must not return PII. + token := issueAccessToken(t, "req-no-subject", "", "openid") + rec, c := call(t, token) + require.Empty(t, c.Errors) + require.Equal(t, http.StatusUnauthorized, rec.Code) + require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error="request_unauthorized"`) + }) +} diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index ffbba370..3af37e8a 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -14,14 +14,19 @@ import ( "time" "github.com/go-webauthn/webauthn/protocol" + "github.com/google/uuid" "github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/ory/fosite" + "github.com/ory/fosite/compose" + fositejwt "github.com/ory/fosite/token/jwt" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/utils" jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk" @@ -38,6 +43,13 @@ type TestService struct { externalIdPKey jwk.Key } +const ( + e2eRefreshTokenUserID = "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" + e2eRefreshTokenClientID = "3654a746-35d4-4321-ac61-0bdcff2b4055" + e2eRefreshTokenValidFixtureToken = "ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo" + e2eRefreshTokenExpiredFixtureToken = "X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4" +) + func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) { s := &TestService{ db: db, @@ -173,8 +185,8 @@ func (s *TestService) SeedDatabase(baseURL string) error { Name: "Nextcloud", LaunchURL: new("https://nextcloud.local"), Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY - CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"}, - LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"}, + CallbackURLs: model.UrlList{"http://nextcloud.localhost/auth/callback"}, + LogoutCallbackURLs: model.UrlList{"http://nextcloud.localhost/auth/logout/callback"}, ImageType: new("png"), CreatedByID: new(users[0].ID), }, @@ -184,7 +196,7 @@ func (s *TestService) SeedDatabase(baseURL string) error { }, Name: "Immich", Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x - CallbackURLs: model.UrlList{"http://immich/auth/callback"}, + CallbackURLs: model.UrlList{"http://immich.localhost/auth/callback"}, CreatedByID: new(users[1].ID), IsGroupRestricted: true, AllowedUserGroups: []model.UserGroup{ @@ -197,8 +209,8 @@ func (s *TestService) SeedDatabase(baseURL string) error { }, Name: "Tailscale", Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo - CallbackURLs: model.UrlList{"http://tailscale/auth/callback"}, - LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"}, + CallbackURLs: model.UrlList{"http://tailscale.localhost/auth/callback"}, + LogoutCallbackURLs: model.UrlList{"http://tailscale.localhost/auth/logout/callback"}, IsGroupRestricted: true, CreatedByID: new(users[0].ID), }, @@ -208,7 +220,7 @@ func (s *TestService) SeedDatabase(baseURL string) error { }, Name: "Federated", Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x - CallbackURLs: model.UrlList{"http://federated/auth/callback"}, + CallbackURLs: model.UrlList{"http://federated.localhost/auth/callback"}, CreatedByID: new(users[1].ID), AllowedUserGroups: []model.UserGroup{}, Credentials: model.OidcClientCredentials{ @@ -228,7 +240,7 @@ func (s *TestService) SeedDatabase(baseURL string) error { }, Name: "SCIM Client", Secret: "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK", // nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn - CallbackURLs: model.UrlList{"http://scimclient/auth/callback"}, + CallbackURLs: model.UrlList{"http://scimclient.localhost/auth/callback"}, CreatedByID: new(users[0].ID), IsGroupRestricted: true, AllowedUserGroups: []model.UserGroup{ @@ -242,7 +254,7 @@ func (s *TestService) SeedDatabase(baseURL string) error { }, Name: "PAR Test Client", Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY - CallbackURLs: model.UrlList{"http://par-client/auth/callback"}, + CallbackURLs: model.UrlList{"http://par-client.localhost/auth/callback"}, CreatedByID: new(users[0].ID), }, } @@ -252,45 +264,6 @@ func (s *TestService) SeedDatabase(baseURL string) error { } } - authCodes := []model.OidcAuthorizationCode{ - { - Code: "auth-code", - Scope: "openid profile", - Nonce: "nonce", - AuthenticationMethod: AuthenticationMethodPhishingResistant, - ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)), - UserID: users[0].ID, - ClientID: oidcClients[0].ID, - }, - { - Code: "federated", - Scope: "openid profile", - Nonce: "nonce", - AuthenticationMethod: AuthenticationMethodPhishingResistant, - ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)), - UserID: users[1].ID, - ClientID: oidcClients[3].ID, - }, - } - for _, authCode := range authCodes { - if err := tx.Create(&authCode).Error; err != nil { - return err - } - } - - refreshToken := model.OidcRefreshToken{ - Token: utils.CreateSha256Hash("ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo"), - IdTokenJti: new("dd75f6f6-ce0a-44b7-a645-7de390ccd2fa"), - AuthenticationMethod: AuthenticationMethodPhishingResistant, - ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)), - Scope: "openid profile email", - UserID: users[0].ID, - ClientID: oidcClients[0].ID, - } - if err := tx.Create(&refreshToken).Error; err != nil { - return err - } - accessToken := model.OneTimeAccessToken{ Token: "one-time-token", ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)), @@ -302,25 +275,25 @@ func (s *TestService) SeedDatabase(baseURL string) error { userAuthorizedClients := []model.UserAuthorizedOidcClient{ { - Scope: "openid profile email", + Scope: datatype.StringList{"openid", "profile", "email"}, UserID: users[0].ID, ClientID: oidcClients[0].ID, LastUsedAt: datatype.DateTime(time.Date(2025, 8, 1, 13, 0, 0, 0, time.UTC)), }, { - Scope: "openid profile email", + Scope: datatype.StringList{"openid", "profile", "email"}, UserID: users[0].ID, ClientID: oidcClients[2].ID, LastUsedAt: datatype.DateTime(time.Date(2025, 8, 10, 14, 0, 0, 0, time.UTC)), }, { - Scope: "openid profile email", + Scope: datatype.StringList{"openid", "profile", "email"}, UserID: users[1].ID, ClientID: oidcClients[3].ID, LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)), }, { - Scope: "openid profile email", + Scope: datatype.StringList{"openid", "profile", "email"}, UserID: users[0].ID, ClientID: oidcClients[5].ID, LastUsedAt: datatype.DateTime(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)), @@ -646,8 +619,173 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error { return nil } -func (s *TestService) SignRefreshToken(userID, clientID, refreshToken string) (string, error) { - return s.jwtService.GenerateOAuthRefreshToken(userID, clientID, refreshToken) +func (s *TestService) SignRefreshToken(ctx context.Context, userID, clientID, fixtureRefreshToken string) (string, error) { + globalSecret, err := oidc.DeriveGlobalSecret(string(common.EnvConfig.EncryptionKey)) + if err != nil { + return "", err + } + strategy := compose.NewOAuth2HMACStrategy(&fosite.Config{ + GlobalSecret: globalSecret, + RefreshTokenLifespan: RefreshTokenDuration, + }) + + token, signature, err := strategy.GenerateRefreshToken(ctx, nil) + if err != nil { + return "", err + } + + // The e2e API always returns a newly generated fosite token. The legacy fixture + // token only selects whether to seed the matching stored session as valid/expired. + session, ok := seededRefreshTokenSession(userID, clientID, fixtureRefreshToken) + if !ok { + return token, nil + } + session.Signature = signature + + if err := s.seedFositeTokenSession(ctx, session); err != nil { + return "", err + } + + return token, nil +} + +func seededRefreshTokenSession(userID string, clientID string, fixtureRefreshToken string) (fositeTokenSession, bool) { + expired, ok := seededRefreshTokenFixture(userID, clientID, fixtureRefreshToken) + if !ok { + return fositeTokenSession{}, false + } + + expiresAt := time.Now().UTC().Add(24 * time.Hour) + if expired { + expiresAt = time.Now().UTC().Add(-24 * time.Hour) + } + + return fositeTokenSession{ + Kind: "refresh_token", + RequestID: "e2e-refresh-" + utils.CreateSha256Hash(fixtureRefreshToken), + UserID: userID, + ClientID: clientID, + AuthenticationMethod: AuthenticationMethodPhishingResistant, + Scopes: []string{"openid", "profile", "email"}, + TokenType: fosite.RefreshToken, + ExpiresAt: expiresAt, + }, true +} + +func seededRefreshTokenFixture(userID string, clientID string, fixtureRefreshToken string) (expired bool, ok bool) { + if userID != e2eRefreshTokenUserID || clientID != e2eRefreshTokenClientID { + return false, false + } + + switch fixtureRefreshToken { + case e2eRefreshTokenValidFixtureToken: + return false, true + case e2eRefreshTokenExpiredFixtureToken: + return true, true + default: + return false, false + } +} + +func (s *TestService) SignAccessToken(ctx context.Context, userID, clientID string, expired bool) (string, error) { + globalSecret, err := oidc.DeriveGlobalSecret(string(common.EnvConfig.EncryptionKey)) + if err != nil { + return "", err + } + fositeConfig := &fosite.Config{ + GlobalSecret: globalSecret, + AccessTokenLifespan: AccessTokenDuration, + AccessTokenIssuer: common.EnvConfig.AppURL, + } + coreStrategy := compose.NewOAuth2HMACStrategy(fositeConfig) + keyGetter := func(context.Context) (interface{}, error) { + return oidc.SigningKeyFromSigner(s.jwtService) + } + strategy := compose.NewOAuth2JWTStrategy(keyGetter, coreStrategy, fositeConfig) + + expiresAt := time.Now().UTC().Add(AccessTokenDuration) + if expired { + expiresAt = time.Now().UTC().Add(-time.Minute) + } + + session := fositeTokenSession{ + Kind: "access_token", + RequestID: "e2e-access-" + uuid.NewString(), + UserID: userID, + ClientID: clientID, + AuthenticationMethod: AuthenticationMethodPhishingResistant, + Scopes: []string{"openid", "profile", "email"}, + TokenType: fosite.AccessToken, + ExpiresAt: expiresAt, + } + + request := s.newFositeTokenRequest(session) + token, signature, err := strategy.GenerateAccessToken(ctx, request) + if err != nil { + return "", err + } + + session.Signature = signature + err = s.seedFositeTokenSession(ctx, session) + if err != nil { + return "", err + } + + return token, nil +} + +type fositeTokenSession struct { + Kind string + Signature string + RequestID string + UserID string + ClientID string + AuthenticationMethod string + Scopes []string + TokenType fosite.TokenType + ExpiresAt time.Time +} + +func (s *TestService) seedFositeTokenSession(ctx context.Context, session fositeTokenSession) error { + request := s.newFositeTokenRequest(session) + + store := oidc.NewStore(s.db) + switch session.Kind { + case "access_token": + return store.CreateAccessTokenSession(ctx, session.Signature, request) + case "refresh_token": + return store.CreateRefreshTokenSession(ctx, session.Signature, "", request) + default: + return fmt.Errorf("unsupported token session kind %q", session.Kind) + } +} + +func (s *TestService) newFositeTokenRequest(session fositeTokenSession) *fosite.Request { + requestedAt := time.Now().UTC() + + oidcSession := &oidc.Session{ + Subject: session.UserID, + AuthenticationMethod: session.AuthenticationMethod, + Claims: &fositejwt.IDTokenClaims{ + RequestedAt: requestedAt, + AuthTime: requestedAt, + Subject: session.UserID, + Issuer: common.EnvConfig.AppURL, + }, + } + oidcSession.SetExpiresAt(session.TokenType, session.ExpiresAt) + + request := fosite.NewRequest() + request.ID = session.RequestID + request.RequestedAt = requestedAt + request.Client = oidc.Client{OidcClient: model.OidcClient{Base: model.Base{ID: session.ClientID}}} + request.RequestedScope = session.Scopes + request.GrantedScope = session.Scopes + request.RequestedAudience = fosite.Arguments{session.ClientID} + request.GrantedAudience = fosite.Arguments{session.ClientID} + request.Session = oidcSession + + return request } // GetExternalIdPJWKS returns the JWKS for the "external IdP". @@ -672,6 +810,7 @@ func (s *TestService) SignExternalIdPToken(iss, sub, aud string) (string, error) Subject(sub). Expiration(now.Add(time.Hour)). IssuedAt(now). + JwtID(uuid.NewString()). Issuer(iss). Audience([]string{aud}). Build() diff --git a/backend/internal/service/jwt_service.go b/backend/internal/service/jwt_service.go index 63e0c339..5eafd310 100644 --- a/backend/internal/service/jwt_service.go +++ b/backend/internal/service/jwt_service.go @@ -29,30 +29,15 @@ const ( // TokenTypeClaim is the claim used to identify the type of token TokenTypeClaim = "type" - // RefreshTokenClaim is the claim used for the refresh token's value - RefreshTokenClaim = "rt" - - // AuthenticationMethodsClaim is the claim used to identify how the user authenticated - AuthenticationMethodsClaim = "amr" - // AuthenticationMethodPhishingResistant identifies phishing-resistant authentication, such as passkeys AuthenticationMethodPhishingResistant = "phr" // AuthenticationMethodOneTimePassword identifies one-time password/code authentication AuthenticationMethodOneTimePassword = "otp" - // OAuthAccessTokenJWTType identifies a JWT as an OAuth access token - OAuthAccessTokenJWTType = "oauth-access-token" //nolint:gosec - - // OAuthRefreshTokenJWTType identifies a JWT as an OAuth refresh token - OAuthRefreshTokenJWTType = "refresh-token" - // AccessTokenJWTType identifies a JWT as an access token used by Pocket ID AccessTokenJWTType = "access-token" - // IDTokenJWTType identifies a JWT as an ID token used by Pocket ID - IDTokenJWTType = "id-token" - // Acceptable clock skew for verifying tokens clockSkew = time.Minute ) @@ -227,7 +212,7 @@ func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod s err = SetAuthenticationMethods(token, authenticationMethod) if err != nil { - return "", fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err) + return "", fmt.Errorf("failed to set '%s' claim in token: %w", common.AuthenticationMethodsClaim, err) } alg, _ := s.privateKey.Algorithm() @@ -257,256 +242,6 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) { return token, nil } -// BuildIDToken creates an ID token with all claims -func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string, authenticationMethod string) (jwt.Token, string, error) { - now := time.Now() - jti := uuid.New().String() - token, err := jwt.NewBuilder(). - Expiration(now.Add(1 * time.Hour)). - IssuedAt(now). - Issuer(s.envConfig.AppURL). - JwtID(jti). - Build() - if err != nil { - return nil, "", fmt.Errorf("failed to build token: %w", err) - } - - err = SetAudienceString(token, clientID) - if err != nil { - return nil, "", fmt.Errorf("failed to set 'aud' claim in token: %w", err) - } - - err = SetTokenType(token, IDTokenJWTType) - if err != nil { - return nil, "", fmt.Errorf("failed to set 'type' claim in token: %w", err) - } - - err = SetAuthenticationMethods(token, authenticationMethod) - if err != nil { - return nil, "", fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err) - } - - for k, v := range userClaims { - err = token.Set(k, v) - if err != nil { - return nil, "", fmt.Errorf("failed to set claim '%s': %w", k, err) - } - } - - if nonce != "" { - err = token.Set("nonce", nonce) - if err != nil { - return nil, "", fmt.Errorf("failed to set claim 'nonce': %w", err) - } - } - - return token, jti, nil -} - -// GenerateIDToken creates and signs an ID token -func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string, authenticationMethod string) (signedToken, jti string, err error) { - token, jti, err := s.BuildIDToken(userClaims, clientID, nonce, authenticationMethod) - if err != nil { - return "", "", err - } - - alg, _ := s.privateKey.Algorithm() - signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey)) - if err != nil { - return "", "", fmt.Errorf("failed to sign token: %w", err) - } - - return string(signed), jti, nil -} - -func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool) (jwt.Token, error) { - alg, _ := s.privateKey.Algorithm() - - opts := make([]jwt.ParseOption, 0) - - // These options are always present - opts = append(opts, - jwt.WithValidate(true), - jwt.WithKey(alg, s.privateKey), - jwt.WithAcceptableSkew(clockSkew), - jwt.WithIssuer(s.envConfig.AppURL), - jwt.WithValidator(TokenTypeValidator(IDTokenJWTType)), - ) - - // By default, jwt.Parse includes 3 default validators for "nbf", "iat", and "exp" - // In case we want to accept expired tokens (during logout), we need to set the validators explicitly without validating "exp" - if acceptExpiredTokens { - // This is equivalent to the default validators except it doesn't validate "exp" - opts = append(opts, - jwt.WithResetValidators(true), - jwt.WithValidator(jwt.IsIssuedAtValid()), - jwt.WithValidator(jwt.IsNbfValid()), - ) - } - - token, err := jwt.ParseString(tokenString, opts...) - if err != nil { - return nil, fmt.Errorf("failed to parse token: %w", err) - } - - return token, nil -} - -// BuildOAuthAccessToken creates an OAuth access token with all claims -func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string, authenticationMethod string) (jwt.Token, error) { - now := time.Now() - token, err := jwt.NewBuilder(). - Subject(user.ID). - Expiration(now.Add(1 * time.Hour)). - IssuedAt(now). - Issuer(s.envConfig.AppURL). - JwtID(uuid.New().String()). - Build() - if err != nil { - return nil, fmt.Errorf("failed to build token: %w", err) - } - - err = SetAudienceString(token, clientID) - if err != nil { - return nil, fmt.Errorf("failed to set 'aud' claim in token: %w", err) - } - - err = SetTokenType(token, OAuthAccessTokenJWTType) - if err != nil { - return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err) - } - - err = SetAuthenticationMethods(token, authenticationMethod) - if err != nil { - return nil, fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err) - } - - return token, nil -} - -// GenerateOAuthAccessToken creates and signs an OAuth access token -func (s *JwtService) GenerateOAuthAccessToken(user model.User, clientID string, authenticationMethod string) (string, error) { - token, err := s.BuildOAuthAccessToken(user, clientID, authenticationMethod) - if err != nil { - return "", err - } - - alg, _ := s.privateKey.Algorithm() - signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey)) - if err != nil { - return "", fmt.Errorf("failed to sign token: %w", err) - } - - return string(signed), nil -} - -func (s *JwtService) VerifyOAuthAccessToken(tokenString string) (jwt.Token, error) { - alg, _ := s.privateKey.Algorithm() - token, err := jwt.ParseString( - tokenString, - jwt.WithValidate(true), - jwt.WithKey(alg, s.privateKey), - jwt.WithAcceptableSkew(clockSkew), - jwt.WithIssuer(s.envConfig.AppURL), - jwt.WithValidator(TokenTypeValidator(OAuthAccessTokenJWTType)), - ) - if err != nil { - return nil, fmt.Errorf("failed to parse token: %w", err) - } - - return token, nil -} - -func (s *JwtService) GenerateOAuthRefreshToken(userID string, clientID string, refreshToken string) (string, error) { - now := time.Now() - token, err := jwt.NewBuilder(). - Subject(userID). - Expiration(now.Add(RefreshTokenDuration)). - IssuedAt(now). - Issuer(s.envConfig.AppURL). - Build() - if err != nil { - return "", fmt.Errorf("failed to build token: %w", err) - } - - err = token.Set(RefreshTokenClaim, refreshToken) - if err != nil { - return "", fmt.Errorf("failed to set 'rt' claim in token: %w", err) - } - - err = SetAudienceString(token, clientID) - if err != nil { - return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err) - } - - err = SetTokenType(token, OAuthRefreshTokenJWTType) - if err != nil { - return "", fmt.Errorf("failed to set 'type' claim in token: %w", err) - } - - alg, _ := s.privateKey.Algorithm() - signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey)) - if err != nil { - return "", fmt.Errorf("failed to sign token: %w", err) - } - - return string(signed), nil -} - -func (s *JwtService) VerifyOAuthRefreshToken(tokenString string) (userID, clientID, rt string, err error) { - alg, _ := s.privateKey.Algorithm() - token, err := jwt.ParseString( - tokenString, - jwt.WithValidate(true), - jwt.WithKey(alg, s.privateKey), - jwt.WithAcceptableSkew(clockSkew), - jwt.WithIssuer(s.envConfig.AppURL), - jwt.WithValidator(TokenTypeValidator(OAuthRefreshTokenJWTType)), - ) - if err != nil { - return "", "", "", fmt.Errorf("failed to parse token: %w", err) - } - - err = token.Get(RefreshTokenClaim, &rt) - if err != nil { - return "", "", "", fmt.Errorf("failed to get '%s' claim from token: %w", RefreshTokenClaim, err) - } - - audiences, ok := token.Audience() - if !ok || len(audiences) != 1 || audiences[0] == "" { - return "", "", "", errors.New("failed to get 'aud' claim from token") - } - clientID = audiences[0] - - userID, ok = token.Subject() - if !ok { - return "", "", "", errors.New("failed to get 'sub' claim from token") - } - - return userID, clientID, rt, nil -} - -// GetTokenType returns the type of the JWT token issued by Pocket ID, but **does not validate it**. -func (s *JwtService) GetTokenType(tokenString string) (string, jwt.Token, error) { - // Disable validation and verification to parse the token without checking it - token, err := jwt.ParseString( - tokenString, - jwt.WithValidate(false), - jwt.WithVerify(false), - ) - if err != nil { - return "", nil, fmt.Errorf("failed to parse token: %w", err) - } - - var tokenType string - err = token.Get(TokenTypeClaim, &tokenType) - if err != nil { - return "", nil, fmt.Errorf("failed to get token type claim: %w", err) - } - - return tokenType, token, nil -} - // GetPublicJWK returns the JSON Web Key (JWK) for the public key. func (s *JwtService) GetPublicJWK() (jwk.Key, error) { if s.privateKey == nil { @@ -547,6 +282,14 @@ func (s *JwtService) GetKeyAlg() (jwa.KeyAlgorithm, error) { return alg, nil } +// GetKeyID returns the key ID (kid) of the signing key, if one is set. +func (s *JwtService) GetKeyID() (string, bool) { + if s.privateKey == nil { + return "", false + } + return s.privateKey.KeyID() +} + // GetIsAdmin returns the value of the "isAdmin" claim in the token func GetIsAdmin(token jwt.Token) (bool, error) { if !token.Has(IsAdminClaim) { @@ -562,13 +305,13 @@ func GetIsAdmin(token jwt.Token) (bool, error) { // GetAuthenticationMethod returns the first authentication method in the "amr" claim in the token func GetAuthenticationMethod(token jwt.Token) (string, error) { - if !token.Has(AuthenticationMethodsClaim) { + if !token.Has(common.AuthenticationMethodsClaim) { return "", nil } var rawAuthenticationMethods []any - err := token.Get(AuthenticationMethodsClaim, &rawAuthenticationMethods) + err := token.Get(common.AuthenticationMethodsClaim, &rawAuthenticationMethods) if err != nil { - return "", fmt.Errorf("failed to get '%s' claim from token: %w", AuthenticationMethodsClaim, err) + return "", fmt.Errorf("failed to get '%s' claim from token: %w", common.AuthenticationMethodsClaim, err) } if len(rawAuthenticationMethods) == 0 { @@ -576,7 +319,7 @@ func GetAuthenticationMethod(token jwt.Token) (string, error) { } authenticationMethod, ok := rawAuthenticationMethods[0].(string) if !ok { - return "", fmt.Errorf("invalid '%s' claim in token: expected array of strings", AuthenticationMethodsClaim) + return "", fmt.Errorf("invalid '%s' claim in token: expected array of strings", common.AuthenticationMethodsClaim) } return authenticationMethod, nil } @@ -603,7 +346,7 @@ func SetAuthenticationMethods(token jwt.Token, authenticationMethod string) erro if authenticationMethod == "" { return nil } - return token.Set(AuthenticationMethodsClaim, []string{authenticationMethod}) + return token.Set(common.AuthenticationMethodsClaim, []string{authenticationMethod}) } // SetAudienceString sets the "aud" claim with a value that is a string, and not an array @@ -626,3 +369,9 @@ func TokenTypeValidator(expectedTokenType string) jwt.ValidatorFunc { return nil } } + +func (s *JwtService) GetPrivateKey() any { + var privateKey any + _ = jwk.Export(s.privateKey, &privateKey) + return privateKey +} diff --git a/backend/internal/service/jwt_service_test.go b/backend/internal/service/jwt_service_test.go index d049ee6a..75408bfc 100644 --- a/backend/internal/service/jwt_service_test.go +++ b/backend/internal/service/jwt_service_test.go @@ -515,515 +515,6 @@ func TestGenerateVerifyAccessToken(t *testing.T) { }) } -func TestGenerateVerifyIdToken(t *testing.T) { - mockConfig := NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes - }) - - t.Run("generates and verifies ID token with standard claims", func(t *testing.T) { - service, _, _ := setupJwtService(t, mockConfig) - - userClaims := map[string]any{ - "sub": "user123", - "name": "Test User", - "email": "user@example.com", - } - const clientID = "test-client-123" - - tokenString, jti, err := service.GenerateIDToken(userClaims, clientID, "", "") - require.NoError(t, err, "Failed to generate ID token") - assert.NotEmpty(t, tokenString, "Token should not be empty") - assert.Regexp(t, uuidRegexPattern, jti, "Returned JWT ID is not a UUID") - - claims, err := service.VerifyIdToken(tokenString, false) - require.NoError(t, err, "Failed to verify generated ID token") - - subject, ok := claims.Subject() - _ = assert.True(t, ok, "User ID not found in token") && - assert.Equal(t, "user123", subject, "Token subject should match user ID") - audience, ok := claims.Audience() - _ = assert.True(t, ok, "Audience not found in token") && - assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID") - issuer, ok := claims.Issuer() - _ = assert.True(t, ok, "Issuer not found in token") && - assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL") - jwtID, ok := claims.JwtID() - _ = assert.True(t, ok, "JWT ID not found in token") && - assert.Regexp(t, uuidRegexPattern, jwtID, "JWT ID is not a UUID") - assert.Equal(t, jti, jwtID, "Returned JWT ID should match token claim") - - expectedExp := time.Now().Add(1 * time.Hour) - expiration, ok := claims.Expiration() - assert.True(t, ok, "Expiration not found in token") - timeDiff := expectedExp.Sub(expiration).Minutes() - assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour") - }) - - t.Run("can accept expired tokens if told so", func(t *testing.T) { - service, _, _ := setupJwtService(t, mockConfig) - - userClaims := map[string]any{ - "sub": "user123", - "name": "Test User", - "email": "user@example.com", - } - const clientID = "test-client-123" - - token, err := jwt.NewBuilder(). - Subject(userClaims["sub"].(string)). - Issuer(service.envConfig.AppURL). - Audience([]string{clientID}). - IssuedAt(time.Now().Add(-2 * time.Hour)). - Expiration(time.Now().Add(-1 * time.Hour)). - Build() - require.NoError(t, err, "Failed to build token") - - err = SetTokenType(token, IDTokenJWTType) - require.NoError(t, err, "Failed to set token type") - - for k, v := range userClaims { - if k != "sub" { - err = token.Set(k, v) - require.NoError(t, err, "Failed to set claim") - } - } - - signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey)) - require.NoError(t, err, "Failed to sign token") - tokenString := string(signed) - - _, err = service.VerifyIdToken(tokenString, false) - require.Error(t, err, "Verification should fail with expired token when not allowing expired tokens") - assert.Contains(t, err.Error(), "\"exp\" not satisfied", "Error message should indicate token verification failure") - - claims, err := service.VerifyIdToken(tokenString, true) - require.NoError(t, err, "Verification should succeed with expired token when allowing expired tokens") - - subject, ok := claims.Subject() - _ = assert.True(t, ok, "User ID not found in token") && - assert.Equal(t, userClaims["sub"], subject, "Token subject should match user ID") - issuer, ok := claims.Issuer() - _ = assert.True(t, ok, "Issuer not found in token") && - assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL") - }) - - t.Run("generates and verifies ID token with nonce", func(t *testing.T) { - service, _, _ := setupJwtService(t, mockConfig) - - userClaims := map[string]any{ - "sub": "user456", - "name": "Another User", - } - const clientID = "test-client-456" - nonce := "random-nonce-value" - - tokenString, _, err := service.GenerateIDToken(userClaims, clientID, nonce, "") - require.NoError(t, err, "Failed to generate ID token with nonce") - - publicKey, err := service.GetPublicJWK() - require.NoError(t, err, "Failed to get public key") - token, err := jwt.Parse([]byte(tokenString), jwt.WithKey(jwa.RS256(), publicKey)) - require.NoError(t, err, "Failed to parse token") - - var tokenNonce string - err = token.Get("nonce", &tokenNonce) - require.NoError(t, err, "Failed to get claims") - - assert.Equal(t, nonce, tokenNonce, "Token should contain the correct nonce") - }) - - t.Run("fails verification with incorrect issuer", func(t *testing.T) { - service, _, _ := setupJwtService(t, mockConfig) - - userClaims := map[string]any{ - "sub": "user789", - } - tokenString, _, err := service.GenerateIDToken(userClaims, "client-789", "", "") - require.NoError(t, err, "Failed to generate ID token") - - service.envConfig.AppURL = "https://wrong-issuer.com" - - _, err = service.VerifyIdToken(tokenString, false) - require.Error(t, err, "Verification should fail with incorrect issuer") - assert.Contains(t, err.Error(), "\"iss\" not satisfied", "Error message should indicate token verification failure") - }) - - t.Run("works with Ed25519 keys", func(t *testing.T) { - db, envConfig := newTestDbAndEnv(t) - origKeyID := createEdDSAKeyJWK(t, db, envConfig, mockConfig) - service := initJwtService(t, db, mockConfig, envConfig) - - loadedKeyID, ok := service.privateKey.KeyID() - require.True(t, ok) - assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") - - userClaims := map[string]any{ - "sub": "eddsauser456", - "name": "EdDSA User", - "email": "eddsauser@example.com", - } - const clientID = "eddsa-client-123" - - tokenString, _, err := service.GenerateIDToken(userClaims, clientID, "", "") - require.NoError(t, err, "Failed to generate ID token with key") - assert.NotEmpty(t, tokenString, "Token should not be empty") - - claims, err := service.VerifyIdToken(tokenString, false) - require.NoError(t, err, "Failed to verify generated ID token with key") - - subject, ok := claims.Subject() - _ = assert.True(t, ok, "User ID not found in token") && - assert.Equal(t, "eddsauser456", subject, "Token subject should match user ID") - issuer, ok := claims.Issuer() - _ = assert.True(t, ok, "Issuer not found in token") && - assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL") - - publicKey, err := service.GetPublicJWK() - require.NoError(t, err) - assert.Equal(t, jwa.OKP().String(), publicKey.KeyType().String(), "Key type should be OKP") - alg, ok := publicKey.Algorithm() - require.True(t, ok) - assert.Equal(t, jwa.EdDSA().String(), alg.String(), "Algorithm should be EdDSA") - }) - - t.Run("works with P-256 keys", func(t *testing.T) { - db, envConfig := newTestDbAndEnv(t) - origKeyID := createECDSAKeyJWK(t, db, envConfig, mockConfig) - service := initJwtService(t, db, mockConfig, envConfig) - - loadedKeyID, ok := service.privateKey.KeyID() - require.True(t, ok) - assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") - - userClaims := map[string]any{ - "sub": "ecdsauser456", - "email": "ecdsauser@example.com", - } - const clientID = "ecdsa-client-123" - - tokenString, _, err := service.GenerateIDToken(userClaims, clientID, "", "") - require.NoError(t, err, "Failed to generate ID token with key") - assert.NotEmpty(t, tokenString, "Token should not be empty") - - claims, err := service.VerifyIdToken(tokenString, false) - require.NoError(t, err, "Failed to verify generated ID token with key") - - subject, ok := claims.Subject() - _ = assert.True(t, ok, "User ID not found in token") && - assert.Equal(t, "ecdsauser456", subject, "Token subject should match user ID") - issuer, ok := claims.Issuer() - _ = assert.True(t, ok, "Issuer not found in token") && - assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL") - - publicKey, err := service.GetPublicJWK() - require.NoError(t, err) - assert.Equal(t, jwa.EC().String(), publicKey.KeyType().String(), "Key type should be EC") - alg, ok := publicKey.Algorithm() - require.True(t, ok) - assert.Equal(t, jwa.ES256().String(), alg.String(), "Algorithm should be ES256") - }) - - t.Run("works with RSA-4096 keys", func(t *testing.T) { - db, envConfig := newTestDbAndEnv(t) - origKeyID := createRSA4096KeyJWK(t, db, envConfig, mockConfig) - service := initJwtService(t, db, mockConfig, envConfig) - - loadedKeyID, ok := service.privateKey.KeyID() - require.True(t, ok) - assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") - - userClaims := map[string]any{ - "sub": "rsauser456", - "name": "RSA User", - "email": "rsauser@example.com", - } - const clientID = "rsa-client-123" - - tokenString, _, err := service.GenerateIDToken(userClaims, clientID, "", "") - require.NoError(t, err, "Failed to generate ID token with key") - assert.NotEmpty(t, tokenString, "Token should not be empty") - - claims, err := service.VerifyIdToken(tokenString, false) - require.NoError(t, err, "Failed to verify generated ID token with key") - - subject, ok := claims.Subject() - _ = assert.True(t, ok, "User ID not found in token") && - assert.Equal(t, "rsauser456", subject, "Token subject should match user ID") - issuer, ok := claims.Issuer() - _ = assert.True(t, ok, "Issuer not found in token") && - assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL") - }) -} - -func TestGenerateVerifyOAuthAccessToken(t *testing.T) { - mockConfig := NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes - }) - - t.Run("generates and verifies OAuth access token with standard claims", func(t *testing.T) { - service, _, _ := setupJwtService(t, mockConfig) - - user := model.User{ - Base: model.Base{ID: "user123"}, - Email: new("user@example.com"), - } - const clientID = "test-client-123" - - tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "") - require.NoError(t, err, "Failed to generate OAuth access token") - assert.NotEmpty(t, tokenString, "Token should not be empty") - - claims, err := service.VerifyOAuthAccessToken(tokenString) - require.NoError(t, err, "Failed to verify generated OAuth access token") - - subject, ok := claims.Subject() - _ = assert.True(t, ok, "User ID not found in token") && - assert.Equal(t, user.ID, subject, "Token subject should match user ID") - audience, ok := claims.Audience() - _ = assert.True(t, ok, "Audience not found in token") && - assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID") - issuer, ok := claims.Issuer() - _ = assert.True(t, ok, "Issuer not found in token") && - assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL") - jwtID, ok := claims.JwtID() - _ = assert.True(t, ok, "JWT ID not found in token") && - assert.Regexp(t, uuidRegexPattern, jwtID, "JWT ID is not a UUID") - - expectedExp := time.Now().Add(1 * time.Hour) - expiration, ok := claims.Expiration() - assert.True(t, ok, "Expiration not found in token") - timeDiff := expectedExp.Sub(expiration).Minutes() - assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour") - }) - - t.Run("sets authentication method references claim when provided", func(t *testing.T) { - service, _, _ := setupJwtService(t, mockConfig) - - user := model.User{ - Base: model.Base{ID: "oauth-amr-user"}, - } - const clientID = "test-client-amr" - - tokenString, err := service.GenerateOAuthAccessToken(user, clientID, AuthenticationMethodPhishingResistant) - require.NoError(t, err, "Failed to generate OAuth access token") - - claims, err := service.VerifyOAuthAccessToken(tokenString) - require.NoError(t, err, "Failed to verify generated OAuth access token") - - authenticationMethod, err := GetAuthenticationMethod(claims) - _ = assert.NoError(t, err, "Failed to get amr claim") && - assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match") - }) - - t.Run("fails verification for expired token", func(t *testing.T) { - service, _, _ := setupJwtService(t, mockConfig) - - user := model.User{Base: model.Base{ID: "user456"}} - const clientID = "test-client-456" - - token, err := jwt.NewBuilder(). - Subject(user.ID). - Expiration(time.Now().Add(-1 * time.Hour)). - IssuedAt(time.Now().Add(-2 * time.Hour)). - Audience([]string{clientID}). - Issuer(service.envConfig.AppURL). - Build() - require.NoError(t, err, "Failed to build token") - - err = SetTokenType(token, OAuthAccessTokenJWTType) - require.NoError(t, err, "Failed to set token type") - - signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey)) - require.NoError(t, err, "Failed to sign token") - - _, err = service.VerifyOAuthAccessToken(string(signed)) - require.Error(t, err, "Verification should fail with expired token") - assert.Contains(t, err.Error(), "\"exp\" not satisfied", "Error message should indicate token verification failure") - }) - - t.Run("fails verification with invalid signature", func(t *testing.T) { - service1, _, _ := setupJwtService(t, mockConfig) - service2, _, _ := setupJwtService(t, mockConfig) - - user := model.User{Base: model.Base{ID: "user789"}} - const clientID = "test-client-789" - - tokenString, err := service1.GenerateOAuthAccessToken(user, clientID, "") - require.NoError(t, err, "Failed to generate OAuth access token") - - _, err = service2.VerifyOAuthAccessToken(tokenString) - require.Error(t, err, "Verification should fail with invalid signature") - assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure") - }) - - t.Run("works with Ed25519 keys", func(t *testing.T) { - db, envConfig := newTestDbAndEnv(t) - origKeyID := createEdDSAKeyJWK(t, db, envConfig, mockConfig) - service := initJwtService(t, db, mockConfig, envConfig) - - loadedKeyID, ok := service.privateKey.KeyID() - require.True(t, ok) - assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") - - user := model.User{ - Base: model.Base{ID: "eddsauser789"}, - Email: new("eddsaoauth@example.com"), - } - const clientID = "eddsa-oauth-client" - - tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "") - require.NoError(t, err, "Failed to generate OAuth access token with key") - assert.NotEmpty(t, tokenString, "Token should not be empty") - - claims, err := service.VerifyOAuthAccessToken(tokenString) - require.NoError(t, err, "Failed to verify generated OAuth access token with key") - - subject, ok := claims.Subject() - _ = assert.True(t, ok, "User ID not found in token") && - assert.Equal(t, user.ID, subject, "Token subject should match user ID") - audience, ok := claims.Audience() - _ = assert.True(t, ok, "Audience not found in token") && - assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID") - - publicKey, err := service.GetPublicJWK() - require.NoError(t, err) - assert.Equal(t, jwa.OKP().String(), publicKey.KeyType().String(), "Key type should be OKP") - alg, ok := publicKey.Algorithm() - require.True(t, ok) - assert.Equal(t, jwa.EdDSA().String(), alg.String(), "Algorithm should be EdDSA") - }) - - t.Run("works with ECDSA keys", func(t *testing.T) { - db, envConfig := newTestDbAndEnv(t) - origKeyID := createECDSAKeyJWK(t, db, envConfig, mockConfig) - service := initJwtService(t, db, mockConfig, envConfig) - - loadedKeyID, ok := service.privateKey.KeyID() - require.True(t, ok) - assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") - - user := model.User{ - Base: model.Base{ID: "ecdsauser789"}, - Email: new("ecdsaoauth@example.com"), - } - const clientID = "ecdsa-oauth-client" - - tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "") - require.NoError(t, err, "Failed to generate OAuth access token with key") - assert.NotEmpty(t, tokenString, "Token should not be empty") - - claims, err := service.VerifyOAuthAccessToken(tokenString) - require.NoError(t, err, "Failed to verify generated OAuth access token with key") - - subject, ok := claims.Subject() - _ = assert.True(t, ok, "User ID not found in token") && - assert.Equal(t, user.ID, subject, "Token subject should match user ID") - audience, ok := claims.Audience() - _ = assert.True(t, ok, "Audience not found in token") && - assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID") - - publicKey, err := service.GetPublicJWK() - require.NoError(t, err) - assert.Equal(t, jwa.EC().String(), publicKey.KeyType().String(), "Key type should be EC") - alg, ok := publicKey.Algorithm() - require.True(t, ok) - assert.Equal(t, jwa.ES256().String(), alg.String(), "Algorithm should be ES256") - }) - - t.Run("works with RSA keys", func(t *testing.T) { - db, envConfig := newTestDbAndEnv(t) - origKeyID := createRSA4096KeyJWK(t, db, envConfig, mockConfig) - service := initJwtService(t, db, mockConfig, envConfig) - - loadedKeyID, ok := service.privateKey.KeyID() - require.True(t, ok) - assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") - - user := model.User{ - Base: model.Base{ID: "rsauser789"}, - Email: new("rsaoauth@example.com"), - } - const clientID = "rsa-oauth-client" - - tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "") - require.NoError(t, err, "Failed to generate OAuth access token with key") - assert.NotEmpty(t, tokenString, "Token should not be empty") - - claims, err := service.VerifyOAuthAccessToken(tokenString) - require.NoError(t, err, "Failed to verify generated OAuth access token with key") - - subject, ok := claims.Subject() - _ = assert.True(t, ok, "User ID not found in token") && - assert.Equal(t, user.ID, subject, "Token subject should match user ID") - audience, ok := claims.Audience() - _ = assert.True(t, ok, "Audience not found in token") && - assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID") - - publicKey, err := service.GetPublicJWK() - require.NoError(t, err) - assert.Equal(t, jwa.RSA().String(), publicKey.KeyType().String(), "Key type should be RSA") - alg, ok := publicKey.Algorithm() - require.True(t, ok) - assert.Equal(t, jwa.RS256().String(), alg.String(), "Algorithm should be RS256") - }) -} - -func TestGenerateVerifyOAuthRefreshToken(t *testing.T) { - mockConfig := NewTestAppConfigService(&model.AppConfig{}) - - t.Run("generates and verifies refresh token", func(t *testing.T) { - service, _, _ := setupJwtService(t, mockConfig) - - const ( - userID = "user123" - clientID = "client123" - refreshToken = "rt-123" - ) - - tokenString, err := service.GenerateOAuthRefreshToken(userID, clientID, refreshToken) - require.NoError(t, err, "Failed to generate refresh token") - assert.NotEmpty(t, tokenString, "Token should not be empty") - - resUser, resClient, resRT, err := service.VerifyOAuthRefreshToken(tokenString) - require.NoError(t, err, "Failed to verify generated token") - assert.Equal(t, userID, resUser, "Should return correct user ID") - assert.Equal(t, clientID, resClient, "Should return correct client ID") - assert.Equal(t, refreshToken, resRT, "Should return correct refresh token") - }) - - t.Run("fails verification for expired token", func(t *testing.T) { - service, _, _ := setupJwtService(t, mockConfig) - - token, err := jwt.NewBuilder(). - Subject("user789"). - Expiration(time.Now().Add(-1 * time.Hour)). - IssuedAt(time.Now().Add(-2 * time.Hour)). - Audience([]string{"client123"}). - Issuer(service.envConfig.AppURL). - Build() - require.NoError(t, err, "Failed to build token") - - signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey)) - require.NoError(t, err, "Failed to sign token") - - _, _, _, err = service.VerifyOAuthRefreshToken(string(signed)) - require.Error(t, err, "Verification should fail with expired token") - assert.Contains(t, err.Error(), "\"exp\" not satisfied", "Error message should indicate token verification failure") - }) - - t.Run("fails verification with invalid signature", func(t *testing.T) { - service1, _, _ := setupJwtService(t, mockConfig) - service2, _, _ := setupJwtService(t, mockConfig) - - tokenString, err := service1.GenerateOAuthRefreshToken("user789", "client123", "my-rt-123") - require.NoError(t, err, "Failed to generate refresh token") - - _, _, _, err = service2.VerifyOAuthRefreshToken(tokenString) - require.Error(t, err, "Verification should fail with invalid signature") - assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure") - }) -} - func TestTokenTypeValidator(t *testing.T) { // Create a context for the validator function ctx := context.Background() @@ -1045,16 +536,16 @@ func TestTokenTypeValidator(t *testing.T) { t.Run("fails when token type doesn't match expected type", func(t *testing.T) { // Create a token with a different type token := jwt.New() - err := token.Set(TokenTypeClaim, OAuthAccessTokenJWTType) + err := token.Set(TokenTypeClaim, "other-token") require.NoError(t, err, "Failed to set token type claim") // Create a validator function for a different expected type - validator := TokenTypeValidator(IDTokenJWTType) + validator := TokenTypeValidator(AccessTokenJWTType) // Validate the token err = validator(ctx, token) require.Error(t, err, "Validator should reject token with non-matching type") - assert.Contains(t, err.Error(), "invalid token type: expected id-token, got oauth-access-token") + assert.Contains(t, err.Error(), "invalid token type: expected access-token, got other-token") }) t.Run("fails when token type claim is missing", func(t *testing.T) { @@ -1071,57 +562,6 @@ func TestTokenTypeValidator(t *testing.T) { }) } -func TestGetTokenType(t *testing.T) { - mockConfig := NewTestAppConfigService(&model.AppConfig{}) - service, _, _ := setupJwtService(t, mockConfig) - - buildTokenForType := func(t *testing.T, typ string, setClaimsFn func(b *jwt.Builder)) string { - t.Helper() - - b := jwt.NewBuilder() - b.Subject("user123") - if setClaimsFn != nil { - setClaimsFn(b) - } - - token, err := b.Build() - require.NoError(t, err, "Failed to build token") - - err = SetTokenType(token, typ) - require.NoError(t, err, "Failed to set token type") - - alg, _ := service.privateKey.Algorithm() - signed, err := jwt.Sign(token, jwt.WithKey(alg, service.privateKey)) - require.NoError(t, err, "Failed to sign token") - - return string(signed) - } - - t.Run("correctly identifies access tokens", func(t *testing.T) { - tokenString := buildTokenForType(t, AccessTokenJWTType, nil) - - tokenType, _, err := service.GetTokenType(tokenString) - require.NoError(t, err, "GetTokenType should not return an error") - assert.Equal(t, AccessTokenJWTType, tokenType, "Should identify access token type") - }) - - t.Run("correctly identifies ID tokens", func(t *testing.T) { - tokenString := buildTokenForType(t, IDTokenJWTType, nil) - - tokenType, _, err := service.GetTokenType(tokenString) - require.NoError(t, err, "GetTokenType should not return an error") - assert.Equal(t, IDTokenJWTType, tokenType, "Should identify ID token type") - }) - - t.Run("fails when token type claim is missing", func(t *testing.T) { - tokenString := buildTokenForType(t, "", nil) - - _, _, err := service.GetTokenType(tokenString) - require.Error(t, err, "GetTokenType should return an error for tokens without type claim") - assert.Contains(t, err.Error(), "failed to get token type claim", "Error message should indicate missing token type claim") - }) -} - func importKey(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, privateKeyRaw any) string { t.Helper() diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index ef6f0054..e8fd2d61 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -2,28 +2,16 @@ package service import ( "context" - "crypto/sha256" - "crypto/subtle" - "crypto/tls" - "encoding/base64" - "encoding/json" "errors" "fmt" "io" - "log/slog" "mime/multipart" "net/http" "net/url" "path" - "slices" "strings" "time" - "github.com/lestrrat-go/httprc/v3" - "github.com/lestrrat-go/httprc/v3/errsink" - "github.com/lestrrat-go/jwx/v3/jwk" - "github.com/lestrrat-go/jwx/v3/jws" - "github.com/lestrrat-go/jwx/v3/jwt" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -32,6 +20,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/utils" imageutil "github.com/pocket-id/pocket-id/backend/internal/utils/image" @@ -43,832 +32,51 @@ const ( GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" GrantTypeClientCredentials = "client_credentials" - ClientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec - AccessTokenDuration = time.Hour RefreshTokenDuration = 30 * 24 * time.Hour // 30 days - DeviceCodeDuration = 15 * time.Minute - PARDuration = 90 * time.Second - - parRequestURIPrefix = "urn:ietf:params:oauth:request_uri:" ) type OidcService struct { - db *gorm.DB - jwtService *JwtService - appConfigService *AppConfigService - auditLogService *AuditLogService - customClaimService *CustomClaimService - webAuthnService *WebAuthnService - scimService *ScimService + db *gorm.DB + jwtService *JwtService + appConfigService *AppConfigService + previewBuilder oidcClientPreviewBuilder + scimService *ScimService httpClient *http.Client - jwkCache *jwk.Cache fileStorage storage.FileStorage } +type oidcClientPreviewBuilder interface { + BuildClientPreview(ctx context.Context, client model.OidcClient, userID string, scopes []string, authenticationMethod string) (*oidc.ClientPreview, error) +} + func NewOidcService( - ctx context.Context, db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, - auditLogService *AuditLogService, - customClaimService *CustomClaimService, - webAuthnService *WebAuthnService, + previewBuilder oidcClientPreviewBuilder, scimService *ScimService, httpClient *http.Client, fileStorage storage.FileStorage, ) (s *OidcService, err error) { s = &OidcService{ - db: db, - jwtService: jwtService, - appConfigService: appConfigService, - auditLogService: auditLogService, - customClaimService: customClaimService, - webAuthnService: webAuthnService, - scimService: scimService, - httpClient: httpClient, - fileStorage: fileStorage, - } - - // Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace - s.jwkCache, err = s.getJWKCache(ctx) - if err != nil { - return nil, err + db: db, + jwtService: jwtService, + appConfigService: appConfigService, + previewBuilder: previewBuilder, + scimService: scimService, + httpClient: httpClient, + fileStorage: fileStorage, } return s, nil } -func (s *OidcService) getJWKCache(ctx context.Context) (*jwk.Cache, error) { - // We need to create a custom HTTP client to set a timeout. - client := s.httpClient - if client == nil { - client = &http.Client{ - Timeout: 20 * time.Second, - } - - defaultTransport, ok := http.DefaultTransport.(*http.Transport) - if !ok { - // Indicates a development-time error - panic("Default transport is not of type *http.Transport") - } - transport := defaultTransport.Clone() - transport.TLSClientConfig.MinVersion = tls.VersionTLS12 - client.Transport = transport - } - - // Create the JWKS cache - return jwk.NewCache(ctx, - httprc.NewClient( - httprc.WithErrorSink(errsink.NewSlog(slog.Default())), - httprc.WithHTTPClient(client), - ), - ) -} - -func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID string, authenticationMethod string, ipAddress, userAgent string) (string, string, error) { - tx := s.db.Begin() - defer tx.Rollback() - - var client model.OidcClient - err := tx. - WithContext(ctx). - Preload("AllowedUserGroups"). - First(&client, "id = ?", input.ClientID). - Error - if err != nil { - return "", "", err - } - - // If the client requires PAR, a request_uri must be provided - if client.RequiresPushedAuthorizationRequests && input.RequestURI == "" { - return "", "", &common.OidcPARRequiredError{} - } - - // If a request_uri is provided, consume the stored PAR and overwrite input fields - if input.RequestURI != "" { - if err := s.applyPushedAuthorizationRequest(ctx, tx, &input); err != nil { - return "", "", err - } - } - - // If the client is not public, the code challenge must be provided - if client.IsPublic && input.CodeChallenge == "" { - return "", "", &common.OidcMissingCodeChallengeError{} - } - - // Validate the callback URL before any prompt checks, so that prompt-related - // error responses never contain an unvalidated redirect target - callbackURL, err := s.getCallbackURL(&client, input.CallbackURL, tx, ctx) - if err != nil { - return "", "", err - } - - // Parse prompt parameter (space-delimited list per OIDC spec) - promptValues := parsePromptParameter(input.Prompt) - hasPromptNone := slices.Contains(promptValues, "none") - hasPromptLogin := slices.Contains(promptValues, "login") - hasPromptConsent := slices.Contains(promptValues, "consent") - hasPromptSelectAccount := slices.Contains(promptValues, "select_account") - - // Validate prompt parameter conflicts early. - // Per OIDC Core §3.1.2.6, prompt=none must not be combined with any - // value that requires user interaction. - if hasPromptNone && (hasPromptConsent || hasPromptLogin || hasPromptSelectAccount) { - return "", "", common.NewOidcInvalidRequestError("prompt type 'none' cannot be combined with others") - } - - // prompt=select_account is handled entirely in the UI - // Pocket ID holds one session per browser, so the frontend renders the current user as the sole selectable account and then calls Authorize normally. - - // If prompt=login is specified or the client requires reauthentication, check the reauthentication token - if hasPromptLogin || client.RequiresReauthentication { - if input.ReauthenticationToken == "" { - return "", "", &common.ReauthenticationRequiredError{} - } - - err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID) - if err != nil { - return "", "", err - } - } - - // Check if the user group is allowed to authorize the client - var user model.User - err = tx. - WithContext(ctx). - Preload("UserGroups"). - First(&user, "id = ?", userID). - Error - if err != nil { - return "", "", err - } - - if !IsUserGroupAllowedToAuthorize(user, client) { - return "", "", &common.OidcAccessDeniedError{} - } - - // Handle prompt=none - if consent would be required, we can't show UI - if hasPromptNone { - hasAlreadyAuthorized, err := s.hasAuthorizedClientInternal(ctx, input.ClientID, userID, input.Scope, tx) - if err != nil { - return "", "", err - } - if !hasAlreadyAuthorized { - return "", callbackURL, &common.OidcConsentRequiredError{} - } - } - - hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx) - if err != nil { - return "", "", err - } - - // Create the authorization code - code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, authenticationMethod, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx) - if err != nil { - return "", "", err - } - - // Log the authorization event - if hasAlreadyAuthorizedClient { - s.auditLogService.Create( - ctx, model.AuditLogEventClientAuthorization, - ipAddress, userAgent, userID, - model.AuditLogData{"clientName": client.Name}, - tx, - ) - } else { - s.auditLogService.Create( - ctx, model.AuditLogEventNewClientAuthorization, - ipAddress, userAgent, userID, - model.AuditLogData{"clientName": client.Name}, - tx, - ) - } - - err = tx.Commit().Error - if err != nil { - return "", "", fmt.Errorf("failed to commit transaction: %w", err) - } - - return code, callbackURL, nil -} - -// applyPushedAuthorizationRequest consumes the stored PAR for the given request_uri -// and overwrites the corresponding fields on input. -func (s *OidcService) applyPushedAuthorizationRequest(ctx context.Context, tx *gorm.DB, input *dto.AuthorizeOidcClientRequestDto) error { - parMeta, err := s.getAndConsumePushedAuthorizationRequest(ctx, tx, input.ClientID, input.RequestURI) - if err != nil { - return err - } - par := parMeta.Parameters - - input.Scope = par.Scope - input.CallbackURL = par.RedirectURI - input.Nonce = par.Nonce - input.Prompt = par.Prompt - input.CodeChallenge = par.CodeChallenge - input.CodeChallengeMethod = par.CodeChallengeMethod - - return nil -} - -// HasAuthorizedClient checks if the user has already authorized the client with the given scope -func (s *OidcService) HasAuthorizedClient(ctx context.Context, clientID, userID, scope string) (bool, error) { - return s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, s.db) -} - -// AuthorizationRequired reports whether the user must confirm authorization for the client. -func (s *OidcService) AuthorizationRequired(ctx context.Context, clientID, userID, scope, requestURI string) (required bool, resolvedScope string, err error) { - if requestURI != "" { - par, err := s.getPushedAuthorizationRequestInternal(ctx, s.db, clientID, requestURI) - if err != nil { - return false, "", err - } - scope = par.Parameters.Scope - } - - hasAuthorized, err := s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, s.db) - if err != nil { - return false, "", err - } - - return !hasAuthorized, scope, nil -} - -func (s *OidcService) hasAuthorizedClientInternal(ctx context.Context, clientID, userID, scope string, tx *gorm.DB) (bool, error) { - var userAuthorizedOidcClient model.UserAuthorizedOidcClient - err := tx. - WithContext(ctx). - First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID). - Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return false, err - } - - if userAuthorizedOidcClient.Scope != scope { - return false, nil - } - - return true, nil -} - -// IsUserGroupAllowedToAuthorize checks if the user group of the user is allowed to authorize the client -func IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool { - if !client.IsGroupRestricted { - return true - } - - isAllowedToAuthorize := false - for _, userGroup := range client.AllowedUserGroups { - for _, userGroupUser := range user.UserGroups { - if userGroup.ID == userGroupUser.ID { - isAllowedToAuthorize = true - break - } - } - } - - return isAllowedToAuthorize -} - -type CreatedTokens struct { - IdToken string - AccessToken string - RefreshToken string - ExpiresIn time.Duration -} - -func (s *OidcService) CreateTokens(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) { - switch input.GrantType { - case GrantTypeAuthorizationCode: - return s.createTokenFromAuthorizationCode(ctx, input) - case GrantTypeRefreshToken: - return s.createTokenFromRefreshToken(ctx, input) - case GrantTypeDeviceCode: - return s.createTokenFromDeviceCode(ctx, input) - case GrantTypeClientCredentials: - return s.createTokenFromClientCredentials(ctx, input) - default: - return CreatedTokens{}, &common.OidcGrantTypeNotSupportedError{} - } -} - -func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) { - tx := s.db.Begin() - defer func() { - tx.Rollback() - }() - - _, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true) - if err != nil { - return CreatedTokens{}, err - } - - // Get the device authorization from database with explicit query conditions - var deviceAuth model.OidcDeviceCode - err = tx. - WithContext(ctx). - Preload("User"). - Where("device_code = ? AND client_id = ?", input.DeviceCode, input.ClientID). - First(&deviceAuth). - Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return CreatedTokens{}, &common.OidcInvalidDeviceCodeError{} - } - return CreatedTokens{}, err - } - - // Check if device code has expired - if time.Now().After(deviceAuth.ExpiresAt.ToTime()) { - return CreatedTokens{}, &common.OidcDeviceCodeExpiredError{} - } - - // Check if device code has been authorized - if !deviceAuth.IsAuthorized || deviceAuth.UserID == nil { - return CreatedTokens{}, &common.OidcAuthorizationPendingError{} - } - - // Get user claims for the ID token - ensure UserID is not nil - if deviceAuth.UserID == nil { - return CreatedTokens{}, &common.OidcAuthorizationPendingError{} - } - - userClaims, err := s.getUserClaimsForClientInternal(ctx, *deviceAuth.UserID, input.ClientID, tx) - if err != nil { - return CreatedTokens{}, err - } - - // Explicitly use the input clientID for the audience claim to ensure consistency - idToken, idTokenJti, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, deviceAuth.Nonce, deviceAuth.AuthenticationMethod) - if err != nil { - return CreatedTokens{}, err - } - - refreshToken, err := s.createRefreshToken(ctx, input.ClientID, *deviceAuth.UserID, deviceAuth.Scope, deviceAuth.AuthenticationMethod, idTokenJti, tx) - if err != nil { - return CreatedTokens{}, err - } - - accessToken, err := s.jwtService.GenerateOAuthAccessToken(deviceAuth.User, input.ClientID, deviceAuth.AuthenticationMethod) - if err != nil { - return CreatedTokens{}, err - } - - // Delete the used device code - err = tx.WithContext(ctx).Delete(&deviceAuth).Error - if err != nil { - return CreatedTokens{}, err - } - - err = tx.Commit().Error - if err != nil { - return CreatedTokens{}, err - } - - return CreatedTokens{ - IdToken: idToken, - AccessToken: accessToken, - RefreshToken: refreshToken, - ExpiresIn: AccessTokenDuration, - }, nil -} - -func (s *OidcService) createTokenFromClientCredentials(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) { - client, err := s.verifyClientCredentialsInternal(ctx, s.db, clientAuthCredentialsFromCreateTokensDto(&input), false) - if err != nil { - return CreatedTokens{}, err - } - - // GenerateOAuthAccessToken uses user.ID as a "sub" claim. Prefix is used to take those security considerations - // into account: https://datatracker.ietf.org/doc/html/rfc9068#name-security-considerations - dummyUser := model.User{ - Base: model.Base{ID: "client-" + client.ID}, - } - - audClaim := client.ID - if input.Resource != "" { - audClaim = input.Resource - } - - accessToken, err := s.jwtService.GenerateOAuthAccessToken(dummyUser, audClaim, "") - if err != nil { - return CreatedTokens{}, err - } - - return CreatedTokens{ - AccessToken: accessToken, - ExpiresIn: AccessTokenDuration, - }, nil -} - -func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) { - tx := s.db.Begin() - defer func() { - tx.Rollback() - }() - - client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true) - if err != nil { - return CreatedTokens{}, err - } - - var authorizationCodeMetaData model.OidcAuthorizationCode - err = tx. - WithContext(ctx). - Preload("User"). - First(&authorizationCodeMetaData, "code = ?", input.Code). - Error - if err != nil { - return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{} - } - - // If the client is public or PKCE is enabled, the code verifier must match the code challenge - if client.IsPublic || client.PkceEnabled { - if !validateCodeVerifier(input.CodeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) { - return CreatedTokens{}, &common.OidcInvalidCodeVerifierError{} - } - } - - if authorizationCodeMetaData.ClientID != input.ClientID || authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) { - return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{} - } - - userClaims, err := s.getUserClaimsForClientInternal(ctx, authorizationCodeMetaData.UserID, input.ClientID, tx) - if err != nil { - return CreatedTokens{}, err - } - - authenticationMethod := authorizationCodeMetaData.AuthenticationMethod - - idToken, idTokenJti, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, authorizationCodeMetaData.Nonce, authenticationMethod) - if err != nil { - return CreatedTokens{}, err - } - - // Generate a refresh token - refreshToken, err := s.createRefreshToken(ctx, input.ClientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope, authenticationMethod, idTokenJti, tx) - if err != nil { - return CreatedTokens{}, err - } - - accessToken, err := s.jwtService.GenerateOAuthAccessToken(authorizationCodeMetaData.User, input.ClientID, authenticationMethod) - if err != nil { - return CreatedTokens{}, err - } - - err = tx. - WithContext(ctx). - Delete(&authorizationCodeMetaData). - Error - if err != nil { - return CreatedTokens{}, err - } - - err = tx.Commit().Error - if err != nil { - return CreatedTokens{}, err - } - - return CreatedTokens{ - IdToken: idToken, - AccessToken: accessToken, - RefreshToken: refreshToken, - ExpiresIn: AccessTokenDuration, - }, nil -} - -func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) { - if input.RefreshToken == "" { - return CreatedTokens{}, &common.OidcMissingRefreshTokenError{} - } - - // Validate the signed refresh token and extract the actual token (which is a claim in the signed one) - userID, clientID, rt, err := s.jwtService.VerifyOAuthRefreshToken(input.RefreshToken) - if err != nil { - return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{} - } - - tx := s.db.Begin() - defer func() { - tx.Rollback() - }() - - client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true) - if err != nil { - return CreatedTokens{}, err - } - - // The ID of the client that made the call must match the client ID in the token - if client.ID != clientID { - return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{} - } - - // Verify refresh token - var storedRefreshToken model.OidcRefreshToken - err = tx. - WithContext(ctx). - Preload("User.UserGroups"). - Where( - "token = ? AND expires_at > ? AND user_id = ? AND client_id = ?", - utils.CreateSha256Hash(rt), - datatype.DateTime(time.Now()), - userID, - input.ClientID, - ). - First(&storedRefreshToken). - Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{} - } else if err != nil { - return CreatedTokens{}, err - } - - // Verify that the refresh token belongs to the provided client - if storedRefreshToken.ClientID != input.ClientID { - return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{} - } - - if storedRefreshToken.User.Disabled { - return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{} - } - - var authorizedClient model.UserAuthorizedOidcClient - err = tx. - WithContext(ctx). - Where("user_id = ? AND client_id = ?", storedRefreshToken.UserID, input.ClientID). - First(&authorizedClient). - Error - if errors.Is(err, gorm.ErrRecordNotFound) { - err = tx.WithContext(ctx).Delete(&storedRefreshToken).Error - if err != nil { - return CreatedTokens{}, err - } - - err = tx.Commit().Error - if err != nil { - return CreatedTokens{}, err - } - - return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{} - } else if err != nil { - return CreatedTokens{}, err - } - - if client.IsGroupRestricted { - err = tx.WithContext(ctx).Model(client).Association("AllowedUserGroups").Find(&client.AllowedUserGroups) - if err != nil { - return CreatedTokens{}, err - } - } - - if !IsUserGroupAllowedToAuthorize(storedRefreshToken.User, *client) { - return CreatedTokens{}, &common.OidcAccessDeniedError{} - } - - // Generate a new access token - authenticationMethods := storedRefreshToken.AuthenticationMethod - accessToken, err := s.jwtService.GenerateOAuthAccessToken(storedRefreshToken.User, input.ClientID, authenticationMethods) - if err != nil { - return CreatedTokens{}, err - } - - // Load the profile, which we need for the ID token - userClaims, err := s.getUserClaims(ctx, &storedRefreshToken.User, storedRefreshToken.Scopes(), tx) - if err != nil { - return CreatedTokens{}, err - } - - // Generate a new ID token - // There's no nonce here because we don't have one with the refresh token, but that's not required - idToken, idTokenJti, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "", authenticationMethods) - if err != nil { - return CreatedTokens{}, err - } - - // Generate a new refresh token and invalidate the old one - newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, authenticationMethods, idTokenJti, tx) - if err != nil { - return CreatedTokens{}, err - } - - // Delete the used refresh token - err = tx. - WithContext(ctx). - Delete(&storedRefreshToken). - Error - if err != nil { - return CreatedTokens{}, err - } - - err = tx.Commit().Error - if err != nil { - return CreatedTokens{}, err - } - - return CreatedTokens{ - AccessToken: accessToken, - RefreshToken: newRefreshToken, - IdToken: idToken, - ExpiresIn: AccessTokenDuration, - }, nil -} - -func (s *OidcService) IntrospectToken(ctx context.Context, creds ClientAuthCredentials, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) { - client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds, false) - if err != nil { - return introspectDto, err - } - - // Get the type of the token and the client ID - tokenType, token, err := s.jwtService.GetTokenType(tokenString) - if err != nil { - // We just treat the token as invalid - introspectDto.Active = false - return introspectDto, nil //nolint:nilerr - } - - // Get the audience from the token - tokenAudiences, _ := token.Audience() - if len(tokenAudiences) != 1 || tokenAudiences[0] == "" { - introspectDto.Active = false - return introspectDto, nil - } - - // Audience must match the client ID - if client.ID != tokenAudiences[0] { - return introspectDto, &common.OidcMissingClientCredentialsError{} - } - - // Introspect the token - switch tokenType { - case OAuthAccessTokenJWTType: - return s.introspectAccessToken(client.ID, tokenString) - case OAuthRefreshTokenJWTType: - return s.introspectRefreshToken(ctx, client.ID, tokenString) - default: - // We just treat the token as invalid - introspectDto.Active = false - return introspectDto, nil - } -} - -func (s *OidcService) introspectAccessToken(clientID string, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) { - token, err := s.jwtService.VerifyOAuthAccessToken(tokenString) - if err != nil { - // Every failure we get means the token is invalid. Nothing more to do with the error. - introspectDto.Active = false - return introspectDto, nil //nolint:nilerr - } - - // The ID of the client that made the request must match the client ID in the token - audience, ok := token.Audience() - if !ok || len(audience) != 1 || audience[0] == "" { - introspectDto.Active = false - return introspectDto, nil - } - if audience[0] != clientID { - return introspectDto, &common.OidcMissingClientCredentialsError{} - } - - introspectDto.Active = true - introspectDto.TokenType = "access_token" - introspectDto.Audience = audience - if token.Has("scope") { - var ( - asString string - asStrings []string - ) - if err := token.Get("scope", &asString); err == nil { - introspectDto.Scope = asString - } else if err := token.Get("scope", &asStrings); err == nil { - introspectDto.Scope = strings.Join(asStrings, " ") - } - } - if expiration, ok := token.Expiration(); ok { - introspectDto.Expiration = expiration.Unix() - } - if issuedAt, ok := token.IssuedAt(); ok { - introspectDto.IssuedAt = issuedAt.Unix() - } - if notBefore, ok := token.NotBefore(); ok { - introspectDto.NotBefore = notBefore.Unix() - } - if subject, ok := token.Subject(); ok { - introspectDto.Subject = subject - } - if issuer, ok := token.Issuer(); ok { - introspectDto.Issuer = issuer - } - if identifier, ok := token.JwtID(); ok { - introspectDto.Identifier = identifier - } - - return introspectDto, nil -} - -func (s *OidcService) introspectRefreshToken(ctx context.Context, clientID string, refreshToken string) (introspectDto dto.OidcIntrospectionResponseDto, err error) { - // Validate the signed refresh token and extract the actual token (which is a claim in the signed one) - tokenUserID, tokenClientID, tokenRT, err := s.jwtService.VerifyOAuthRefreshToken(refreshToken) - if err != nil { - return introspectDto, fmt.Errorf("invalid refresh token: %w", err) - } - - // The ID of the client that made the call must match the client ID in the token - if tokenClientID != clientID { - return introspectDto, errors.New("invalid refresh token: client ID does not match") - } - - var storedRefreshToken model.OidcRefreshToken - err = s.db. - WithContext(ctx). - Preload("User"). - Where( - "token = ? AND expires_at > ? AND user_id = ? AND client_id = ?", - utils.CreateSha256Hash(tokenRT), - datatype.DateTime(time.Now()), - tokenUserID, - tokenClientID, - ). - First(&storedRefreshToken). - Error - if errors.Is(err, gorm.ErrRecordNotFound) { - introspectDto.Active = false - return introspectDto, nil - } else if err != nil { - return introspectDto, err - } - - introspectDto.Active = true - introspectDto.TokenType = "refresh_token" - return introspectDto, nil -} - func (s *OidcService) GetClient(ctx context.Context, clientID string) (model.OidcClient, error) { return s.getClientInternal(ctx, clientID, s.db, false) } -func (s *OidcService) ResolveAllowedCallbackURL(ctx context.Context, clientID, inputCallbackURL string) (string, error) { - client, err := s.GetClient(ctx, clientID) - if err != nil { - return "", err - } - - return resolveConfiguredCallbackURL(&client, inputCallbackURL) -} - -// ResolveAuthorizeCallbackURL resolves the callback URL for a browser authorization -// request without authorizing the user or consuming PAR state. -func (s *OidcService) ResolveAuthorizeCallbackURL(ctx context.Context, clientID, inputCallbackURL, requestURI string) (string, error) { - client, err := s.GetClient(ctx, clientID) - if err != nil { - return "", err - } - - if client.RequiresPushedAuthorizationRequests && requestURI == "" { - return "", &common.OidcPARRequiredError{} - } - - if requestURI != "" { - par, err := s.GetPushedAuthorizationRequest(ctx, clientID, requestURI) - if err != nil { - return "", err - } - inputCallbackURL = par.Parameters.RedirectURI - } - - return resolveConfiguredCallbackURL(&client, inputCallbackURL) -} - -func resolveConfiguredCallbackURL(client *model.OidcClient, inputCallbackURL string) (string, error) { - if inputCallbackURL == "" { - if len(client.CallbackURLs) > 0 { - return client.CallbackURLs[0], nil - } - return "", &common.OidcMissingCallbackURLError{} - } - - if len(client.CallbackURLs) == 0 { - return "", &common.OidcMissingCallbackURLError{} - } - - matched, err := utils.GetCallbackURLFromList(client.CallbackURLs, inputCallbackURL) - if err != nil { - return "", err - } - if matched == "" { - return "", &common.OidcInvalidCallbackURLError{} - } - - return matched, nil -} - func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx *gorm.DB, forUpdate bool) (model.OidcClient, error) { var client model.OidcClient q := tx. @@ -1011,8 +219,7 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien // PKCE is required for public clients client.PkceEnabled = input.IsPublic || input.PkceEnabled client.RequiresReauthentication = input.RequiresReauthentication - // PAR is not available for public clients, so ignore the flag if the client is public - client.RequiresPushedAuthorizationRequests = !input.IsPublic && input.RequiresPushedAuthorizationRequests + client.RequiresPushedAuthorizationRequests = input.RequiresPushedAuthorizationRequests client.LaunchURL = input.LaunchURL client.IsGroupRestricted = input.IsGroupRestricted @@ -1020,10 +227,11 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities)) for i, fi := range input.Credentials.FederatedIdentities { client.Credentials.FederatedIdentities[i] = model.OidcClientFederatedIdentity{ - Issuer: fi.Issuer, - Audience: fi.Audience, - Subject: fi.Subject, - JWKS: fi.JWKS, + Issuer: fi.Issuer, + Audience: fi.Audience, + Subject: fi.Subject, + JWKS: fi.JWKS, + ReplayProtection: fi.ReplayProtection, } } @@ -1288,476 +496,6 @@ func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, in return client, nil } -// ValidateEndSession returns the logout callback URL for the client if all the validations pass -func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogoutDto, userID string) (callbackURL string, err error) { - // If no ID token hint is provided, return an error - if input.IdTokenHint == "" { - return "", &common.TokenInvalidError{} - } - - // If the ID token hint is provided, verify the ID token - // Here we also accept expired ID tokens, which are fine per spec - token, err := s.jwtService.VerifyIdToken(input.IdTokenHint, true) - if err != nil { - return "", &common.TokenInvalidError{} - } - - // If the client ID is provided check if the client ID in the ID token matches the client ID in the request - clientID, ok := token.Audience() - if !ok || len(clientID) == 0 { - return "", &common.TokenInvalidError{} - } - if input.ClientId != "" && clientID[0] != input.ClientId { - return "", &common.OidcClientIdNotMatchingError{} - } - - subject, ok := token.Subject() - if !ok || subject != userID { - return "", &common.TokenInvalidError{} - } - - idTokenJti, ok := token.JwtID() - if !ok { - return "", &common.TokenInvalidError{} - } - - tx := s.db.Begin() - defer tx.Rollback() - - // Check if the user has authorized the client before - var userAuthorizedOIDCClient model.UserAuthorizedOidcClient - err = tx. - WithContext(ctx). - Preload("Client"). - First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientID[0], userID). - Error - if err != nil { - return "", &common.OidcMissingAuthorizationError{} - } - - // If the client has a callback URL, validate it - if len(userAuthorizedOIDCClient.Client.LogoutCallbackURLs) > 0 { - callbackURL, err = s.getLogoutCallbackURL(&userAuthorizedOIDCClient.Client, input.PostLogoutRedirectUri) - if err != nil { - return "", err - } - } - - err = tx. - WithContext(ctx). - Where("user_id = ? AND client_id = ? AND id_token_jti = ?", userID, clientID[0], idTokenJti). - Delete(&model.OidcRefreshToken{}). - Error - if err != nil { - return "", err - } - - if err := tx.Commit().Error; err != nil { - return "", fmt.Errorf("failed to commit transaction: %w", err) - } - - return callbackURL, nil -} - -func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, authenticationMethod string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) { - randomString, err := utils.GenerateRandomAlphanumericString(32) - if err != nil { - return "", err - } - - codeChallengeMethodSha256, err := codeChallengeMethodIsSha256(codeChallengeMethod) - if err != nil { - return "", err - } - - oidcAuthorizationCode := model.OidcAuthorizationCode{ - ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)), - Code: randomString, - ClientID: clientID, - UserID: userID, - Scope: scope, - AuthenticationMethod: authenticationMethod, - Nonce: nonce, - CodeChallenge: &codeChallenge, - CodeChallengeMethodSha256: &codeChallengeMethodSha256, - } - - err = tx. - WithContext(ctx). - Create(&oidcAuthorizationCode). - Error - if err != nil { - return "", err - } - - return randomString, nil -} - -func codeChallengeMethodIsSha256(codeChallengeMethod string) (bool, error) { - switch strings.ToUpper(codeChallengeMethod) { - case "": - return false, nil - case "PLAIN": - return false, nil - case "S256": - return true, nil - default: - return false, common.NewOidcInvalidRequestError("code challenge method not supported") - } -} - -// CreatePushedAuthorizationRequest validates and stores authorization parameters for PAR (RFC 9126). -// Only confidential clients (non-public) may use this endpoint. -func (s *OidcService) CreatePushedAuthorizationRequest(ctx context.Context, creds ClientAuthCredentials, input dto.OidcPARRequestDto) (requestURI string, expiresIn int, err error) { - // Public clients are not allowed, but we allow them in this step for better error messages - client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds, true) - if err != nil { - return "", 0, err - } - - // Reject public clients here - if client.IsPublic { - return "", 0, &common.OidcPARNotSupportedForPublicClientsError{} - } - - if input.ResponseType != "code" { - return "", 0, common.NewOidcInvalidRequestError("unsupported response_type: only 'code' is supported") - } - - // Validate redirect_uri at push time (BCP requirement) - if _, err = s.getCallbackURL(client, input.RedirectURI, s.db, ctx); err != nil { - return "", 0, err - } - - randomSuffix, err := utils.GenerateRandomAlphanumericString(32) - if err != nil { - return "", 0, fmt.Errorf("failed to generate request URI: %w", err) - } - requestURI = parRequestURIPrefix + randomSuffix - - par := model.OidcPushedAuthorizationRequest{ - RequestURI: requestURI, - ClientID: client.ID, - ExpiresAt: datatype.DateTime(time.Now().Add(PARDuration)), - Parameters: model.OidcAuthorizationRequestParameters{ - Scope: input.Scope, - RedirectURI: input.RedirectURI, - State: input.State, - Nonce: input.Nonce, - CodeChallenge: input.CodeChallenge, - CodeChallengeMethod: input.CodeChallengeMethod, - ResponseType: input.ResponseType, - Prompt: input.Prompt, - ResponseMode: input.ResponseMode, - }, - } - - if err = s.db.WithContext(ctx).Create(&par).Error; err != nil { - return "", 0, fmt.Errorf("failed to store pushed authorization request: %w", err) - } - - return requestURI, int(PARDuration.Seconds()), nil -} - -// GetPushedAuthorizationRequest retrieves a PAR record without consuming it. -func (s *OidcService) GetPushedAuthorizationRequest(ctx context.Context, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) { - return s.getPushedAuthorizationRequestInternal(ctx, s.db, clientID, requestURI) -} - -// getPushedAuthorizationRequestInternal retrieves a PAR record without consuming it. -func (s *OidcService) getPushedAuthorizationRequestInternal(ctx context.Context, tx *gorm.DB, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) { - var par model.OidcPushedAuthorizationRequest - err := tx. - WithContext(ctx). - Where( - "request_uri = ? AND client_id = ? AND expires_at > ?", - requestURI, - clientID, - datatype.DateTime(time.Now()), - ). - First(&par). - Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return par, &common.OidcInvalidRequestURIError{} - } - return par, err - } - - return par, nil -} - -// getAndConsumePushedAuthorizationRequest atomically retrieves and deletes a PAR record. -// Returns OidcInvalidRequestURIError if the record is not found, expired, or belongs to a different client. -func (s *OidcService) getAndConsumePushedAuthorizationRequest(ctx context.Context, tx *gorm.DB, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) { - var par model.OidcPushedAuthorizationRequest - err := tx. - WithContext(ctx). - Clauses(clause.Returning{}). - Where( - "request_uri = ? AND client_id = ? AND expires_at > ?", - requestURI, - clientID, - datatype.DateTime(time.Now()), - ). - Delete(&par). - Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return par, &common.OidcInvalidRequestURIError{} - } - return par, err - } - // After DELETE … RETURNING, check if a row was actually deleted - if par.ID == "" { - return par, &common.OidcInvalidRequestURIError{} - } - - return par, nil -} - -func validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool { - if codeVerifier == "" || codeChallenge == "" { - return false - } - - if !codeChallengeMethodSha256 { - return subtle.ConstantTimeCompare([]byte(codeVerifier), []byte(codeChallenge)) == 1 - } - - // Base64 URL decode the challenge - // If it's not valid base64url, fail the operation - codeChallengeBytes, err := base64.RawURLEncoding.DecodeString(codeChallenge) - if err != nil { - return false - } - - // Compute SHA-256 hash of the codeVerifier - h := sha256.New() - h.Write([]byte(codeVerifier)) - codeVerifierHash := h.Sum(nil) - - return subtle.ConstantTimeCompare(codeVerifierHash, codeChallengeBytes) == 1 -} - -func (s *OidcService) getCallbackURL(client *model.OidcClient, inputCallbackURL string, tx *gorm.DB, ctx context.Context) (callbackURL string, err error) { - // If no input callback URL provided, use the first configured URL - if inputCallbackURL == "" { - if len(client.CallbackURLs) > 0 { - return client.CallbackURLs[0], nil - } - // If no URLs are configured and no input URL, this is an error - return "", &common.OidcMissingCallbackURLError{} - } - - // If URLs are already configured, validate against them - if len(client.CallbackURLs) > 0 { - matched, err := utils.GetCallbackURLFromList(client.CallbackURLs, inputCallbackURL) - if err != nil { - return "", err - } else if matched == "" { - return "", &common.OidcInvalidCallbackURLError{} - } - - return matched, nil - } - - // If no URLs are configured, trust and store the first URL (TOFU) - err = s.addCallbackURLToClient(ctx, client, inputCallbackURL, tx) - if err != nil { - return "", err - } - return inputCallbackURL, nil -} - -func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogoutCallbackURL string) (callbackURL string, err error) { - if inputLogoutCallbackURL == "" { - return client.LogoutCallbackURLs[0], nil - } - - matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL) - if err != nil { - return "", err - } else if matched == "" { - return "", &common.OidcInvalidCallbackURLError{} - } - - return matched, nil -} - -func (s *OidcService) addCallbackURLToClient(ctx context.Context, client *model.OidcClient, callbackURL string, tx *gorm.DB) error { - // Add the new callback URL to the existing list - client.CallbackURLs = append(client.CallbackURLs, callbackURL) - - err := tx.WithContext(ctx).Save(client).Error - if err != nil { - return err - } - - return nil -} - -func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.OidcDeviceAuthorizationRequestDto) (*dto.OidcDeviceAuthorizationResponseDto, error) { - client, err := s.verifyClientCredentialsInternal(ctx, s.db, ClientAuthCredentials{ - ClientID: input.ClientID, - ClientSecret: input.ClientSecret, - ClientAssertionType: input.ClientAssertionType, - ClientAssertion: input.ClientAssertion, - }, true) - if err != nil { - return nil, err - } - - // Generate codes - deviceCode, err := utils.GenerateRandomAlphanumericString(32) - if err != nil { - return nil, err - } - userCode, err := utils.GenerateRandomAlphanumericString(8) - if err != nil { - return nil, err - } - - // Create device authorization - deviceAuth := &model.OidcDeviceCode{ - DeviceCode: deviceCode, - UserCode: userCode, - Scope: input.Scope, - ExpiresAt: datatype.DateTime(time.Now().Add(DeviceCodeDuration)), - IsAuthorized: false, - ClientID: client.ID, - Nonce: input.Nonce, - } - - if err := s.db.Create(deviceAuth).Error; err != nil { - return nil, err - } - - return &dto.OidcDeviceAuthorizationResponseDto{ - DeviceCode: deviceCode, - UserCode: userCode, - VerificationURI: common.EnvConfig.AppURL + "/device", - VerificationURIComplete: common.EnvConfig.AppURL + "/device?code=" + userCode, - ExpiresIn: int(DeviceCodeDuration.Seconds()), - Interval: 5, - }, nil -} - -func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, userID string, authenticationMethod string, ipAddress string, userAgent string) error { - tx := s.db.Begin() - defer func() { - tx.Rollback() - }() - - var deviceAuth model.OidcDeviceCode - err := tx. - WithContext(ctx). - Preload("Client.AllowedUserGroups"). - First(&deviceAuth, "user_code = ?", userCode). - Error - if err != nil { - return fmt.Errorf("error finding device code: %w", err) - } - - if time.Now().After(deviceAuth.ExpiresAt.ToTime()) { - return &common.OidcDeviceCodeExpiredError{} - } - - // Check if the user group is allowed to authorize the client - var user model.User - err = tx. - WithContext(ctx). - Preload("UserGroups"). - First(&user, "id = ?", userID). - Error - if err != nil { - return fmt.Errorf("error finding user groups: %w", err) - } - - if !IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) { - return &common.OidcAccessDeniedError{} - } - - err = tx. - WithContext(ctx). - Preload("Client"). - First(&deviceAuth, "user_code = ?", userCode). - Error - if err != nil { - return fmt.Errorf("error finding device code: %w", err) - } - - if time.Now().After(deviceAuth.ExpiresAt.ToTime()) { - return &common.OidcDeviceCodeExpiredError{} - } - - deviceAuth.UserID = &userID - deviceAuth.AuthenticationMethod = authenticationMethod - deviceAuth.IsAuthorized = true - - err = tx. - WithContext(ctx). - Save(&deviceAuth). - Error - if err != nil { - return fmt.Errorf("error saving device auth: %w", err) - } - - hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, deviceAuth.ClientID, deviceAuth.Scope, tx) - if err != nil { - return err - } - - auditLogData := model.AuditLogData{"clientName": deviceAuth.Client.Name} - if hasAlreadyAuthorizedClient { - s.auditLogService.Create(ctx, model.AuditLogEventDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx) - } else { - s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx) - } - - return tx.Commit().Error -} - -func (s *OidcService) GetDeviceCodeInfo(ctx context.Context, userCode string, userID string) (*dto.DeviceCodeInfoDto, error) { - var deviceAuth model.OidcDeviceCode - err := s.db. - WithContext(ctx). - Preload("Client"). - First(&deviceAuth, "user_code = ?", userCode). - Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, &common.OidcInvalidDeviceCodeError{} - } - return nil, err - } - - if time.Now().After(deviceAuth.ExpiresAt.ToTime()) { - return nil, &common.OidcDeviceCodeExpiredError{} - } - - // Check if the user has already authorized this client with this scope - hasAuthorizedClient := false - if userID != "" { - var err error - hasAuthorizedClient, err = s.HasAuthorizedClient(ctx, deviceAuth.ClientID, userID, deviceAuth.Scope) - if err != nil { - return nil, err - } - } - - return &dto.DeviceCodeInfoDto{ - Client: dto.OidcClientMetaDataDto{ - ID: deviceAuth.Client.ID, - Name: deviceAuth.Client.Name, - HasLogo: deviceAuth.Client.HasLogo(), - HasDarkLogo: deviceAuth.Client.HasDarkLogo(), - }, - Scope: deviceAuth.Scope, - AuthorizationRequired: !hasAuthorizedClient, - }, nil -} - func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id string) (int64, error) { // We only perform select queries here, so we can rollback in all cases tx := s.db.Begin() @@ -1809,12 +547,7 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string, return err } - err = tx. - WithContext(ctx). - Where("user_id = ? AND client_id = ?", userID, clientID). - Delete(&model.OidcRefreshToken{}). - Error - if err != nil { + if err = oidc.RevokeUserClientSessions(ctx, tx, userID, clientID); err != nil { return err } @@ -1905,256 +638,14 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri return dtos, response, err } -func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, authenticationMethod string, idTokenJti string, tx *gorm.DB) (string, error) { - refreshToken, err := utils.GenerateRandomAlphanumericString(40) - if err != nil { - return "", err - } - - // Compute the hash of the refresh token to store in the DB - // Refresh tokens are pretty long already, so a "simple" SHA-256 hash is enough - refreshTokenHash := utils.CreateSha256Hash(refreshToken) - - m := model.OidcRefreshToken{ - ExpiresAt: datatype.DateTime(time.Now().Add(RefreshTokenDuration)), - Token: refreshTokenHash, - IdTokenJti: &idTokenJti, - ClientID: clientID, - UserID: userID, - Scope: scope, - AuthenticationMethod: authenticationMethod, - } - - err = tx. - WithContext(ctx). - Create(&m). - Error - if err != nil { - return "", err - } - - // Sign the refresh token - signed, err := s.jwtService.GenerateOAuthRefreshToken(userID, clientID, refreshToken) - if err != nil { - return "", fmt.Errorf("failed to sign refresh token: %w", err) - } - - return signed, nil -} - -func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) (hasAlreadyAuthorizedClient bool, err error) { - - // Check if the user has already authorized the client with the given scope - hasAlreadyAuthorizedClient, err = s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, tx) - if err != nil { - return false, err - } - - if hasAlreadyAuthorizedClient { - err = tx. - WithContext(ctx). - Model(&model.UserAuthorizedOidcClient{}). - Where("user_id = ? AND client_id = ?", userID, clientID). - Update("last_used_at", datatype.DateTime(time.Now())). - Error - - if err != nil { - return hasAlreadyAuthorizedClient, err - } - - return hasAlreadyAuthorizedClient, nil - } - - userAuthorizedClient := model.UserAuthorizedOidcClient{ - UserID: userID, - ClientID: clientID, - Scope: scope, - LastUsedAt: datatype.DateTime(time.Now()), - } - - err = tx.WithContext(ctx). - Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}}, - DoUpdates: clause.AssignmentColumns([]string{"scope"}), - }). - Create(&userAuthorizedClient). - Error - - return hasAlreadyAuthorizedClient, err -} - -type ClientAuthCredentials struct { - ClientID string - ClientSecret string - ClientAssertion string - ClientAssertionType string -} - -func clientAuthCredentialsFromCreateTokensDto(d *dto.OidcCreateTokensDto) ClientAuthCredentials { - return ClientAuthCredentials{ - ClientID: d.ClientID, - ClientSecret: d.ClientSecret, - ClientAssertion: d.ClientAssertion, - ClientAssertionType: d.ClientAssertionType, - } -} - -func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials, allowPublicClientsWithoutAuth bool) (client *model.OidcClient, err error) { - if input.ClientID == "" { - return nil, &common.OidcMissingClientCredentialsError{} - } - - // Load the OIDC client's configuration - err = tx. - WithContext(ctx). - First(&client, "id = ?", input.ClientID). - Error - if errors.Is(err, gorm.ErrRecordNotFound) { - slog.WarnContext(ctx, "Client not found", slog.String("client", input.ClientID)) - return nil, &common.OidcClientNotFoundError{} - } else if err != nil { - return nil, err - } - - // Validate credentials based on the authentication method - switch { - // First, if we have a client secret, we validate it unless client is marked as public - case input.ClientSecret != "" && !client.IsPublic: - err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(input.ClientSecret)) - if err != nil { - return nil, &common.OidcClientSecretInvalidError{} - } - return client, nil - - // Next, check if we want to use client assertions from federated identities - case input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != "": - err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input) - if err != nil { - slog.WarnContext(ctx, "Invalid assertion for client", slog.String("client", client.ID), slog.Any("error", err)) - return nil, &common.OidcClientAssertionInvalidError{} - } - return client, nil - - // There's no credentials - // This is allowed only if the client is public - case client.IsPublic && allowPublicClientsWithoutAuth: - return client, nil - - // If we're here, we have no credentials AND the client is not public, so credentials are required - default: - return nil, &common.OidcMissingClientCredentialsError{} - } -} - -func (s *OidcService) jwkSetForURL(ctx context.Context, url string) (set jwk.Set, err error) { - // Check if we have already registered the URL - if !s.jwkCache.IsRegistered(ctx, url) { - // We set a timeout because otherwise Register will keep trying in case of errors - registerCtx, registerCancel := context.WithTimeout(ctx, 15*time.Second) - defer registerCancel() - - registerOptions := []jwk.RegisterOption{ - jwk.WithMaxInterval(24 * time.Hour), - jwk.WithMinInterval(15 * time.Minute), - jwk.WithWaitReady(true), - } - if s.httpClient != nil { - registerOptions = append(registerOptions, jwk.WithHTTPClient(s.httpClient)) - } - - // We need to register the URL - err = s.jwkCache.Register(registerCtx, url, registerOptions...) - // In case of race conditions (two goroutines calling jwkCache.Register at the same time), it's possible we can get a conflict anyways, so we ignore that error - if err != nil && !errors.Is(err, httprc.ErrResourceAlreadyExists()) { - return nil, fmt.Errorf("failed to register JWK set: %w", err) - } - } - - jwks, err := s.jwkCache.CachedSet(url) - if err != nil { - return nil, fmt.Errorf("failed to get cached JWK set: %w", err) - } - - return jwks, nil -} - -func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.Context, client *model.OidcClient, input ClientAuthCredentials) error { - // First, parse the assertion JWT, without validating it, to check the issuer - assertion := []byte(input.ClientAssertion) - insecureToken, err := jwt.ParseInsecure(assertion) - if err != nil { - return fmt.Errorf("failed to parse client assertion JWT: %w", err) - } - - issuer, _ := insecureToken.Issuer() - if issuer == "" { - return errors.New("client assertion does not contain an issuer claim") - } - - // Ensure that this client is federated with the one that issued the token - ocfi, ok := client.Credentials.FederatedIdentityForIssuer(issuer) - if !ok { - return fmt.Errorf("client assertion is not from an allowed issuer: %s", issuer) - } - - // Get the JWK set for the issuer - jwksURL := ocfi.JWKS - if jwksURL == "" { - // Default URL is from the issuer - if strings.HasSuffix(issuer, "/") { - jwksURL = issuer + ".well-known/jwks.json" - } else { - jwksURL = issuer + "/.well-known/jwks.json" - } - } - jwks, err := s.jwkSetForURL(ctx, jwksURL) - if err != nil { - return fmt.Errorf("failed to get JWK set for issuer '%s': %w", issuer, err) - } - - // Set default audience and subject if missing - audience := ocfi.Audience - if audience == "" { - // Default to the Pocket ID's URL - audience = common.EnvConfig.AppURL - } - subject := ocfi.Subject - if subject == "" { - // Default to the client ID, per RFC 7523 - subject = client.ID - } - - // Now re-parse the token with proper validation - // (Note: we don't use jwt.WithIssuer() because that would be redundant) - _, err = jwt.Parse(assertion, - jwt.WithValidate(true), - - jwt.WithAcceptableSkew(clockSkew), - jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)), - jwt.WithAudience(audience), - jwt.WithSubject(subject), - ) - if err != nil { - return fmt.Errorf("client assertion could not be verified: %w", err) - } - - // If we're here, the assertion is valid - return nil -} - func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string, authenticationMethod string) (*dto.OidcClientPreviewDto, error) { - tx := s.db.Begin() - defer func() { - tx.Rollback() - }() - - client, err := s.getClientInternal(ctx, clientID, tx, false) + client, err := s.getClientInternal(ctx, clientID, s.db, false) if err != nil { return nil, err } var user model.User - err = tx. + err = s.db. WithContext(ctx). Preload("UserGroups"). First(&user, "id = ?", userID). @@ -2163,136 +654,21 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use return nil, err } - if !IsUserGroupAllowedToAuthorize(user, client) { + if !oidc.IsUserGroupAllowedToAuthorize(user, client) { return nil, &common.OidcAccessDeniedError{} } - userClaims, err := s.getUserClaims(ctx, &user, scopes, tx) + preview, err := s.previewBuilder.BuildClientPreview(ctx, client, userID, scopes, authenticationMethod) if err != nil { return nil, err } - - // Commit the transaction before signing tokens to avoid locking the database for longer - err = tx.Commit().Error - if err != nil { - return nil, err - } - - idToken, _, err := s.jwtService.BuildIDToken(userClaims, clientID, "", authenticationMethod) - if err != nil { - return nil, err - } - - accessToken, err := s.jwtService.BuildOAuthAccessToken(user, clientID, authenticationMethod) - if err != nil { - return nil, err - } - - idTokenPayload, err := utils.GetClaimsFromToken(idToken) - if err != nil { - return nil, err - } - - accessTokenPayload, err := utils.GetClaimsFromToken(accessToken) - if err != nil { - return nil, err - } - return &dto.OidcClientPreviewDto{ - IdToken: idTokenPayload, - AccessToken: accessTokenPayload, - UserInfo: userClaims, + IdToken: preview.IDToken, + AccessToken: preview.AccessToken, + UserInfo: preview.UserInfo, }, nil } -func (s *OidcService) GetUserClaimsForClient(ctx context.Context, userID string, clientID string) (map[string]any, error) { - return s.getUserClaimsForClientInternal(ctx, userID, clientID, s.db) -} - -func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID string, clientID string, tx *gorm.DB) (map[string]any, error) { - var authorizedOidcClient model.UserAuthorizedOidcClient - err := tx. - WithContext(ctx). - Preload("User.UserGroups"). - First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID). - Error - if err != nil { - return nil, err - } - - return s.getUserClaims(ctx, &authorizedOidcClient.User, authorizedOidcClient.Scopes(), tx) -} - -func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scopes []string, tx *gorm.DB) (map[string]any, error) { - claims := make(map[string]any, 10) - - claims["sub"] = user.ID - if slices.Contains(scopes, "email") { - claims["email"] = user.Email - claims["email_verified"] = user.EmailVerified - } - - if slices.Contains(scopes, "groups") { - userGroups := make([]string, len(user.UserGroups)) - for i, group := range user.UserGroups { - userGroups[i] = group.Name - } - claims["groups"] = userGroups - } - - if slices.Contains(scopes, "profile") { - // Add custom claims - customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx) - if err != nil { - return nil, err - } - - for _, customClaim := range customClaims { - // The value of the custom claim can be a JSON object or a string - var jsonValue any - err := json.Unmarshal([]byte(customClaim.Value), &jsonValue) - if err == nil { - // It's JSON, so we store it as an object - claims[customClaim.Key] = jsonValue - } else { - // Marshaling failed, so we store it as a string - claims[customClaim.Key] = customClaim.Value - } - } - - // Add profile claims - claims["given_name"] = user.FirstName - claims["family_name"] = user.LastName - claims["name"] = user.FullName() - claims["display_name"] = user.DisplayName - - claims["preferred_username"] = user.Username - claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png" - } - - if slices.Contains(scopes, "email") { - claims["email"] = user.Email - } - - return claims, nil -} - -func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID string, userID string) (bool, error) { - var user model.User - err := s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error - if err != nil { - return false, err - } - - var client model.OidcClient - err = s.db.WithContext(ctx).Preload("AllowedUserGroups").First(&client, "id = ?", clientID).Error - if err != nil { - return false, err - } - - return IsUserGroupAllowedToAuthorize(user, client), nil -} - var errLogoTooLarge = errors.New("logo is too large") func httpClientWithCheckRedirect(source *http.Client, checkRedirect func(req *http.Request, via []*http.Request) error) *http.Client { @@ -2471,11 +847,3 @@ func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID return provider, nil } - -// parsePromptParameter parses the OIDC prompt parameter which is a space-delimited list of values -func parsePromptParameter(prompt string) []string { - if prompt == "" { - return []string{} - } - return strings.Fields(prompt) -} diff --git a/backend/internal/service/oidc_service_test.go b/backend/internal/service/oidc_service_test.go index 7323759f..ab6470c5 100644 --- a/backend/internal/service/oidc_service_test.go +++ b/backend/internal/service/oidc_service_test.go @@ -1,823 +1,21 @@ package service import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" "io" "net/http" - "slices" "strconv" "strings" "testing" - "time" - "github.com/lestrrat-go/jwx/v3/jwa" - "github.com/lestrrat-go/jwx/v3/jwk" - "github.com/lestrrat-go/jwx/v3/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/common" - "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" - datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/storage" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) -// generateTestECDSAKey creates an ECDSA key for testing -func generateTestECDSAKey(t *testing.T) (jwk.Key, []byte) { - t.Helper() - - privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - privateJwk, err := jwk.Import(privateKey) - require.NoError(t, err) - - err = privateJwk.Set(jwk.KeyIDKey, "test-key-1") - require.NoError(t, err) - err = privateJwk.Set(jwk.AlgorithmKey, "ES256") - require.NoError(t, err) - err = privateJwk.Set("use", "sig") - require.NoError(t, err) - - publicJwk, err := jwk.PublicKeyOf(privateJwk) - require.NoError(t, err) - - // Create a JWK Set with the public key - jwkSet := jwk.NewSet() - err = jwkSet.AddKey(publicJwk) - require.NoError(t, err) - jwkSetJSON, err := json.Marshal(jwkSet) - require.NoError(t, err) - - return privateJwk, jwkSetJSON -} - -func TestOidcService_jwkSetForURL(t *testing.T) { - // Generate a test key for JWKS - _, jwkSetJSON1 := generateTestECDSAKey(t) - _, jwkSetJSON2 := generateTestECDSAKey(t) - - // Create a mock HTTP client with responses for different URLs - const ( - url1 = "https://example.com/.well-known/jwks.json" - url2 = "https://other-issuer.com/jwks" - ) - mockResponses := map[string]*http.Response{ - //nolint:bodyclose - url1: testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON1)), - //nolint:bodyclose - url2: testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON2)), - } - httpClient := &http.Client{ - Transport: &testutils.MockRoundTripper{ - Responses: mockResponses, - }, - } - - // Create the OidcService with our mock client - s := &OidcService{ - httpClient: httpClient, - } - - var err error - s.jwkCache, err = s.getJWKCache(t.Context()) - require.NoError(t, err) - - t.Run("Fetches and caches JWK set", func(t *testing.T) { - jwks, err := s.jwkSetForURL(t.Context(), url1) - require.NoError(t, err) - require.NotNil(t, jwks) - - // Verify the JWK set contains our key - require.Equal(t, 1, jwks.Len()) - }) - - t.Run("Fails with invalid URL", func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - _, err := s.jwkSetForURL(ctx, "https://bad-url.com") - require.Error(t, err) - require.ErrorIs(t, err, context.DeadlineExceeded) - }) - - t.Run("Safe for concurrent use", func(t *testing.T) { - const concurrency = 20 - - // Channel to collect errors - errChan := make(chan error, concurrency) - - // Start concurrent requests - for range concurrency { - go func() { - jwks, err := s.jwkSetForURL(t.Context(), url2) - if err != nil { - errChan <- err - return - } - - // Verify the JWK set is valid - if jwks == nil || jwks.Len() != 1 { - errChan <- assert.AnError - return - } - - errChan <- nil - }() - } - - // Check for errors - for range concurrency { - assert.NoError(t, <-errChan, "Concurrent JWK set fetching should not produce errors") - } - }) -} - -func TestOidcService_verifyClientCredentialsInternal(t *testing.T) { - const ( - federatedClientIssuer = "https://external-idp.com" - federatedClientAudience = "https://pocket-id.com" - federatedClientIssuerDefaults = "https://external-idp-defaults.com/" - ) - - var err error - // Create a test database - db := testutils.NewDatabaseForTest(t) - common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef") - - // Create two JWKs for testing - privateJWK, jwkSetJSON := generateTestECDSAKey(t) - require.NoError(t, err) - privateJWKDefaults, jwkSetJSONDefaults := generateTestECDSAKey(t) - require.NoError(t, err) - - // Create a mock config and JwtService to test complete a token creation process - mockConfig := NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes - }) - mockJwtService, err := NewJwtService(t.Context(), db, mockConfig) - require.NoError(t, err) - - // Create a mock HTTP client with custom transport to return the JWKS - httpClient := &http.Client{ - Transport: &testutils.MockRoundTripper{ - Responses: map[string]*http.Response{ - //nolint:bodyclose - federatedClientIssuer + "/jwks.json": testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON)), - //nolint:bodyclose - federatedClientIssuerDefaults + ".well-known/jwks.json": testutils.NewMockResponse(http.StatusOK, string(jwkSetJSONDefaults)), - }, - }, - } - - // Init the OidcService - s := &OidcService{ - db: db, - jwtService: mockJwtService, - appConfigService: mockConfig, - httpClient: httpClient, - } - s.jwkCache, err = s.getJWKCache(t.Context()) - require.NoError(t, err) - - // Create the test clients - // 1. Confidential client - confidentialClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{ - OidcClientUpdateDto: dto.OidcClientUpdateDto{ - Name: "Confidential Client", - CallbackURLs: []string{"https://example.com/callback"}, - }, - }, "test-user-id") - require.NoError(t, err) - - // Create a client secret for the confidential client - confidentialSecret, err := s.CreateClientSecret(t.Context(), confidentialClient.ID) - require.NoError(t, err) - - // 2. Public client - publicClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{ - OidcClientUpdateDto: dto.OidcClientUpdateDto{ - Name: "Public Client", - CallbackURLs: []string{"https://example.com/callback"}, - IsPublic: true, - }, - }, "test-user-id") - require.NoError(t, err) - - // 3. Confidential client with federated identity - federatedClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{ - OidcClientUpdateDto: dto.OidcClientUpdateDto{ - Name: "Federated Client", - CallbackURLs: []string{"https://example.com/callback"}, - }, - }, "test-user-id") - require.NoError(t, err) - - federatedClient, err = s.UpdateClient(t.Context(), federatedClient.ID, dto.OidcClientUpdateDto{ - Name: federatedClient.Name, - CallbackURLs: federatedClient.CallbackURLs, - Credentials: dto.OidcClientCredentialsDto{ - FederatedIdentities: []dto.OidcClientFederatedIdentityDto{ - { - Issuer: federatedClientIssuer, - Audience: federatedClientAudience, - Subject: federatedClient.ID, - JWKS: federatedClientIssuer + "/jwks.json", - }, - { - Issuer: "federated-issuer-2", - Audience: federatedClientAudience, - Subject: "my-federated-client", - JWKS: federatedClientIssuer + "/jwks.json", - }, - {Issuer: federatedClientIssuerDefaults}, - }, - }, - }) - require.NoError(t, err) - - // Test cases for confidential client (using client secret) - t.Run("Confidential client", func(t *testing.T) { - t.Run("Succeeds with valid secret", func(t *testing.T) { - // Test with valid client credentials - client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{ - ClientID: confidentialClient.ID, - ClientSecret: confidentialSecret, - }, true) - require.NoError(t, err) - require.NotNil(t, client) - assert.Equal(t, confidentialClient.ID, client.ID) - }) - - t.Run("Fails with invalid secret", func(t *testing.T) { - // Test with invalid client secret - client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{ - ClientID: confidentialClient.ID, - ClientSecret: "invalid-secret", - }, true) - require.Error(t, err) - require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{}) - assert.Nil(t, client) - }) - - t.Run("Fails with missing secret", func(t *testing.T) { - // Test with missing client secret - client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{ - ClientID: confidentialClient.ID, - }, true) - require.Error(t, err) - require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{}) - assert.Nil(t, client) - }) - }) - - // Test cases for public client - t.Run("Public client", func(t *testing.T) { - t.Run("Succeeds with no credentials", func(t *testing.T) { - // Public clients don't require client secret - client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{ - ClientID: publicClient.ID, - }, true) - require.NoError(t, err) - require.NotNil(t, client) - assert.Equal(t, publicClient.ID, client.ID) - }) - - t.Run("Fails with no credentials if allowPublicClientsWithoutAuth is false", func(t *testing.T) { - // Public clients don't require client secret - client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{ - ClientID: publicClient.ID, - }, false) - require.Error(t, err) - require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{}) - assert.Nil(t, client) - }) - }) - - // Test cases for federated client using JWT assertion - t.Run("Federated client", func(t *testing.T) { - t.Run("Succeeds with valid JWT", func(t *testing.T) { - // Create JWT for federated identity - token, err := jwt.NewBuilder(). - Issuer(federatedClientIssuer). - Audience([]string{federatedClientAudience}). - Subject(federatedClient.ID). - IssuedAt(time.Now()). - Expiration(time.Now().Add(10 * time.Minute)). - Build() - require.NoError(t, err) - signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK)) - require.NoError(t, err) - - // Test with valid JWT assertion - client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{ - ClientID: federatedClient.ID, - ClientAssertionType: ClientAssertionTypeJWTBearer, - ClientAssertion: string(signedToken), - }, true) - require.NoError(t, err) - require.NotNil(t, client) - assert.Equal(t, federatedClient.ID, client.ID) - }) - - t.Run("Fails with malformed JWT", func(t *testing.T) { - // Test with invalid JWT assertion (just a random string) - client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{ - ClientID: federatedClient.ID, - ClientAssertionType: ClientAssertionTypeJWTBearer, - ClientAssertion: "invalid.jwt.token", - }, true) - require.Error(t, err) - require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{}) - assert.Nil(t, client) - }) - - testBadJWT := func(builderFn func(builder *jwt.Builder)) func(t *testing.T) { - return func(t *testing.T) { - // Populate all claims with valid values - builder := jwt.NewBuilder(). - Issuer(federatedClientIssuer). - Audience([]string{federatedClientAudience}). - Subject(federatedClient.ID). - IssuedAt(time.Now()). - Expiration(time.Now().Add(10 * time.Minute)) - - // Call builderFn to override the claims - builderFn(builder) - - token, err := builder.Build() - require.NoError(t, err) - signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK)) - require.NoError(t, err) - - // Test with invalid JWT assertion - client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{ - ClientID: federatedClient.ID, - ClientAssertionType: ClientAssertionTypeJWTBearer, - ClientAssertion: string(signedToken), - }, true) - require.Error(t, err) - require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{}) - require.Nil(t, client) - } - } - - t.Run("Fails with expired JWT", testBadJWT(func(builder *jwt.Builder) { - builder.Expiration(time.Now().Add(-30 * time.Minute)) - })) - - t.Run("Fails with wrong issuer in JWT", testBadJWT(func(builder *jwt.Builder) { - builder.Issuer("https://bad-issuer.com") - })) - - t.Run("Fails with wrong audience in JWT", testBadJWT(func(builder *jwt.Builder) { - builder.Audience([]string{"bad-audience"}) - })) - - t.Run("Fails with wrong subject in JWT", testBadJWT(func(builder *jwt.Builder) { - builder.Subject("bad-subject") - })) - - t.Run("Uses default values for audience and subject", func(t *testing.T) { - // Create JWT for federated identity - token, err := jwt.NewBuilder(). - Issuer(federatedClientIssuerDefaults). - Audience([]string{common.EnvConfig.AppURL}). - Subject(federatedClient.ID). - IssuedAt(time.Now()). - Expiration(time.Now().Add(10 * time.Minute)). - Build() - require.NoError(t, err) - signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWKDefaults)) - require.NoError(t, err) - - // Test with valid JWT assertion - client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{ - ClientID: federatedClient.ID, - ClientAssertionType: ClientAssertionTypeJWTBearer, - ClientAssertion: string(signedToken), - }, true) - require.NoError(t, err) - require.NotNil(t, client) - assert.Equal(t, federatedClient.ID, client.ID) - }) - }) - - t.Run("Complete token creation flow", func(t *testing.T) { - t.Run("Client Credentials flow", func(t *testing.T) { - t.Run("Succeeds with valid secret", func(t *testing.T) { - // Generate a token - input := dto.OidcCreateTokensDto{ - ClientID: confidentialClient.ID, - ClientSecret: confidentialSecret, - } - token, err := s.createTokenFromClientCredentials(t.Context(), input) - require.NoError(t, err) - require.NotNil(t, token) - - // Verify the token - claims, err := s.jwtService.VerifyOAuthAccessToken(token.AccessToken) - require.NoError(t, err, "Failed to verify generated token") - - // Check the claims - subject, ok := claims.Subject() - _ = assert.True(t, ok, "User ID not found in token") && - assert.Equal(t, "client-"+confidentialClient.ID, subject, "Token subject should match confidential client ID with prefix") - audience, ok := claims.Audience() - _ = assert.True(t, ok, "Audience not found in token") && - assert.Equal(t, []string{confidentialClient.ID}, audience, "Audience should contain confidential client ID") - }) - - t.Run("Fails with invalid secret", func(t *testing.T) { - input := dto.OidcCreateTokensDto{ - ClientID: confidentialClient.ID, - ClientSecret: "invalid-secret", - } - _, err := s.createTokenFromClientCredentials(t.Context(), input) - require.Error(t, err) - require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{}) - }) - - t.Run("Fails without client secret for public clients", func(t *testing.T) { - input := dto.OidcCreateTokensDto{ - ClientID: publicClient.ID, - } - _, err := s.createTokenFromClientCredentials(t.Context(), input) - require.Error(t, err) - require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{}) - }) - - t.Run("Succeeds with valid assertion", func(t *testing.T) { - // Create JWT for federated identity - token, err := jwt.NewBuilder(). - Issuer(federatedClientIssuer). - Audience([]string{federatedClientAudience}). - Subject(federatedClient.ID). - IssuedAt(time.Now()). - Expiration(time.Now().Add(10 * time.Minute)). - Build() - require.NoError(t, err) - signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK)) - require.NoError(t, err) - - // Generate a token - input := dto.OidcCreateTokensDto{ - ClientID: federatedClient.ID, - ClientAssertion: string(signedToken), - ClientAssertionType: ClientAssertionTypeJWTBearer, - } - createdToken, err := s.createTokenFromClientCredentials(t.Context(), input) - require.NoError(t, err) - require.NotNil(t, token) - - // Verify the token - claims, err := s.jwtService.VerifyOAuthAccessToken(createdToken.AccessToken) - require.NoError(t, err, "Failed to verify generated token") - - // Check the claims - subject, ok := claims.Subject() - _ = assert.True(t, ok, "User ID not found in token") && - assert.Equal(t, "client-"+federatedClient.ID, subject, "Token subject should match federated client ID with prefix") - audience, ok := claims.Audience() - _ = assert.True(t, ok, "Audience not found in token") && - assert.Equal(t, []string{federatedClient.ID}, audience, "Audience should contain the federated client ID") - }) - - t.Run("Succeeds with valid assertion and custom subject", func(t *testing.T) { - // Create JWT for federated identity - token, err := jwt.NewBuilder(). - Issuer("federated-issuer-2"). - Audience([]string{federatedClientAudience}). - Subject("my-federated-client"). - IssuedAt(time.Now()). - Expiration(time.Now().Add(10 * time.Minute)). - Build() - require.NoError(t, err) - signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK)) - require.NoError(t, err) - - // Generate a token - input := dto.OidcCreateTokensDto{ - ClientID: federatedClient.ID, - ClientAssertion: string(signedToken), - ClientAssertionType: ClientAssertionTypeJWTBearer, - } - createdToken, err := s.createTokenFromClientCredentials(t.Context(), input) - require.NoError(t, err) - require.NotNil(t, token) - - // Verify the token - claims, err := s.jwtService.VerifyOAuthAccessToken(createdToken.AccessToken) - require.NoError(t, err, "Failed to verify generated token") - - // Check the claims - subject, ok := claims.Subject() - _ = assert.True(t, ok, "User ID not found in token") && - assert.Equal(t, "client-"+federatedClient.ID, subject, "Token subject should match federated client ID with prefix") - audience, ok := claims.Audience() - _ = assert.True(t, ok, "Audience not found in token") && - assert.Equal(t, []string{federatedClient.ID}, audience, "Audience should contain the federated client ID") - }) - - t.Run("Fails with invalid assertion", func(t *testing.T) { - input := dto.OidcCreateTokensDto{ - ClientID: confidentialClient.ID, - ClientAssertion: "invalid.jwt.token", - ClientAssertionType: ClientAssertionTypeJWTBearer, - } - _, err := s.createTokenFromClientCredentials(t.Context(), input) - require.Error(t, err) - require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{}) - }) - - t.Run("Succeeds with custom resource", func(t *testing.T) { - // Generate a token - input := dto.OidcCreateTokensDto{ - ClientID: confidentialClient.ID, - ClientSecret: confidentialSecret, - Resource: "https://example.com/", - } - token, err := s.createTokenFromClientCredentials(t.Context(), input) - require.NoError(t, err) - require.NotNil(t, token) - - // Verify the token - claims, err := s.jwtService.VerifyOAuthAccessToken(token.AccessToken) - require.NoError(t, err, "Failed to verify generated token") - - // Check the claims - subject, ok := claims.Subject() - _ = assert.True(t, ok, "User ID not found in token") && - assert.Equal(t, "client-"+confidentialClient.ID, subject, "Token subject should match confidential client ID with prefix") - audience, ok := claims.Audience() - _ = assert.True(t, ok, "Audience not found in token") && - assert.Equal(t, []string{input.Resource}, audience, "Audience should contain the resource provided in request") - }) - }) - }) -} - -func TestOidcServiceRefreshTokenAuthorizationState(t *testing.T) { - newFixture := func(t *testing.T, isGroupRestricted bool) (*OidcService, *gorm.DB, model.User, model.OidcClient, string, string, *model.UserGroup) { - t.Helper() - - db := testutils.NewDatabaseForTest(t) - common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef") - - mockConfig := NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, - }) - jwtService, err := NewJwtService(t.Context(), db, mockConfig) - require.NoError(t, err) - - service := &OidcService{ - db: db, - jwtService: jwtService, - appConfigService: mockConfig, - } - - user := model.User{ - Username: "refresh-token-user", - Email: new("refresh-token-user@example.com"), - EmailVerified: true, - FirstName: "Refresh", - LastName: "User", - } - require.NoError(t, db.Create(&user).Error) - - client, err := service.CreateClient(t.Context(), dto.OidcClientCreateDto{ - OidcClientUpdateDto: dto.OidcClientUpdateDto{ - Name: "Refresh Token Client", - CallbackURLs: []string{"https://example.com/callback"}, - IsGroupRestricted: isGroupRestricted, - }, - }, user.ID) - require.NoError(t, err) - - clientSecret, err := service.CreateClientSecret(t.Context(), client.ID) - require.NoError(t, err) - - var userGroup *model.UserGroup - if isGroupRestricted { - group := model.UserGroup{ - FriendlyName: "Allowed Group", - Name: "allowed-group", - } - require.NoError(t, db.Create(&group).Error) - require.NoError(t, db.Model(&user).Association("UserGroups").Append(&group)) - require.NoError(t, db.Model(&client).Association("AllowedUserGroups").Append(&group)) - userGroup = &group - } - - scope := "openid profile email groups" - require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{ - UserID: user.ID, - ClientID: client.ID, - Scope: scope, - }).Error) - - refreshToken, err := service.createRefreshToken(t.Context(), client.ID, user.ID, scope, AuthenticationMethodPhishingResistant, "03f94e54-53c4-42f8-afe5-918ffd97a30e", db) - require.NoError(t, err) - - return service, db, user, client, clientSecret, refreshToken, userGroup - } - - refreshInput := func(client model.OidcClient, clientSecret string, refreshToken string) dto.OidcCreateTokensDto { - return dto.OidcCreateTokensDto{ - GrantType: GrantTypeRefreshToken, - RefreshToken: refreshToken, - ClientID: client.ID, - ClientSecret: clientSecret, - } - } - - t.Run("rejects refresh token after authorization revocation", func(t *testing.T) { - service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false) - - err := service.RevokeAuthorizedClient(t.Context(), user.ID, client.ID) - require.NoError(t, err) - - var refreshTokenCount int64 - require.NoError(t, db.Model(&model.OidcRefreshToken{}). - Where("user_id = ? AND client_id = ?", user.ID, client.ID). - Count(&refreshTokenCount).Error) - assert.Zero(t, refreshTokenCount) - - _, err = service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken)) - require.Error(t, err) - require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{}) - }) - - t.Run("rejects and deletes stale refresh token without authorization record", func(t *testing.T) { - service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false) - - require.NoError(t, db. - Where("user_id = ? AND client_id = ?", user.ID, client.ID). - Delete(&model.UserAuthorizedOidcClient{}).Error) - - _, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken)) - require.Error(t, err) - require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{}) - - var refreshTokenCount int64 - require.NoError(t, db.Model(&model.OidcRefreshToken{}). - Where("user_id = ? AND client_id = ?", user.ID, client.ID). - Count(&refreshTokenCount).Error) - assert.Zero(t, refreshTokenCount) - }) - - t.Run("rejects refresh token for disabled user", func(t *testing.T) { - service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false) - - require.NoError(t, db.Model(&model.User{}). - Where("id = ?", user.ID). - Update("disabled", true).Error) - - _, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken)) - require.Error(t, err) - require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{}) - }) - - t.Run("rejects refresh token after user leaves allowed group", func(t *testing.T) { - service, db, user, client, clientSecret, refreshToken, userGroup := newFixture(t, true) - require.NotNil(t, userGroup) - - require.NoError(t, db.Model(&user).Association("UserGroups").Delete(userGroup)) - - _, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken)) - require.Error(t, err) - require.ErrorIs(t, err, &common.OidcAccessDeniedError{}) - }) -} - -func TestOidcServiceAuthenticationMethodsPersistence(t *testing.T) { - mockConfig := NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, - }) - jwtService, db, _ := setupJwtService(t, mockConfig) - service := &OidcService{ - db: db, - jwtService: jwtService, - } - authenticationMethod := AuthenticationMethodPhishingResistant - - t.Run("stores authentication method on authorization codes", func(t *testing.T) { - code, err := service.createAuthorizationCode( - t.Context(), - "amr-client", - "amr-user", - "openid profile", - authenticationMethod, - "", - "", - "", - db, - ) - require.NoError(t, err) - - var authorizationCode model.OidcAuthorizationCode - require.NoError(t, db.First(&authorizationCode, "code = ?", code).Error) - assert.Equal(t, authenticationMethod, authorizationCode.AuthenticationMethod) - }) - - t.Run("stores authentication methods on refresh tokens", func(t *testing.T) { - _, err := service.createRefreshToken(t.Context(), "amr-client", "amr-user", "openid profile", authenticationMethod, "03f94e54-53c4-42f8-afe5-918ffd97a30e", db) - require.NoError(t, err) - - var refreshToken model.OidcRefreshToken - require.NoError(t, db.First(&refreshToken, "client_id = ? AND user_id = ?", "amr-client", "amr-user").Error) - assert.Equal(t, authenticationMethod, refreshToken.AuthenticationMethod) - }) -} - -func TestValidateCodeVerifier_Plain(t *testing.T) { - require.False(t, validateCodeVerifier("", "", false)) - require.False(t, validateCodeVerifier("", "", true)) - - t.Run("plain", func(t *testing.T) { - require.False(t, validateCodeVerifier("", "challenge", false)) - require.False(t, validateCodeVerifier("verifier", "", false)) - require.True(t, validateCodeVerifier("plainVerifier", "plainVerifier", false)) - require.False(t, validateCodeVerifier("plainVerifier", "otherVerifier", false)) - }) - - t.Run("SHA 256", func(t *testing.T) { - codeVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" - hash := sha256.Sum256([]byte(codeVerifier)) - codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) - - require.True(t, validateCodeVerifier(codeVerifier, codeChallenge, true)) - require.False(t, validateCodeVerifier("wrongVerifier", codeChallenge, true)) - require.False(t, validateCodeVerifier(codeVerifier, "!", true)) - - // Invalid base64 - require.False(t, validateCodeVerifier("NOT!VALID", codeChallenge, true)) - }) -} - -func TestCodeChallengeMethodIsSha256(t *testing.T) { - tests := []struct { - name string - method string - wantSha256 bool - wantErr bool - }{ - { - name: "omitted defaults to plain", - method: "", - wantSha256: false, - }, - { - name: "plain", - method: "plain", - wantSha256: false, - }, - { - name: "plain case insensitive", - method: "PLAIN", - wantSha256: false, - }, - { - name: "s256", - method: "S256", - wantSha256: true, - }, - { - name: "s256 case insensitive", - method: "s256", - wantSha256: true, - }, - { - name: "unknown method", - method: "S384", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := codeChallengeMethodIsSha256(tt.method) - if tt.wantErr { - require.Error(t, err) - var invalidRequest *common.OidcInvalidRequestError - require.ErrorAs(t, err, &invalidRequest) - return - } - - require.NoError(t, err) - assert.Equal(t, tt.wantSha256, got) - }) - } -} - func TestOidcService_updateClientLogoType(t *testing.T) { // Create a test database db := testutils.NewDatabaseForTest(t) @@ -1251,165 +449,3 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) { require.ErrorContains(t, err, "failed to look up client") }) } - -func TestOidcService_ValidateEndSessionDeletesMatchingRefreshToken(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef") - mockConfig := NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, - }) - mockJwtService, err := NewJwtService(t.Context(), db, mockConfig) - require.NoError(t, err) - - oidcService := &OidcService{ - db: db, - jwtService: mockJwtService, - } - - userID := "test-user-123" - clientID := "test-client-456" - otherClientID := "other-client-789" - otherIDTokenJti := "ac653f42-4781-49f2-bc7c-cc44503c3a1a" //nolint:gosec - userEmail := "test@example.com" - - user := model.User{ - Base: model.Base{ID: userID}, - Email: &userEmail, - } - require.NoError(t, db.Create(&user).Error) - - client := model.OidcClient{ - Base: model.Base{ID: clientID}, - Name: "Test Client", - LogoutCallbackURLs: []string{"https://example.com/logout"}, - } - require.NoError(t, db.Create(&client).Error) - - otherClient := model.OidcClient{ - Base: model.Base{ID: otherClientID}, - Name: "Other Client", - LogoutCallbackURLs: []string{"https://other.example.com/logout"}, - } - require.NoError(t, db.Create(&otherClient).Error) - - require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{ - UserID: userID, - ClientID: clientID, - }).Error) - - userClaims := map[string]any{ - "sub": userID, - "name": "Test User", - "email": userEmail, - } - idToken, idTokenJti, err := mockJwtService.GenerateIDToken(userClaims, clientID, "", "") - require.NoError(t, err) - - refreshTokens := []model.OidcRefreshToken{ - { - Token: "matching-refresh-token", - UserID: userID, - ClientID: clientID, - IdTokenJti: &idTokenJti, - ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)), - Scope: "openid profile", - }, - { - Token: "same-client-different-session", - UserID: userID, - ClientID: clientID, - IdTokenJti: &otherIDTokenJti, - ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)), - Scope: "openid profile", - }, - { - Token: "other-client-same-jti", - UserID: userID, - ClientID: otherClientID, - IdTokenJti: &idTokenJti, - ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)), - Scope: "openid profile", - }, - { - Token: "legacy-refresh-token", - UserID: userID, - ClientID: clientID, - ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)), - Scope: "openid profile", - }, - } - require.NoError(t, db.Create(&refreshTokens).Error) - - callbackURL, err := oidcService.ValidateEndSession(t.Context(), dto.OidcLogoutDto{ - IdTokenHint: idToken, - ClientId: clientID, - PostLogoutRedirectUri: "https://example.com/logout", - }, userID) - require.NoError(t, err) - assert.Equal(t, "https://example.com/logout", callbackURL) - - var remainingTokens []model.OidcRefreshToken - require.NoError(t, db.Order("token").Find(&remainingTokens).Error) - remainingTokenValues := make([]string, len(remainingTokens)) - for i, token := range remainingTokens { - remainingTokenValues[i] = token.Token - } - assert.ElementsMatch(t, []string{ - "legacy-refresh-token", - "other-client-same-jti", - "same-client-different-session", - }, remainingTokenValues) -} - -// Tests for prompt parameter parsing and handling -func TestParsePromptParameter(t *testing.T) { - t.Run("empty prompt returns empty slice", func(t *testing.T) { - result := parsePromptParameter("") - assert.Equal(t, []string{}, result) - }) - - t.Run("single prompt value", func(t *testing.T) { - result := parsePromptParameter("none") - assert.Equal(t, []string{"none"}, result) - }) - - t.Run("multiple prompt values space-delimited", func(t *testing.T) { - result := parsePromptParameter("login consent") - assert.Equal(t, []string{"login", "consent"}, result) - }) - - t.Run("multiple prompt values with extra spaces", func(t *testing.T) { - result := parsePromptParameter(" none login ") - assert.Equal(t, []string{"none", "login"}, result) - }) -} - -func TestPromptParameterConflicts(t *testing.T) { - tests := []struct { - name string - prompt string - expectConflict bool - }{ - {"none alone is valid", "none", false}, - {"login alone is valid", "login", false}, - {"consent alone is valid", "consent", false}, - {"login consent is valid", "login consent", false}, - {"none consent conflicts", "none consent", true}, - {"none login conflicts", "none login", true}, - {"none select_account conflicts", "none select_account", true}, - {"none consent login conflicts", "none consent login", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - values := parsePromptParameter(tt.prompt) - hasNone := slices.Contains(values, "none") - hasConsent := slices.Contains(values, "consent") - hasLogin := slices.Contains(values, "login") - hasSelectAccount := slices.Contains(values, "select_account") - - conflict := hasNone && (hasConsent || hasLogin || hasSelectAccount) - assert.Equal(t, tt.expectConflict, conflict) - }) - } -} diff --git a/backend/internal/service/scim_service.go b/backend/internal/service/scim_service.go index f21f8be7..3d81b894 100644 --- a/backend/internal/service/scim_service.go +++ b/backend/internal/service/scim_service.go @@ -20,6 +20,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/utils" "gorm.io/gorm" ) @@ -364,7 +365,7 @@ func (s *ScimService) syncUser(ctx context.Context, userResource *dto.ScimUser, ) (scimSyncAction, *dto.ScimUser, error) { // If user is not allowed for the client, delete it from SCIM provider - if userResource != nil && !IsUserGroupAllowedToAuthorize(user, provider.OidcClient) { + if userResource != nil && !oidc.IsUserGroupAllowedToAuthorize(user, provider.OidcClient) { return scimActionDeleted, nil, s.deleteScimResource(ctx, provider, fmt.Sprintf("/Users/%s", url.PathEscape(userResource.ID))) } diff --git a/backend/internal/service/webauthn_service.go b/backend/internal/service/webauthn_service.go index 18e8ac29..ce6c7e04 100644 --- a/backend/internal/service/webauthn_service.go +++ b/backend/internal/service/webauthn_service.go @@ -479,19 +479,20 @@ func (s *WebAuthnService) CreateReauthenticationTokenWithWebauthn(ctx context.Co return token, nil } -func (s *WebAuthnService) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) error { +func (s *WebAuthnService) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error) { hashedToken := utils.CreateSha256Hash(token) + var reauthToken model.ReauthenticationToken result := tx.WithContext(ctx). Clauses(clause.Returning{}). - Delete(&model.ReauthenticationToken{}, "token = ? AND user_id = ? AND expires_at > ?", hashedToken, userID, datatype.DateTime(time.Now())) + Delete(&reauthToken, "token = ? AND user_id = ? AND expires_at > ?", hashedToken, userID, datatype.DateTime(time.Now())) if result.Error != nil { - return result.Error + return time.Time{}, result.Error } if result.RowsAffected == 0 { - return &common.ReauthenticationRequiredError{} + return time.Time{}, &common.ReauthenticationRequiredError{} } - return nil + return reauthToken.CreatedAt.UTC(), nil } func (s *WebAuthnService) createReauthenticationToken(ctx context.Context, tx *gorm.DB, userID string) (string, error) { diff --git a/backend/internal/service/webauthn_service_test.go b/backend/internal/service/webauthn_service_test.go index 79ab0572..41553f67 100644 --- a/backend/internal/service/webauthn_service_test.go +++ b/backend/internal/service/webauthn_service_test.go @@ -2,12 +2,15 @@ package service import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/utils" ) func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) { @@ -66,3 +69,37 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) { assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError)) }) } + +func TestConsumeReauthenticationTokenReturnsTokenCreationTime(t *testing.T) { + mockConfig := NewTestAppConfigService(&model.AppConfig{ + SessionDuration: model.AppConfigVariable{Value: "60"}, + }) + jwtService, db, _ := setupJwtService(t, mockConfig) + service := &WebAuthnService{ + db: db, + jwtService: jwtService, + } + + const ( + userID = "reauth-user" + token = "reauthentication-token" + ) + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: userID}, + }).Error) + require.NoError(t, db.Create(&model.ReauthenticationToken{ + Token: utils.CreateSha256Hash(token), + ExpiresAt: datatype.DateTime(time.Now().Add(time.Minute)), + UserID: userID, + }).Error) + + var storedToken model.ReauthenticationToken + require.NoError(t, db.First(&storedToken, "user_id = ?", userID).Error) + + tx := db.Begin() + reauthenticatedAt, err := service.ConsumeReauthenticationToken(t.Context(), tx, token, userID) + require.NoError(t, err) + require.NoError(t, tx.Commit().Error) + + require.Equal(t, storedToken.CreatedAt.UTC(), reauthenticatedAt) +} diff --git a/backend/internal/utils/callback_url_util.go b/backend/internal/utils/callback_url_util.go index 7fa44a97..d92af89d 100644 --- a/backend/internal/utils/callback_url_util.go +++ b/backend/internal/utils/callback_url_util.go @@ -1,6 +1,7 @@ package utils import ( + "errors" "log/slog" "net" "net/url" @@ -19,12 +20,73 @@ func ValidateCallbackURLPattern(pattern string) error { } pattern, _, _ = strings.Cut(pattern, "#") + if err := validateCallbackURLPatternURL(pattern); err != nil { + return err + } + pattern = normalizeToURLPatternStandard(pattern) _, err := urlpattern.New(pattern, "", nil) return err } +func validateCallbackURLPatternURL(pattern string) error { + parseablePattern := callbackURLPatternForURLParse(pattern) + u, err := url.Parse(parseablePattern) + if err != nil { + return err + } + if u.Scheme == "" { + return errors.New("callback URL pattern must include a scheme") + } + + switch strings.ToLower(u.Scheme) { + case "javascript", "data": + return errors.New("callback URL pattern scheme is not allowed") + default: + return nil + } +} + +func callbackURLPatternForURLParse(pattern string) string { + if strings.HasPrefix(pattern, "*://") { + pattern = "https://" + strings.TrimPrefix(pattern, "*://") + } + + scheme, rest, ok := strings.Cut(pattern, "://") + if !ok { + return pattern + } + + authority := rest + suffix := "" + if i := strings.IndexAny(rest, "/?#"); i >= 0 { + authority = rest[:i] + suffix = rest[i:] + } + + userinfo := "" + hostport := authority + if i := strings.LastIndex(authority, "@"); i >= 0 { + userinfo = authority[:i+1] + hostport = authority[i+1:] + } + + if strings.HasPrefix(hostport, "[") { + end := strings.Index(hostport, "]") + if end == -1 { + return pattern + } + if len(hostport) > end+1 && hostport[end+1] == ':' && strings.Contains(hostport[end+2:], "*") { + hostport = hostport[:end+2] + "443" + } + } else if i := strings.LastIndex(hostport, ":"); i >= 0 && strings.Contains(hostport[i+1:], "*") { + hostport = hostport[:i+1] + "443" + } + + return scheme + "://" + userinfo + hostport + suffix +} + // GetCallbackURLFromList returns the first callback URL that matches the input callback URL. func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) { // Special case for Loopback Interface Redirection. Quoting from RFC 8252 section 7.3: diff --git a/backend/internal/utils/callback_url_util_test.go b/backend/internal/utils/callback_url_util_test.go index a6eb1625..251d3ae2 100644 --- a/backend/internal/utils/callback_url_util_test.go +++ b/backend/internal/utils/callback_url_util_test.go @@ -58,6 +58,21 @@ func TestValidateCallbackURLPattern(t *testing.T) { pattern: "https://exa[mple.com/callback", shouldError: true, }, + { + name: "malformed IPv6 host", + pattern: "http://[::1", + shouldError: true, + }, + { + name: "javascript scheme", + pattern: "javascript:alert(1)", + shouldError: true, + }, + { + name: "data scheme", + pattern: "data:text/html;base64,PGgxPkhlbGxvPC9oMT4=", + shouldError: true, + }, } for _, tt := range tests { diff --git a/backend/internal/utils/cookie/add_cookie.go b/backend/internal/utils/cookie/add_cookie.go index 8c33d781..c2b8ab74 100644 --- a/backend/internal/utils/cookie/add_cookie.go +++ b/backend/internal/utils/cookie/add_cookie.go @@ -17,3 +17,7 @@ func AddSessionIdCookie(c *gin.Context, maxAgeInSeconds int, sessionID string) { func AddDeviceTokenCookie(c *gin.Context, deviceToken string) { c.SetCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token", "", true, true) } + +func AddReauthenticationTokenCookie(c *gin.Context, reauthenticationToken string) { + c.SetCookie(ReauthenticationTokenCookieName, reauthenticationToken, int(3*time.Minute.Seconds()), "/", "", true, true) +} diff --git a/backend/internal/utils/cookie/cookie_names.go b/backend/internal/utils/cookie/cookie_names.go index 73acdf13..9465701a 100644 --- a/backend/internal/utils/cookie/cookie_names.go +++ b/backend/internal/utils/cookie/cookie_names.go @@ -8,12 +8,14 @@ import ( var AccessTokenCookieName = "__Host-access_token" var SessionIdCookieName = "__Host-session" -var DeviceTokenCookieName = "__Secure-device_token" //nolint:gosec +var DeviceTokenCookieName = "__Secure-device_token" //nolint:gosec +var ReauthenticationTokenCookieName = "__Secure-reauthentication_token" //nolint:gosec func init() { if strings.HasPrefix(common.EnvConfig.AppURL, "http://") { AccessTokenCookieName = "access_token" SessionIdCookieName = "session" DeviceTokenCookieName = "device_token" + ReauthenticationTokenCookieName = "reauthentication_token" } } diff --git a/backend/internal/utils/csp.go b/backend/internal/utils/csp.go new file mode 100644 index 00000000..0f3d7bd2 --- /dev/null +++ b/backend/internal/utils/csp.go @@ -0,0 +1,67 @@ +package utils + +import ( + "crypto/rand" + "encoding/base64" + "strings" + + "github.com/gin-gonic/gin" +) + +const cspNonceContextKey = "csp_nonce" + +// GetCSPNonce returns the CSP nonce generated for this request, if any. +func GetCSPNonce(c *gin.Context) string { + if v, ok := c.Get(cspNonceContextKey); ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// SetCSPNonce stores a per-request CSP nonce so handlers can reference it in +// Content-Security-Policy headers they emit themselves. +func SetCSPNonce(c *gin.Context, nonce string) { + c.Set(cspNonceContextKey, nonce) +} + +func BuildCSP(nonce string, formActionExtra ...string) string { + formAction := "'self'" + scriptSrc := "script-src 'self'" + if nonce != "" { + scriptSrc += " 'nonce-" + nonce + "'" + } + + if len(formActionExtra) > 0 { + b := strings.Builder{} + + for _, extra := range formActionExtra { + if extra != "" { + b.WriteByte(' ') + b.WriteString(extra) + } + } + + formAction += b.String() + } + + return "default-src 'self'; " + + "base-uri 'self'; " + + "object-src 'none'; " + + "frame-ancestors 'none'; " + + "form-action " + formAction + "; " + + "img-src * blob:;" + + "font-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + scriptSrc +} + +// GenerateCSPNonce returns a random base64 nonce for use in a CSP header. +func GenerateCSPNonce() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "" // if generation fails, return empty; policy will omit nonce + } + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/backend/resources/migrations/postgres/20260607120000_oauth2_storage.down.sql b/backend/resources/migrations/postgres/20260607120000_oauth2_storage.down.sql new file mode 100644 index 00000000..87ddc9b5 --- /dev/null +++ b/backend/resources/migrations/postgres/20260607120000_oauth2_storage.down.sql @@ -0,0 +1,83 @@ +CREATE TABLE oidc_authorization_codes +( + id UUID NOT NULL PRIMARY KEY, + created_at TIMESTAMPTZ, + code VARCHAR(255) NOT NULL UNIQUE, + scope TEXT NOT NULL, + nonce VARCHAR(255), + expires_at TIMESTAMPTZ NOT NULL, + user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE, + client_id TEXT NOT NULL REFERENCES oidc_clients (id) ON DELETE CASCADE, + code_challenge VARCHAR(255), + code_challenge_method_sha256 BOOLEAN, + authentication_method TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at); + +CREATE TABLE oidc_refresh_tokens ( + id UUID NOT NULL PRIMARY KEY, + created_at TIMESTAMPTZ, + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + scope TEXT NOT NULL, + user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE, + client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE, + authentication_method TEXT NOT NULL DEFAULT '', + id_token_jti UUID +); + +CREATE INDEX idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at); +CREATE INDEX idx_oidc_refresh_tokens_id_token_jti + ON oidc_refresh_tokens(user_id, client_id, id_token_jti); + +CREATE TABLE oidc_device_codes +( + id UUID NOT NULL PRIMARY KEY, + created_at TIMESTAMPTZ, + device_code TEXT NOT NULL UNIQUE, + user_code TEXT NOT NULL UNIQUE, + scope TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + is_authorized BOOLEAN NOT NULL DEFAULT FALSE, + user_id UUID REFERENCES users ON DELETE CASCADE, + client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE, + authentication_method TEXT NOT NULL DEFAULT '', + nonce VARCHAR(255) +); + +CREATE TABLE oidc_pushed_authorization_requests ( + id UUID NOT NULL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + request_uri TEXT NOT NULL UNIQUE, + client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE, + parameters JSONB NOT NULL DEFAULT '{}', + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_oidc_par_expires_at ON oidc_pushed_authorization_requests (expires_at); + +ALTER TABLE user_authorized_oidc_clients ADD COLUMN scope_text TEXT; +UPDATE user_authorized_oidc_clients +SET scope_text = CASE + WHEN scope IS NULL THEN NULL + WHEN jsonb_typeof(scope) = 'array' THEN ( + SELECT string_agg(scope_value.value, ' ') + FROM jsonb_array_elements_text(scope) AS scope_value(value) + ) + ELSE scope #>> '{}' +END; +ALTER TABLE user_authorized_oidc_clients DROP COLUMN scope; +ALTER TABLE user_authorized_oidc_clients RENAME COLUMN scope_text TO scope; + +DROP INDEX IF EXISTS idx_interaction_sessions_client_id; +DROP INDEX IF EXISTS idx_interaction_sessions_user_id; +DROP TABLE IF EXISTS interaction_sessions; + +DROP INDEX IF EXISTS idx_oauth2_jtis_expires_at; +DROP TABLE IF EXISTS oauth2_jtis; + +DROP INDEX IF EXISTS idx_oauth2_sessions_expires_at; +DROP INDEX IF EXISTS idx_oauth2_sessions_kind_request; +DROP INDEX IF EXISTS idx_oauth2_sessions_kind_key; +DROP TABLE IF EXISTS oauth2_sessions; diff --git a/backend/resources/migrations/postgres/20260607120000_oauth2_storage.up.sql b/backend/resources/migrations/postgres/20260607120000_oauth2_storage.up.sql new file mode 100644 index 00000000..bbfbc6b2 --- /dev/null +++ b/backend/resources/migrations/postgres/20260607120000_oauth2_storage.up.sql @@ -0,0 +1,63 @@ +CREATE TABLE oauth2_sessions ( + id UUID NOT NULL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + kind TEXT NOT NULL, + key TEXT NOT NULL, + request_id TEXT NOT NULL, + access_token_signature TEXT NOT NULL DEFAULT '', + active BOOLEAN NOT NULL DEFAULT TRUE, + request_data JSONB NOT NULL, + expires_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX idx_oauth2_sessions_kind_key ON oauth2_sessions (kind, key); +CREATE INDEX idx_oauth2_sessions_kind_request ON oauth2_sessions (kind, request_id); +CREATE INDEX idx_oauth2_sessions_expires_at ON oauth2_sessions (expires_at); + +CREATE TABLE oauth2_jtis ( + id UUID NOT NULL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + jti TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_oauth2_jtis_expires_at ON oauth2_jtis (expires_at); + +CREATE TABLE interaction_sessions +( + id UUID NOT NULL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + + consent_required BOOLEAN NOT NULL DEFAULT FALSE, + reauthentication_required BOOLEAN NOT NULL DEFAULT FALSE, + authentication_required BOOLEAN NOT NULL DEFAULT FALSE, + account_selection_required BOOLEAN NOT NULL DEFAULT FALSE, + + scopes JSONB NOT NULL DEFAULT '[]', + client_id TEXT NOT NULL REFERENCES oidc_clients (id) ON DELETE CASCADE, + user_id UUID REFERENCES users (id) ON DELETE CASCADE, + requested_at TIMESTAMPTZ NOT NULL, + reauthenticated_at TIMESTAMPTZ, + parameters JSONB NOT NULL DEFAULT '{}' +); + +CREATE INDEX idx_interaction_sessions_client_id + ON interaction_sessions (client_id); +CREATE INDEX idx_interaction_sessions_user_id + ON interaction_sessions (user_id); + +-- Convert scope from string to json +ALTER TABLE user_authorized_oidc_clients + ALTER COLUMN scope TYPE jsonb USING ( + CASE + WHEN scope IS NULL OR btrim(scope) = '' THEN '[]'::jsonb + ELSE to_jsonb(array_remove(regexp_split_to_array(scope, '[[:space:]]+'), '')) + END + ), + ALTER COLUMN scope SET DEFAULT '[]'::jsonb, + ALTER COLUMN scope SET NOT NULL; + +DROP TABLE IF EXISTS oidc_pushed_authorization_requests; +DROP TABLE IF EXISTS oidc_device_codes; +DROP TABLE IF EXISTS oidc_refresh_tokens; +DROP TABLE IF EXISTS oidc_authorization_codes; diff --git a/backend/resources/migrations/sqlite/20260607120000_oauth2_storage.down.sql b/backend/resources/migrations/sqlite/20260607120000_oauth2_storage.down.sql new file mode 100644 index 00000000..a99e2667 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260607120000_oauth2_storage.down.sql @@ -0,0 +1,95 @@ +CREATE TABLE oidc_authorization_codes +( + id TEXT NOT NULL PRIMARY KEY, + created_at INTEGER, + code TEXT NOT NULL UNIQUE, + scope TEXT NOT NULL, + nonce TEXT, + expires_at INTEGER NOT NULL, + user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE, + client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE, + code_challenge TEXT, + code_challenge_method_sha256 NUMERIC, + authentication_method TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at); + +CREATE TABLE oidc_refresh_tokens ( + id TEXT NOT NULL PRIMARY KEY, + created_at INTEGER, + token TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + scope TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE, + authentication_method TEXT NOT NULL DEFAULT '', + id_token_jti TEXT +); + +CREATE INDEX idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at); +CREATE INDEX idx_oidc_refresh_tokens_id_token_jti + ON oidc_refresh_tokens(user_id, client_id, id_token_jti); + +CREATE TABLE oidc_device_codes +( + id TEXT NOT NULL PRIMARY KEY, + created_at INTEGER, + device_code TEXT NOT NULL UNIQUE, + user_code TEXT NOT NULL UNIQUE, + scope TEXT NOT NULL, + expires_at INTEGER NOT NULL, + is_authorized BOOLEAN NOT NULL DEFAULT FALSE, + user_id TEXT REFERENCES users ON DELETE CASCADE, + client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE, + authentication_method TEXT NOT NULL DEFAULT '', + nonce TEXT +); + +CREATE TABLE oidc_pushed_authorization_requests ( + id TEXT NOT NULL PRIMARY KEY, + created_at INTEGER NOT NULL, + request_uri TEXT NOT NULL UNIQUE, + client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE, + parameters TEXT NOT NULL DEFAULT '{}', + expires_at INTEGER NOT NULL +); + +CREATE INDEX idx_oidc_par_expires_at ON oidc_pushed_authorization_requests (expires_at); + +CREATE TABLE user_authorized_oidc_clients_new ( + scope TEXT, + user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE, + client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE, + last_used_at DATETIME NOT NULL, + PRIMARY KEY (user_id, client_id) +); + +INSERT INTO user_authorized_oidc_clients_new (scope, user_id, client_id, last_used_at) +SELECT CASE + WHEN scope IS NULL THEN NULL + WHEN json_valid(CAST(scope AS TEXT)) AND json_type(CAST(scope AS TEXT)) = 'array' THEN ( + SELECT group_concat(value, ' ') + FROM json_each(CAST(scope AS TEXT)) + ) + ELSE CAST(scope AS TEXT) + END, + user_id, + client_id, + last_used_at +FROM user_authorized_oidc_clients; + +DROP TABLE user_authorized_oidc_clients; +ALTER TABLE user_authorized_oidc_clients_new RENAME TO user_authorized_oidc_clients; + +DROP INDEX IF EXISTS idx_interaction_sessions_client_id; +DROP INDEX IF EXISTS idx_interaction_sessions_user_id; +DROP TABLE IF EXISTS interaction_sessions; + +DROP INDEX IF EXISTS idx_oauth2_jtis_expires_at; +DROP TABLE IF EXISTS oauth2_jtis; + +DROP INDEX IF EXISTS idx_oauth2_sessions_expires_at; +DROP INDEX IF EXISTS idx_oauth2_sessions_kind_request; +DROP INDEX IF EXISTS idx_oauth2_sessions_kind_key; +DROP TABLE IF EXISTS oauth2_sessions; diff --git a/backend/resources/migrations/sqlite/20260607120000_oauth2_storage.up.sql b/backend/resources/migrations/sqlite/20260607120000_oauth2_storage.up.sql new file mode 100644 index 00000000..fe51d449 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260607120000_oauth2_storage.up.sql @@ -0,0 +1,80 @@ +CREATE TABLE oauth2_sessions ( + id TEXT NOT NULL PRIMARY KEY, + created_at INTEGER NOT NULL, + kind TEXT NOT NULL, + key TEXT NOT NULL, + request_id TEXT NOT NULL, + access_token_signature TEXT NOT NULL DEFAULT '', + active BOOLEAN NOT NULL DEFAULT TRUE, + request_data TEXT NOT NULL, + expires_at INTEGER +); + +CREATE UNIQUE INDEX idx_oauth2_sessions_kind_key ON oauth2_sessions (kind, key); +CREATE INDEX idx_oauth2_sessions_kind_request ON oauth2_sessions (kind, request_id); +CREATE INDEX idx_oauth2_sessions_expires_at ON oauth2_sessions (expires_at); + +CREATE TABLE oauth2_jtis ( + id TEXT NOT NULL PRIMARY KEY, + created_at INTEGER NOT NULL, + jti TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL +); + +CREATE INDEX idx_oauth2_jtis_expires_at ON oauth2_jtis (expires_at); + +CREATE TABLE interaction_sessions ( + id TEXT NOT NULL PRIMARY KEY, + created_at INTEGER NOT NULL, + consent_required BOOLEAN NOT NULL DEFAULT FALSE, + reauthentication_required BOOLEAN NOT NULL DEFAULT FALSE, + authentication_required BOOLEAN NOT NULL DEFAULT FALSE, + account_selection_required BOOLEAN NOT NULL DEFAULT FALSE, + scopes TEXT NOT NULL DEFAULT '[]', + client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + requested_at INTEGER NOT NULL, + reauthenticated_at INTEGER, + parameters TEXT NOT NULL DEFAULT '{}' +); + +CREATE INDEX idx_interaction_sessions_client_id ON interaction_sessions (client_id); +CREATE INDEX idx_interaction_sessions_user_id ON interaction_sessions (user_id); + +CREATE TABLE user_authorized_oidc_clients_new ( + scope BLOB NOT NULL DEFAULT X'5B5D', + user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE, + client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE, + last_used_at DATETIME NOT NULL, + PRIMARY KEY (user_id, client_id) +); + +-- Convert scope from string to json +INSERT INTO user_authorized_oidc_clients_new (scope, user_id, client_id, last_used_at) +SELECT CAST(CASE + WHEN scope IS NULL OR trim(CAST(scope AS TEXT)) = '' THEN '[]' + ELSE ( + WITH RECURSIVE split(value, rest) AS ( + SELECT '', trim(CAST(scope AS TEXT)) || ' ' + UNION ALL + SELECT substr(rest, 0, instr(rest, ' ')), ltrim(substr(rest, instr(rest, ' ') + 1)) + FROM split + WHERE rest <> '' + ) + SELECT json_group_array(value) + FROM split + WHERE value <> '' + ) + END AS BLOB), + user_id, + client_id, + last_used_at +FROM user_authorized_oidc_clients; + +DROP TABLE user_authorized_oidc_clients; +ALTER TABLE user_authorized_oidc_clients_new RENAME TO user_authorized_oidc_clients; + +DROP TABLE IF EXISTS oidc_pushed_authorization_requests; +DROP TABLE IF EXISTS oidc_device_codes; +DROP TABLE IF EXISTS oidc_refresh_tokens; +DROP TABLE IF EXISTS oidc_authorization_codes; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 3af1742b..ea6ed82b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -55,7 +55,6 @@ "sign_in_to": "Sign in to {name}", "account_selection_signin_confirmation": "Do you want to use the following account to continue to {name}?", "use_a_different_account": "Use a different account", - "client_not_found": "Client not found", "client_wants_to_access_the_following_information": "{client} wants to access the following information:", "do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to {client} with your {appName} account?", "email": "Email", @@ -290,7 +289,7 @@ "requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in", "par": "PAR", "requires_pushed_authorization_requests": "Requires Pushed Authorization Requests", - "requires_pushed_authorization_requests_description": "Requires clients to use the PAR endpoint (/api/oidc/par) to pre-register authorization parameters before initiating the flow. Not available for public clients.", + "requires_pushed_authorization_requests_description": "Requires clients to use the PAR endpoint to pre-register authorization parameters before initiating the flow.", "name_logo": "{name} logo", "change_logo": "Change Logo", "upload_logo": "Upload Logo", @@ -529,5 +528,7 @@ "email_verification_sent": "Verification email sent successfully.", "emails_verified_by_default": "Emails verified by default", "emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.", - "user_has_no_passkeys_yet": "This user has no passkeys yet." + "user_has_no_passkeys_yet": "This user has no passkeys yet.", + "replay_protection": "Replay Protection", + "replay_protection_description": "If enabled the provided token can only be used once. If your provider uses the same token multiple times, you may need to disable this option." } diff --git a/frontend/src/lib/components/header/header.svelte b/frontend/src/lib/components/header/header.svelte index be887162..f9f4e4de 100644 --- a/frontend/src/lib/components/header/header.svelte +++ b/frontend/src/lib/components/header/header.svelte @@ -8,7 +8,7 @@ import ModeSwitcher from './mode-switcher.svelte'; const authUrls = [ - /^\/authorize$/, + /^\/interaction$/, /^\/device$/, /^\/login(?:\/.*)?$/, /^\/logout$/, diff --git a/frontend/src/lib/components/scope-list.svelte b/frontend/src/lib/components/scope-list.svelte index 9260ec0d..fadeb6f3 100644 --- a/frontend/src/lib/components/scope-list.svelte +++ b/frontend/src/lib/components/scope-list.svelte @@ -4,21 +4,21 @@ import { LucideMail, LucideUser, LucideUsers } from '@lucide/svelte'; import ScopeItem from './scope-item.svelte'; - let { scope }: { scope: string } = $props(); + let { scopes }: { scopes: string[] } = $props(); - {#if scope!.includes('email')} + {#if scopes.includes('email')} {/if} - {#if scope!.includes('profile')} + {#if scopes.includes('profile')} {/if} - {#if scope!.includes('groups')} + {#if scopes.includes('groups')} { - 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(`/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( + `/oidc/interactions/${id}/complete`, + { step } + ); + return data; }; listClients = async (options?: ListRequestOptions) => { diff --git a/frontend/src/lib/services/webauthn-service.ts b/frontend/src/lib/services/webauthn-service.ts index 0595929b..b7250ff8 100644 --- a/frontend/src/lib/services/webauthn-service.ts +++ b/frontend/src/lib/services/webauthn-service.ts @@ -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); }; } diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index 8c110225..aa9b04d5 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -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; }; diff --git a/frontend/src/lib/utils/form-util.ts b/frontend/src/lib/utils/form-util.ts index 7c665608..68554344 100644 --- a/frontend/src/lib/utils/form-util.ts +++ b/frontend/src/lib/utils/form-util.ts @@ -127,7 +127,7 @@ export function createForm>(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'; diff --git a/frontend/src/lib/utils/redirection-util.ts b/frontend/src/lib/utils/redirection-util.ts index ceb158f3..eb787288 100644 --- a/frontend/src/lib/utils/redirection-util.ts +++ b/frontend/src/lib/utils/redirection-util.ts @@ -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/'); diff --git a/frontend/src/lib/utils/zod-util.ts b/frontend/src/lib/utils/zod-util.ts index fe3aa45b..0ed0f219 100644 --- a/frontend/src/lib/utils/zod-util.ts +++ b/frontend/src/lib/utils/zod-util.ts @@ -14,6 +14,9 @@ export const callbackUrlSchema = z .nonempty() .refine( (val) => { + if (/^(javascript|data):/i.test(val)) { + return false; + } if (val.includes('*')) { return true; } diff --git a/frontend/src/routes/authorize/+page.svelte b/frontend/src/routes/authorize/+page.svelte deleted file mode 100644 index 9b065fcd..00000000 --- a/frontend/src/routes/authorize/+page.svelte +++ /dev/null @@ -1,374 +0,0 @@ - - - - {m.sign_in_to({ name: client.name })} - - -{#if client == null} -

{m.client_not_found()}

-{:else} - - -

- {m.sign_in_to({ name: client.name })} -

- {#if errorMessage} -

- {errorMessage}. -

- {/if} - {#if authorizationRequired} -
- - -

- -

-
- - - -
-
- {:else if accountSelectionRequired && $userStore && !errorMessage} -
-

- -

- - - - - -
-

- {primaryName} -

- {#if fullName && $userStore.email} -

- {$userStore.email} -

- {/if} -
-
-
-
- -
-
- {:else if !authorizationRequired && !errorMessage} -

- -

- {/if} - -
- {#if !errorMessage} - - {:else} - - {/if} - -
-
-{/if} diff --git a/frontend/src/routes/authorize/+page.ts b/frontend/src/routes/authorize/+page.ts deleted file mode 100644 index 7a291db1..00000000 --- a/frontend/src/routes/authorize/+page.ts +++ /dev/null @@ -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 - }; -}; diff --git a/frontend/src/routes/device/+page.svelte b/frontend/src/routes/device/+page.svelte index 7377fae2..c8763a18 100644 --- a/frontend/src/routes/device/+page.svelte +++ b/frontend/src/routes/device/+page.svelte @@ -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); + } + } @@ -86,6 +110,15 @@

{:else if success}

{m.the_device_has_been_authorized()}

+ {:else if reauthenticationRequired && deviceInfo?.client} +

+ +

{:else if authorizationRequired}
@@ -99,7 +132,7 @@

- +
diff --git a/frontend/src/routes/device/+page.ts b/frontend/src/routes/device/+page.ts index 1bc7247e..74a57211 100644 --- a/frontend/src/routes/device/+page.ts +++ b/frontend/src/routes/device/+page.ts @@ -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 diff --git a/frontend/src/routes/interaction/+page.svelte b/frontend/src/routes/interaction/+page.svelte new file mode 100644 index 00000000..36bc0124 --- /dev/null +++ b/frontend/src/routes/interaction/+page.svelte @@ -0,0 +1,197 @@ + + + + {m.sign_in_to({ name: interactionSession.client.name })} + + + + +

+ {m.sign_in_to({ name: interactionSession.client.name })} +

+

+ {#if errorMessage} + {errorMessage}. + {:else if currentStep == 'select_account' && $userStore} + + {:else} + + {/if} +

+ {#if !$userStore || errorMessage} + + {:else if currentStep === 'select_account'} +
+ {#if $userStore} + + + + + +
+

+ {primaryName} +

+ {#if fullName && $userStore.email} +

+ {$userStore.email} +

+ {/if} +
+
+
+ {/if} +
+ +
+
+ {:else if currentStep === 'consent'} +
+ + +

+ +

+
+ + + +
+
+ {/if} +
+ + +
+
diff --git a/frontend/src/routes/interaction/+page.ts b/frontend/src/routes/interaction/+page.ts new file mode 100644 index 00000000..b80ab00c --- /dev/null +++ b/frontend/src/routes/interaction/+page.ts @@ -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 + }; +}; diff --git a/frontend/src/routes/settings/admin/oidc-clients/federated-identities-input.svelte b/frontend/src/routes/settings/admin/oidc-clients/federated-identities-input.svelte index 83cae748..7227ca79 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/federated-identities-input.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/federated-identities-input.svelte @@ -1,5 +1,6 @@