mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-15 21:48:13 +03:00
refactor: use fosite for OAuth 2.0 logic (#1520)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
148
backend/go.sum
148
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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
8
backend/internal/common/claims.go
Normal file
8
backend/internal/common/claims.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package common
|
||||
|
||||
// AuthenticationMethodsClaim is the JWT claim ("amr") used to identify how the user
|
||||
// authenticated. It is shared between the session JWTs and the OIDC tokens.
|
||||
const AuthenticationMethodsClaim = "amr"
|
||||
|
||||
// TokenTypeClaim is the JWT claim ("type") used to identify the type of token.
|
||||
const TokenTypeClaim = "type"
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
18
backend/internal/model/types/string_list.go
Normal file
18
backend/internal/model/types/string_list.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package datatype
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type StringList []string //nolint:recvcheck
|
||||
|
||||
func (s *StringList) Scan(value any) error {
|
||||
return utils.UnmarshalJSONFromDatabase(s, value)
|
||||
}
|
||||
|
||||
func (s StringList) Value() (driver.Value, error) {
|
||||
return json.Marshal(s)
|
||||
}
|
||||
156
backend/internal/oidc/authorization_handler.go
Normal file
156
backend/internal/oidc/authorization_handler.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
const parRequestURIPrefix = "urn:ietf:params:oauth:request_uri:"
|
||||
|
||||
type authorizationHandler struct {
|
||||
provider fosite.OAuth2Provider
|
||||
authorizationService *authorizationService
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func newAuthorizationHandler(
|
||||
provider fosite.OAuth2Provider,
|
||||
authorizationService *authorizationService,
|
||||
baseURL string,
|
||||
) *authorizationHandler {
|
||||
return &authorizationHandler{
|
||||
provider: provider,
|
||||
authorizationService: authorizationService,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *authorizationHandler) authorize(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
userID := c.GetString("userID")
|
||||
authenticationMethod := c.GetString("authenticationMethod")
|
||||
authenticationTime, _ := c.Get("authenticationTime")
|
||||
typedAuthenticationTime, _ := authenticationTime.(time.Time)
|
||||
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
|
||||
|
||||
// A request that resumes an interaction only carries the interaction ID; the original
|
||||
// parameters are restored from the stored session so they never travel through the
|
||||
// front channel.
|
||||
interactionID := c.Query("interaction")
|
||||
if interactionID != "" {
|
||||
query, err := h.authorizationService.interactionRequestQuery(ctx, interactionID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "Failed to restore authorize request from interaction session", "error", err.Error())
|
||||
h.provider.WriteAuthorizeError(ctx, c.Writer, fosite.NewAuthorizeRequest(), err)
|
||||
return
|
||||
}
|
||||
c.Request.URL.RawQuery = query.Encode()
|
||||
}
|
||||
|
||||
// Treat the request as a pushed authorization request only when the request_uri carries the
|
||||
// PAR prefix. Without this, a client required to use PAR could bypass that requirement by
|
||||
// sending an arbitrary (non-prefixed) request_uri, which fosite silently ignores.
|
||||
hasPushedAuthorizationRequest := strings.HasPrefix(c.Query("request_uri"), parRequestURIPrefix)
|
||||
|
||||
ar, err := h.provider.NewAuthorizeRequest(ctx, c.Request)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to create authorize request", "error", err.Error())
|
||||
h.provider.WriteAuthorizeError(ctx, c.Writer, ar, err)
|
||||
return
|
||||
}
|
||||
|
||||
authorization, err := h.authorizationService.authorize(ctx, authorizeInput{
|
||||
userID: userID,
|
||||
authenticationMethod: authenticationMethod,
|
||||
authenticationTime: typedAuthenticationTime,
|
||||
requester: ar,
|
||||
hasPushedAuthorizationRequest: hasPushedAuthorizationRequest,
|
||||
reauthenticationToken: reauthenticationToken,
|
||||
interactionID: interactionID,
|
||||
requestParams: authorizeRequestParams(ar),
|
||||
meta: requestMetaFromGin(c),
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to authorize request", "error", err.Error())
|
||||
h.provider.WriteAuthorizeError(ctx, c.Writer, ar, err)
|
||||
return
|
||||
}
|
||||
|
||||
if authorization.RequiresInteraction {
|
||||
c.Redirect(http.StatusFound, "/interaction?interaction="+authorization.InteractionID)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.provider.NewAuthorizeResponse(ctx, ar, authorization.Session)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to create authorize response", "error", err.Error())
|
||||
h.provider.WriteAuthorizeError(ctx, c.Writer, ar, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.AddParameter("iss", h.baseURL)
|
||||
if ar.GetResponseMode() == fosite.ResponseModeFormPost && ar.GetRedirectURI() != nil {
|
||||
c.Header("Content-Security-Policy", utils.BuildCSP(utils.GetCSPNonce(c), ar.GetRedirectURI().String()))
|
||||
}
|
||||
h.provider.WriteAuthorizeResponse(ctx, c.Writer, ar, response)
|
||||
}
|
||||
|
||||
func requestMetaFromGin(c *gin.Context) requestMeta {
|
||||
return requestMeta{
|
||||
IPAddress: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
}
|
||||
}
|
||||
|
||||
func authorizeRequestParams(requester fosite.AuthorizeRequester) map[string]string {
|
||||
params := make(map[string]string)
|
||||
for key, values := range requester.GetRequestForm() {
|
||||
if len(values) == 0 || key == "request_uri" || key == "interaction" {
|
||||
continue
|
||||
}
|
||||
params[key] = values[0]
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
func (h *authorizationHandler) getInteractionSession(c *gin.Context) {
|
||||
interactionID := c.Param("id")
|
||||
|
||||
interactionSession, err := h.authorizationService.getInteractionSession(c.Request.Context(), interactionID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, interactionSession)
|
||||
}
|
||||
|
||||
func (h *authorizationHandler) completeInteraction(c *gin.Context) {
|
||||
interactionID := c.Param("id")
|
||||
authenticationTime, _ := c.Get("authenticationTime")
|
||||
typedAuthenticationTime, _ := authenticationTime.(time.Time)
|
||||
|
||||
var request completeInteractionRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
_ = c.Error(&common.ValidationError{Message: "invalid interaction request"})
|
||||
return
|
||||
}
|
||||
|
||||
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
|
||||
response, err := h.authorizationService.completeInteractionStep(c.Request.Context(), interactionID, c.GetString("userID"), request.Step, reauthenticationToken, typedAuthenticationTime, requestMetaFromGin(c))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
690
backend/internal/oidc/authorization_service.go
Normal file
690
backend/internal/oidc/authorization_service.go
Normal file
@@ -0,0 +1,690 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func newAuthorizationService(db *gorm.DB, interactionSessionService *interactionSessionService, claimsService *ClaimsService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger) *authorizationService {
|
||||
return &authorizationService{
|
||||
db: db,
|
||||
interactionSessionService: interactionSessionService,
|
||||
claimsService: claimsService,
|
||||
reauth: reauth,
|
||||
auditLog: auditLog,
|
||||
}
|
||||
}
|
||||
|
||||
type authorizationService struct {
|
||||
db *gorm.DB
|
||||
interactionSessionService *interactionSessionService
|
||||
claimsService *ClaimsService
|
||||
reauth ReauthenticationTokenConsumer
|
||||
auditLog AuditLogger
|
||||
}
|
||||
|
||||
type requestMeta struct {
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type authorizationResult struct {
|
||||
RequiresInteraction bool
|
||||
InteractionID string
|
||||
Session *Session
|
||||
}
|
||||
|
||||
type promptValues []string
|
||||
|
||||
func newPromptValues(prompt string) promptValues {
|
||||
return strings.Fields(prompt)
|
||||
}
|
||||
|
||||
func (p promptValues) has(value string) bool {
|
||||
return slices.Contains(p, value)
|
||||
}
|
||||
|
||||
// authorizeInput is the authorization request as provided by the handler.
|
||||
type authorizeInput struct {
|
||||
userID string
|
||||
authenticationMethod string
|
||||
authenticationTime time.Time
|
||||
requester fosite.AuthorizeRequester
|
||||
hasPushedAuthorizationRequest bool
|
||||
reauthenticationToken string
|
||||
interactionID string
|
||||
requestParams map[string]string
|
||||
meta requestMeta
|
||||
}
|
||||
|
||||
// authorizeRequest is the input enriched with everything the service derives from it.
|
||||
type authorizeRequest struct {
|
||||
authorizeInput
|
||||
|
||||
client Client
|
||||
prompt promptValues
|
||||
interactionSession *InteractionSession
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (s *authorizationService) authorize(ctx context.Context, input authorizeInput) (authorizationResult, error) {
|
||||
client := input.requester.GetClient().(Client)
|
||||
prompt := newPromptValues(input.requester.GetRequestForm().Get("prompt"))
|
||||
|
||||
if err := validateClientPKCERequirement(client, input.requester); err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
interactionSession, err := s.boundInteractionSession(ctx, input.interactionID, input.userID, client, input.requester)
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
// Reject authorization requests that require PAR when the request is not a resumed interaction and doesn't have a valid PAR.
|
||||
if client.RequiresPushedAuthorizationRequests && !input.hasPushedAuthorizationRequest && interactionSession == nil {
|
||||
return authorizationResult{}, &common.OidcPARRequiredError{}
|
||||
}
|
||||
|
||||
if input.userID == "" {
|
||||
if prompt.has("none") {
|
||||
return authorizationResult{}, fosite.ErrLoginRequired
|
||||
}
|
||||
|
||||
interactionSession, err := s.createInteractionSession(ctx, input.requester, input.requestParams, "", interactionRequirements{
|
||||
AuthenticationRequired: true,
|
||||
ReauthenticationRequired: prompt.has("login") || client.RequiresReauthentication,
|
||||
AccountSelectionRequired: prompt.has("select_account"),
|
||||
ConsentRequired: prompt.has("consent"),
|
||||
})
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
return authorizationResult{RequiresInteraction: true, InteractionID: interactionSession.ID}, nil
|
||||
}
|
||||
|
||||
req := authorizeRequest{
|
||||
authorizeInput: input,
|
||||
client: client,
|
||||
prompt: prompt,
|
||||
interactionSession: interactionSession,
|
||||
now: time.Now().UTC(),
|
||||
}
|
||||
|
||||
var result authorizationResult
|
||||
err = withTx(ctx, s.db, func(ctx context.Context) error {
|
||||
var err error
|
||||
result, err = s.authorizeAuthenticated(ctx, req)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
if result.Session == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if err := s.claimsService.applyIDTokenClaims(ctx, result.Session, input.requester.GetGrantedScopes()); err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// authorizeAuthenticated either reports the interaction the user still has to complete or grants the request.
|
||||
func (s *authorizationService) authorizeAuthenticated(ctx context.Context, req authorizeRequest) (authorizationResult, error) {
|
||||
var user model.User
|
||||
err := dbFromContext(ctx, s.db).
|
||||
Preload("UserGroups").
|
||||
First(&user, "id = ?", req.userID).
|
||||
Error
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
if !IsUserGroupAllowedToAuthorize(user, req.client.OidcClient) {
|
||||
return authorizationResult{}, fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
|
||||
}
|
||||
|
||||
interactionSession := req.interactionSession
|
||||
if interactionSession != nil && interactionSession.UserID != nil && *interactionSession.UserID != req.userID {
|
||||
if err := s.switchInteractionSessionUser(ctx, interactionSession, req.userID, req.authenticationTime); err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
if err := s.interactionSessionService.update(ctx, *interactionSession); err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
requirements, authenticationTime, err := s.resolveRequirements(ctx, req, interactionSession)
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
if requirements.any() {
|
||||
if interactionSession != nil {
|
||||
return authorizationResult{RequiresInteraction: true, InteractionID: interactionSession.ID}, nil
|
||||
}
|
||||
|
||||
created, err := s.createInteractionSession(ctx, req.requester, req.requestParams, req.userID, requirements)
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
return authorizationResult{RequiresInteraction: true, InteractionID: created.ID}, nil
|
||||
}
|
||||
|
||||
if interactionSession != nil {
|
||||
err := s.interactionSessionService.delete(ctx, interactionSession.ID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return authorizationResult{}, fosite.ErrInvalidRequest.WithHint("The interaction session has already been used.")
|
||||
}
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
hasAlreadyAuthorizedClient, err := s.consent(ctx, req.userID, req.client.GetID(), req.requester.GetRequestedScopes())
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
session := s.buildAuthorizedSession(req, interactionSession, authenticationTime)
|
||||
|
||||
for _, scope := range req.requester.GetRequestedScopes() {
|
||||
req.requester.GrantScope(scope)
|
||||
}
|
||||
|
||||
authorizationEvent := model.AuditLogEventClientAuthorization
|
||||
if !hasAlreadyAuthorizedClient {
|
||||
authorizationEvent = model.AuditLogEventNewClientAuthorization
|
||||
}
|
||||
if s.auditLog != nil {
|
||||
s.auditLog.Create(ctx, authorizationEvent, req.meta.IPAddress, req.meta.UserAgent, req.userID, model.AuditLogData{"clientName": req.client.Name}, dbFromContext(ctx, s.db))
|
||||
}
|
||||
|
||||
return authorizationResult{Session: session}, nil
|
||||
}
|
||||
|
||||
// resolveRequirements determines the interaction steps still required; the requirements
|
||||
// of a resumed interaction session win over the ones derived from the request.
|
||||
func (s *authorizationService) resolveRequirements(ctx context.Context, req authorizeRequest, interactionSession *InteractionSession) (interactionRequirements, time.Time, error) {
|
||||
authenticationTime := req.authenticationTime
|
||||
|
||||
hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, req.client.GetID(), req.userID, req.requester.GetRequestedScopes())
|
||||
if err != nil {
|
||||
return interactionRequirements{}, authenticationTime, err
|
||||
}
|
||||
|
||||
maxAgeReauthenticationRequired, err := requiresReauthenticationForMaxAge(req.requester.GetRequestForm().Get("max_age"), authenticationTime, req.now)
|
||||
if err != nil {
|
||||
return interactionRequirements{}, authenticationTime, err
|
||||
}
|
||||
|
||||
requirements := interactionRequirements{
|
||||
ConsentRequired: !hasAlreadyAuthorizedClient || req.prompt.has("consent"),
|
||||
ReauthenticationRequired: req.prompt.has("login") || req.client.RequiresReauthentication || maxAgeReauthenticationRequired,
|
||||
AccountSelectionRequired: req.prompt.has("select_account"),
|
||||
AuthenticationRequired: false,
|
||||
}
|
||||
|
||||
if interactionSession != nil {
|
||||
requirements = interactionRequirements{
|
||||
ConsentRequired: interactionSession.ConsentRequired,
|
||||
ReauthenticationRequired: interactionSession.ReauthenticationRequired,
|
||||
AccountSelectionRequired: interactionSession.AccountSelectionRequired,
|
||||
AuthenticationRequired: interactionSession.AuthenticationRequired,
|
||||
}
|
||||
if interactionSession.ReauthenticatedAt != nil {
|
||||
authenticationTime = interactionSession.ReauthenticatedAt.UTC()
|
||||
}
|
||||
}
|
||||
|
||||
if req.prompt.has("none") && requirements.ConsentRequired {
|
||||
return interactionRequirements{}, authenticationTime, fosite.ErrConsentRequired
|
||||
}
|
||||
if req.prompt.has("none") && requirements.ReauthenticationRequired {
|
||||
return interactionRequirements{}, authenticationTime, fosite.ErrLoginRequired
|
||||
}
|
||||
|
||||
if requirements.ReauthenticationRequired && req.reauthenticationToken != "" && s.reauth != nil {
|
||||
reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), req.reauthenticationToken, req.userID)
|
||||
if err == nil {
|
||||
requirements.ReauthenticationRequired = false
|
||||
authenticationTime = reauthenticatedAt
|
||||
}
|
||||
}
|
||||
|
||||
return requirements, authenticationTime, nil
|
||||
}
|
||||
|
||||
func (s *authorizationService) buildAuthorizedSession(req authorizeRequest, interactionSession *InteractionSession, authenticationTime time.Time) *Session {
|
||||
if authenticationTime.IsZero() {
|
||||
authenticationTime = req.now
|
||||
}
|
||||
requestedAt := req.requester.GetRequestedAt()
|
||||
if interactionSession != nil && !interactionSession.RequestedAt.ToTime().IsZero() {
|
||||
requestedAt = interactionSession.RequestedAt.UTC()
|
||||
}
|
||||
if requestedAt.IsZero() {
|
||||
requestedAt = req.now
|
||||
}
|
||||
|
||||
return NewAuthenticatedSession(req.userID, req.authenticationMethod, authenticationTime, requestedAt)
|
||||
}
|
||||
|
||||
// interactionRequestQuery returns the authorize parameters stored for the interaction
|
||||
// session, so the handler can rebuild the re-entry request server-side.
|
||||
func (s *authorizationService) interactionRequestQuery(ctx context.Context, interactionID string) (query url.Values, err error) {
|
||||
interactionSession, err := s.interactionSessionService.get(ctx, interactionID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fosite.ErrInvalidRequest.WithHint("The interaction session is invalid or has expired.")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query = url.Values{}
|
||||
for key, value := range interactionSession.Parameters {
|
||||
if value != "" {
|
||||
query.Set(key, value)
|
||||
}
|
||||
}
|
||||
query.Set("interaction", interactionSession.ID)
|
||||
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// boundInteractionSession loads the referenced interaction session and validates the request against it.
|
||||
func (s *authorizationService) boundInteractionSession(ctx context.Context, interactionID string, userID string, client Client, requester fosite.AuthorizeRequester) (*InteractionSession, error) {
|
||||
if interactionID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
session, err := s.interactionSessionService.get(ctx, interactionID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fosite.ErrInvalidRequest.WithHint("The interaction session is invalid or has expired.")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateInteractionSessionBinding(session, userID, client, requester); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// validateInteractionSessionBinding ensures a request matches the interaction session
|
||||
// it resumes: the requirement flags and the PAR exemption come from the session, so a
|
||||
// tampered request (extra scopes, another client's interaction) must not inherit them.
|
||||
func validateInteractionSessionBinding(interactionSession InteractionSession, userID string, client Client, requester fosite.AuthorizeRequester) error {
|
||||
if interactionSession.ClientID != client.GetID() {
|
||||
return fosite.ErrInvalidRequest.WithHint("The interaction session does not belong to this client.")
|
||||
}
|
||||
|
||||
if interactionSession.UserID != nil {
|
||||
if userID == "" {
|
||||
return fosite.ErrLoginRequired
|
||||
}
|
||||
}
|
||||
|
||||
for _, scope := range requester.GetRequestedScopes() {
|
||||
if !slices.Contains(interactionSession.Scopes, scope) {
|
||||
return fosite.ErrInvalidRequest.WithHint("The requested scopes exceed the scopes of the interaction session.")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type interactionRequirements struct {
|
||||
ConsentRequired bool
|
||||
ReauthenticationRequired bool
|
||||
AuthenticationRequired bool
|
||||
AccountSelectionRequired bool
|
||||
}
|
||||
|
||||
func (r interactionRequirements) any() bool {
|
||||
return r.ConsentRequired || r.ReauthenticationRequired || r.AuthenticationRequired || r.AccountSelectionRequired
|
||||
}
|
||||
|
||||
func (s *authorizationService) createInteractionSession(ctx context.Context, requester fosite.AuthorizeRequester, requestParams map[string]string, userID string, requirements interactionRequirements) (InteractionSession, error) {
|
||||
parameters := make(map[string]string, len(requestParams))
|
||||
for key, value := range requestParams {
|
||||
parameters[key] = value
|
||||
}
|
||||
|
||||
return s.interactionSessionService.create(ctx, InteractionSession{
|
||||
Base: model.Base{
|
||||
ID: requester.GetID(),
|
||||
},
|
||||
Scopes: datatype.StringList(requester.GetRequestedScopes()),
|
||||
ClientID: requester.GetClient().GetID(),
|
||||
UserID: utils.PtrOrNil(userID),
|
||||
ConsentRequired: requirements.ConsentRequired,
|
||||
ReauthenticationRequired: requirements.ReauthenticationRequired,
|
||||
AuthenticationRequired: requirements.AuthenticationRequired,
|
||||
AccountSelectionRequired: requirements.AccountSelectionRequired,
|
||||
RequestedAt: datatype.DateTime(requester.GetRequestedAt()),
|
||||
Parameters: parameters,
|
||||
})
|
||||
}
|
||||
|
||||
func bindInteractionSessionUser(interactionSession *InteractionSession, userID string) error {
|
||||
if userID == "" {
|
||||
return fosite.ErrLoginRequired
|
||||
}
|
||||
if interactionSession.UserID != nil && *interactionSession.UserID != userID {
|
||||
return fosite.ErrInvalidRequest.WithHint("The interaction session belongs to another user.")
|
||||
}
|
||||
if interactionSession.UserID == nil {
|
||||
interactionSession.UserID = &userID
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *authorizationService) switchInteractionSessionUser(ctx context.Context, interactionSession *InteractionSession, userID string, authenticationTime time.Time) error {
|
||||
if userID == "" {
|
||||
return fosite.ErrLoginRequired
|
||||
}
|
||||
interactionSession.UserID = &userID
|
||||
|
||||
requirements, err := s.interactionRequirementsForUser(ctx, userID, interactionSession, authenticationTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
interactionSession.AuthenticationRequired = false
|
||||
interactionSession.ConsentRequired = requirements.ConsentRequired
|
||||
interactionSession.ReauthenticationRequired = requirements.ReauthenticationRequired
|
||||
interactionSession.ReauthenticatedAt = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *authorizationService) getInteractionSession(ctx context.Context, interactionSessionID string) (interactionSessionForUser, error) {
|
||||
interactionSession, err := s.interactionSessionService.get(ctx, interactionSessionID)
|
||||
if err != nil {
|
||||
return interactionSessionForUser{}, err
|
||||
}
|
||||
|
||||
return newInteractionSessionForUser(interactionSession)
|
||||
}
|
||||
|
||||
func (s *authorizationService) completeInteractionStep(ctx context.Context, interactionSessionID, userID string, step interactionStep, reauthenticationToken string, authenticationTime time.Time, meta requestMeta) (completeInteractionResponse, error) {
|
||||
var interactionSession InteractionSession
|
||||
var response completeInteractionResponse
|
||||
err := withTx(ctx, s.db, func(ctx context.Context) error {
|
||||
var err error
|
||||
interactionSession, err = s.interactionSessionService.get(ctx, interactionSessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if interactionSession.UserID != nil && *interactionSession.UserID != userID {
|
||||
if err := s.switchInteractionSessionUser(ctx, &interactionSession, userID, authenticationTime); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
return fosite.ErrLoginRequired
|
||||
}
|
||||
|
||||
requiredSteps := requiredInteractionSteps(interactionSession)
|
||||
if len(requiredSteps) == 0 {
|
||||
response = completeInteractionResponse{RedirectURL: authorizeRedirectURL(interactionSession.ID)}
|
||||
return nil
|
||||
}
|
||||
|
||||
if requiredSteps[0] != step {
|
||||
return &common.ValidationError{Message: "expected interaction step " + string(requiredSteps[0]) + " but got " + string(step)}
|
||||
}
|
||||
|
||||
if err := s.applyInteractionStep(ctx, &interactionSession, userID, step, reauthenticationToken, authenticationTime, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.interactionSessionService.update(ctx, interactionSession)
|
||||
})
|
||||
if err != nil {
|
||||
return completeInteractionResponse{}, err
|
||||
}
|
||||
if response.RedirectURL != "" {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
if !hasRemainingInteractionSteps(interactionSession) {
|
||||
return completeInteractionResponse{RedirectURL: authorizeRedirectURL(interactionSession.ID)}, nil
|
||||
}
|
||||
|
||||
interaction, err := newInteractionSessionForUser(interactionSession)
|
||||
if err != nil {
|
||||
return completeInteractionResponse{}, err
|
||||
}
|
||||
|
||||
return completeInteractionResponse{Interaction: &interaction}, nil
|
||||
}
|
||||
|
||||
func (s *authorizationService) applyInteractionStep(ctx context.Context, interactionSession *InteractionSession, userID string, step interactionStep, reauthenticationToken string, authenticationTime time.Time, meta requestMeta) error {
|
||||
switch step {
|
||||
case interactionStepAuthenticate:
|
||||
if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
interactionSession.AuthenticationRequired = false
|
||||
return s.populatePostAuthenticationRequirements(ctx, userID, interactionSession, authenticationTime)
|
||||
case interactionStepSelectAccount:
|
||||
if err := s.switchInteractionSessionUser(ctx, interactionSession, userID, authenticationTime); err != nil {
|
||||
return err
|
||||
}
|
||||
interactionSession.AccountSelectionRequired = false
|
||||
return nil
|
||||
case interactionStepReauthenticate:
|
||||
return s.completeReauthenticationStep(ctx, interactionSession, userID, reauthenticationToken)
|
||||
case interactionStepConsent:
|
||||
return s.completeConsentStep(ctx, interactionSession, userID, meta)
|
||||
default:
|
||||
return &common.ValidationError{Message: "unknown interaction step " + string(step)}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *authorizationService) completeReauthenticationStep(ctx context.Context, interactionSession *InteractionSession, userID, reauthenticationToken string) error {
|
||||
if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if reauthenticationToken == "" {
|
||||
return &common.ValidationError{Message: "reauthentication token is required"}
|
||||
}
|
||||
reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
interactionSession.ReauthenticationRequired = false
|
||||
interactionSession.ReauthenticatedAt = new(datatype.DateTime(reauthenticatedAt))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *authorizationService) completeConsentStep(ctx context.Context, interactionSession *InteractionSession, userID string, meta requestMeta) error {
|
||||
if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
hasAlreadyAuthorizedClient, err := s.consent(ctx, userID, interactionSession.ClientID, interactionSession.Scopes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasAlreadyAuthorizedClient && s.auditLog != nil {
|
||||
s.auditLog.Create(ctx, model.AuditLogEventNewClientAuthorization, meta.IPAddress, meta.UserAgent, userID, model.AuditLogData{"clientName": interactionSession.Client.Name}, dbFromContext(ctx, s.db))
|
||||
}
|
||||
interactionSession.ConsentRequired = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *authorizationService) populatePostAuthenticationRequirements(ctx context.Context, userID string, interactionSession *InteractionSession, authenticationTime time.Time) error {
|
||||
if userID == "" {
|
||||
return errors.New("user is required to complete authentication")
|
||||
}
|
||||
|
||||
requirements, err := s.interactionRequirementsForUser(ctx, userID, interactionSession, authenticationTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
interactionSession.ConsentRequired = interactionSession.ConsentRequired || requirements.ConsentRequired
|
||||
interactionSession.AccountSelectionRequired = interactionSession.AccountSelectionRequired || requirements.AccountSelectionRequired
|
||||
interactionSession.ReauthenticationRequired = interactionSession.ReauthenticationRequired || requirements.ReauthenticationRequired
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *authorizationService) interactionRequirementsForUser(ctx context.Context, userID string, interactionSession *InteractionSession, authenticationTime time.Time) (interactionRequirements, error) {
|
||||
prompt := newPromptValues(interactionSession.Parameters["prompt"])
|
||||
hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, interactionSession.ClientID, userID, interactionSession.Scopes)
|
||||
if err != nil {
|
||||
return interactionRequirements{}, err
|
||||
}
|
||||
|
||||
maxAgeReauthenticationRequired, err := requiresReauthenticationForMaxAge(interactionSession.Parameters["max_age"], authenticationTime, time.Now().UTC())
|
||||
if err != nil {
|
||||
return interactionRequirements{}, err
|
||||
}
|
||||
|
||||
return interactionRequirements{
|
||||
ConsentRequired: !hasAlreadyAuthorizedClient || prompt.has("consent"),
|
||||
ReauthenticationRequired: prompt.has("login") || interactionSession.Client.RequiresReauthentication || maxAgeReauthenticationRequired,
|
||||
AccountSelectionRequired: prompt.has("select_account"),
|
||||
AuthenticationRequired: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// authorizeRedirectURL only references the interaction session; the authorize endpoint
|
||||
// restores the request parameters from it server-side.
|
||||
func authorizeRedirectURL(interactionSessionID string) string {
|
||||
return "/authorize?interaction=" + url.QueryEscape(interactionSessionID)
|
||||
}
|
||||
|
||||
// validateClientPKCERequirement enforces the per-client PkceEnabled flag. fosite only
|
||||
// enforces PKCE for public clients (EnforcePKCEForPublicClients).
|
||||
func validateClientPKCERequirement(client Client, requester fosite.AuthorizeRequester) error {
|
||||
if !client.PkceEnabled {
|
||||
return nil
|
||||
}
|
||||
if requester.GetRequestForm().Get("code_challenge") == "" {
|
||||
return fosite.ErrInvalidRequest.WithHint("This client requires PKCE, but the 'code_challenge' parameter is missing.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiresReauthenticationForMaxAge(maxAgeRaw string, authenticationTime time.Time, now time.Time) (bool, error) {
|
||||
if maxAgeRaw == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
maxAge, err := strconv.ParseInt(maxAgeRaw, 10, 64)
|
||||
if err != nil || maxAge < 0 {
|
||||
return false, fosite.ErrInvalidRequest.WithHint("Parameter 'max_age' must be a non-negative integer.")
|
||||
}
|
||||
|
||||
if authenticationTime.IsZero() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return !now.Before(authenticationTime.UTC().Add(time.Duration(maxAge) * time.Second)), nil
|
||||
}
|
||||
|
||||
func (s *authorizationService) consent(ctx context.Context, userID string, clientID string, scope []string) (hasAlreadyAuthorizedClient bool, err error) {
|
||||
db := dbFromContext(ctx, s.db)
|
||||
|
||||
hasAlreadyAuthorizedClient, err = s.hasAuthorizedClient(ctx, clientID, userID, scope)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if hasAlreadyAuthorizedClient {
|
||||
err = db.
|
||||
Model(&model.UserAuthorizedOidcClient{}).
|
||||
Where("user_id = ? AND client_id = ?", userID, clientID).
|
||||
Update("last_used_at", datatype.DateTime(time.Now())).
|
||||
Error
|
||||
|
||||
if err != nil {
|
||||
return hasAlreadyAuthorizedClient, err
|
||||
}
|
||||
|
||||
return hasAlreadyAuthorizedClient, nil
|
||||
}
|
||||
|
||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
Scope: scope,
|
||||
LastUsedAt: datatype.DateTime(time.Now()),
|
||||
}
|
||||
|
||||
err = db.
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"scope"}),
|
||||
}).
|
||||
Create(&userAuthorizedClient).
|
||||
Error
|
||||
|
||||
return hasAlreadyAuthorizedClient, err
|
||||
}
|
||||
|
||||
func (s *authorizationService) hasAuthorizedClient(ctx context.Context, clientID, userID string, scope []string) (bool, error) {
|
||||
var userAuthorizedOidcClient model.UserAuthorizedOidcClient
|
||||
err := dbFromContext(ctx, s.db).
|
||||
First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
authorizedScopes := userAuthorizedOidcClient.Scope
|
||||
for _, requestedScope := range scope {
|
||||
if !slices.Contains(authorizedScopes, requestedScope) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// IsUserGroupAllowedToAuthorize reports whether the user may use the group-restricted client.
|
||||
func IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
|
||||
if !client.IsGroupRestricted {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, userGroup := range client.AllowedUserGroups {
|
||||
for _, userGroupUser := range user.UserGroups {
|
||||
if userGroup.ID == userGroupUser.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
918
backend/internal/oidc/authorization_service_test.go
Normal file
918
backend/internal/oidc/authorization_service_test.go
Normal file
@@ -0,0 +1,918 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type fakeAuditLogger struct {
|
||||
events []model.AuditLogEvent
|
||||
data []model.AuditLogData
|
||||
}
|
||||
|
||||
func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, _, _, _ string, data model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
|
||||
f.events = append(f.events, event)
|
||||
f.data = append(f.data, data)
|
||||
return model.AuditLog{}, true
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
auditLogger := &fakeAuditLogger{}
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
Scope: datatype.StringList{"openid"},
|
||||
}).Error)
|
||||
|
||||
authorization, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: time.Now().UTC(),
|
||||
requester: newTestAuthorizeRequester("audit-request", clientID, ""),
|
||||
meta: requestMeta{IPAddress: "203.0.113.1", UserAgent: "test-agent"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, authorization.RequiresInteraction)
|
||||
|
||||
require.Equal(t, []model.AuditLogEvent{model.AuditLogEventClientAuthorization}, auditLogger.events)
|
||||
require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0])
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceConsentStepLogsNewClientAuthorization(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
auditLogger := &fakeAuditLogger{}
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
interactionID = "test-interaction"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&InteractionSession{
|
||||
Base: model.Base{ID: interactionID},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
ConsentRequired: true,
|
||||
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
||||
Parameters: map[string]string{},
|
||||
}).Error)
|
||||
|
||||
response, err := service.completeInteractionStep(t.Context(), interactionID, userID, interactionStepConsent, "", time.Now().UTC(), requestMeta{})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, response.RedirectURL)
|
||||
|
||||
require.Equal(t, []model.AuditLogEvent{model.AuditLogEventNewClientAuthorization}, auditLogger.events)
|
||||
require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0])
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceAuthorizeConsumesInteractionSession(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
interactionID = "test-interaction"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
Scope: datatype.StringList{"openid"},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&InteractionSession{
|
||||
Base: model.Base{ID: interactionID},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
||||
Parameters: map[string]string{},
|
||||
}).Error)
|
||||
|
||||
authorize := func() (authorizationResult, error) {
|
||||
return service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: time.Now().UTC(),
|
||||
requester: newTestAuthorizeRequester("consume-request", clientID, ""),
|
||||
interactionID: interactionID,
|
||||
})
|
||||
}
|
||||
|
||||
authorization, err := authorize()
|
||||
require.NoError(t, err)
|
||||
require.False(t, authorization.RequiresInteraction)
|
||||
|
||||
// The interaction session is consumed together with the grant and must be single-use
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&InteractionSession{}).Where("id = ?", interactionID).Count(&count).Error)
|
||||
require.Zero(t, count)
|
||||
|
||||
_, err = authorize()
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
func TestInteractionSessionServiceGetRejectsExpiredSession(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newInteractionSessionService(db)
|
||||
|
||||
const (
|
||||
clientID = "test-client"
|
||||
interactionID = "test-interaction"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&InteractionSession{
|
||||
Base: model.Base{ID: interactionID},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
||||
Parameters: map[string]string{},
|
||||
}).Error)
|
||||
|
||||
_, err := service.get(t.Context(), interactionID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Backdate the session past its lifetime; BeforeCreate stamps CreatedAt, so update directly
|
||||
expiredCreatedAt := datatype.DateTime(time.Now().Add(-interactionSessionLifetime - time.Minute))
|
||||
require.NoError(t, db.Model(&InteractionSession{}).Where("id = ?", interactionID).Update("created_at", expiredCreatedAt).Error)
|
||||
|
||||
_, err = service.get(t.Context(), interactionID)
|
||||
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
|
||||
newInteractionSession := func(id string) {
|
||||
require.NoError(t, db.Create(&InteractionSession{
|
||||
Base: model.Base{ID: id},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
||||
Parameters: map[string]string{},
|
||||
}).Error)
|
||||
}
|
||||
|
||||
authorize := func(interactionID string, scopes ...string) (authorizationResult, error) {
|
||||
requester := newTestAuthorizeRequester("scope-binding-request", clientID, "")
|
||||
requester.(*fosite.AuthorizeRequest).RequestedScope = fosite.Arguments(scopes)
|
||||
return service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: time.Now().UTC(),
|
||||
requester: requester,
|
||||
interactionID: interactionID,
|
||||
})
|
||||
}
|
||||
|
||||
// Scopes matching the consented interaction session are granted
|
||||
newInteractionSession("matching-interaction")
|
||||
authorization, err := authorize("matching-interaction", "openid")
|
||||
require.NoError(t, err)
|
||||
require.False(t, authorization.RequiresInteraction)
|
||||
|
||||
// Scopes added to the URL after consent must not be granted silently
|
||||
newInteractionSession("escalated-interaction")
|
||||
_, err = authorize("escalated-interaction", "openid", "profile", "email", "groups")
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
||||
|
||||
// The user must not have been recorded as having authorized the escalated scopes
|
||||
var authorizedClient model.UserAuthorizedOidcClient
|
||||
require.NoError(t, db.First(&authorizedClient, "user_id = ? AND client_id = ?", userID, clientID).Error)
|
||||
require.Equal(t, datatype.StringList{"openid"}, authorizedClient.Scope)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
otherClientID = "other-client"
|
||||
interactionID = "test-interaction"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: otherClientID},
|
||||
Name: "Other Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&InteractionSession{
|
||||
Base: model.Base{ID: interactionID},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
||||
Parameters: map[string]string{},
|
||||
}).Error)
|
||||
|
||||
_, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: time.Now().UTC(),
|
||||
requester: newTestAuthorizeRequester("other-client-request", otherClientID, ""),
|
||||
interactionID: interactionID,
|
||||
})
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
otherUserID = "other-user"
|
||||
clientID = "test-client"
|
||||
interactionID = "test-interaction"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
Username: "test-user",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: otherUserID},
|
||||
Username: "other-user",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
Scope: datatype.StringList{"openid"},
|
||||
}).Error)
|
||||
reauthenticatedAt := datatype.DateTime(time.Now().Add(-time.Minute).UTC())
|
||||
require.NoError(t, db.Create(&InteractionSession{
|
||||
Base: model.Base{ID: interactionID},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
UserID: stringPointer(userID),
|
||||
ReauthenticatedAt: &reauthenticatedAt,
|
||||
ReauthenticationRequired: false,
|
||||
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
||||
Parameters: map[string]string{
|
||||
"prompt": "login",
|
||||
},
|
||||
}).Error)
|
||||
|
||||
authorization, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: otherUserID,
|
||||
authenticationTime: time.Now().UTC(),
|
||||
requester: newTestAuthorizeRequester("other-user-request", clientID, ""),
|
||||
interactionID: interactionID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, authorization.RequiresInteraction)
|
||||
require.Equal(t, interactionID, authorization.InteractionID)
|
||||
|
||||
var interactionSession InteractionSession
|
||||
require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error)
|
||||
require.NotNil(t, interactionSession.UserID)
|
||||
require.Equal(t, otherUserID, *interactionSession.UserID)
|
||||
require.True(t, interactionSession.ConsentRequired)
|
||||
require.True(t, interactionSession.ReauthenticationRequired)
|
||||
require.Nil(t, interactionSession.ReauthenticatedAt)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
interactionID = "test-interaction"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&InteractionSession{
|
||||
Base: model.Base{ID: interactionID},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
UserID: stringPointer(userID),
|
||||
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
||||
Parameters: map[string]string{},
|
||||
}).Error)
|
||||
|
||||
_, err := service.authorize(t.Context(), authorizeInput{
|
||||
requester: newTestAuthorizeRequester("unauthenticated-bound-request", clientID, ""),
|
||||
interactionID: interactionID,
|
||||
})
|
||||
require.ErrorIs(t, err, fosite.ErrLoginRequired)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
interactionID = "test-interaction"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&InteractionSession{
|
||||
Base: model.Base{ID: interactionID},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
ConsentRequired: true,
|
||||
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
||||
Parameters: map[string]string{},
|
||||
}).Error)
|
||||
|
||||
response, err := service.completeInteractionStep(t.Context(), interactionID, userID, interactionStepConsent, "", time.Now().UTC(), requestMeta{})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, response.RedirectURL)
|
||||
|
||||
var interactionSession InteractionSession
|
||||
require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error)
|
||||
require.NotNil(t, interactionSession.UserID)
|
||||
require.Equal(t, userID, *interactionSession.UserID)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirements(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
otherUserID = "other-user"
|
||||
clientID = "test-client"
|
||||
interactionID = "test-interaction"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
Username: "test-user",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: otherUserID},
|
||||
Username: "other-user",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&InteractionSession{
|
||||
Base: model.Base{ID: interactionID},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
UserID: stringPointer(userID),
|
||||
AccountSelectionRequired: true,
|
||||
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
||||
Parameters: map[string]string{
|
||||
"prompt": "select_account",
|
||||
},
|
||||
}).Error)
|
||||
|
||||
response, err := service.completeInteractionStep(t.Context(), interactionID, otherUserID, interactionStepSelectAccount, "", time.Now().UTC(), requestMeta{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, response.RedirectURL)
|
||||
require.NotNil(t, response.Interaction)
|
||||
require.Equal(t, interactionStepConsent, response.Interaction.CurrentStep)
|
||||
|
||||
var interactionSession InteractionSession
|
||||
require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error)
|
||||
require.NotNil(t, interactionSession.UserID)
|
||||
require.Equal(t, otherUserID, *interactionSession.UserID)
|
||||
require.False(t, interactionSession.AccountSelectionRequired)
|
||||
require.True(t, interactionSession.ConsentRequired)
|
||||
}
|
||||
|
||||
// TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser ensures consent is
|
||||
// evaluated for the FINAL selected user. When an already-consented user starts
|
||||
// prompt=select_account and a different, not-yet-consented user is selected, consent must
|
||||
// still be required for that user rather than being inherited from the initiator.
|
||||
func TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
initiatorID = "initiator-user"
|
||||
switchedID = "switched-user"
|
||||
clientID = "test-client"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: initiatorID}, Username: "initiator"}).Error)
|
||||
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: switchedID}, Username: "switched"}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)
|
||||
|
||||
// The initiator has previously consented to the client; the switched-to user has not.
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
||||
UserID: initiatorID,
|
||||
ClientID: clientID,
|
||||
Scope: datatype.StringList{"openid"},
|
||||
}).Error)
|
||||
|
||||
authorization, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: initiatorID,
|
||||
authenticationTime: time.Now().UTC(),
|
||||
requester: newTestAuthorizeRequester("select-account-request", clientID, "select_account"),
|
||||
requestParams: map[string]string{"prompt": "select_account"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, authorization.RequiresInteraction)
|
||||
|
||||
response, err := service.completeInteractionStep(t.Context(), authorization.InteractionID, switchedID, interactionStepSelectAccount, "", time.Now().UTC(), requestMeta{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The flow must not be granted yet: consent is still pending for the switched-to user.
|
||||
require.Empty(t, response.RedirectURL)
|
||||
require.NotNil(t, response.Interaction)
|
||||
require.Equal(t, interactionStepConsent, response.Interaction.CurrentStep)
|
||||
|
||||
// No authorization record may have been silently created for the switched-to user.
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&model.UserAuthorizedOidcClient{}).
|
||||
Where("user_id = ? AND client_id = ?", switchedID, clientID).
|
||||
Count(&count).Error)
|
||||
require.Zero(t, count)
|
||||
}
|
||||
|
||||
func TestValidateClientPKCERequirement(t *testing.T) {
|
||||
pkceClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "c"}, PkceEnabled: true}}
|
||||
plainClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "c"}}}
|
||||
|
||||
withChallenge := newTestAuthorizeRequesterWithForm("with-challenge", "c", url.Values{"code_challenge": {"abc123"}})
|
||||
withoutChallenge := newTestAuthorizeRequesterWithForm("without-challenge", "c", url.Values{})
|
||||
|
||||
require.NoError(t, validateClientPKCERequirement(plainClient, withoutChallenge))
|
||||
require.NoError(t, validateClientPKCERequirement(pkceClient, withChallenge))
|
||||
|
||||
err := validateClientPKCERequirement(pkceClient, withoutChallenge)
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE proves the per-client PkceEnabled
|
||||
// flag is honored for confidential clients (fosite only enforces PKCE for public clients).
|
||||
func TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "pkce-client"
|
||||
)
|
||||
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "test-user"}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "PKCE Client", PkceEnabled: true}).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: userID, ClientID: clientID, Scope: datatype.StringList{"openid"}}).Error)
|
||||
|
||||
pkceClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, Name: "PKCE Client", PkceEnabled: true}}
|
||||
|
||||
// Without a code_challenge the request is rejected before any interaction.
|
||||
missing := newTestAuthorizeRequesterWithForm("authz-no-pkce", clientID, url.Values{})
|
||||
missing.(*fosite.AuthorizeRequest).Client = pkceClient
|
||||
_, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: time.Now().UTC(),
|
||||
requester: missing,
|
||||
})
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
||||
|
||||
// With a code_challenge the PKCE gate passes and the (already-consented) request is granted.
|
||||
withChallenge := newTestAuthorizeRequesterWithForm("authz-pkce", clientID, url.Values{"code_challenge": {"abc123"}})
|
||||
withChallenge.(*fosite.AuthorizeRequest).Client = pkceClient
|
||||
result, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: time.Now().UTC(),
|
||||
requester: withChallenge,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.RequiresInteraction)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
interactionID = "test-interaction"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
RequiresPushedAuthorizationRequests: true,
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
Scope: datatype.StringList{"openid"},
|
||||
}).Error)
|
||||
|
||||
authorize := func(interactionID string, hasPushedAuthorizationRequest bool) (authorizationResult, error) {
|
||||
requester := newTestAuthorizeRequester("par-request", clientID, "")
|
||||
requester.(*fosite.AuthorizeRequest).Client = Client{
|
||||
OidcClient: model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
RequiresPushedAuthorizationRequests: true,
|
||||
},
|
||||
}
|
||||
return service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: time.Now().UTC(),
|
||||
requester: requester,
|
||||
hasPushedAuthorizationRequest: hasPushedAuthorizationRequest,
|
||||
interactionID: interactionID,
|
||||
})
|
||||
}
|
||||
|
||||
// Without a pushed authorization request the client must be rejected
|
||||
_, err := authorize("", false)
|
||||
var parRequiredError *common.OidcPARRequiredError
|
||||
require.ErrorAs(t, err, &parRequiredError)
|
||||
|
||||
// Re-entry after a completed interaction carries no request_uri, but the bound
|
||||
// interaction session proves the original request was PAR-validated
|
||||
require.NoError(t, db.Create(&InteractionSession{
|
||||
Base: model.Base{ID: interactionID},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
||||
Parameters: map[string]string{},
|
||||
}).Error)
|
||||
|
||||
authorization, err := authorize(interactionID, false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, authorization.RequiresInteraction)
|
||||
|
||||
// An unknown interaction ID must not satisfy the PAR requirement
|
||||
_, err = authorize("nonexistent", false)
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
clientID = "test-client"
|
||||
interactionID = "test-interaction"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&InteractionSession{
|
||||
Base: model.Base{ID: interactionID},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
||||
Parameters: map[string]string{
|
||||
"client_id": clientID,
|
||||
"response_type": "code",
|
||||
"scope": "openid",
|
||||
"redirect_uri": "https://client.example/callback",
|
||||
"state": "test-state",
|
||||
},
|
||||
}).Error)
|
||||
|
||||
query, err := service.interactionRequestQuery(t.Context(), interactionID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, clientID, query.Get("client_id"))
|
||||
require.Equal(t, "code", query.Get("response_type"))
|
||||
require.Equal(t, "openid", query.Get("scope"))
|
||||
require.Equal(t, "https://client.example/callback", query.Get("redirect_uri"))
|
||||
require.Equal(t, "test-state", query.Get("state"))
|
||||
require.Equal(t, interactionID, query.Get("interaction"))
|
||||
|
||||
_, err = service.interactionRequestQuery(t.Context(), "nonexistent")
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
)
|
||||
loginTime := time.Now().Add(-10 * time.Minute).UTC().Truncate(time.Second)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
Scope: datatype.StringList{"openid"},
|
||||
}).Error)
|
||||
|
||||
firstAuthorization, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: loginTime,
|
||||
requester: newTestAuthorizeRequester("first-request", clientID, ""),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, firstAuthorization.RequiresInteraction)
|
||||
require.Equal(t, loginTime, firstAuthorization.Session.IDTokenClaims().AuthTime)
|
||||
|
||||
secondAuthorization, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: loginTime,
|
||||
requester: newTestAuthorizeRequester("second-request", clientID, "none"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, secondAuthorization.RequiresInteraction)
|
||||
require.Equal(t, loginTime, secondAuthorization.Session.IDTokenClaims().AuthTime)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
)
|
||||
loginTime := time.Now().Add(-2 * time.Minute).UTC()
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
Scope: datatype.StringList{"openid"},
|
||||
}).Error)
|
||||
|
||||
authorization, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: loginTime,
|
||||
requester: newTestAuthorizeRequesterWithForm("max-age-request", clientID, url.Values{"max_age": {"1"}}),
|
||||
requestParams: map[string]string{"max_age": "1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, authorization.RequiresInteraction)
|
||||
|
||||
interaction, err := service.getInteractionSession(t.Context(), authorization.InteractionID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, interactionStepReauthenticate, interaction.CurrentStep)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
interactionID = "test-interaction"
|
||||
)
|
||||
loginTime := time.Now().Add(-2 * time.Minute).UTC()
|
||||
reauthenticatedAt := time.Now().Add(-1 * time.Second).UTC().Truncate(time.Second)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
Scope: datatype.StringList{"openid"},
|
||||
}).Error)
|
||||
reauthenticatedAtValue := datatype.DateTime(reauthenticatedAt)
|
||||
require.NoError(t, db.Create(&InteractionSession{
|
||||
Base: model.Base{ID: interactionID},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
ReauthenticationRequired: false,
|
||||
ReauthenticatedAt: &reauthenticatedAtValue,
|
||||
Parameters: map[string]string{
|
||||
"max_age": "1",
|
||||
},
|
||||
}).Error)
|
||||
|
||||
authorization, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: loginTime,
|
||||
requester: newTestAuthorizeRequesterWithForm("final-request", clientID, url.Values{"max_age": {"1"}}),
|
||||
interactionID: interactionID,
|
||||
requestParams: map[string]string{"max_age": "1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, authorization.RequiresInteraction)
|
||||
require.Equal(t, reauthenticatedAt, authorization.Session.IDTokenClaims().AuthTime)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceAuthorizeUsesOriginalInteractionRequestTime(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
interactionID = "test-interaction"
|
||||
)
|
||||
originalRequestedAt := time.Now().Add(-10 * time.Second).UTC().Truncate(time.Second)
|
||||
reauthenticatedAt := originalRequestedAt.Add(5 * time.Second)
|
||||
continuationRequestedAt := reauthenticatedAt.Add(5 * time.Second)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
Scope: datatype.StringList{"openid"},
|
||||
}).Error)
|
||||
reauthenticatedAtValue := datatype.DateTime(reauthenticatedAt)
|
||||
require.NoError(t, db.Create(&InteractionSession{
|
||||
Base: model.Base{ID: interactionID},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
ReauthenticationRequired: false,
|
||||
RequestedAt: datatype.DateTime(originalRequestedAt),
|
||||
ReauthenticatedAt: &reauthenticatedAtValue,
|
||||
Parameters: map[string]string{
|
||||
"prompt": "login",
|
||||
},
|
||||
}).Error)
|
||||
|
||||
requester := newTestAuthorizeRequesterWithForm(
|
||||
"final-request",
|
||||
clientID,
|
||||
url.Values{"prompt": {"login"}},
|
||||
)
|
||||
requester.(*fosite.AuthorizeRequest).RequestedAt = continuationRequestedAt
|
||||
|
||||
authorization, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: originalRequestedAt.Add(-time.Minute),
|
||||
requester: requester,
|
||||
interactionID: interactionID,
|
||||
requestParams: map[string]string{"prompt": "login"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, authorization.RequiresInteraction)
|
||||
require.Equal(t, originalRequestedAt, authorization.Session.IDTokenClaims().RequestedAt)
|
||||
require.Equal(t, reauthenticatedAt, authorization.Session.IDTokenClaims().AuthTime)
|
||||
require.False(t, authorization.Session.IDTokenClaims().AuthTime.Before(authorization.Session.IDTokenClaims().RequestedAt))
|
||||
}
|
||||
|
||||
func TestInteractionSessionServiceSavePersistsParameters(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newInteractionSessionService(db)
|
||||
|
||||
const (
|
||||
clientID = "test-client"
|
||||
interactionID = "test-interaction"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
|
||||
interactionSession, err := service.create(t.Context(), InteractionSession{
|
||||
Base: model.Base{ID: interactionID},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: clientID,
|
||||
ReauthenticationRequired: true,
|
||||
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
||||
Parameters: map[string]string{
|
||||
"max_age": "1",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
reauthenticatedAt := datatype.DateTime(time.Now().UTC().Truncate(time.Second))
|
||||
interactionSession.ReauthenticationRequired = false
|
||||
interactionSession.ReauthenticatedAt = &reauthenticatedAt
|
||||
require.NoError(t, service.update(t.Context(), interactionSession))
|
||||
|
||||
storedInteractionSession, err := service.get(t.Context(), interactionID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, storedInteractionSession.ReauthenticationRequired)
|
||||
require.Equal(t, "1", storedInteractionSession.Parameters["max_age"])
|
||||
require.NotNil(t, storedInteractionSession.ReauthenticatedAt)
|
||||
require.Equal(t, reauthenticatedAt.UTC(), storedInteractionSession.ReauthenticatedAt.UTC())
|
||||
}
|
||||
|
||||
func newTestAuthorizeRequester(requestID, clientID, prompt string) fosite.AuthorizeRequester {
|
||||
form := url.Values{}
|
||||
if prompt != "" {
|
||||
form.Set("prompt", prompt)
|
||||
}
|
||||
return newTestAuthorizeRequesterWithForm(requestID, clientID, form)
|
||||
}
|
||||
|
||||
func newTestAuthorizeRequesterWithForm(requestID, clientID string, form url.Values) fosite.AuthorizeRequester {
|
||||
requester := fosite.NewAuthorizeRequest()
|
||||
requester.ID = requestID
|
||||
requester.RequestedAt = time.Now().UTC()
|
||||
requester.Client = Client{
|
||||
OidcClient: model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
},
|
||||
}
|
||||
requester.RequestedScope = fosite.Arguments{"openid"}
|
||||
requester.ResponseTypes = fosite.Arguments{"code"}
|
||||
requester.RedirectURI = &url.URL{Scheme: "https", Host: "client.example", Path: "/callback"}
|
||||
requester.Form = form
|
||||
return requester
|
||||
}
|
||||
|
||||
func stringPointer(value string) *string {
|
||||
return &value
|
||||
}
|
||||
166
backend/internal/oidc/claims_service.go
Normal file
166
backend/internal/oidc/claims_service.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
idTokenType = "id-token"
|
||||
)
|
||||
|
||||
type ClaimsService struct {
|
||||
db *gorm.DB
|
||||
customClaims CustomClaimSource
|
||||
baseURL string
|
||||
signer TokenSigner
|
||||
}
|
||||
|
||||
func newClaimsService(db *gorm.DB, customClaims CustomClaimSource, baseURL string, signer TokenSigner) *ClaimsService {
|
||||
return &ClaimsService{
|
||||
db: db,
|
||||
customClaims: customClaims,
|
||||
baseURL: baseURL,
|
||||
signer: signer,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateUserAccess re-checks, at token-issuance time, that the user behind a grant is
|
||||
// still allowed to obtain tokens for the client.
|
||||
func (s *ClaimsService) ValidateUserAccess(ctx context.Context, userID string, client Client) error {
|
||||
// Grants without a resource owner (e.g. client_credentials) carry an empty subject
|
||||
// and have no user to validate.
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var user model.User
|
||||
err := dbFromContext(ctx, s.db).
|
||||
Preload("UserGroups").
|
||||
First(&user, "id = ?", userID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fosite.ErrInvalidGrant.WithHint("The user account no longer exists.")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
return fosite.ErrInvalidGrant.WithHint("The user account is disabled.")
|
||||
}
|
||||
|
||||
if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) {
|
||||
return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyIDTokenClaims applies the claims of a user to the ID token claims in the session based on the requested scopes.
|
||||
func (s *ClaimsService) applyIDTokenClaims(ctx context.Context, session *Session, scopes fosite.Arguments) error {
|
||||
userID := session.Subject
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
claims, err := s.GetUserClaims(ctx, userID, scopes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Record the signing algorithm on the ID token header so fosite derives the at_hash/
|
||||
// c_hash digest from it (e.g. RS384 -> SHA-384, ES512 -> SHA-512). Without this the
|
||||
// header is empty and fosite defaults to SHA-256, producing wrong hashes whenever the
|
||||
// signing key is not a 256-bit algorithm. ToMap() strips "alg" before signing, so this
|
||||
// never overrides the real JWS header. The signer is always wired in production; it is
|
||||
// only nil in unit tests that do not assert hash correctness.
|
||||
if s.signer != nil {
|
||||
alg, err := s.signer.GetKeyAlg()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session.IDTokenHeaders().Add("alg", alg.String())
|
||||
}
|
||||
|
||||
applyUserClaimsToIDToken(session, userID, claims)
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyUserClaimsToIDToken(session *Session, userID string, claims map[string]any) {
|
||||
idTokenClaims := session.IDTokenClaims()
|
||||
idTokenClaims.Subject = userID
|
||||
idTokenClaims.Extra = claims
|
||||
idTokenClaims.Extra[common.TokenTypeClaim] = idTokenType
|
||||
if session.AuthenticationMethod != "" {
|
||||
idTokenClaims.AuthenticationMethodsReferences = []string{session.AuthenticationMethod}
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserClaims retrieves the claims for a user based on the requested scopes. It includes standard claims
|
||||
// like "sub" and "email" as well as any custom claims defined for the user or their groups.
|
||||
func (s *ClaimsService) GetUserClaims(ctx context.Context, userID string, scopes []string) (map[string]any, error) {
|
||||
db := dbFromContext(ctx, s.db)
|
||||
|
||||
var user model.User
|
||||
err := db.
|
||||
Preload("UserGroups").
|
||||
First(&user, "id = ?", userID).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims := make(map[string]any, 10)
|
||||
|
||||
if slices.Contains(scopes, "profile") {
|
||||
customClaims, err := s.customClaims.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, customClaim := range customClaims {
|
||||
// A custom claim value can be a JSON document or a plain string
|
||||
var jsonValue any
|
||||
if err := json.Unmarshal([]byte(customClaim.Value), &jsonValue); err == nil {
|
||||
claims[customClaim.Key] = jsonValue
|
||||
} else {
|
||||
claims[customClaim.Key] = customClaim.Value
|
||||
}
|
||||
}
|
||||
|
||||
claims["given_name"] = user.FirstName
|
||||
claims["family_name"] = user.LastName
|
||||
claims["name"] = user.FullName()
|
||||
claims["display_name"] = user.DisplayName
|
||||
claims["preferred_username"] = user.Username
|
||||
claims["picture"] = s.baseURL + "/api/users/" + user.ID + "/profile-picture.png"
|
||||
}
|
||||
|
||||
claims["sub"] = user.ID
|
||||
|
||||
// Only release the email claims when the user actually has an email. Emitting
|
||||
// email_verified alongside a null/absent email (OIDC Core §5.1) is malformed and can
|
||||
// mislead relying parties that key trust decisions on email_verified.
|
||||
if slices.Contains(scopes, "email") && user.Email != nil && *user.Email != "" {
|
||||
claims["email"] = *user.Email
|
||||
claims["email_verified"] = user.EmailVerified
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "groups") {
|
||||
userGroups := make([]string, len(user.UserGroups))
|
||||
for i, group := range user.UserGroups {
|
||||
userGroups[i] = group.Name
|
||||
}
|
||||
claims["groups"] = userGroups
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
173
backend/internal/oidc/claims_service_test.go
Normal file
173
backend/internal/oidc/claims_service_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/ory/fosite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
// TestClaimsServiceValidateUserAccess covers the per-grant re-validation that the token
|
||||
// endpoint performs on every grant (notably refresh_token, which fosite replays without
|
||||
// reloading the user). A disabled user, a user removed from a group-restricted client, or
|
||||
// a deleted user must be rejected so they cannot keep minting tokens from a still-valid
|
||||
// refresh token.
|
||||
func TestClaimsServiceValidateUserAccess(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
claimsService := newClaimsService(db, nil, "", nil)
|
||||
|
||||
group := model.UserGroup{Base: model.Base{ID: "group-allowed"}, Name: "allowed", FriendlyName: "Allowed"}
|
||||
require.NoError(t, db.Create(&group).Error)
|
||||
|
||||
enabledUser := model.User{Base: model.Base{ID: "user-enabled"}, Username: "enabled"}
|
||||
require.NoError(t, db.Create(&enabledUser).Error)
|
||||
require.NoError(t, db.Model(&enabledUser).Association("UserGroups").Append(&group))
|
||||
|
||||
disabledUser := model.User{Base: model.Base{ID: "user-disabled"}, Username: "disabled", Disabled: true}
|
||||
require.NoError(t, db.Create(&disabledUser).Error)
|
||||
|
||||
outsiderUser := model.User{Base: model.Base{ID: "user-outsider"}, Username: "outsider"}
|
||||
require.NoError(t, db.Create(&outsiderUser).Error)
|
||||
|
||||
openClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "client-open"}, Name: "Open"}}
|
||||
restrictedClient := Client{OidcClient: model.OidcClient{
|
||||
Base: model.Base{ID: "client-restricted"},
|
||||
Name: "Restricted",
|
||||
IsGroupRestricted: true,
|
||||
AllowedUserGroups: []model.UserGroup{group},
|
||||
}}
|
||||
|
||||
t.Run("empty subject is allowed (client_credentials)", func(t *testing.T) {
|
||||
require.NoError(t, claimsService.ValidateUserAccess(t.Context(), "", openClient))
|
||||
})
|
||||
|
||||
t.Run("enabled user is allowed", func(t *testing.T) {
|
||||
require.NoError(t, claimsService.ValidateUserAccess(t.Context(), enabledUser.ID, openClient))
|
||||
})
|
||||
|
||||
t.Run("disabled user is rejected with invalid_grant", func(t *testing.T) {
|
||||
err := claimsService.ValidateUserAccess(t.Context(), disabledUser.ID, openClient)
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidGrant)
|
||||
})
|
||||
|
||||
t.Run("user in an allowed group may use a group-restricted client", func(t *testing.T) {
|
||||
require.NoError(t, claimsService.ValidateUserAccess(t.Context(), enabledUser.ID, restrictedClient))
|
||||
})
|
||||
|
||||
t.Run("user outside the allowed groups is rejected with access_denied", func(t *testing.T) {
|
||||
err := claimsService.ValidateUserAccess(t.Context(), outsiderUser.ID, restrictedClient)
|
||||
require.ErrorIs(t, err, fosite.ErrAccessDenied)
|
||||
})
|
||||
|
||||
t.Run("deleted user is rejected with invalid_grant", func(t *testing.T) {
|
||||
err := claimsService.ValidateUserAccess(t.Context(), "does-not-exist", openClient)
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidGrant)
|
||||
})
|
||||
}
|
||||
|
||||
type fakeCustomClaimSource struct {
|
||||
claims []model.CustomClaim
|
||||
}
|
||||
|
||||
func (f fakeCustomClaimSource) GetCustomClaimsForUserWithUserGroups(_ context.Context, _ string, _ *gorm.DB) ([]model.CustomClaim, error) {
|
||||
return f.claims, nil
|
||||
}
|
||||
|
||||
// TestClaimsServiceGetUserClaims pins the scope-to-claims mapping that powers both the ID
|
||||
// token and the userinfo endpoint: each OIDC scope must only release its own claims, "sub"
|
||||
// is always present, and custom claims are emitted as parsed JSON when the stored value is
|
||||
// valid JSON and as a raw string otherwise.
|
||||
func TestClaimsServiceGetUserClaims(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
const (
|
||||
baseURL = "https://id.example.com"
|
||||
userID = "user-1"
|
||||
)
|
||||
|
||||
customClaims := fakeCustomClaimSource{claims: []model.CustomClaim{
|
||||
{Key: "department", Value: "engineering"}, // plain string
|
||||
{Key: "roles", Value: `["admin","dev"]`}, // JSON document
|
||||
}}
|
||||
service := newClaimsService(db, customClaims, baseURL, nil)
|
||||
|
||||
group := model.UserGroup{Base: model.Base{ID: "group-1"}, Name: "developers", FriendlyName: "Developers"}
|
||||
require.NoError(t, db.Create(&group).Error)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
Username: "tim",
|
||||
FirstName: "Tim",
|
||||
LastName: "Cook",
|
||||
DisplayName: "Tim Cook",
|
||||
Email: stringPointer("tim@example.com"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
require.NoError(t, db.Model(&user).Association("UserGroups").Append(&group))
|
||||
|
||||
t.Run("openid only releases sub", func(t *testing.T) {
|
||||
claims, err := service.GetUserClaims(t.Context(), userID, []string{"openid"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]any{"sub": userID}, claims)
|
||||
})
|
||||
|
||||
t.Run("email scope releases email claims", func(t *testing.T) {
|
||||
claims, err := service.GetUserClaims(t.Context(), userID, []string{"openid", "email"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userID, claims["sub"])
|
||||
require.Equal(t, "tim@example.com", claims["email"])
|
||||
require.Equal(t, true, claims["email_verified"])
|
||||
require.NotContains(t, claims, "given_name")
|
||||
require.NotContains(t, claims, "groups")
|
||||
})
|
||||
|
||||
t.Run("groups scope releases group names", func(t *testing.T) {
|
||||
claims, err := service.GetUserClaims(t.Context(), userID, []string{"groups"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"developers"}, claims["groups"])
|
||||
})
|
||||
|
||||
t.Run("profile scope releases profile and custom claims", func(t *testing.T) {
|
||||
claims, err := service.GetUserClaims(t.Context(), userID, []string{"profile"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Tim", claims["given_name"])
|
||||
require.Equal(t, "Cook", claims["family_name"])
|
||||
require.Equal(t, "Tim Cook", claims["name"])
|
||||
require.Equal(t, "Tim Cook", claims["display_name"])
|
||||
require.Equal(t, "tim", claims["preferred_username"])
|
||||
require.Equal(t, baseURL+"/api/users/"+userID+"/profile-picture.png", claims["picture"])
|
||||
|
||||
// Custom claims: plain string stays a string, JSON document is decoded.
|
||||
require.Equal(t, "engineering", claims["department"])
|
||||
require.Equal(t, []any{"admin", "dev"}, claims["roles"])
|
||||
|
||||
// Profile must not leak email when the email scope was not requested.
|
||||
require.NotContains(t, claims, "email")
|
||||
})
|
||||
}
|
||||
|
||||
// TestClaimsServiceAppliesSigningAlgToIDTokenHeader verifies the ID token header carries the
|
||||
// signing algorithm so fosite derives the at_hash/c_hash digest from it (e.g. RS384 ->
|
||||
// SHA-384, ES512 -> SHA-512) instead of always defaulting to SHA-256.
|
||||
func TestClaimsServiceAppliesSigningAlgToIDTokenHeader(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: "alg-user"}, Username: "alg"}).Error)
|
||||
|
||||
for _, alg := range []jwa.SignatureAlgorithm{jwa.RS256(), jwa.RS384(), jwa.ES512()} {
|
||||
t.Run(alg.String(), func(t *testing.T) {
|
||||
service := newClaimsService(db, nil, "", algTestSigner{alg: alg})
|
||||
|
||||
session := NewEmptySession()
|
||||
session.Subject = "alg-user"
|
||||
|
||||
require.NoError(t, service.applyIDTokenClaims(t.Context(), session, fosite.Arguments{"openid"}))
|
||||
require.Equal(t, alg.String(), session.IDTokenHeaders().Get("alg"))
|
||||
})
|
||||
}
|
||||
}
|
||||
37
backend/internal/oidc/cleanup.go
Normal file
37
backend/internal/oidc/cleanup.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CleanupExpiredOAuth2Sessions deletes OAuth2 sessions whose tokens or codes have
|
||||
// expired.
|
||||
//
|
||||
// Invalidated-but-unexpired rows are intentionally KEPT until their original expiry: fosite relies on
|
||||
// finding the inactive row to detect a reuse and revoke the affected token family.
|
||||
func CleanupExpiredOAuth2Sessions(ctx context.Context, db *gorm.DB) (int64, error) {
|
||||
st := db.
|
||||
WithContext(ctx).
|
||||
Delete(&OAuth2Session{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
return st.RowsAffected, st.Error
|
||||
}
|
||||
|
||||
// CleanupExpiredClientAssertionJTIs deletes expired JWT IDs used for client assertion replay protection.
|
||||
func CleanupExpiredClientAssertionJTIs(ctx context.Context, db *gorm.DB) (int64, error) {
|
||||
st := db.
|
||||
WithContext(ctx).
|
||||
Delete(&clientAssertionJTI{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
return st.RowsAffected, st.Error
|
||||
}
|
||||
|
||||
// CleanupAbandonedInteractionSessions removes interaction sessions that were never completed.
|
||||
func CleanupAbandonedInteractionSessions(ctx context.Context, db *gorm.DB) (int64, error) {
|
||||
st := db.
|
||||
WithContext(ctx).
|
||||
Delete(&InteractionSession{}, "created_at < ?", datatype.DateTime(time.Now().Add(-interactionSessionLifetime)))
|
||||
return st.RowsAffected, st.Error
|
||||
}
|
||||
40
backend/internal/oidc/cleanup_test.go
Normal file
40
backend/internal/oidc/cleanup_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
// TestCleanupExpiredOAuth2SessionsKeepsInvalidatedButUnexpiredSessions verifies that
|
||||
// rotated/consumed (active=false) sessions are kept until their original expiry, so fosite
|
||||
// can still detect refresh-token reuse and revoke the affected token family. Only rows
|
||||
// past their expiry are removed.
|
||||
func TestCleanupExpiredOAuth2SessionsKeepsInvalidatedButUnexpiredSessions(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
past := datatype.DateTime(time.Now().Add(-time.Hour))
|
||||
future := datatype.DateTime(time.Now().Add(time.Hour))
|
||||
|
||||
rows := []OAuth2Session{
|
||||
{Base: model.Base{ID: "expired"}, Kind: "access_token", Key: "k-expired", RequestID: "r1", Active: true, RequestData: "{}", ExpiresAt: &past},
|
||||
{Base: model.Base{ID: "rotated"}, Kind: "refresh_token", Key: "k-rotated", RequestID: "r2", Active: false, RequestData: "{}", ExpiresAt: &future},
|
||||
{Base: model.Base{ID: "active"}, Kind: "refresh_token", Key: "k-active", RequestID: "r3", Active: true, RequestData: "{}", ExpiresAt: &future},
|
||||
}
|
||||
for i := range rows {
|
||||
require.NoError(t, db.Create(&rows[i]).Error)
|
||||
}
|
||||
|
||||
deleted, err := CleanupExpiredOAuth2Sessions(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), deleted)
|
||||
|
||||
var remaining []string
|
||||
require.NoError(t, db.Model(&OAuth2Session{}).Pluck("id", &remaining).Error)
|
||||
require.ElementsMatch(t, []string{"active", "rotated"}, remaining)
|
||||
}
|
||||
61
backend/internal/oidc/client.go
Normal file
61
backend/internal/oidc/client.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
var _ fosite.Client = (*Client)(nil)
|
||||
var _ fosite.ResponseModeClient = (*Client)(nil)
|
||||
|
||||
type Client struct {
|
||||
model.OidcClient
|
||||
}
|
||||
|
||||
func (c Client) GetID() string {
|
||||
return c.ID
|
||||
}
|
||||
|
||||
func (c Client) GetHashedSecret() []byte {
|
||||
return []byte(c.Secret)
|
||||
}
|
||||
|
||||
func (c Client) GetRedirectURIs() []string {
|
||||
return c.CallbackURLs
|
||||
}
|
||||
|
||||
func (c Client) GetGrantTypes() fosite.Arguments {
|
||||
grantTypes := fosite.Arguments{
|
||||
string(fosite.GrantTypeAuthorizationCode),
|
||||
string(fosite.GrantTypeRefreshToken),
|
||||
string(fosite.GrantTypeDeviceCode),
|
||||
}
|
||||
if !c.IsPublic() {
|
||||
grantTypes = append(grantTypes, string(fosite.GrantTypeClientCredentials))
|
||||
}
|
||||
return grantTypes
|
||||
}
|
||||
|
||||
func (c Client) GetResponseTypes() fosite.Arguments {
|
||||
return fosite.Arguments{"code"}
|
||||
}
|
||||
|
||||
func (c Client) GetScopes() fosite.Arguments {
|
||||
return fosite.Arguments{"openid", "profile", "email", "groups"}
|
||||
}
|
||||
|
||||
func (c Client) IsPublic() bool {
|
||||
return c.OidcClient.IsPublic
|
||||
}
|
||||
|
||||
func (c Client) GetAudience() fosite.Arguments {
|
||||
return fosite.Arguments{c.ID}
|
||||
}
|
||||
|
||||
func (c Client) GetResponseModes() []fosite.ResponseModeType {
|
||||
return []fosite.ResponseModeType{
|
||||
fosite.ResponseModeQuery,
|
||||
fosite.ResponseModeFragment,
|
||||
fosite.ResponseModeFormPost,
|
||||
}
|
||||
}
|
||||
87
backend/internal/oidc/device_handler.go
Normal file
87
backend/internal/oidc/device_handler.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
type deviceHandler struct {
|
||||
provider fosite.OAuth2Provider
|
||||
deviceService *deviceService
|
||||
}
|
||||
|
||||
func newDeviceHandler(provider fosite.OAuth2Provider, deviceService *deviceService) *deviceHandler {
|
||||
return &deviceHandler{
|
||||
provider: provider,
|
||||
deviceService: deviceService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *deviceHandler) authorizeDevice(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
response, request, err := h.deviceService.createDeviceAuthorization(ctx, c.Request)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to create device authorization", "error", err)
|
||||
h.provider.WriteAccessError(ctx, c.Writer, request, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *deviceHandler) verifyDeviceCode(c *gin.Context) {
|
||||
authenticationTime, _ := c.Get("authenticationTime")
|
||||
typedAuthenticationTime, _ := authenticationTime.(time.Time)
|
||||
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
|
||||
|
||||
userCode := c.Query("code")
|
||||
if userCode == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "code is required"})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.deviceService.acceptDeviceCode(
|
||||
c.Request.Context(),
|
||||
userCode,
|
||||
c.GetString("userID"),
|
||||
c.GetString("authenticationMethod"),
|
||||
typedAuthenticationTime,
|
||||
reauthenticationToken,
|
||||
requestMetaFromGin(c),
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, fosite.ErrAccessDenied) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "You're not allowed to access this service."})
|
||||
return
|
||||
}
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *deviceHandler) deviceCodeInfo(c *gin.Context) {
|
||||
userCode := c.Query("code")
|
||||
if userCode == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "code is required"})
|
||||
return
|
||||
}
|
||||
|
||||
deviceCodeInfo, err := h.deviceService.getDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, deviceCodeInfo)
|
||||
}
|
||||
200
backend/internal/oidc/device_service.go
Normal file
200
backend/internal/oidc/device_service.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/ory/fosite/handler/rfc8628"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type deviceService struct {
|
||||
provider fosite.OAuth2Provider
|
||||
store *Store
|
||||
userCodeStrategy rfc8628.UserCodeStrategy
|
||||
authorizationService *authorizationService
|
||||
claimsService *ClaimsService
|
||||
auditLog AuditLogger
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func newDeviceService(
|
||||
provider fosite.OAuth2Provider,
|
||||
store *Store,
|
||||
userCodeStrategy rfc8628.UserCodeStrategy,
|
||||
authorizationService *authorizationService,
|
||||
claimsService *ClaimsService,
|
||||
auditLog AuditLogger,
|
||||
db *gorm.DB,
|
||||
) *deviceService {
|
||||
return &deviceService{
|
||||
provider: provider,
|
||||
store: store,
|
||||
userCodeStrategy: userCodeStrategy,
|
||||
authorizationService: authorizationService,
|
||||
claimsService: claimsService,
|
||||
auditLog: auditLog,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *deviceService) createDeviceAuthorization(ctx context.Context, req *http.Request) (*dto.OidcDeviceAuthorizationResponseDto, fosite.Requester, error) {
|
||||
request, err := s.provider.NewDeviceRequest(ctx, req)
|
||||
if err != nil {
|
||||
return nil, request, err
|
||||
}
|
||||
|
||||
session := NewEmptySession()
|
||||
response, err := s.provider.NewDeviceResponse(ctx, request, session)
|
||||
if err != nil {
|
||||
return nil, request, err
|
||||
}
|
||||
|
||||
return &dto.OidcDeviceAuthorizationResponseDto{
|
||||
DeviceCode: response.GetDeviceCode(),
|
||||
UserCode: response.GetUserCode(),
|
||||
VerificationURI: response.GetVerificationURI(),
|
||||
VerificationURIComplete: response.GetVerificationURIComplete(),
|
||||
ExpiresIn: int(response.GetExpiresIn()),
|
||||
Interval: response.GetInterval(),
|
||||
}, request, nil
|
||||
}
|
||||
|
||||
func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID, authenticationMethod string, authenticationTime time.Time, reauthenticationToken string, meta requestMeta) error {
|
||||
request, userCodeSignature, err := s.deviceRequestFromUserCode(ctx, userCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A user code may be approved only once. Rejecting an already-approved code prevents a second
|
||||
// logged-in user from rebinding a pending device authorization to themselves before the device
|
||||
// polls for its token.
|
||||
if request.GetUserCodeState() != fosite.UserCodeUnused {
|
||||
return &common.OidcInvalidDeviceCodeError{}
|
||||
}
|
||||
|
||||
client := request.GetClient().(Client)
|
||||
var user model.User
|
||||
if err = s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) {
|
||||
return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
|
||||
}
|
||||
|
||||
for _, scope := range request.GetRequestedScopes() {
|
||||
request.GrantScope(scope)
|
||||
}
|
||||
for _, audience := range request.GetRequestedAudience() {
|
||||
request.GrantAudience(audience)
|
||||
}
|
||||
|
||||
return withTx(ctx, s.db, func(ctx context.Context) error {
|
||||
if client.RequiresReauthentication {
|
||||
if reauthenticationToken == "" || s.authorizationService == nil || s.authorizationService.reauth == nil {
|
||||
return &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
|
||||
reauthenticatedAt, err := s.authorizationService.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authenticationTime = reauthenticatedAt
|
||||
}
|
||||
if authenticationTime.IsZero() {
|
||||
authenticationTime = time.Now().UTC()
|
||||
}
|
||||
|
||||
session := NewAuthenticatedSession(userID, authenticationMethod, authenticationTime, request.GetRequestedAt())
|
||||
|
||||
if err = s.claimsService.applyIDTokenClaims(ctx, session, request.GetGrantedScopes()); err != nil {
|
||||
return err
|
||||
}
|
||||
request.SetSession(session)
|
||||
|
||||
hasAlreadyAuthorizedClient, err := s.authorizationService.consent(ctx, userID, client.GetID(), request.GetRequestedScopes())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event := model.AuditLogEventDeviceCodeAuthorization
|
||||
if !hasAlreadyAuthorizedClient {
|
||||
event = model.AuditLogEventNewDeviceCodeAuthorization
|
||||
}
|
||||
s.auditLog.Create(ctx, event, meta.IPAddress, meta.UserAgent, userID, model.AuditLogData{"clientName": client.Name}, dbFromContext(ctx, s.db))
|
||||
|
||||
deviceCodeSignature, err := s.store.AcceptDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if request.GetGrantedScopes().Has("openid") {
|
||||
if err := s.store.CreateOpenIDConnectSession(ctx, deviceCodeSignature, request); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID string) (*dto.DeviceCodeInfoDto, error) {
|
||||
request, _, err := s.deviceRequestFromUserCode(ctx, userCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := request.GetClient().(Client)
|
||||
authorizationRequired := true
|
||||
if userID != "" {
|
||||
hasAuthorizedClient, err := s.authorizationService.hasAuthorizedClient(ctx, client.GetID(), userID, request.GetRequestedScopes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authorizationRequired = !hasAuthorizedClient
|
||||
}
|
||||
|
||||
return &dto.DeviceCodeInfoDto{
|
||||
Client: dto.OidcClientMetaDataDto{
|
||||
ID: client.ID,
|
||||
Name: client.Name,
|
||||
HasLogo: client.HasLogo(),
|
||||
HasDarkLogo: client.HasDarkLogo(),
|
||||
LaunchURL: client.LaunchURL,
|
||||
RequiresReauthentication: client.RequiresReauthentication,
|
||||
},
|
||||
Scope: request.GetRequestedScopes(),
|
||||
AuthorizationRequired: authorizationRequired,
|
||||
ReauthenticationRequired: client.RequiresReauthentication,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode string) (fosite.DeviceRequester, string, error) {
|
||||
userCodeSignature, err := s.userCodeStrategy.UserCodeSignature(ctx, userCode)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
request, err := s.store.GetDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature)
|
||||
if errors.Is(err, fosite.ErrNotFound) {
|
||||
return nil, "", &common.OidcInvalidDeviceCodeError{}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if err = s.userCodeStrategy.ValidateUserCode(ctx, request, userCode); err != nil {
|
||||
if errors.Is(err, fosite.ErrDeviceExpiredToken) {
|
||||
return nil, "", &common.OidcDeviceCodeExpiredError{}
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return request, userCodeSignature, nil
|
||||
}
|
||||
122
backend/internal/oidc/device_service_test.go
Normal file
122
backend/internal/oidc/device_service_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type fakeReauthenticationConsumer struct {
|
||||
token string
|
||||
userID string
|
||||
reauthenticatedAt time.Time
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeReauthenticationConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, userID string) (time.Time, error) {
|
||||
f.calls++
|
||||
if token != f.token || userID != f.userID {
|
||||
return time.Time{}, &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
|
||||
return f.reauthenticatedAt, nil
|
||||
}
|
||||
|
||||
func TestDeviceServiceAcceptRequiresReauthenticationTokenWhenClientRequiresIt(t *testing.T) {
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
)
|
||||
reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential
|
||||
token: "valid-reauth-token",
|
||||
userID: userID,
|
||||
reauthenticatedAt: time.Now().UTC().Truncate(time.Second),
|
||||
}
|
||||
service, _, _, userCode, _ := newTestDeviceServiceWithCode(t, clientID, userID, true, reauth)
|
||||
|
||||
err := service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().UTC(), "", requestMeta{})
|
||||
require.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
|
||||
require.Zero(t, reauth.calls)
|
||||
|
||||
info, err := service.getDeviceCodeInfo(t.Context(), userCode, userID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, info.ReauthenticationRequired)
|
||||
|
||||
err = service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().UTC(), reauth.token, requestMeta{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, reauth.calls)
|
||||
}
|
||||
|
||||
func TestDeviceServiceAcceptUsesReauthenticationTimeForDeviceSession(t *testing.T) {
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
)
|
||||
reauthenticatedAt := time.Now().Add(-30 * time.Second).UTC().Truncate(time.Second)
|
||||
reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential
|
||||
token: "valid-reauth-token",
|
||||
userID: userID,
|
||||
reauthenticatedAt: reauthenticatedAt,
|
||||
}
|
||||
service, store, provider, userCode, deviceCode := newTestDeviceServiceWithCode(t, clientID, userID, true, reauth)
|
||||
|
||||
err := service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().Add(-time.Hour).UTC(), reauth.token, requestMeta{})
|
||||
require.NoError(t, err)
|
||||
|
||||
deviceCodeSignature, err := provider.deviceStrategy.DeviceCodeSignature(t.Context(), deviceCode)
|
||||
require.NoError(t, err)
|
||||
acceptedRequest, err := store.GetDeviceCodeSession(t.Context(), deviceCodeSignature, NewEmptySession())
|
||||
require.NoError(t, err)
|
||||
session := acceptedRequest.GetSession().(*Session)
|
||||
require.Equal(t, reauthenticatedAt, session.IDTokenClaims().AuthTime)
|
||||
}
|
||||
|
||||
func newTestDeviceServiceWithCode(t *testing.T, clientID, userID string, requiresReauthentication bool, reauth ReauthenticationTokenConsumer) (*deviceService, *Store, *oidcProvider, string, string) {
|
||||
t.Helper()
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
IsPublic: true,
|
||||
RequiresReauthentication: requiresReauthentication,
|
||||
}).Error)
|
||||
|
||||
store := NewStore(db)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
provider, err := newProvider(store, nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: "test-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
claimsService := newClaimsService(db, nil, "", nil)
|
||||
authorizationService := newAuthorizationService(db, newInteractionSessionService(db), claimsService, reauth, &fakeAuditLogger{})
|
||||
service := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, &fakeAuditLogger{}, db)
|
||||
|
||||
form := url.Values{
|
||||
"client_id": {clientID},
|
||||
"scope": {"openid"},
|
||||
}
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/device/authorize", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response, _, err := service.createDeviceAuthorization(t.Context(), req)
|
||||
require.NoError(t, err)
|
||||
|
||||
return service, store, provider, response.UserCode, response.DeviceCode
|
||||
}
|
||||
58
backend/internal/oidc/end_session_handler.go
Normal file
58
backend/internal/oidc/end_session_handler.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
type endSessionHandler struct {
|
||||
endSessionService *endSessionService
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func newEndSessionHandler(endSessionService *endSessionService, baseURL string) *endSessionHandler {
|
||||
return &endSessionHandler{
|
||||
endSessionService: endSessionService,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *endSessionHandler) endSession(c *gin.Context) {
|
||||
input, err := bindEndSessionRequest(c)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
callbackURL, err := h.endSessionService.endSession(c.Request.Context(), input, c.GetString("userID"))
|
||||
if err != nil {
|
||||
slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err)
|
||||
c.Redirect(http.StatusFound, h.baseURL+"/logout")
|
||||
return
|
||||
}
|
||||
|
||||
cookie.AddAccessTokenCookie(c, 0, "")
|
||||
if callbackURL == "" {
|
||||
c.Redirect(http.StatusFound, h.baseURL+"/logout")
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, appendStateToURL(callbackURL, input.State))
|
||||
}
|
||||
|
||||
func bindEndSessionRequest(c *gin.Context) (dto.OidcLogoutDto, error) {
|
||||
var input dto.OidcLogoutDto
|
||||
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet:
|
||||
return input, c.ShouldBindQuery(&input)
|
||||
case http.MethodPost:
|
||||
return input, c.ShouldBind(&input)
|
||||
default:
|
||||
return input, nil
|
||||
}
|
||||
}
|
||||
153
backend/internal/oidc/end_session_service.go
Normal file
153
backend/internal/oidc/end_session_service.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type endSessionService struct {
|
||||
db *gorm.DB
|
||||
store *Store
|
||||
signer TokenSigner
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func newEndSessionService(db *gorm.DB, store *Store, signer TokenSigner, baseURL string) *endSessionService {
|
||||
return &endSessionService{
|
||||
db: db,
|
||||
store: store,
|
||||
signer: signer,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// endSession revokes the sessions belonging to the ID token hint and returns the
|
||||
// client's post-logout callback URL (empty if none is configured).
|
||||
func (s *endSessionService) endSession(ctx context.Context, input dto.OidcLogoutDto, userID string) (string, error) {
|
||||
if input.IdTokenHint == "" {
|
||||
return "", &common.TokenInvalidError{}
|
||||
}
|
||||
|
||||
token, err := s.verifyIDTokenHint(input.IdTokenHint)
|
||||
if err != nil {
|
||||
return "", &common.TokenInvalidError{}
|
||||
}
|
||||
|
||||
clientIDs, ok := token.Audience()
|
||||
if !ok || len(clientIDs) == 0 {
|
||||
return "", &common.TokenInvalidError{}
|
||||
}
|
||||
clientID := clientIDs[0]
|
||||
if input.ClientId != "" && clientID != input.ClientId {
|
||||
return "", &common.OidcClientIdNotMatchingError{}
|
||||
}
|
||||
|
||||
subject, ok := token.Subject()
|
||||
if !ok || subject == "" {
|
||||
return "", &common.TokenInvalidError{}
|
||||
}
|
||||
if userID != "" && subject != userID {
|
||||
return "", &common.TokenInvalidError{}
|
||||
}
|
||||
userID = subject
|
||||
|
||||
idTokenJTI, ok := token.JwtID()
|
||||
if !ok {
|
||||
return "", &common.TokenInvalidError{}
|
||||
}
|
||||
|
||||
var callbackURL string
|
||||
err = withTx(ctx, s.db, func(ctx context.Context) error {
|
||||
var authorizedClient model.UserAuthorizedOidcClient
|
||||
err := dbFromContext(ctx, s.db).
|
||||
Preload("Client").
|
||||
First(&authorizedClient, "client_id = ? AND user_id = ?", clientID, userID).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &common.OidcMissingAuthorizationError{}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
callbackURL, err = logoutCallbackURL(&authorizedClient.Client, input.PostLogoutRedirectUri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.store.RevokeSessionsByIDTokenHint(ctx, userID, clientID, idTokenJTI)
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return callbackURL, nil
|
||||
}
|
||||
|
||||
func (s *endSessionService) verifyIDTokenHint(tokenString string) (jwt.Token, error) {
|
||||
alg, err := s.signer.GetKeyAlg()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := jwt.ParseString(
|
||||
tokenString,
|
||||
jwt.WithValidate(true),
|
||||
jwt.WithKey(alg, s.signer.GetPrivateKey()),
|
||||
jwt.WithAcceptableSkew(time.Minute),
|
||||
jwt.WithResetValidators(true),
|
||||
jwt.WithIssuer(s.baseURL),
|
||||
jwt.WithValidator(jwt.IsIssuedAtValid()),
|
||||
jwt.WithValidator(jwt.IsNbfValid()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// id_token_hint must be an ID token, never an access token (both are signed with the same
|
||||
// key). An expired ID token is still accepted here, as required by OIDC RP-Initiated Logout.
|
||||
var tokenType string
|
||||
if err := token.Get(common.TokenTypeClaim, &tokenType); err != nil || tokenType != idTokenType {
|
||||
return nil, &common.TokenInvalidError{}
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func logoutCallbackURL(client *model.OidcClient, inputLogoutCallbackURL string) (string, error) {
|
||||
if len(client.LogoutCallbackURLs) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if inputLogoutCallbackURL == "" {
|
||||
return client.LogoutCallbackURLs[0], nil
|
||||
}
|
||||
|
||||
matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
|
||||
if err != nil || matched == "" {
|
||||
return "", &common.OidcInvalidCallbackURLError{}
|
||||
}
|
||||
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func appendStateToURL(callbackURL string, state string) string {
|
||||
parsed, err := url.Parse(callbackURL)
|
||||
if err != nil {
|
||||
return callbackURL
|
||||
}
|
||||
if state != "" {
|
||||
q := parsed.Query()
|
||||
q.Set("state", state)
|
||||
parsed.RawQuery = q.Encode()
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
307
backend/internal/oidc/end_session_service_test.go
Normal file
307
backend/internal/oidc/end_session_service_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
// TestLogoutCallbackURL locks the post-logout redirect resolution that replaced the
|
||||
// deleted callback_url_util helper. An attacker-supplied post_logout_redirect_uri must
|
||||
// only be honored when it exactly matches one the client registered, otherwise logout
|
||||
// would be an open redirect.
|
||||
func TestLogoutCallbackURL(t *testing.T) {
|
||||
noURLs := &model.OidcClient{Base: model.Base{ID: "c"}}
|
||||
withURLs := &model.OidcClient{Base: model.Base{ID: "c"}, LogoutCallbackURLs: model.UrlList{
|
||||
"https://app.example/logout",
|
||||
"https://app.example/logout2",
|
||||
"https://*.example/logout",
|
||||
}}
|
||||
|
||||
t.Run("no configured logout URLs yields no callback", func(t *testing.T) {
|
||||
got, err := logoutCallbackURL(noURLs, "")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got)
|
||||
})
|
||||
|
||||
t.Run("empty input falls back to first registered URL", func(t *testing.T) {
|
||||
got, err := logoutCallbackURL(withURLs, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://app.example/logout", got)
|
||||
})
|
||||
|
||||
t.Run("exact match is honored", func(t *testing.T) {
|
||||
got, err := logoutCallbackURL(withURLs, "https://app.example/logout2")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://app.example/logout2", got)
|
||||
})
|
||||
|
||||
t.Run("wildcard match is honored with the requested URL", func(t *testing.T) {
|
||||
got, err := logoutCallbackURL(withURLs, "https://tenant.example/logout")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://tenant.example/logout", got)
|
||||
})
|
||||
|
||||
t.Run("unregistered URL is rejected (no open redirect)", func(t *testing.T) {
|
||||
_, err := logoutCallbackURL(withURLs, "https://evil.example/steal")
|
||||
var target *common.OidcInvalidCallbackURLError
|
||||
require.ErrorAs(t, err, &target)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppendStateToURL(t *testing.T) {
|
||||
require.Equal(t, "https://app.example/cb", appendStateToURL("https://app.example/cb", ""))
|
||||
require.Equal(t, "https://app.example/cb?state=xyz", appendStateToURL("https://app.example/cb", "xyz"))
|
||||
|
||||
got := appendStateToURL("https://app.example/cb?foo=bar", "xyz")
|
||||
parsed, err := url.Parse(got)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "bar", parsed.Query().Get("foo"))
|
||||
require.Equal(t, "xyz", parsed.Query().Get("state"))
|
||||
}
|
||||
|
||||
// TestEndSessionService exercises the RP-initiated logout validation and the session
|
||||
// revocation it triggers. The ID token hint must be a valid, first-party token whose
|
||||
// subject identifies the logged-out user and whose audience matches the client; only then
|
||||
// is the client's registered post-logout URL returned and the user/client sessions revoked.
|
||||
func TestEndSessionService(t *testing.T) {
|
||||
const (
|
||||
baseURL = "https://issuer.example.com"
|
||||
userID = "user-1"
|
||||
clientID = "client-1"
|
||||
jti = "id-token-jti"
|
||||
)
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
signer := testTokenSigner{key: key}
|
||||
|
||||
type tokenOptions struct {
|
||||
issuer string
|
||||
subject string
|
||||
audience string
|
||||
jti string
|
||||
omitSubject bool
|
||||
omitJTI bool
|
||||
omitAud bool
|
||||
omitType bool
|
||||
}
|
||||
signToken := func(t *testing.T, opts tokenOptions) string {
|
||||
t.Helper()
|
||||
builder := jwt.NewBuilder().IssuedAt(time.Now())
|
||||
if opts.issuer != "" {
|
||||
builder = builder.Issuer(opts.issuer)
|
||||
}
|
||||
if !opts.omitSubject {
|
||||
builder = builder.Subject(opts.subject)
|
||||
}
|
||||
if !opts.omitAud {
|
||||
builder = builder.Audience([]string{opts.audience})
|
||||
}
|
||||
if !opts.omitJTI {
|
||||
builder = builder.JwtID(opts.jti)
|
||||
}
|
||||
if !opts.omitType {
|
||||
builder = builder.Claim(common.TokenTypeClaim, idTokenType)
|
||||
}
|
||||
token, err := builder.Build()
|
||||
require.NoError(t, err)
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), key))
|
||||
require.NoError(t, err)
|
||||
return string(signed)
|
||||
}
|
||||
|
||||
newService := func(t *testing.T) (*endSessionService, *Store) {
|
||||
t.Helper()
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
LogoutCallbackURLs: model.UrlList{"https://app.example/logout"},
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim"}).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: userID, ClientID: clientID}).Error)
|
||||
store := NewStore(db)
|
||||
return newEndSessionService(db, store, signer, baseURL), store
|
||||
}
|
||||
|
||||
validToken := tokenOptions{issuer: baseURL, subject: userID, audience: clientID, jti: jti}
|
||||
|
||||
t.Run("missing id_token_hint is rejected", func(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{}, userID)
|
||||
var target *common.TokenInvalidError
|
||||
require.ErrorAs(t, err, &target)
|
||||
})
|
||||
|
||||
t.Run("malformed id_token_hint is rejected", func(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: "not-a-jwt"}, userID)
|
||||
var target *common.TokenInvalidError
|
||||
require.ErrorAs(t, err, &target)
|
||||
})
|
||||
|
||||
t.Run("token signed by a foreign key is rejected", func(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
token, err := jwt.NewBuilder().
|
||||
Issuer(baseURL).Subject(userID).Audience([]string{clientID}).JwtID(jti).IssuedAt(time.Now()).Build()
|
||||
require.NoError(t, err)
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), otherKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: string(signed)}, userID)
|
||||
var target *common.TokenInvalidError
|
||||
require.ErrorAs(t, err, &target)
|
||||
})
|
||||
|
||||
t.Run("non-ID token (missing type claim) is rejected as id_token_hint", func(t *testing.T) {
|
||||
// A first-party JWT access token of the same user/client (signed with the same key) must
|
||||
// not be accepted as an id_token_hint; only genuine ID tokens carry the type claim.
|
||||
service, _ := newService(t)
|
||||
token := signToken(t, tokenOptions{issuer: baseURL, subject: userID, audience: clientID, jti: jti, omitType: true})
|
||||
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
|
||||
var target *common.TokenInvalidError
|
||||
require.ErrorAs(t, err, &target)
|
||||
})
|
||||
|
||||
t.Run("subject not matching the logged-in user is rejected", func(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
token := signToken(t, tokenOptions{issuer: baseURL, subject: "someone-else", audience: clientID, jti: jti})
|
||||
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
|
||||
var target *common.TokenInvalidError
|
||||
require.ErrorAs(t, err, &target)
|
||||
})
|
||||
|
||||
t.Run("client_id parameter not matching the token audience is rejected", func(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
token := signToken(t, validToken)
|
||||
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token, ClientId: "different-client"}, userID)
|
||||
var target *common.OidcClientIdNotMatchingError
|
||||
require.ErrorAs(t, err, &target)
|
||||
})
|
||||
|
||||
t.Run("user that never authorized the client is rejected", func(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
// A valid token for a user that has no authorization record for the client.
|
||||
token := signToken(t, tokenOptions{issuer: baseURL, subject: "ghost-user", audience: clientID, jti: jti})
|
||||
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, "ghost-user")
|
||||
var target *common.OidcMissingAuthorizationError
|
||||
require.ErrorAs(t, err, &target)
|
||||
})
|
||||
|
||||
t.Run("unregistered post_logout_redirect_uri is rejected", func(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
token := signToken(t, validToken)
|
||||
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{
|
||||
IdTokenHint: token,
|
||||
PostLogoutRedirectUri: "https://evil.example/steal",
|
||||
}, userID)
|
||||
var target *common.OidcInvalidCallbackURLError
|
||||
require.ErrorAs(t, err, &target)
|
||||
})
|
||||
|
||||
t.Run("valid logout returns the callback URL and revokes the sessions", func(t *testing.T) {
|
||||
service, store := newService(t)
|
||||
|
||||
// Seed an active refresh/access token pair tied to this user, client and ID token.
|
||||
require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-sig", "at-sig", newTestRequester("logout-req", clientID, userID, jti)))
|
||||
require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-sig", newTestRequester("logout-req", clientID, userID, jti)))
|
||||
|
||||
token := signToken(t, validToken)
|
||||
callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://app.example/logout", callback)
|
||||
|
||||
var refresh OAuth2Session
|
||||
require.NoError(t, service.db.First(&refresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-sig").Error)
|
||||
require.False(t, refresh.Active, "refresh token must be revoked on logout")
|
||||
|
||||
var accessCount int64
|
||||
require.NoError(t, service.db.Model(&OAuth2Session{}).
|
||||
Where("kind = ? AND key = ?", sessionKindAccessToken, "at-sig").
|
||||
Count(&accessCount).Error)
|
||||
require.Zero(t, accessCount, "access token must be deleted on logout")
|
||||
})
|
||||
|
||||
t.Run("valid logout revokes only the session matching the id_token_hint", func(t *testing.T) {
|
||||
service, store := newService(t)
|
||||
|
||||
require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-matching", "at-matching", newTestRequester("matching-req", clientID, userID, jti)))
|
||||
require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-matching", newTestRequester("matching-req", clientID, userID, jti)))
|
||||
require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-other", "at-other", newTestRequester("other-req", clientID, userID, "other-id-token-jti")))
|
||||
require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-other", newTestRequester("other-req", clientID, userID, "other-id-token-jti")))
|
||||
|
||||
token := signToken(t, validToken)
|
||||
callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://app.example/logout", callback)
|
||||
|
||||
var matchingRefresh OAuth2Session
|
||||
require.NoError(t, service.db.First(&matchingRefresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-matching").Error)
|
||||
require.False(t, matchingRefresh.Active, "refresh token matching id_token_hint must be revoked on logout")
|
||||
|
||||
var otherRefresh OAuth2Session
|
||||
require.NoError(t, service.db.First(&otherRefresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-other").Error)
|
||||
require.True(t, otherRefresh.Active, "unrelated refresh token for the same user/client must remain active")
|
||||
|
||||
var matchingAccessCount int64
|
||||
require.NoError(t, service.db.Model(&OAuth2Session{}).
|
||||
Where("kind = ? AND key = ?", sessionKindAccessToken, "at-matching").
|
||||
Count(&matchingAccessCount).Error)
|
||||
require.Zero(t, matchingAccessCount, "access token matching id_token_hint must be deleted on logout")
|
||||
|
||||
var otherAccessCount int64
|
||||
require.NoError(t, service.db.Model(&OAuth2Session{}).
|
||||
Where("kind = ? AND key = ?", sessionKindAccessToken, "at-other").
|
||||
Count(&otherAccessCount).Error)
|
||||
require.Equal(t, int64(1), otherAccessCount, "unrelated access token for the same user/client must remain active")
|
||||
})
|
||||
|
||||
t.Run("valid logout without UI session derives the user from id_token_hint", func(t *testing.T) {
|
||||
service, store := newService(t)
|
||||
|
||||
// The OP browser session is absent, but the ID token hint still identifies the
|
||||
// End-User and RP session that should be logged out.
|
||||
require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-no-session", "at-no-session", newTestRequester("logout-req", clientID, userID, jti)))
|
||||
require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-no-session", newTestRequester("logout-req", clientID, userID, jti)))
|
||||
|
||||
token := signToken(t, validToken)
|
||||
callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://app.example/logout", callback)
|
||||
|
||||
var refresh OAuth2Session
|
||||
require.NoError(t, service.db.First(&refresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-no-session").Error)
|
||||
require.False(t, refresh.Active, "refresh token must be revoked on logout")
|
||||
|
||||
var accessCount int64
|
||||
require.NoError(t, service.db.Model(&OAuth2Session{}).
|
||||
Where("kind = ? AND key = ?", sessionKindAccessToken, "at-no-session").
|
||||
Count(&accessCount).Error)
|
||||
require.Zero(t, accessCount, "access token must be deleted on logout")
|
||||
})
|
||||
|
||||
t.Run("valid logout honors a registered post_logout_redirect_uri", func(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
token := signToken(t, validToken)
|
||||
callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{
|
||||
IdTokenHint: token,
|
||||
PostLogoutRedirectUri: "https://app.example/logout",
|
||||
}, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://app.example/logout", callback)
|
||||
})
|
||||
}
|
||||
228
backend/internal/oidc/federated_client_auth.go
Normal file
228
backend/internal/oidc/federated_client_auth.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/httprc/v3"
|
||||
"github.com/lestrrat-go/httprc/v3/errsink"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jws"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/ory/fosite"
|
||||
)
|
||||
|
||||
const clientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
|
||||
|
||||
var errNoFederatedClientAssertion = errors.New("no federated client assertion")
|
||||
|
||||
// federatedClientStore is the subset of the store the federated authenticator needs.
|
||||
type federatedClientStore interface {
|
||||
GetClient(ctx context.Context, id string) (fosite.Client, error)
|
||||
ClientAssertionJWTValid(ctx context.Context, jti string) error
|
||||
SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error
|
||||
}
|
||||
|
||||
// federatedClientAuthenticator authenticates clients via JWT bearer assertions issued
|
||||
// by a federated identity provider configured per client.
|
||||
type federatedClientAuthenticator struct {
|
||||
clients federatedClientStore
|
||||
httpClient *http.Client
|
||||
jwksCache *jwk.Cache
|
||||
defaultAudience string
|
||||
}
|
||||
|
||||
func newFederatedClientAuthenticator(ctx context.Context, clients federatedClientStore, httpClient *http.Client, defaultAudience string) (*federatedClientAuthenticator, error) {
|
||||
authenticator := &federatedClientAuthenticator{
|
||||
clients: clients,
|
||||
httpClient: httpClient,
|
||||
defaultAudience: defaultAudience,
|
||||
}
|
||||
|
||||
jwksCache, err := authenticator.getJWKCache(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authenticator.jwksCache = jwksCache
|
||||
|
||||
return authenticator, nil
|
||||
}
|
||||
|
||||
func (a *federatedClientAuthenticator) getJWKCache(ctx context.Context) (*jwk.Cache, error) {
|
||||
// We need to create a custom HTTP client to set a timeout.
|
||||
client := a.httpClient
|
||||
if client == nil {
|
||||
client = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
// Indicates a development-time error
|
||||
panic("Default transport is not of type *http.Transport")
|
||||
}
|
||||
transport := defaultTransport.Clone()
|
||||
transport.TLSClientConfig.MinVersion = tls.VersionTLS12
|
||||
client.Transport = transport
|
||||
}
|
||||
|
||||
return jwk.NewCache(ctx,
|
||||
httprc.NewClient(
|
||||
httprc.WithErrorSink(errsink.NewSlog(slog.Default())),
|
||||
httprc.WithHTTPClient(client),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// newClientAuthenticationStrategy accepts federated client assertions before falling
|
||||
// back to fosite's default client authentication.
|
||||
func newClientAuthenticationStrategy(authenticator *federatedClientAuthenticator, provider *fosite.Fosite) fosite.ClientAuthenticationStrategy {
|
||||
return func(ctx context.Context, r *http.Request, form url.Values) (fosite.Client, error) {
|
||||
client, err := authenticator.authenticateForm(ctx, form)
|
||||
if err == nil {
|
||||
return client, nil
|
||||
}
|
||||
if !errors.Is(err, errNoFederatedClientAssertion) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return provider.DefaultClientAuthenticationStrategy(ctx, r, form)
|
||||
}
|
||||
}
|
||||
|
||||
// authenticateForm returns errNoFederatedClientAssertion when the form carries no
|
||||
// federated assertion, so the caller can fall back to other authentication methods.
|
||||
func (a *federatedClientAuthenticator) authenticateForm(ctx context.Context, form url.Values) (fosite.Client, error) {
|
||||
if form.Get("client_assertion_type") != clientAssertionTypeJWTBearer || form.Get("client_assertion") == "" {
|
||||
return nil, errNoFederatedClientAssertion
|
||||
}
|
||||
|
||||
return a.authenticateAssertion(ctx, form.Get("client_assertion"), form.Get("client_id"))
|
||||
}
|
||||
|
||||
// authenticateAssertion validates the assertion JWT against the client's configured
|
||||
// federated identity. An empty clientID falls back to the assertion's subject.
|
||||
func (a *federatedClientAuthenticator) authenticateAssertion(ctx context.Context, assertion string, clientID string) (fosite.Client, error) {
|
||||
rawAssertion := []byte(assertion)
|
||||
insecureToken, err := jwt.ParseInsecure(rawAssertion)
|
||||
if err != nil {
|
||||
return nil, fosite.ErrInvalidClient.WithHint("Invalid client assertion.").WithWrap(err)
|
||||
}
|
||||
|
||||
issuer, _ := insecureToken.Issuer()
|
||||
if issuer == "" {
|
||||
return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing issuer.")
|
||||
}
|
||||
|
||||
if clientID == "" {
|
||||
clientID, _ = insecureToken.Subject()
|
||||
}
|
||||
if clientID == "" {
|
||||
return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing subject.")
|
||||
}
|
||||
|
||||
client, err := a.clients.GetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
return nil, fosite.ErrInvalidClient.WithWrap(err)
|
||||
}
|
||||
|
||||
oidcClient, ok := client.(Client)
|
||||
if !ok {
|
||||
return nil, errNoFederatedClientAssertion
|
||||
}
|
||||
|
||||
federatedIdentity, ok := oidcClient.Credentials.FederatedIdentityForIssuer(issuer)
|
||||
if !ok {
|
||||
return nil, errNoFederatedClientAssertion
|
||||
}
|
||||
|
||||
jwksURL := federatedIdentity.JWKS
|
||||
if jwksURL == "" {
|
||||
jwksURL = strings.TrimRight(issuer, "/") + "/.well-known/jwks.json"
|
||||
}
|
||||
|
||||
jwks, err := a.fetchJWKSet(ctx, jwksURL)
|
||||
if err != nil {
|
||||
return nil, fosite.ErrInvalidClient.WithHint("Unable to fetch client assertion JWKS.").WithWrap(err)
|
||||
}
|
||||
|
||||
audience := federatedIdentity.Audience
|
||||
if audience == "" {
|
||||
audience = a.defaultAudience
|
||||
}
|
||||
subject := federatedIdentity.Subject
|
||||
if subject == "" {
|
||||
subject = client.GetID()
|
||||
}
|
||||
|
||||
parsed, err := jwt.Parse(rawAssertion,
|
||||
jwt.WithValidate(true),
|
||||
jwt.WithAcceptableSkew(30*time.Second),
|
||||
jwt.WithRequiredClaim(jwt.ExpirationKey),
|
||||
jwt.WithIssuer(issuer),
|
||||
jwt.WithSubject(subject),
|
||||
jwt.WithAudience(audience),
|
||||
jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fosite.ErrInvalidClient.WithHint("Invalid client assertion.").WithWrap(err)
|
||||
}
|
||||
|
||||
if federatedIdentity.ReplayProtection {
|
||||
jti, ok := parsed.JwtID()
|
||||
if !ok || jti == "" {
|
||||
return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing jti claim, which is required for replay protection.")
|
||||
}
|
||||
|
||||
// Check if the jti has been used before
|
||||
if err := a.clients.ClientAssertionJWTValid(ctx, jti); err != nil {
|
||||
return nil, fosite.ErrInvalidClient.WithHint("Client assertion has already been used.").WithWrap(err)
|
||||
}
|
||||
// Store the jti to prevent future reuse
|
||||
exp, _ := parsed.Expiration()
|
||||
if err := a.clients.SetClientAssertionJWT(ctx, jti, exp); err != nil {
|
||||
return nil, fosite.ErrInvalidClient.WithWrap(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (a *federatedClientAuthenticator) fetchJWKSet(ctx context.Context, jwksURL string) (jwk.Set, error) {
|
||||
if !a.jwksCache.IsRegistered(ctx, jwksURL) {
|
||||
// We set a timeout because otherwise Register will keep trying in case of errors
|
||||
registerCtx, registerCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer registerCancel()
|
||||
|
||||
registerOptions := []jwk.RegisterOption{
|
||||
jwk.WithMaxInterval(24 * time.Hour),
|
||||
jwk.WithMinInterval(15 * time.Minute),
|
||||
jwk.WithWaitReady(true),
|
||||
}
|
||||
if a.httpClient != nil {
|
||||
registerOptions = append(registerOptions, jwk.WithHTTPClient(a.httpClient))
|
||||
}
|
||||
|
||||
// We need to register the URL
|
||||
err := a.jwksCache.Register(registerCtx, jwksURL, registerOptions...)
|
||||
// In case of race conditions (two goroutines calling jwkCache.Register at the same time), it's possible we can get a conflict anyways, so we ignore that error
|
||||
if err != nil && !errors.Is(err, httprc.ErrResourceAlreadyExists()) {
|
||||
return nil, fmt.Errorf("failed to register JWK set: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
jwks, err := a.jwksCache.CachedSet(jwksURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cached JWK set: %w", err)
|
||||
}
|
||||
|
||||
return jwks, nil
|
||||
}
|
||||
310
backend/internal/oidc/federated_client_auth_test.go
Normal file
310
backend/internal/oidc/federated_client_auth_test.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/ory/fosite"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
)
|
||||
|
||||
type fakeFederatedStore struct {
|
||||
client fosite.Client
|
||||
jtis map[string]time.Time
|
||||
}
|
||||
|
||||
func (f *fakeFederatedStore) GetClient(_ context.Context, id string) (fosite.Client, error) {
|
||||
if f.client == nil || f.client.GetID() != id {
|
||||
return nil, fosite.ErrNotFound
|
||||
}
|
||||
return f.client, nil
|
||||
}
|
||||
|
||||
func (f *fakeFederatedStore) ClientAssertionJWTValid(_ context.Context, jti string) error {
|
||||
if exp, ok := f.jtis[jti]; ok && exp.After(time.Now()) {
|
||||
return fosite.ErrJTIKnown
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeFederatedStore) SetClientAssertionJWT(_ context.Context, jti string, exp time.Time) error {
|
||||
f.jtis[jti] = exp
|
||||
return nil
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func newJWKSetHTTPClient(t *testing.T, set jwk.Set) *http.Client {
|
||||
t.Helper()
|
||||
|
||||
client, _ := newCountingJWKSetHTTPClient(t, set, false)
|
||||
return client
|
||||
}
|
||||
|
||||
func newCountingJWKSetHTTPClient(t *testing.T, set jwk.Set, failAfterFirst bool) (*http.Client, *atomic.Int64) {
|
||||
t.Helper()
|
||||
|
||||
raw, err := json.Marshal(set)
|
||||
require.NoError(t, err)
|
||||
|
||||
var requests atomic.Int64
|
||||
return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if failAfterFirst && requests.Load() > 0 {
|
||||
return nil, errors.New("jwks endpoint unavailable")
|
||||
}
|
||||
requests.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(string(raw))),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}, &requests
|
||||
}
|
||||
|
||||
// TestFederatedClientAuthenticatorAssertionValidation covers federated client assertions
|
||||
// issued by providers that may cache and reuse tokens until their exp claim.
|
||||
func TestFederatedClientAuthenticatorAssertionValidation(t *testing.T) {
|
||||
signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
|
||||
require.NoError(t, err)
|
||||
signingAlg, ok := signingKey.Algorithm()
|
||||
require.True(t, ok)
|
||||
|
||||
publicKey, err := signingKey.PublicKey()
|
||||
require.NoError(t, err)
|
||||
jwks := jwk.NewSet()
|
||||
require.NoError(t, jwks.AddKey(publicKey))
|
||||
|
||||
const (
|
||||
issuer = "https://idp.example.com"
|
||||
clientID = "federated-client"
|
||||
audience = "https://pocket-id.example.com"
|
||||
jwksURL = "https://idp.example.com/jwks.json"
|
||||
)
|
||||
|
||||
newAuthenticator := func(t *testing.T, replayProtection bool) (*federatedClientAuthenticator, *fakeFederatedStore) {
|
||||
t.Helper()
|
||||
store := &fakeFederatedStore{
|
||||
client: Client{OidcClient: model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Federated Client",
|
||||
Credentials: model.OidcClientCredentials{
|
||||
FederatedIdentities: []model.OidcClientFederatedIdentity{{
|
||||
Issuer: issuer,
|
||||
JWKS: jwksURL,
|
||||
ReplayProtection: replayProtection,
|
||||
}},
|
||||
},
|
||||
}},
|
||||
jtis: map[string]time.Time{},
|
||||
}
|
||||
authenticator, err := newFederatedClientAuthenticator(t.Context(), store, newJWKSetHTTPClient(t, jwks), audience)
|
||||
require.NoError(t, err)
|
||||
return authenticator, store
|
||||
}
|
||||
|
||||
signAssertion := func(t *testing.T, mutate func(b *jwt.Builder) *jwt.Builder) string {
|
||||
t.Helper()
|
||||
builder := jwt.NewBuilder().
|
||||
Issuer(issuer).
|
||||
Subject(clientID).
|
||||
Audience([]string{audience}).
|
||||
IssuedAt(time.Now())
|
||||
token, err := mutate(builder).Build()
|
||||
require.NoError(t, err)
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(signingAlg, signingKey))
|
||||
require.NoError(t, err)
|
||||
return string(signed)
|
||||
}
|
||||
|
||||
t.Run("valid assertion with jti authenticates once when replay protection is enabled", func(t *testing.T) {
|
||||
authenticator, _ := newAuthenticator(t, true)
|
||||
assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder {
|
||||
return b.JwtID("replay-protected-token").Expiration(time.Now().Add(5 * time.Minute))
|
||||
})
|
||||
|
||||
client, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, clientID, client.GetID())
|
||||
|
||||
_, err = authenticator.authenticateAssertion(t.Context(), assertion, clientID)
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidClient)
|
||||
})
|
||||
|
||||
t.Run("valid assertion with jti can be reused when replay protection is disabled", func(t *testing.T) {
|
||||
authenticator, store := newAuthenticator(t, false)
|
||||
assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder {
|
||||
return b.JwtID("cached-provider-token").Expiration(time.Now().Add(5 * time.Minute))
|
||||
})
|
||||
|
||||
client, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, clientID, client.GetID())
|
||||
|
||||
client, err = authenticator.authenticateAssertion(t.Context(), assertion, clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, clientID, client.GetID())
|
||||
require.Empty(t, store.jtis)
|
||||
})
|
||||
|
||||
t.Run("assertion without exp is rejected", func(t *testing.T) {
|
||||
authenticator, _ := newAuthenticator(t, true)
|
||||
assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder {
|
||||
return b.JwtID("jti-no-exp")
|
||||
})
|
||||
_, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID)
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidClient)
|
||||
})
|
||||
|
||||
t.Run("assertion without jti is rejected when replay protection is enabled", func(t *testing.T) {
|
||||
authenticator, _ := newAuthenticator(t, true)
|
||||
assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder {
|
||||
return b.Expiration(time.Now().Add(5 * time.Minute))
|
||||
})
|
||||
_, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID)
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidClient)
|
||||
})
|
||||
|
||||
t.Run("assertion without jti authenticates when replay protection is disabled", func(t *testing.T) {
|
||||
authenticator, store := newAuthenticator(t, false)
|
||||
assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder {
|
||||
return b.Expiration(time.Now().Add(5 * time.Minute))
|
||||
})
|
||||
client, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, clientID, client.GetID())
|
||||
require.Empty(t, store.jtis)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFederatedClientAuthenticatorAllowsConfiguredSubjectDifferentFromClientID(t *testing.T) {
|
||||
signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
|
||||
require.NoError(t, err)
|
||||
signingAlg, ok := signingKey.Algorithm()
|
||||
require.True(t, ok)
|
||||
|
||||
publicKey, err := signingKey.PublicKey()
|
||||
require.NoError(t, err)
|
||||
jwks := jwk.NewSet()
|
||||
require.NoError(t, jwks.AddKey(publicKey))
|
||||
|
||||
const (
|
||||
issuer = "https://idp.example.com"
|
||||
clientID = "pocket-id-client"
|
||||
subject = "external-workload-subject"
|
||||
audience = "api://pocket-id"
|
||||
jwksURL = "https://idp.example.com/jwks.json"
|
||||
)
|
||||
|
||||
store := &fakeFederatedStore{
|
||||
client: Client{OidcClient: model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Federated Client",
|
||||
Credentials: model.OidcClientCredentials{
|
||||
FederatedIdentities: []model.OidcClientFederatedIdentity{{
|
||||
Issuer: issuer,
|
||||
Subject: subject,
|
||||
Audience: audience,
|
||||
JWKS: jwksURL,
|
||||
}},
|
||||
},
|
||||
}},
|
||||
jtis: map[string]time.Time{},
|
||||
}
|
||||
authenticator, err := newFederatedClientAuthenticator(t.Context(), store, newJWKSetHTTPClient(t, jwks), "unused-default-audience")
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := jwt.NewBuilder().
|
||||
Issuer(issuer).
|
||||
Subject(subject).
|
||||
Audience([]string{audience}).
|
||||
IssuedAt(time.Now()).
|
||||
Expiration(time.Now().Add(5 * time.Minute)).
|
||||
JwtID("jti-custom-subject").
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
assertion, err := jwt.Sign(token, jwt.WithKey(signingAlg, signingKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
client, err := authenticator.authenticateAssertion(t.Context(), string(assertion), clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, clientID, client.GetID())
|
||||
}
|
||||
|
||||
func TestFederatedClientAuthenticatorCachesJWKS(t *testing.T) {
|
||||
signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
|
||||
require.NoError(t, err)
|
||||
signingAlg, ok := signingKey.Algorithm()
|
||||
require.True(t, ok)
|
||||
|
||||
publicKey, err := signingKey.PublicKey()
|
||||
require.NoError(t, err)
|
||||
jwks := jwk.NewSet()
|
||||
require.NoError(t, jwks.AddKey(publicKey))
|
||||
|
||||
const (
|
||||
issuer = "https://idp.example.com"
|
||||
clientID = "federated-client"
|
||||
audience = "https://pocket-id.example.com"
|
||||
jwksURL = "https://idp.example.com/jwks.json"
|
||||
)
|
||||
|
||||
httpClient, requests := newCountingJWKSetHTTPClient(t, jwks, true)
|
||||
store := &fakeFederatedStore{
|
||||
client: Client{OidcClient: model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Federated Client",
|
||||
Credentials: model.OidcClientCredentials{
|
||||
FederatedIdentities: []model.OidcClientFederatedIdentity{{
|
||||
Issuer: issuer,
|
||||
JWKS: jwksURL,
|
||||
}},
|
||||
},
|
||||
}},
|
||||
jtis: map[string]time.Time{},
|
||||
}
|
||||
authenticator, err := newFederatedClientAuthenticator(t.Context(), store, httpClient, audience)
|
||||
require.NoError(t, err)
|
||||
|
||||
signAssertion := func(t *testing.T, jti string) string {
|
||||
t.Helper()
|
||||
token, err := jwt.NewBuilder().
|
||||
Issuer(issuer).
|
||||
Subject(clientID).
|
||||
Audience([]string{audience}).
|
||||
IssuedAt(time.Now()).
|
||||
Expiration(time.Now().Add(5 * time.Minute)).
|
||||
JwtID(jti).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(signingAlg, signingKey))
|
||||
require.NoError(t, err)
|
||||
return string(signed)
|
||||
}
|
||||
|
||||
client, err := authenticator.authenticateAssertion(t.Context(), signAssertion(t, "jti-cache-1"), clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, clientID, client.GetID())
|
||||
|
||||
client, err = authenticator.authenticateAssertion(t.Context(), signAssertion(t, "jti-cache-2"), clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, clientID, client.GetID())
|
||||
require.EqualValues(t, 1, requests.Load())
|
||||
}
|
||||
70
backend/internal/oidc/interaction_session_dto.go
Normal file
70
backend/internal/oidc/interaction_session_dto.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
)
|
||||
|
||||
type interactionStep string
|
||||
|
||||
const (
|
||||
interactionStepAuthenticate interactionStep = "authenticate"
|
||||
interactionStepSelectAccount interactionStep = "select_account"
|
||||
interactionStepReauthenticate interactionStep = "reauthenticate"
|
||||
interactionStepConsent interactionStep = "consent"
|
||||
)
|
||||
|
||||
type interactionSessionForUser struct {
|
||||
ID string `json:"id"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Client dto.OidcClientMetaDataDto `json:"client"`
|
||||
CurrentStep interactionStep `json:"currentStep,omitempty"`
|
||||
RequiredSteps []interactionStep `json:"requiredSteps"`
|
||||
}
|
||||
|
||||
type completeInteractionRequest struct {
|
||||
Step interactionStep `json:"step"`
|
||||
}
|
||||
|
||||
type completeInteractionResponse struct {
|
||||
Interaction *interactionSessionForUser `json:"interaction,omitempty"`
|
||||
RedirectURL string `json:"redirectUrl,omitempty"`
|
||||
}
|
||||
|
||||
func newInteractionSessionForUser(interactionSession InteractionSession) (interactionSessionForUser, error) {
|
||||
var client dto.OidcClientMetaDataDto
|
||||
if err := dto.MapStruct(interactionSession.Client, &client); err != nil {
|
||||
return interactionSessionForUser{}, err
|
||||
}
|
||||
|
||||
requiredSteps := requiredInteractionSteps(interactionSession)
|
||||
var currentStep interactionStep
|
||||
if len(requiredSteps) > 0 {
|
||||
currentStep = requiredSteps[0]
|
||||
}
|
||||
|
||||
return interactionSessionForUser{
|
||||
ID: interactionSession.ID,
|
||||
Scopes: interactionSession.Scopes,
|
||||
Client: client,
|
||||
CurrentStep: currentStep,
|
||||
RequiredSteps: requiredSteps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func requiredInteractionSteps(interactionSession InteractionSession) []interactionStep {
|
||||
steps := make([]interactionStep, 0, 4)
|
||||
if interactionSession.AuthenticationRequired {
|
||||
steps = append(steps, interactionStepAuthenticate)
|
||||
}
|
||||
if interactionSession.AccountSelectionRequired {
|
||||
steps = append(steps, interactionStepSelectAccount)
|
||||
}
|
||||
if interactionSession.ReauthenticationRequired {
|
||||
steps = append(steps, interactionStepReauthenticate)
|
||||
}
|
||||
if interactionSession.ConsentRequired {
|
||||
steps = append(steps, interactionStepConsent)
|
||||
}
|
||||
|
||||
return steps
|
||||
}
|
||||
85
backend/internal/oidc/interaction_session_service.go
Normal file
85
backend/internal/oidc/interaction_session_service.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// interactionSessionLifetime is how long a pending interaction stays valid before the
|
||||
// user has to restart the authorization flow.
|
||||
const interactionSessionLifetime = time.Hour
|
||||
|
||||
type interactionSessionService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func newInteractionSessionService(db *gorm.DB) *interactionSessionService {
|
||||
return &interactionSessionService{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *interactionSessionService) create(ctx context.Context, interactionSession InteractionSession) (InteractionSession, error) {
|
||||
err := dbFromContext(ctx, s.db).
|
||||
Create(&interactionSession).
|
||||
Error
|
||||
if err != nil {
|
||||
return InteractionSession{}, err
|
||||
}
|
||||
|
||||
return interactionSession, nil
|
||||
}
|
||||
|
||||
func (s *interactionSessionService) get(ctx context.Context, id string) (InteractionSession, error) {
|
||||
var interactionSession InteractionSession
|
||||
err := dbFromContext(ctx, s.db).
|
||||
Preload("Client").
|
||||
First(&interactionSession, "id = ?", id).
|
||||
Error
|
||||
if err != nil {
|
||||
return InteractionSession{}, err
|
||||
}
|
||||
|
||||
if time.Since(interactionSession.CreatedAt.ToTime()) > interactionSessionLifetime {
|
||||
return InteractionSession{}, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
return interactionSession, nil
|
||||
}
|
||||
|
||||
func (s *interactionSessionService) update(ctx context.Context, interactionSession InteractionSession) error {
|
||||
return dbFromContext(ctx, s.db).
|
||||
Model(&InteractionSession{}).
|
||||
Where("id = ?", interactionSession.ID).
|
||||
Updates(map[string]any{
|
||||
"authentication_required": interactionSession.AuthenticationRequired,
|
||||
"account_selection_required": interactionSession.AccountSelectionRequired,
|
||||
"reauthentication_required": interactionSession.ReauthenticationRequired,
|
||||
"consent_required": interactionSession.ConsentRequired,
|
||||
"user_id": interactionSession.UserID,
|
||||
"reauthenticated_at": interactionSession.ReauthenticatedAt,
|
||||
"parameters": interactionSession.Parameters,
|
||||
}).
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *interactionSessionService) delete(ctx context.Context, id string) error {
|
||||
tx := dbFromContext(ctx, s.db).Where("id = ?", id).Delete(&InteractionSession{})
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasRemainingInteractionSteps(interactionSession InteractionSession) bool {
|
||||
return interactionSession.AuthenticationRequired ||
|
||||
interactionSession.AccountSelectionRequired ||
|
||||
interactionSession.ReauthenticationRequired ||
|
||||
interactionSession.ConsentRequired
|
||||
}
|
||||
115
backend/internal/oidc/introspection_handler.go
Normal file
115
backend/internal/oidc/introspection_handler.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
)
|
||||
|
||||
type introspectionHandler struct {
|
||||
provider fosite.OAuth2Provider
|
||||
authenticator *federatedClientAuthenticator
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func newIntrospectionHandler(provider fosite.OAuth2Provider, authenticator *federatedClientAuthenticator, baseURL string) *introspectionHandler {
|
||||
return &introspectionHandler{
|
||||
provider: provider,
|
||||
authenticator: authenticator,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// introspectToken godoc
|
||||
// @Summary Introspect OIDC tokens
|
||||
// @Description Pass a token to verify if it is considered valid.
|
||||
// @Tags OIDC
|
||||
// @Produce json
|
||||
// @Param token formData string true "The token to be introspected."
|
||||
// @Success 200 {object} object "Response with the introspection result."
|
||||
// @Router /api/oidc/introspect [post]
|
||||
func (h *introspectionHandler) introspectToken(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if h.tryFederatedClientAssertionIntrospection(c) {
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.provider.NewIntrospectionRequest(ctx, c.Request, NewEmptySession())
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to create introspection request", "error", err)
|
||||
h.provider.WriteIntrospectionError(ctx, c.Writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
// A client may only introspect its own tokens. If it was issued to another client, report it as
|
||||
// inactive instead of leaking its existence or contents.
|
||||
callerClientID, err := h.callerClientID(ctx, c)
|
||||
if err != nil || callerClientID == "" || response.GetAccessRequester().GetClient().GetID() != callerClientID {
|
||||
h.provider.WriteIntrospectionResponse(ctx, c.Writer, &fosite.IntrospectionResponse{Active: false})
|
||||
return
|
||||
}
|
||||
|
||||
h.provider.WriteIntrospectionResponse(ctx, c.Writer, response)
|
||||
}
|
||||
|
||||
// tryFederatedClientAssertionIntrospection handles introspection requests authenticated
|
||||
// with a federated client assertion passed as bearer token instead of client credentials.
|
||||
func (h *introspectionHandler) tryFederatedClientAssertionIntrospection(c *gin.Context) bool {
|
||||
ctx := c.Request.Context()
|
||||
assertion := fosite.AccessTokenFromRequest(c.Request)
|
||||
clientID := c.PostForm("client_id")
|
||||
if assertion == "" || clientID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
client, err := h.authenticator.authenticateAssertion(ctx, assertion, clientID)
|
||||
if err != nil {
|
||||
h.provider.WriteIntrospectionError(ctx, c.Writer, fosite.ErrRequestUnauthorized.WithWrap(err))
|
||||
return true
|
||||
}
|
||||
|
||||
tokenUse, accessRequester, err := h.provider.IntrospectToken(ctx, c.PostForm("token"), fosite.TokenUse(c.PostForm("token_type_hint")), NewEmptySession(), strings.Fields(c.PostForm("scope"))...)
|
||||
if err != nil {
|
||||
h.provider.WriteIntrospectionError(ctx, c.Writer, fosite.ErrInactiveToken.WithWrap(err))
|
||||
return true
|
||||
}
|
||||
|
||||
response := &fosite.IntrospectionResponse{
|
||||
Active: true,
|
||||
AccessRequester: accessRequester,
|
||||
TokenUse: tokenUse,
|
||||
}
|
||||
if tokenUse == fosite.AccessToken {
|
||||
response.AccessTokenType = fosite.BearerAccessToken
|
||||
}
|
||||
|
||||
if accessRequester.GetClient().GetID() != client.GetID() {
|
||||
h.provider.WriteIntrospectionResponse(ctx, c.Writer, &fosite.IntrospectionResponse{Active: false})
|
||||
return true
|
||||
}
|
||||
|
||||
h.provider.WriteIntrospectionResponse(ctx, c.Writer, response)
|
||||
return true
|
||||
}
|
||||
|
||||
// callerClientID resolves the client that authenticated this introspection request.
|
||||
func (h *introspectionHandler) callerClientID(ctx context.Context, c *gin.Context) (string, error) {
|
||||
if bearer := fosite.AccessTokenFromRequest(c.Request); bearer != "" {
|
||||
_, accessRequester, err := h.provider.IntrospectToken(ctx, bearer, fosite.AccessToken, NewEmptySession())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return accessRequester.GetClient().GetID(), nil
|
||||
}
|
||||
|
||||
if id, _, ok := c.Request.BasicAuth(); ok {
|
||||
return url.QueryUnescape(id)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
207
backend/internal/oidc/introspection_handler_test.go
Normal file
207
backend/internal/oidc/introspection_handler_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/ory/fosite"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
// TestIntrospectionHandlerBindsTokenToCallerClient verifies that an RFC 6750 bearer-auth
|
||||
// caller (which omits client_id) can only introspect its own client's tokens. A client
|
||||
// must not be able to introspect another client's token — doing so would leak the token's
|
||||
// validity and the user PII it carries.
|
||||
func TestIntrospectionHandlerBindsTokenToCallerClient(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-a"}, Name: "Client A"}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-b"}, Name: "Client B"}).Error)
|
||||
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: "test-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
issueAccessToken := func(t *testing.T, requestID, clientID, subject string) string {
|
||||
t.Helper()
|
||||
session := NewEmptySession()
|
||||
session.Subject = subject
|
||||
session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
request := fosite.NewAccessRequest(session)
|
||||
request.ID = requestID
|
||||
request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}
|
||||
request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
|
||||
request.RequestedScope = fosite.Arguments{"openid"}
|
||||
request.GrantedScope = fosite.Arguments{"openid"}
|
||||
request.RequestedAudience = fosite.Arguments{clientID}
|
||||
request.GrantedAudience = fosite.Arguments{clientID}
|
||||
|
||||
response, err := provider.NewAccessResponse(t.Context(), request)
|
||||
require.NoError(t, err)
|
||||
return response.GetAccessToken()
|
||||
}
|
||||
|
||||
clientAToken := issueAccessToken(t, "req-a", "client-a", "user-a")
|
||||
clientBToken := issueAccessToken(t, "req-b", "client-b", "user-b")
|
||||
clientBOtherToken := issueAccessToken(t, "req-b-2", "client-b", "user-b")
|
||||
|
||||
handler := newIntrospectionHandler(provider, nil, "https://issuer.example.com")
|
||||
|
||||
introspect := func(t *testing.T, bearer, token string) map[string]any {
|
||||
t.Helper()
|
||||
body := url.Values{"token": {token}}.Encode()
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/introspect", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
|
||||
handler.introspectToken(c)
|
||||
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
|
||||
return out
|
||||
}
|
||||
|
||||
// A client using its own token as bearer must not learn about another client's token.
|
||||
cross := introspect(t, clientBToken, clientAToken)
|
||||
require.Equal(t, false, cross["active"])
|
||||
|
||||
// A client may introspect another of its own tokens.
|
||||
same := introspect(t, clientBToken, clientBOtherToken)
|
||||
require.Equal(t, true, same["active"])
|
||||
}
|
||||
|
||||
func TestIntrospectionHandlerAllowsReusedFederatedClientAssertion(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
const (
|
||||
baseURL = "https://issuer.example.com"
|
||||
issuer = "https://idp.example.com"
|
||||
clientID = "federated-client"
|
||||
subject = "external-workload"
|
||||
audience = "api://pocket-id"
|
||||
jwksURL = "https://idp.example.com/jwks.json"
|
||||
)
|
||||
|
||||
signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
|
||||
require.NoError(t, err)
|
||||
signingAlg, ok := signingKey.Algorithm()
|
||||
require.True(t, ok)
|
||||
|
||||
publicKey, err := signingKey.PublicKey()
|
||||
require.NoError(t, err)
|
||||
jwks := jwk.NewSet()
|
||||
require.NoError(t, jwks.AddKey(publicKey))
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
replayProtection := false
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Federated Client",
|
||||
Credentials: model.OidcClientCredentials{
|
||||
FederatedIdentities: []model.OidcClientFederatedIdentity{{
|
||||
Issuer: issuer,
|
||||
Subject: subject,
|
||||
Audience: audience,
|
||||
JWKS: jwksURL,
|
||||
ReplayProtection: replayProtection,
|
||||
}},
|
||||
},
|
||||
}).Error)
|
||||
|
||||
store := NewStore(db)
|
||||
authenticator, err := newFederatedClientAuthenticator(t.Context(), store, newJWKSetHTTPClient(t, jwks), baseURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
provider, err := newProvider(store, authenticator, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: "test-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
session := NewEmptySession()
|
||||
session.Subject = "user-a"
|
||||
session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
accessRequest := fosite.NewAccessRequest(session)
|
||||
accessRequest.ID = "replay-test-request"
|
||||
accessRequest.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}
|
||||
accessRequest.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
|
||||
accessRequest.RequestedScope = fosite.Arguments{"openid"}
|
||||
accessRequest.GrantedScope = fosite.Arguments{"openid"}
|
||||
accessRequest.RequestedAudience = fosite.Arguments{clientID}
|
||||
accessRequest.GrantedAudience = fosite.Arguments{clientID}
|
||||
|
||||
accessResponse, err := provider.NewAccessResponse(t.Context(), accessRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertionToken, err := jwt.NewBuilder().
|
||||
Issuer(issuer).
|
||||
Subject(subject).
|
||||
Audience([]string{audience}).
|
||||
IssuedAt(time.Now()).
|
||||
Expiration(time.Now().Add(5 * time.Minute)).
|
||||
JwtID("cached-provider-token").
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
signedAssertion, err := jwt.Sign(assertionToken, jwt.WithKey(signingAlg, signingKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := newIntrospectionHandler(provider, authenticator, baseURL)
|
||||
introspect := func(t *testing.T) (int, map[string]any) {
|
||||
t.Helper()
|
||||
body := url.Values{
|
||||
"client_id": {clientID},
|
||||
"token": {accessResponse.GetAccessToken()},
|
||||
}.Encode()
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/introspect", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer "+string(signedAssertion))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
handler.introspectToken(c)
|
||||
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
|
||||
return rec.Code, out
|
||||
}
|
||||
|
||||
status, body := introspect(t)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, true, body["active"])
|
||||
|
||||
status, body = introspect(t)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, true, body["active"])
|
||||
}
|
||||
75
backend/internal/oidc/models.go
Normal file
75
backend/internal/oidc/models.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
// OAuth2Session is a fosite requester persisted by the Store. A single table holds
|
||||
// all session kinds (authorization codes, tokens, PKCE, PAR, device codes, ...).
|
||||
type OAuth2Session struct {
|
||||
model.Base
|
||||
|
||||
Kind string
|
||||
Key string
|
||||
RequestID string
|
||||
AccessTokenSignature string
|
||||
Active bool
|
||||
RequestData string
|
||||
ExpiresAt *datatype.DateTime
|
||||
}
|
||||
|
||||
func (OAuth2Session) TableName() string {
|
||||
return "oauth2_sessions"
|
||||
}
|
||||
|
||||
// clientAssertionJTI stores consumed client assertion JWT IDs for replay protection.
|
||||
type clientAssertionJTI struct {
|
||||
model.Base
|
||||
|
||||
JTI string `gorm:"not null;uniqueIndex"`
|
||||
ExpiresAt datatype.DateTime `gorm:"not null;index"`
|
||||
}
|
||||
|
||||
func (clientAssertionJTI) TableName() string {
|
||||
return "oauth2_jtis"
|
||||
}
|
||||
|
||||
// InteractionSession tracks the user-facing steps (authentication, account selection,
|
||||
// reauthentication, consent) that must be completed before an authorization request
|
||||
// can be granted.
|
||||
type InteractionSession struct {
|
||||
model.Base
|
||||
|
||||
Scopes datatype.StringList
|
||||
|
||||
ClientID string
|
||||
Client model.OidcClient
|
||||
|
||||
UserID *string
|
||||
User model.User
|
||||
|
||||
ConsentRequired bool
|
||||
ReauthenticationRequired bool
|
||||
AuthenticationRequired bool
|
||||
AccountSelectionRequired bool
|
||||
|
||||
RequestedAt datatype.DateTime
|
||||
ReauthenticatedAt *datatype.DateTime
|
||||
|
||||
Parameters InteractionSessionParameters
|
||||
}
|
||||
|
||||
type InteractionSessionParameters map[string]string //nolint:recvcheck
|
||||
|
||||
func (p *InteractionSessionParameters) Scan(value any) error {
|
||||
return utils.UnmarshalJSONFromDatabase(p, value)
|
||||
}
|
||||
|
||||
func (p InteractionSessionParameters) Value() (driver.Value, error) {
|
||||
return json.Marshal(p)
|
||||
}
|
||||
121
backend/internal/oidc/module.go
Normal file
121
backend/internal/oidc/module.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
TokenBaseURL string
|
||||
Secret string
|
||||
}
|
||||
|
||||
type TokenSigner interface {
|
||||
GetPrivateKey() any
|
||||
GetKeyAlg() (jwa.KeyAlgorithm, error)
|
||||
GetKeyID() (string, bool)
|
||||
}
|
||||
|
||||
type CustomClaimSource interface {
|
||||
GetCustomClaimsForUserWithUserGroups(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomClaim, error)
|
||||
}
|
||||
|
||||
type ReauthenticationTokenConsumer interface {
|
||||
ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error)
|
||||
}
|
||||
|
||||
type AuditLogger interface {
|
||||
Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Config Config
|
||||
HTTPClient *http.Client
|
||||
|
||||
Signer TokenSigner
|
||||
CustomClaims CustomClaimSource
|
||||
Reauth ReauthenticationTokenConsumer
|
||||
AuditLog AuditLogger
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
Preview *ClientPreviewBuilder
|
||||
|
||||
config Config
|
||||
store *Store
|
||||
|
||||
authorizationHandler *authorizationHandler
|
||||
tokenHandler *tokenHandler
|
||||
userInfoHandler *userInfoHandler
|
||||
parHandler *parHandler
|
||||
introspectionHandler *introspectionHandler
|
||||
endSessionHandler *endSessionHandler
|
||||
deviceHandler *deviceHandler
|
||||
}
|
||||
|
||||
func New(ctx context.Context, deps Dependencies) (*Module, error) {
|
||||
store := NewStore(deps.DB)
|
||||
authenticator, err := newFederatedClientAuthenticator(ctx, store, deps.HTTPClient, deps.Config.BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create federated client authenticator: %w", err)
|
||||
}
|
||||
provider, err := newProvider(store, authenticator, deps.Signer, deps.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OAuth2 provider: %w", err)
|
||||
}
|
||||
|
||||
claimsService := newClaimsService(deps.DB, deps.CustomClaims, deps.Config.BaseURL, deps.Signer)
|
||||
previewBuilder := newClientPreviewBuilder(claimsService, provider.tokenStrategies)
|
||||
interactionSessionService := newInteractionSessionService(deps.DB)
|
||||
authorizationService := newAuthorizationService(deps.DB, interactionSessionService, claimsService, deps.Reauth, deps.AuditLog)
|
||||
deviceService := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, deps.AuditLog, deps.DB)
|
||||
endSessionService := newEndSessionService(deps.DB, store, deps.Signer, deps.Config.BaseURL)
|
||||
|
||||
return &Module{
|
||||
Preview: previewBuilder,
|
||||
|
||||
config: deps.Config,
|
||||
store: store,
|
||||
|
||||
authorizationHandler: newAuthorizationHandler(provider, authorizationService, deps.Config.BaseURL),
|
||||
tokenHandler: newTokenHandler(provider, claimsService),
|
||||
userInfoHandler: newUserInfoHandler(provider, claimsService),
|
||||
parHandler: newPARHandler(provider),
|
||||
introspectionHandler: newIntrospectionHandler(provider, authenticator, deps.Config.BaseURL),
|
||||
endSessionHandler: newEndSessionHandler(endSessionService, deps.Config.BaseURL),
|
||||
deviceHandler: newDeviceHandler(provider, deviceService),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Module) RegisterRoutes(rootGroup *gin.RouterGroup, apiGroup *gin.RouterGroup, optionalBrowserAuth gin.HandlerFunc, browserAuth gin.HandlerFunc) {
|
||||
rootGroup.GET("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize)
|
||||
rootGroup.POST("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize)
|
||||
|
||||
apiGroup.GET("/oidc/interactions/:id", m.authorizationHandler.getInteractionSession)
|
||||
apiGroup.POST("/oidc/interactions/:id/complete", browserAuth, m.authorizationHandler.completeInteraction)
|
||||
|
||||
apiGroup.POST("/oidc/par", m.parHandler.pushedAuthorizationRequest)
|
||||
|
||||
apiGroup.POST("/oidc/token", m.tokenHandler.token)
|
||||
|
||||
apiGroup.GET("/oidc/userinfo", m.userInfoHandler.userInfo)
|
||||
apiGroup.POST("/oidc/userinfo", m.userInfoHandler.userInfo)
|
||||
|
||||
apiGroup.POST("/oidc/introspect", m.introspectionHandler.introspectToken)
|
||||
|
||||
apiGroup.GET("/oidc/end-session", optionalBrowserAuth, m.endSessionHandler.endSession)
|
||||
apiGroup.POST("/oidc/end-session", optionalBrowserAuth, m.endSessionHandler.endSession)
|
||||
|
||||
apiGroup.POST("/oidc/device/authorize", m.deviceHandler.authorizeDevice)
|
||||
apiGroup.POST("/oidc/device/verify", browserAuth, m.deviceHandler.verifyDeviceCode)
|
||||
apiGroup.GET("/oidc/device/info", browserAuth, m.deviceHandler.deviceCodeInfo)
|
||||
}
|
||||
38
backend/internal/oidc/par_handler.go
Normal file
38
backend/internal/oidc/par_handler.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
)
|
||||
|
||||
type parHandler struct {
|
||||
provider fosite.OAuth2Provider
|
||||
}
|
||||
|
||||
func newPARHandler(provider fosite.OAuth2Provider) *parHandler {
|
||||
return &parHandler{
|
||||
provider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *parHandler) pushedAuthorizationRequest(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
ar, err := h.provider.NewPushedAuthorizeRequest(ctx, c.Request)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to create pushed authorize request", "error", err)
|
||||
h.provider.WritePushedAuthorizeError(ctx, c.Writer, ar, err)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.provider.NewPushedAuthorizeResponse(ctx, ar, NewEmptySession())
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to create pushed authorize response", "error", err)
|
||||
h.provider.WritePushedAuthorizeError(ctx, c.Writer, ar, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.provider.WritePushedAuthorizeResponse(ctx, c.Writer, ar, response)
|
||||
}
|
||||
122
backend/internal/oidc/preview.go
Normal file
122
backend/internal/oidc/preview.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
jwxjwt "github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/ory/fosite"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type ClientPreview struct {
|
||||
IDToken map[string]any
|
||||
AccessToken map[string]any
|
||||
UserInfo map[string]any
|
||||
}
|
||||
|
||||
type ClientPreviewBuilder struct {
|
||||
claimsService *ClaimsService
|
||||
strategies tokenStrategies
|
||||
}
|
||||
|
||||
func newClientPreviewBuilder(claimsService *ClaimsService, strategies tokenStrategies) *ClientPreviewBuilder {
|
||||
return &ClientPreviewBuilder{
|
||||
claimsService: claimsService,
|
||||
strategies: strategies,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ClientPreviewBuilder) BuildClientPreview(ctx context.Context, client model.OidcClient, userID string, scopes []string, authenticationMethod string) (*ClientPreview, error) {
|
||||
scopeArgs, err := b.validatedScopes(ctx, client, scopes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userInfo, err := b.claimsService.GetUserClaims(ctx, userID, scopeArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request := b.newPreviewRequest(ctx, client, userID, scopeArgs, authenticationMethod)
|
||||
session := request.GetSession().(*Session)
|
||||
applyUserClaimsToIDToken(session, userID, userInfo)
|
||||
|
||||
idToken, err := b.strategies.idToken.GenerateIDToken(ctx, b.strategies.config.GetIDTokenLifespan(ctx), request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate preview ID token: %w", err)
|
||||
}
|
||||
|
||||
accessToken, _, err := b.strategies.accessToken.GenerateAccessToken(ctx, request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate preview access token: %w", err)
|
||||
}
|
||||
|
||||
idTokenPayload, err := claimsFromJWTString(idToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode preview ID token: %w", err)
|
||||
}
|
||||
|
||||
accessTokenPayload, err := claimsFromJWTString(accessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode preview access token: %w", err)
|
||||
}
|
||||
|
||||
return &ClientPreview{
|
||||
IDToken: idTokenPayload,
|
||||
AccessToken: accessTokenPayload,
|
||||
UserInfo: userInfo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *ClientPreviewBuilder) validatedScopes(ctx context.Context, client model.OidcClient, scopes []string) (fosite.Arguments, error) {
|
||||
scopeStrategy := b.strategies.config.GetScopeStrategy(ctx)
|
||||
clientScopes := Client{OidcClient: client}.GetScopes()
|
||||
|
||||
scopeArgs := make(fosite.Arguments, 0, len(scopes))
|
||||
for _, scope := range fosite.RemoveEmpty(scopes) {
|
||||
if !scopeStrategy(clientScopes, scope) {
|
||||
return nil, fosite.ErrInvalidScope.WithHintf("The OAuth 2.0 Client is not allowed to request scope '%s'.", scope)
|
||||
}
|
||||
if !scopeArgs.Has(scope) {
|
||||
scopeArgs = append(scopeArgs, scope)
|
||||
}
|
||||
}
|
||||
|
||||
return scopeArgs, nil
|
||||
}
|
||||
|
||||
func (b *ClientPreviewBuilder) newPreviewRequest(ctx context.Context, client model.OidcClient, userID string, scopes fosite.Arguments, authenticationMethod string) *fosite.Request {
|
||||
now := time.Now().UTC()
|
||||
session := NewAuthenticatedSession(userID, authenticationMethod, now, now)
|
||||
session.SetExpiresAt(fosite.AccessToken, now.Add(b.strategies.config.GetAccessTokenLifespan(ctx)))
|
||||
|
||||
request := fosite.NewRequest()
|
||||
request.RequestedAt = now
|
||||
request.Client = Client{OidcClient: client}
|
||||
request.RequestedScope = scopes
|
||||
request.GrantedScope = scopes
|
||||
request.RequestedAudience = fosite.Arguments{client.ID}
|
||||
request.GrantedAudience = fosite.Arguments{client.ID}
|
||||
request.Form = url.Values{}
|
||||
request.Session = session
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
func claimsFromJWTString(tokenString string) (map[string]any, error) {
|
||||
token, err := jwxjwt.ParseString(
|
||||
tokenString,
|
||||
jwxjwt.WithValidate(false),
|
||||
jwxjwt.WithVerify(false),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return utils.GetClaimsFromToken(token)
|
||||
}
|
||||
102
backend/internal/oidc/preview_test.go
Normal file
102
backend/internal/oidc/preview_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: "test-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil), provider.tokenStrategies)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
)
|
||||
email := "user@example.com"
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
Username: "test-user",
|
||||
Email: &email,
|
||||
EmailVerified: true,
|
||||
}).Error)
|
||||
|
||||
preview, err := builder.BuildClientPreview(t.Context(), model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}, userID, []string{"openid", "email"}, "phr")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "https://issuer.example.com", preview.AccessToken["iss"])
|
||||
require.ElementsMatch(t, []string{"openid", "email"}, stringSliceClaim(t, preview.AccessToken["scp"]))
|
||||
require.ElementsMatch(t, []string{clientID}, stringSliceClaim(t, preview.AccessToken["aud"]))
|
||||
require.NotContains(t, preview.AccessToken, "type")
|
||||
|
||||
require.Equal(t, userID, preview.IDToken["sub"])
|
||||
// ID tokens carry the "type" marker (so the end-session endpoint can reject access tokens
|
||||
// passed as id_token_hint) and the amr from the authentication method.
|
||||
require.Equal(t, idTokenType, preview.IDToken["type"])
|
||||
require.ElementsMatch(t, []string{"phr"}, stringSliceClaim(t, preview.IDToken["amr"]))
|
||||
|
||||
require.Equal(t, email, preview.UserInfo["email"])
|
||||
require.Equal(t, true, preview.UserInfo["email_verified"])
|
||||
}
|
||||
|
||||
func TestClientPreviewBuilderRejectsInvalidScope(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: "test-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil), provider.tokenStrategies)
|
||||
_, err = builder.BuildClientPreview(t.Context(), model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
Name: "Test Client",
|
||||
}, "test-user", []string{"openid", "unknown"}, "")
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "invalid_scope")
|
||||
}
|
||||
|
||||
func stringSliceClaim(t *testing.T, value any) []string {
|
||||
t.Helper()
|
||||
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return typed
|
||||
case []any:
|
||||
values := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
value, ok := item.(string)
|
||||
require.Truef(t, ok, "expected string claim item, got %T", item)
|
||||
values = append(values, value)
|
||||
}
|
||||
return values
|
||||
case string:
|
||||
return []string{typed}
|
||||
default:
|
||||
require.Failf(t, "unexpected claim type", "expected string slice claim, got %T", value)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
137
backend/internal/oidc/provider.go
Normal file
137
backend/internal/oidc/provider.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/ory/fosite/compose"
|
||||
fositeoauth2 "github.com/ory/fosite/handler/oauth2"
|
||||
"github.com/ory/fosite/handler/openid"
|
||||
"github.com/ory/fosite/handler/rfc8628"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
type oidcProvider struct {
|
||||
fosite.OAuth2Provider
|
||||
deviceStrategy *rfc8628.DefaultDeviceStrategy
|
||||
tokenStrategies
|
||||
}
|
||||
|
||||
type tokenStrategies struct {
|
||||
accessToken fositeoauth2.AccessTokenStrategy
|
||||
idToken openid.OpenIDConnectTokenStrategy
|
||||
config *fosite.Config
|
||||
}
|
||||
|
||||
func newProvider(store *Store, authenticator *federatedClientAuthenticator, signer TokenSigner, config Config) (*oidcProvider, error) {
|
||||
secret, err := DeriveGlobalSecret(config.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var fositeConfig = &fosite.Config{
|
||||
RefreshTokenLifespan: 30 * 24 * time.Hour,
|
||||
DeviceAndUserCodeLifespan: 15 * time.Minute,
|
||||
DeviceAuthTokenPollingInterval: 5 * time.Second,
|
||||
DeviceVerificationURL: config.BaseURL + "/device",
|
||||
PushedAuthorizeContextLifespan: 90 * time.Second,
|
||||
IDTokenIssuer: config.BaseURL,
|
||||
AccessTokenIssuer: config.BaseURL,
|
||||
TokenURL: config.TokenBaseURL + "/api/oidc/token",
|
||||
ScopeStrategy: fosite.ExactScopeStrategy,
|
||||
AudienceMatchingStrategy: fosite.ExactAudienceMatchingStrategy,
|
||||
RedirectURIMatcher: matchRedirectURI,
|
||||
EnforcePKCEForPublicClients: true,
|
||||
EnablePKCEPlainChallengeMethod: true,
|
||||
RefreshTokenScopes: []string{},
|
||||
GlobalSecret: secret,
|
||||
}
|
||||
|
||||
keyGetter := func(context.Context) (interface{}, error) {
|
||||
return SigningKeyFromSigner(signer)
|
||||
}
|
||||
sig := newJWTSigner(keyGetter)
|
||||
coreStrategy := compose.NewOAuth2HMACStrategy(fositeConfig)
|
||||
deviceStrategy := compose.NewDeviceStrategy(fositeConfig)
|
||||
accessTokenStrategy := &fositeoauth2.DefaultJWTStrategy{
|
||||
Signer: sig,
|
||||
HMACSHAStrategy: coreStrategy,
|
||||
Config: fositeConfig,
|
||||
}
|
||||
idTokenStrategy := &openid.DefaultStrategy{
|
||||
Signer: sig,
|
||||
Config: fositeConfig,
|
||||
}
|
||||
provider := compose.Compose(
|
||||
fositeConfig,
|
||||
store,
|
||||
&compose.CommonStrategy{
|
||||
CoreStrategy: accessTokenStrategy,
|
||||
RFC8628CodeStrategy: deviceStrategy,
|
||||
OpenIDConnectTokenStrategy: idTokenStrategy,
|
||||
Signer: sig,
|
||||
},
|
||||
compose.OAuth2AuthorizeExplicitFactory,
|
||||
compose.OAuth2ClientCredentialsGrantFactory,
|
||||
compose.OAuth2RefreshTokenGrantFactory,
|
||||
compose.RFC8628DeviceFactory,
|
||||
compose.RFC8628DeviceAuthorizationTokenFactory,
|
||||
compose.OpenIDConnectExplicitFactory,
|
||||
compose.OpenIDConnectRefreshFactory,
|
||||
compose.OpenIDConnectDeviceFactory,
|
||||
compose.OAuth2TokenIntrospectionFactory,
|
||||
compose.OAuth2PKCEFactory,
|
||||
compose.PushedAuthorizeHandlerFactory,
|
||||
).(*fosite.Fosite)
|
||||
|
||||
fositeConfig.ClientAuthenticationStrategy = newClientAuthenticationStrategy(authenticator, provider)
|
||||
return &oidcProvider{
|
||||
OAuth2Provider: provider,
|
||||
deviceStrategy: deviceStrategy,
|
||||
tokenStrategies: tokenStrategies{
|
||||
accessToken: accessTokenStrategy,
|
||||
idToken: idTokenStrategy,
|
||||
config: fositeConfig,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func matchRedirectURI(rawurl string, client fosite.Client) (*url.URL, error) {
|
||||
redirectURI, err := fosite.MatchRedirectURIWithClientRedirectURIs(rawurl, client)
|
||||
if err == nil || rawurl == "" {
|
||||
return redirectURI, err
|
||||
}
|
||||
|
||||
invalidRedirectErr := err
|
||||
matchedURL, matchErr := utils.GetCallbackURLFromList(client.GetRedirectURIs(), rawurl)
|
||||
if matchErr != nil || matchedURL == "" {
|
||||
slog.Debug("Redirect URI does not match any of the registered callback URLs", "rawurl", rawurl, "client_id", client.GetID(), "error", matchErr)
|
||||
return nil, invalidRedirectErr
|
||||
}
|
||||
|
||||
redirectURI, err = url.Parse(matchedURL)
|
||||
if err != nil || !fosite.IsValidRedirectURI(redirectURI) {
|
||||
slog.Debug("Matched callback URL is invalid", "matchedURL", matchedURL, "client_id", client.GetID(), "error", err)
|
||||
return nil, invalidRedirectErr
|
||||
}
|
||||
|
||||
return redirectURI, nil
|
||||
}
|
||||
|
||||
// DeriveGlobalSecret derives a 32-byte secret from the provided secret.
|
||||
func DeriveGlobalSecret(secret string) ([]byte, error) {
|
||||
const info = "pocketid/fosite_global_secret"
|
||||
r := hkdf.New(sha256.New, []byte(secret), nil, []byte(info))
|
||||
|
||||
key := make([]byte, 32)
|
||||
if _, err := io.ReadFull(r, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
272
backend/internal/oidc/provider_test.go
Normal file
272
backend/internal/oidc/provider_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testTokenSigner struct {
|
||||
key *rsa.PrivateKey
|
||||
}
|
||||
|
||||
func (s testTokenSigner) GetPrivateKey() any {
|
||||
return s.key
|
||||
}
|
||||
|
||||
func (s testTokenSigner) GetKeyAlg() (jwa.KeyAlgorithm, error) {
|
||||
return jwa.RS256(), nil
|
||||
}
|
||||
|
||||
func (s testTokenSigner) GetKeyID() (string, bool) {
|
||||
return "test-key-id", true
|
||||
}
|
||||
|
||||
func TestProviderIssuesJWTAccessTokens(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: "test-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
session := NewEmptySession()
|
||||
session.Subject = "test-user"
|
||||
session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
request := fosite.NewAccessRequest(session)
|
||||
request.ID = "test-request"
|
||||
request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: "test-client"}}}
|
||||
request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
|
||||
request.RequestedScope = fosite.Arguments{"openid"}
|
||||
request.GrantedScope = fosite.Arguments{"openid"}
|
||||
request.RequestedAudience = fosite.Arguments{"test-client"}
|
||||
request.GrantedAudience = fosite.Arguments{"test-client"}
|
||||
|
||||
response, err := provider.NewAccessResponse(t.Context(), request)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, strings.Split(response.GetAccessToken(), "."), 3)
|
||||
|
||||
// The issued JWT must carry a `kid` header matching the signing key so RPs can
|
||||
// select the verification key from the published JWKS (esp. after key rotation).
|
||||
header := decodeJWTPart(t, response.GetAccessToken(), 0)
|
||||
require.Equal(t, "test-key-id", header["kid"])
|
||||
}
|
||||
|
||||
func TestProviderAcceptsWildcardRedirectURI(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
Name: "Test Client",
|
||||
CallbackURLs: model.UrlList{"https://*.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: "test-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
const requestedRedirectURI = "https://tenant.example.com/callback"
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodGet,
|
||||
"/api/oidc/authorize?client_id=test-client&response_type=code&scope=openid&state=state-with-enough-entropy&redirect_uri="+requestedRedirectURI,
|
||||
nil,
|
||||
)
|
||||
|
||||
ar, err := provider.NewAuthorizeRequest(req.Context(), req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, requestedRedirectURI, ar.GetRedirectURI().String())
|
||||
require.True(t, ar.IsRedirectURIValid())
|
||||
require.NotContains(t, ar.GetClient().GetRedirectURIs(), requestedRedirectURI)
|
||||
}
|
||||
|
||||
func TestProviderAcceptsPushedAuthorizationWildcardRedirectURI(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
Name: "Test Client",
|
||||
CallbackURLs: model.UrlList{"https://*.example.com/callback"},
|
||||
IsPublic: true,
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: "test-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
const requestedRedirectURI = "https://tenant.example.com/callback"
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/api/oidc/par?client_id=test-client&response_type=code&scope=openid&state=state-with-enough-entropy&redirect_uri="+requestedRedirectURI,
|
||||
nil,
|
||||
)
|
||||
|
||||
ar, err := provider.NewPushedAuthorizeRequest(req.Context(), req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, requestedRedirectURI, ar.GetRedirectURI().String())
|
||||
require.True(t, ar.IsRedirectURIValid())
|
||||
require.NotContains(t, ar.GetClient().GetRedirectURIs(), requestedRedirectURI)
|
||||
}
|
||||
|
||||
func TestProviderRejectsUnmatchedWildcardRedirectURI(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
Name: "Test Client",
|
||||
CallbackURLs: model.UrlList{"https://*.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: "test-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
const requestedRedirectURI = "https://evil.example.net/callback"
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodGet,
|
||||
"/api/oidc/authorize?client_id=test-client&response_type=code&scope=openid&state=state-with-enough-entropy&redirect_uri="+requestedRedirectURI,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err = provider.NewAuthorizeRequest(req.Context(), req)
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// decodeJWTPart base64url-decodes the header (index 0) or claims (index 1) segment of a
|
||||
// JWT without verifying the signature, for assertions in tests.
|
||||
func decodeJWTPart(t *testing.T, token string, index int) map[string]any {
|
||||
t.Helper()
|
||||
parts := strings.Split(token, ".")
|
||||
require.Len(t, parts, 3)
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[index])
|
||||
require.NoError(t, err)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &out))
|
||||
return out
|
||||
}
|
||||
|
||||
type algTestSigner struct {
|
||||
key any
|
||||
alg jwa.KeyAlgorithm
|
||||
}
|
||||
|
||||
func (s algTestSigner) GetPrivateKey() any { return s.key }
|
||||
func (s algTestSigner) GetKeyAlg() (jwa.KeyAlgorithm, error) { return s.alg, nil }
|
||||
func (s algTestSigner) GetKeyID() (string, bool) { return "test-key-id", true }
|
||||
|
||||
// TestProviderIssuesAndValidatesTokensForSupportedAlgorithms guards against the
|
||||
// regression where fosite's DefaultSigner derived the JWT algorithm from the Go key type
|
||||
// alone: it broke EdDSA/ES384/ES512 token issuance entirely and silently downgraded
|
||||
// RS384/RS512 to RS256. The provider must both ISSUE (NewAccessResponse) and VALIDATE
|
||||
// (IntrospectToken -> Signer.Decode, the path used by introspection and userinfo) tokens
|
||||
// for every signing algorithm Pocket ID supports.
|
||||
func TestProviderIssuesAndValidatesTokensForSupportedAlgorithms(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
alg jwa.KeyAlgorithm
|
||||
gen func(t *testing.T) any
|
||||
}{
|
||||
{"RS256", jwa.RS256(), generateRSATestKey},
|
||||
{"RS384", jwa.RS384(), generateRSATestKey},
|
||||
{"RS512", jwa.RS512(), generateRSATestKey},
|
||||
{"ES256", jwa.ES256(), func(t *testing.T) any { return generateECTestKey(t, elliptic.P256()) }},
|
||||
{"ES384", jwa.ES384(), func(t *testing.T) any { return generateECTestKey(t, elliptic.P384()) }},
|
||||
{"ES512", jwa.ES512(), func(t *testing.T) any { return generateECTestKey(t, elliptic.P521()) }},
|
||||
{"EdDSA", jwa.EdDSA(), generateEd25519TestKey},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "test-client"}, Name: "Test Client"}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, algTestSigner{key: tc.gen(t), alg: tc.alg}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: "test-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
session := NewEmptySession()
|
||||
session.Subject = "test-user"
|
||||
session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
request := fosite.NewAccessRequest(session)
|
||||
request.ID = "test-request-" + tc.name
|
||||
request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: "test-client"}}}
|
||||
request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
|
||||
request.RequestedScope = fosite.Arguments{"openid"}
|
||||
request.GrantedScope = fosite.Arguments{"openid"}
|
||||
request.RequestedAudience = fosite.Arguments{"test-client"}
|
||||
request.GrantedAudience = fosite.Arguments{"test-client"}
|
||||
|
||||
response, err := provider.NewAccessResponse(t.Context(), request)
|
||||
require.NoError(t, err)
|
||||
|
||||
accessToken := response.GetAccessToken()
|
||||
require.Len(t, strings.Split(accessToken, "."), 3)
|
||||
header := decodeJWTPart(t, accessToken, 0)
|
||||
require.Equal(t, tc.alg.String(), header["alg"])
|
||||
|
||||
tokenUse, introspected, err := provider.IntrospectToken(t.Context(), accessToken, fosite.AccessToken, NewEmptySession())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fosite.AccessToken, tokenUse)
|
||||
require.Equal(t, "test-client", introspected.GetClient().GetID())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func generateRSATestKey(t *testing.T) any {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
return key
|
||||
}
|
||||
|
||||
func generateECTestKey(t *testing.T, curve elliptic.Curve) any {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(curve, rand.Reader)
|
||||
require.NoError(t, err)
|
||||
return key
|
||||
}
|
||||
|
||||
func generateEd25519TestKey(t *testing.T) any {
|
||||
t.Helper()
|
||||
_, key, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
return key
|
||||
}
|
||||
148
backend/internal/oidc/session.go
Normal file
148
backend/internal/oidc/session.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ory/fosite"
|
||||
fositeoauth2 "github.com/ory/fosite/handler/oauth2"
|
||||
"github.com/ory/fosite/handler/openid"
|
||||
fositejwt "github.com/ory/fosite/token/jwt"
|
||||
)
|
||||
|
||||
var _ openid.Session = (*Session)(nil)
|
||||
var _ fositeoauth2.JWTSessionContainer = (*Session)(nil)
|
||||
|
||||
type Session struct {
|
||||
Claims *fositejwt.IDTokenClaims `json:"id_token_claims"`
|
||||
Headers *fositejwt.Headers `json:"headers"`
|
||||
JWTClaims *fositejwt.JWTClaims `json:"jwt_claims,omitempty"`
|
||||
JWTHeader *fositejwt.Headers `json:"jwt_header,omitempty"`
|
||||
ExpiresAt map[fosite.TokenType]time.Time `json:"expires_at,omitempty"`
|
||||
Subject string `json:"subject"`
|
||||
AuthenticationMethod string `json:"authentication_method,omitempty"`
|
||||
}
|
||||
|
||||
func NewEmptySession() *Session {
|
||||
return &Session{
|
||||
Claims: &fositejwt.IDTokenClaims{
|
||||
RequestedAt: time.Now().UTC(),
|
||||
Extra: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewAuthenticatedSession(subject, authenticationMethod string, authenticationTime, requestedAt time.Time) *Session {
|
||||
now := time.Now().UTC()
|
||||
if authenticationTime.IsZero() {
|
||||
authenticationTime = now
|
||||
}
|
||||
if requestedAt.IsZero() {
|
||||
requestedAt = now
|
||||
}
|
||||
|
||||
session := NewEmptySession()
|
||||
session.Subject = subject
|
||||
session.AuthenticationMethod = authenticationMethod
|
||||
session.Claims.Subject = subject
|
||||
session.Claims.AuthTime = authenticationTime.UTC()
|
||||
session.Claims.RequestedAt = requestedAt.UTC()
|
||||
session.Claims.JTI = uuid.NewString()
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
func (s *Session) SetExpiresAt(key fosite.TokenType, exp time.Time) {
|
||||
if s.ExpiresAt == nil {
|
||||
s.ExpiresAt = make(map[fosite.TokenType]time.Time)
|
||||
}
|
||||
s.ExpiresAt[key] = exp
|
||||
}
|
||||
|
||||
func (s *Session) GetExpiresAt(key fosite.TokenType) time.Time {
|
||||
if s.ExpiresAt == nil {
|
||||
s.ExpiresAt = make(map[fosite.TokenType]time.Time)
|
||||
}
|
||||
return s.ExpiresAt[key]
|
||||
}
|
||||
|
||||
func (s *Session) GetUsername() string {
|
||||
return s.GetSubject()
|
||||
}
|
||||
|
||||
func (s *Session) GetExtraClaims() map[string]interface{} {
|
||||
if s == nil || s.Claims == nil || s.Claims.Issuer == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"iss": s.Claims.Issuer,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) GetSubject() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.Subject
|
||||
}
|
||||
|
||||
func (s *Session) Clone() fosite.Session {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var clone Session
|
||||
data, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return NewEmptySession()
|
||||
}
|
||||
if err = json.Unmarshal(data, &clone); err != nil {
|
||||
return NewEmptySession()
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (s *Session) IDTokenClaims() *fositejwt.IDTokenClaims {
|
||||
if s.Claims == nil {
|
||||
s.Claims = &fositejwt.IDTokenClaims{}
|
||||
}
|
||||
if s.Claims.Extra == nil {
|
||||
s.Claims.Extra = map[string]interface{}{}
|
||||
}
|
||||
return s.Claims
|
||||
}
|
||||
|
||||
func (s *Session) IDTokenHeaders() *fositejwt.Headers {
|
||||
if s.Headers == nil {
|
||||
s.Headers = &fositejwt.Headers{}
|
||||
}
|
||||
if s.Headers.Extra == nil {
|
||||
s.Headers.Extra = map[string]interface{}{}
|
||||
}
|
||||
return s.Headers
|
||||
}
|
||||
|
||||
func (s *Session) GetJWTClaims() fositejwt.JWTClaimsContainer {
|
||||
if s.JWTClaims == nil {
|
||||
s.JWTClaims = &fositejwt.JWTClaims{}
|
||||
}
|
||||
if s.JWTClaims.Subject == "" {
|
||||
s.JWTClaims.Subject = s.GetSubject()
|
||||
}
|
||||
if s.JWTClaims.Extra == nil {
|
||||
s.JWTClaims.Extra = map[string]interface{}{}
|
||||
}
|
||||
return s.JWTClaims
|
||||
}
|
||||
|
||||
func (s *Session) GetJWTHeader() *fositejwt.Headers {
|
||||
if s.JWTHeader == nil {
|
||||
s.JWTHeader = &fositejwt.Headers{}
|
||||
}
|
||||
if s.JWTHeader.Extra == nil {
|
||||
s.JWTHeader.Extra = map[string]interface{}{}
|
||||
}
|
||||
return s.JWTHeader
|
||||
}
|
||||
34
backend/internal/oidc/session_test.go
Normal file
34
backend/internal/oidc/session_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewAuthenticatedSession(t *testing.T) {
|
||||
authenticationTime := time.Date(2026, 6, 16, 10, 0, 0, 0, time.FixedZone("CEST", 2*60*60))
|
||||
requestedAt := time.Date(2026, 6, 16, 9, 59, 0, 0, time.FixedZone("CEST", 2*60*60))
|
||||
|
||||
session := NewAuthenticatedSession("user-id", "passkey", authenticationTime, requestedAt)
|
||||
|
||||
require.Equal(t, "user-id", session.Subject)
|
||||
require.Equal(t, "user-id", session.Claims.Subject)
|
||||
require.Equal(t, "passkey", session.AuthenticationMethod)
|
||||
require.Equal(t, authenticationTime.UTC(), session.Claims.AuthTime)
|
||||
require.Equal(t, requestedAt.UTC(), session.Claims.RequestedAt)
|
||||
require.NotNil(t, session.Claims.Extra)
|
||||
}
|
||||
|
||||
func TestNewAuthenticatedSessionDefaultsTimes(t *testing.T) {
|
||||
before := time.Now().UTC()
|
||||
session := NewAuthenticatedSession("user-id", "passkey", time.Time{}, time.Time{})
|
||||
after := time.Now().UTC()
|
||||
|
||||
require.False(t, session.Claims.AuthTime.IsZero())
|
||||
require.False(t, session.Claims.RequestedAt.IsZero())
|
||||
require.False(t, session.Claims.AuthTime.Before(before))
|
||||
require.False(t, session.Claims.AuthTime.After(after))
|
||||
require.Equal(t, session.Claims.AuthTime, session.Claims.RequestedAt)
|
||||
}
|
||||
71
backend/internal/oidc/signer.go
Normal file
71
backend/internal/oidc/signer.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
fositejwt "github.com/ory/fosite/token/jwt"
|
||||
)
|
||||
|
||||
// SigningKeyFromSigner wraps the raw signing key in a *jose.JSONWebKey that carries the
|
||||
// key's algorithm.
|
||||
//
|
||||
// fosite's DefaultSigner picks the JOSE algorithm from the Go key TYPE alone: every
|
||||
// *rsa.PrivateKey is signed with RS256 and every *ecdsa.PrivateKey with ES256, while an
|
||||
// ed25519.PrivateKey is not supported at all.
|
||||
func SigningKeyFromSigner(signer TokenSigner) (*jose.JSONWebKey, error) {
|
||||
rawKey := signer.GetPrivateKey()
|
||||
if rawKey == nil {
|
||||
return nil, errors.New("signing key is not available")
|
||||
}
|
||||
|
||||
alg, err := signer.GetKeyAlg()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signingKey := &jose.JSONWebKey{
|
||||
Key: rawKey,
|
||||
Algorithm: alg.String(),
|
||||
}
|
||||
|
||||
if keyID, ok := signer.GetKeyID(); ok {
|
||||
signingKey.KeyID = keyID
|
||||
}
|
||||
|
||||
return signingKey, nil
|
||||
}
|
||||
|
||||
type jwtSigner struct {
|
||||
*fositejwt.DefaultSigner
|
||||
}
|
||||
|
||||
func newJWTSigner(keyGetter fositejwt.GetPrivateKeyFunc) *jwtSigner {
|
||||
return &jwtSigner{
|
||||
DefaultSigner: &fositejwt.DefaultSigner{GetPrivateKey: keyGetter},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *jwtSigner) Validate(ctx context.Context, token string) (string, error) {
|
||||
if _, err := s.Decode(ctx, token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.GetSignature(ctx, token)
|
||||
}
|
||||
|
||||
func (s *jwtSigner) Decode(ctx context.Context, token string) (*fositejwt.Token, error) {
|
||||
key, err := s.GetPrivateKey(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
verificationKey := key
|
||||
if jsonWebKey, ok := key.(*jose.JSONWebKey); ok {
|
||||
verificationKey = new(jsonWebKey.Public())
|
||||
}
|
||||
|
||||
return fositejwt.Parse(token, func(*fositejwt.Token) (any, error) {
|
||||
return verificationKey, nil
|
||||
})
|
||||
}
|
||||
818
backend/internal/oidc/store.go
Normal file
818
backend/internal/oidc/store.go
Normal file
@@ -0,0 +1,818 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
fositeoauth2 "github.com/ory/fosite/handler/oauth2"
|
||||
"github.com/ory/fosite/handler/openid"
|
||||
"github.com/ory/fosite/handler/pkce"
|
||||
"github.com/ory/fosite/handler/rfc8628"
|
||||
fositestorage "github.com/ory/fosite/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionKindAuthorizeCode = "authorize_code"
|
||||
sessionKindAccessToken = "access_token"
|
||||
sessionKindRefreshToken = "refresh_token"
|
||||
sessionKindPKCE = "pkce"
|
||||
sessionKindOpenID = "openid"
|
||||
sessionKindPAR = "par"
|
||||
sessionKindDeviceCode = "device_code"
|
||||
sessionKindUserCode = "user_code"
|
||||
)
|
||||
|
||||
var (
|
||||
_ fosite.Storage = (*Store)(nil)
|
||||
_ fosite.PARStorage = (*Store)(nil)
|
||||
_ fositeoauth2.CoreStorage = (*Store)(nil)
|
||||
_ fositeoauth2.TokenRevocationStorage = (*Store)(nil)
|
||||
_ rfc8628.RFC8628CoreStorage = (*Store)(nil)
|
||||
_ openid.OpenIDConnectRequestStorage = (*Store)(nil)
|
||||
_ pkce.PKCERequestStorage = (*Store)(nil)
|
||||
_ fositestorage.Transactional = (*Store)(nil)
|
||||
)
|
||||
|
||||
// NewStore creates the fosite storage. Exported for packages that need to seed or
|
||||
// revoke sessions (e.g. the e2e test service).
|
||||
func NewStore(db *gorm.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type storedRequester struct {
|
||||
Authorize bool `json:"authorize,omitempty"`
|
||||
|
||||
ID string `json:"id"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
ClientID string `json:"client_id"`
|
||||
RequestedScope fosite.Arguments `json:"requested_scope,omitempty"`
|
||||
GrantedScope fosite.Arguments `json:"granted_scope,omitempty"`
|
||||
Form url.Values `json:"form,omitempty"`
|
||||
Session *Session `json:"session,omitempty"`
|
||||
RequestedAudience fosite.Arguments `json:"requested_audience,omitempty"`
|
||||
GrantedAudience fosite.Arguments `json:"granted_audience,omitempty"`
|
||||
Device bool `json:"device,omitempty"`
|
||||
UserCodeState fosite.UserCodeState `json:"user_code_state,omitempty"`
|
||||
|
||||
ResponseTypes fosite.Arguments `json:"response_types,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
HandledResponseTypes fosite.Arguments `json:"handled_response_types,omitempty"`
|
||||
ResponseMode fosite.ResponseModeType `json:"response_mode,omitempty"`
|
||||
DefaultResponseMode fosite.ResponseModeType `json:"default_response_mode,omitempty"`
|
||||
}
|
||||
|
||||
// Satisfies fosite.Storage
|
||||
|
||||
func (s *Store) GetClient(ctx context.Context, id string) (fosite.Client, error) {
|
||||
var client model.OidcClient
|
||||
err := s.dbFor(ctx).
|
||||
Preload("AllowedUserGroups").
|
||||
First(&client, "id = ?", id).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fosite.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return Client{OidcClient: client}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ClientAssertionJWTValid(ctx context.Context, jti string) error {
|
||||
var count int64
|
||||
err := s.dbFor(ctx).
|
||||
Model(&clientAssertionJTI{}).
|
||||
Where("jti = ? AND expires_at > ?", jti, datatype.DateTime(time.Now())).
|
||||
Count(&count).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return fosite.ErrJTIKnown
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error {
|
||||
err := s.dbFor(ctx).Create(&clientAssertionJTI{
|
||||
JTI: jti,
|
||||
ExpiresAt: datatype.DateTime(exp),
|
||||
}).Error
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return fosite.ErrJTIKnown
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Satisfies fositeoauth2.CoreStorage
|
||||
|
||||
func (s *Store) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) error {
|
||||
return s.upsertSession(ctx, sessionKindAuthorizeCode, code, request, "", true, fosite.AuthorizeCode)
|
||||
}
|
||||
|
||||
func (s *Store) GetAuthorizeCodeSession(ctx context.Context, code string, _ fosite.Session) (fosite.Requester, error) {
|
||||
request, active, err := s.getRequesterSession(ctx, sessionKindAuthorizeCode, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !active {
|
||||
return request, fosite.ErrInvalidatedAuthorizeCode
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Store) InvalidateAuthorizeCodeSession(ctx context.Context, code string) error {
|
||||
return s.deactivateSession(ctx, sessionKindAuthorizeCode, code)
|
||||
}
|
||||
|
||||
func (s *Store) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) error {
|
||||
return s.upsertSession(ctx, sessionKindAccessToken, signature, request, "", true, fosite.AccessToken)
|
||||
}
|
||||
|
||||
func (s *Store) GetAccessTokenSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) {
|
||||
request, _, err := s.getRequesterSession(ctx, sessionKindAccessToken, signature)
|
||||
return request, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAccessTokenSession(ctx context.Context, signature string) error {
|
||||
return s.deleteSession(ctx, sessionKindAccessToken, signature)
|
||||
}
|
||||
|
||||
func (s *Store) CreateRefreshTokenSession(ctx context.Context, signature string, accessSignature string, request fosite.Requester) error {
|
||||
return s.upsertSession(ctx, sessionKindRefreshToken, signature, request, accessSignature, true, fosite.RefreshToken)
|
||||
}
|
||||
|
||||
func (s *Store) GetRefreshTokenSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) {
|
||||
request, active, err := s.getRequesterSession(ctx, sessionKindRefreshToken, signature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !active {
|
||||
return request, fosite.ErrInactiveToken
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteRefreshTokenSession(ctx context.Context, signature string) error {
|
||||
return s.deleteSession(ctx, sessionKindRefreshToken, signature)
|
||||
}
|
||||
|
||||
func (s *Store) RotateRefreshToken(ctx context.Context, requestID string, refreshTokenSignature string) error {
|
||||
if err := s.deactivateSession(ctx, sessionKindRefreshToken, refreshTokenSignature); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.RevokeAccessToken(ctx, requestID)
|
||||
}
|
||||
|
||||
// Satisfies fositeoauth2.TokenRevocationStorage
|
||||
|
||||
func (s *Store) RevokeRefreshToken(ctx context.Context, requestID string) error {
|
||||
return s.dbFor(ctx).
|
||||
Model(&OAuth2Session{}).
|
||||
Where("kind = ? AND request_id = ?", sessionKindRefreshToken, requestID).
|
||||
Update("active", false).
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *Store) RevokeAccessToken(ctx context.Context, requestID string) error {
|
||||
return s.dbFor(ctx).
|
||||
Where("kind = ? AND request_id = ?", sessionKindAccessToken, requestID).
|
||||
Delete(&OAuth2Session{}).
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *Store) RevokeSessionsByIDTokenHint(ctx context.Context, userID, clientID, idTokenJTI string) error {
|
||||
_, jtiMatches, err := s.findUserClientRequestIDs(ctx, userID, clientID, idTokenJTI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.revokeRequestIDs(ctx, jtiMatches)
|
||||
}
|
||||
|
||||
func RevokeUserClientSessions(ctx context.Context, db *gorm.DB, userID, clientID string) error {
|
||||
s := NewStore(db)
|
||||
requestIDs, _, err := s.findUserClientRequestIDs(ctx, userID, clientID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.revokeRequestIDs(ctx, requestIDs)
|
||||
}
|
||||
|
||||
func (s *Store) findUserClientRequestIDs(ctx context.Context, userID, clientID, idTokenJTI string) (candidates []string, jtiMatches []string, err error) {
|
||||
var sessions []OAuth2Session
|
||||
err = s.dbFor(ctx).
|
||||
Where("kind = ? AND active = ?", sessionKindRefreshToken, true).
|
||||
Find(&sessions).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
candidateRequestIDs := map[string]struct{}{}
|
||||
matchingRequestIDs := map[string]struct{}{}
|
||||
for _, session := range sessions {
|
||||
requester, err := s.decodeRequester(ctx, session.RequestData)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
requestSession := requester.GetSession()
|
||||
if requestSession == nil || requester.GetClient().GetID() != clientID || requestSession.GetSubject() != userID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add all sessions that match the user and client
|
||||
candidateRequestIDs[session.RequestID] = struct{}{}
|
||||
// Only add sessions that also match the ID token hint JTI
|
||||
if storedSession, ok := requestSession.(*Session); ok && idTokenJTI != "" && storedSession.IDTokenClaims().JTI == idTokenJTI {
|
||||
matchingRequestIDs[session.RequestID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return mapKeys(candidateRequestIDs), mapKeys(matchingRequestIDs), nil
|
||||
}
|
||||
|
||||
func (s *Store) revokeRequestIDs(ctx context.Context, requestIDs []string) error {
|
||||
if len(requestIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.dbFor(ctx).
|
||||
Model(&OAuth2Session{}).
|
||||
Where("kind = ? AND request_id IN ?", sessionKindRefreshToken, requestIDs).
|
||||
Update("active", false).
|
||||
Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.dbFor(ctx).
|
||||
Where("kind = ? AND request_id IN ?", sessionKindAccessToken, requestIDs).
|
||||
Delete(&OAuth2Session{}).
|
||||
Error
|
||||
}
|
||||
|
||||
func mapKeys(m map[string]struct{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Satisfies pkce.PKCERequestStorage
|
||||
|
||||
func (s *Store) CreatePKCERequestSession(ctx context.Context, signature string, requester fosite.Requester) error {
|
||||
// PKCE sessions share the authorize code lifespan so abandoned ones expire and get cleaned up
|
||||
return s.upsertSession(ctx, sessionKindPKCE, signature, requester, "", true, fosite.AuthorizeCode)
|
||||
}
|
||||
|
||||
func (s *Store) GetPKCERequestSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) {
|
||||
request, _, err := s.getRequesterSession(ctx, sessionKindPKCE, signature)
|
||||
return request, err
|
||||
}
|
||||
|
||||
func (s *Store) DeletePKCERequestSession(ctx context.Context, signature string) error {
|
||||
return s.deleteSession(ctx, sessionKindPKCE, signature)
|
||||
}
|
||||
|
||||
// Satisfies openid.OpenIDConnectRequestStorage
|
||||
|
||||
func (s *Store) CreateOpenIDConnectSession(ctx context.Context, authorizeCode string, requester fosite.Requester) error {
|
||||
return s.upsertSession(ctx, sessionKindOpenID, authorizeCode, requester, "", true, fosite.AuthorizeCode)
|
||||
}
|
||||
|
||||
func (s *Store) GetOpenIDConnectSession(ctx context.Context, authorizeCode string, _ fosite.Requester) (fosite.Requester, error) {
|
||||
request, _, err := s.getRequesterSession(ctx, sessionKindOpenID, authorizeCode)
|
||||
if errors.Is(err, fosite.ErrNotFound) {
|
||||
return nil, openid.ErrNoSessionFound
|
||||
}
|
||||
return request, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOpenIDConnectSession(ctx context.Context, authorizeCode string) error {
|
||||
return s.deleteSession(ctx, sessionKindOpenID, authorizeCode)
|
||||
}
|
||||
|
||||
// Satisfies fosite.PARStorage
|
||||
|
||||
func (s *Store) CreatePARSession(ctx context.Context, requestURI string, request fosite.AuthorizeRequester) error {
|
||||
return s.upsertAuthorizeSession(ctx, sessionKindPAR, requestURI, request, true, fosite.PushedAuthorizeRequestContext)
|
||||
}
|
||||
|
||||
func (s *Store) GetPARSession(ctx context.Context, requestURI string) (fosite.AuthorizeRequester, error) {
|
||||
session, err := s.getSession(ctx, sessionKindPAR, requestURI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !session.Active || session.ExpiresAt == nil || session.ExpiresAt.ToTime().Before(time.Now()) {
|
||||
return nil, fosite.ErrNotFound
|
||||
}
|
||||
|
||||
result := s.dbFor(ctx).
|
||||
Model(&OAuth2Session{}).
|
||||
Where("kind = ? AND key = ? AND active = ?", sessionKindPAR, requestURI, true).
|
||||
Update("active", false)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, fosite.ErrNotFound
|
||||
}
|
||||
|
||||
return s.decodeAuthorizeRequester(ctx, session.RequestData)
|
||||
}
|
||||
|
||||
func (s *Store) DeletePARSession(ctx context.Context, requestURI string) error {
|
||||
return s.deleteSession(ctx, sessionKindPAR, requestURI)
|
||||
}
|
||||
|
||||
// Satisfies rfc8628.RFC8628CoreStorage
|
||||
|
||||
func (s *Store) CreateDeviceAuthSession(ctx context.Context, deviceCodeSignature, userCodeSignature string, request fosite.DeviceRequester) error {
|
||||
requestData, err := s.encodeDeviceRequester(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.getSession(ctx, sessionKindUserCode, userCodeSignature); err == nil {
|
||||
return fosite.ErrExistingUserCodeSignature
|
||||
} else if !errors.Is(err, fosite.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
expDeviceCode := expiresAt(request.GetSession(), fosite.DeviceCode)
|
||||
expUserCode := expiresAt(request.GetSession(), fosite.UserCode)
|
||||
if err := s.storeSession(ctx, sessionKindDeviceCode, deviceCodeSignature, request.GetID(), "", true, requestData, expDeviceCode); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.storeSession(ctx, sessionKindUserCode, userCodeSignature, request.GetID(), "", true, requestData, expUserCode)
|
||||
}
|
||||
|
||||
func (s *Store) GetDeviceCodeSession(ctx context.Context, signature string, _ fosite.Session) (fosite.DeviceRequester, error) {
|
||||
request, active, err := s.getDeviceRequesterSession(ctx, sessionKindDeviceCode, signature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !active {
|
||||
return request, fosite.ErrInvalidatedDeviceCode
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Store) InvalidateDeviceCodeSession(ctx context.Context, signature string) error {
|
||||
session, err := s.getSession(ctx, sessionKindDeviceCode, signature)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only flip rows that are still active so two concurrent token requests for the same
|
||||
// device code cannot both pass the single-use check and each mint a token set.
|
||||
result := s.dbFor(ctx).
|
||||
Model(&OAuth2Session{}).
|
||||
Where("kind IN ? AND request_id = ? AND active = ?", []string{sessionKindDeviceCode, sessionKindUserCode}, session.RequestID, true).
|
||||
Update("active", false)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fosite.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetDeviceCodeSessionByUserCodeSignature(ctx context.Context, signature string) (fosite.DeviceRequester, error) {
|
||||
request, active, err := s.getDeviceRequesterSession(ctx, sessionKindUserCode, signature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !active {
|
||||
return nil, fosite.ErrNotFound
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Store) AcceptDeviceCodeSessionByUserCodeSignature(ctx context.Context, signature string, request fosite.DeviceRequester) (string, error) {
|
||||
userCodeSession, err := s.getSession(ctx, sessionKindUserCode, signature)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.SetUserCodeState(fosite.UserCodeAccepted)
|
||||
|
||||
requestData, err := s.encodeDeviceRequester(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
deviceCodeSession, err := s.getSessionByRequestID(ctx, sessionKindDeviceCode, userCodeSession.RequestID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = s.dbFor(ctx).
|
||||
Model(&OAuth2Session{}).
|
||||
Where("kind IN ? AND request_id = ?", []string{sessionKindDeviceCode, sessionKindUserCode}, userCodeSession.RequestID).
|
||||
Updates(map[string]any{
|
||||
"request_data": requestData,
|
||||
}).
|
||||
Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return deviceCodeSession.Key, nil
|
||||
}
|
||||
|
||||
// Satisfies fositestorage.Transactional
|
||||
|
||||
func (s *Store) BeginTX(ctx context.Context) (context.Context, error) {
|
||||
tx := s.db.WithContext(ctx).Begin()
|
||||
if tx.Error != nil {
|
||||
return ctx, tx.Error
|
||||
}
|
||||
return contextWithTx(ctx, tx), nil
|
||||
}
|
||||
|
||||
func (s *Store) Commit(ctx context.Context) error {
|
||||
tx, ok := ctx.Value(txContextKey{}).(*gorm.DB)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func (s *Store) Rollback(ctx context.Context) error {
|
||||
tx, ok := ctx.Value(txContextKey{}).(*gorm.DB)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return tx.Rollback().Error
|
||||
}
|
||||
|
||||
func (s *Store) upsertSession(ctx context.Context, kind string, key string, requester fosite.Requester, accessTokenSignature string, active bool, expiresAtKey fosite.TokenType) error {
|
||||
requestData, err := s.encodeRequester(requester)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.storeSession(ctx, kind, key, requester.GetID(), accessTokenSignature, active, requestData, expiresAt(requester.GetSession(), expiresAtKey))
|
||||
}
|
||||
|
||||
func (s *Store) upsertAuthorizeSession(ctx context.Context, kind string, key string, requester fosite.AuthorizeRequester, active bool, expiresAtKey fosite.TokenType) error {
|
||||
requestData, err := s.encodeAuthorizeRequester(requester)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.storeSession(ctx, kind, key, requester.GetID(), "", active, requestData, expiresAt(requester.GetSession(), expiresAtKey))
|
||||
}
|
||||
|
||||
func (s *Store) storeSession(ctx context.Context, kind string, key string, requestID string, accessTokenSignature string, active bool, requestData string, exp *datatype.DateTime) error {
|
||||
session := OAuth2Session{
|
||||
Kind: kind,
|
||||
Key: key,
|
||||
RequestID: requestID,
|
||||
AccessTokenSignature: accessTokenSignature,
|
||||
Active: active,
|
||||
RequestData: requestData,
|
||||
ExpiresAt: exp,
|
||||
}
|
||||
|
||||
return s.dbFor(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "kind"}, {Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"request_id",
|
||||
"access_token_signature",
|
||||
"active",
|
||||
"request_data",
|
||||
"expires_at",
|
||||
}),
|
||||
}).
|
||||
Create(&session).
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *Store) getRequesterSession(ctx context.Context, kind string, key string) (fosite.Requester, bool, error) {
|
||||
session, err := s.getSession(ctx, kind, key)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
requester, err := s.decodeRequester(ctx, session.RequestData)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return requester, session.Active, nil
|
||||
}
|
||||
|
||||
func (s *Store) getDeviceRequesterSession(ctx context.Context, kind string, key string) (fosite.DeviceRequester, bool, error) {
|
||||
session, err := s.getSession(ctx, kind, key)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
requester, err := s.decodeDeviceRequester(ctx, session.RequestData)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return requester, session.Active, nil
|
||||
}
|
||||
|
||||
func (s *Store) getSession(ctx context.Context, kind string, key string) (session OAuth2Session, err error) {
|
||||
err = s.dbFor(ctx).
|
||||
Where("kind = ? AND key = ?", kind, key).
|
||||
First(&session).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return session, fosite.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return session, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *Store) getSessionByRequestID(ctx context.Context, kind string, requestID string) (session OAuth2Session, err error) {
|
||||
err = s.dbFor(ctx).
|
||||
Where("kind = ? AND request_id = ?", kind, requestID).
|
||||
First(&session).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return session, fosite.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return session, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *Store) deleteSession(ctx context.Context, kind string, key string) error {
|
||||
return s.dbFor(ctx).
|
||||
Where("kind = ? AND key = ?", kind, key).
|
||||
Delete(&OAuth2Session{}).
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *Store) deactivateSession(ctx context.Context, kind string, key string) error {
|
||||
result := s.dbFor(ctx).
|
||||
Model(&OAuth2Session{}).
|
||||
Where("kind = ? AND key = ? AND active = ?", kind, key, true).
|
||||
Update("active", false)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fosite.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) encodeRequester(requester fosite.Requester) (string, error) {
|
||||
stored, err := s.storedRequesterFromRequester(requester)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (s *Store) encodeAuthorizeRequester(requester fosite.AuthorizeRequester) (string, error) {
|
||||
stored, err := s.storedRequesterFromRequester(requester)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
stored.Authorize = true
|
||||
stored.ResponseTypes = cloneArguments(requester.GetResponseTypes())
|
||||
if redirectURI := requester.GetRedirectURI(); redirectURI != nil {
|
||||
stored.RedirectURI = redirectURI.String()
|
||||
}
|
||||
stored.State = requester.GetState()
|
||||
stored.ResponseMode = requester.GetResponseMode()
|
||||
stored.DefaultResponseMode = requester.GetDefaultResponseMode()
|
||||
|
||||
if ar, ok := requester.(*fosite.AuthorizeRequest); ok {
|
||||
stored.HandledResponseTypes = cloneArguments(ar.HandledResponseTypes)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (s *Store) encodeDeviceRequester(requester fosite.DeviceRequester) (string, error) {
|
||||
stored, err := s.storedRequesterFromRequester(requester)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
stored.Device = true
|
||||
stored.UserCodeState = requester.GetUserCodeState()
|
||||
|
||||
data, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (s *Store) storedRequesterFromRequester(requester fosite.Requester) (storedRequester, error) {
|
||||
if requester == nil {
|
||||
return storedRequester{}, fosite.ErrServerError.WithHint("requester must not be nil")
|
||||
}
|
||||
|
||||
return storedRequester{
|
||||
ID: requester.GetID(),
|
||||
RequestedAt: requester.GetRequestedAt(),
|
||||
ClientID: requester.GetClient().GetID(),
|
||||
RequestedScope: cloneArguments(requester.GetRequestedScopes()),
|
||||
GrantedScope: cloneArguments(requester.GetGrantedScopes()),
|
||||
Form: sanitizeStoredForm(requester.GetRequestForm()),
|
||||
Session: cloneSession(requester.GetSession()),
|
||||
RequestedAudience: cloneArguments(requester.GetRequestedAudience()),
|
||||
GrantedAudience: cloneArguments(requester.GetGrantedAudience()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) decodeRequester(ctx context.Context, data string) (fosite.Requester, error) {
|
||||
var stored storedRequester
|
||||
if err := json.Unmarshal([]byte(data), &stored); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stored.Authorize {
|
||||
return s.requesterFromStoredAuthorize(ctx, stored)
|
||||
}
|
||||
return s.requesterFromStored(ctx, stored)
|
||||
}
|
||||
|
||||
func (s *Store) decodeAuthorizeRequester(ctx context.Context, data string) (fosite.AuthorizeRequester, error) {
|
||||
var stored storedRequester
|
||||
if err := json.Unmarshal([]byte(data), &stored); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored.Authorize = true
|
||||
return s.requesterFromStoredAuthorize(ctx, stored)
|
||||
}
|
||||
|
||||
func (s *Store) decodeDeviceRequester(ctx context.Context, data string) (fosite.DeviceRequester, error) {
|
||||
var stored storedRequester
|
||||
if err := json.Unmarshal([]byte(data), &stored); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored.Device = true
|
||||
return s.requesterFromStoredDevice(ctx, stored)
|
||||
}
|
||||
|
||||
func (s *Store) requesterFromStored(ctx context.Context, stored storedRequester) (fosite.Requester, error) {
|
||||
client, err := s.GetClient(ctx, stored.ClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request := fosite.NewRequest()
|
||||
request.ID = stored.ID
|
||||
request.RequestedAt = stored.RequestedAt
|
||||
request.Client = client
|
||||
request.RequestedScope = cloneArguments(stored.RequestedScope)
|
||||
request.GrantedScope = cloneArguments(stored.GrantedScope)
|
||||
request.Form = cloneValues(stored.Form)
|
||||
request.Session = stored.Session
|
||||
request.RequestedAudience = cloneArguments(stored.RequestedAudience)
|
||||
request.GrantedAudience = cloneArguments(stored.GrantedAudience)
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Store) requesterFromStoredDevice(ctx context.Context, stored storedRequester) (fosite.DeviceRequester, error) {
|
||||
requester, err := s.requesterFromStored(ctx, stored)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
base := requester.(*fosite.Request)
|
||||
request := fosite.NewDeviceRequest()
|
||||
request.Request = *base
|
||||
request.UserCodeState = stored.UserCodeState
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Store) requesterFromStoredAuthorize(ctx context.Context, stored storedRequester) (fosite.AuthorizeRequester, error) {
|
||||
requester, err := s.requesterFromStored(ctx, stored)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
base := requester.(*fosite.Request)
|
||||
request := fosite.NewAuthorizeRequest()
|
||||
request.Request = *base
|
||||
request.ResponseTypes = cloneArguments(stored.ResponseTypes)
|
||||
request.State = stored.State
|
||||
request.HandledResponseTypes = cloneArguments(stored.HandledResponseTypes)
|
||||
request.ResponseMode = stored.ResponseMode
|
||||
request.DefaultResponseMode = stored.DefaultResponseMode
|
||||
|
||||
if stored.RedirectURI != "" {
|
||||
redirectURI, err := url.Parse(stored.RedirectURI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.RedirectURI = redirectURI
|
||||
}
|
||||
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func cloneSession(session fosite.Session) *Session {
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if s, ok := session.(*Session); ok {
|
||||
cloned := s.Clone()
|
||||
if typed, ok := cloned.(*Session); ok {
|
||||
return typed
|
||||
}
|
||||
}
|
||||
|
||||
cloned := NewEmptySession()
|
||||
cloned.Subject = session.GetSubject()
|
||||
for _, tokenType := range []fosite.TokenType{
|
||||
fosite.AccessToken,
|
||||
fosite.RefreshToken,
|
||||
fosite.AuthorizeCode,
|
||||
fosite.IDToken,
|
||||
fosite.PushedAuthorizeRequestContext,
|
||||
fosite.DeviceCode,
|
||||
fosite.UserCode,
|
||||
} {
|
||||
if exp := session.GetExpiresAt(tokenType); !exp.IsZero() {
|
||||
cloned.SetExpiresAt(tokenType, exp)
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneArguments(arguments fosite.Arguments) fosite.Arguments {
|
||||
if len(arguments) == 0 {
|
||||
return fosite.Arguments{}
|
||||
}
|
||||
cloned := make(fosite.Arguments, len(arguments))
|
||||
copy(cloned, arguments)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func sanitizeStoredForm(values url.Values) url.Values {
|
||||
cloned := cloneValues(values)
|
||||
cloned.Del("client_secret")
|
||||
cloned.Del("client_assertion")
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneValues(values url.Values) url.Values {
|
||||
if len(values) == 0 {
|
||||
return url.Values{}
|
||||
}
|
||||
cloned := make(url.Values, len(values))
|
||||
for key, value := range values {
|
||||
cloned[key] = append([]string(nil), value...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func expiresAt(session fosite.Session, tokenType fosite.TokenType) *datatype.DateTime {
|
||||
if session == nil || tokenType == "" {
|
||||
return nil
|
||||
}
|
||||
exp := session.GetExpiresAt(tokenType)
|
||||
if exp.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return new(datatype.DateTime(exp))
|
||||
}
|
||||
|
||||
func (s *Store) dbFor(ctx context.Context) *gorm.DB {
|
||||
return dbFromContext(ctx, s.db)
|
||||
}
|
||||
219
backend/internal/oidc/store_test.go
Normal file
219
backend/internal/oidc/store_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
fositejwt "github.com/ory/fosite/token/jwt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
// TestStoreGetPARSessionIsSingleUse verifies that a pushed-authorization request_uri can be
|
||||
// consumed only once (RFC 9126 §4). The consumption (read + invalidate) must be atomic so
|
||||
// two concurrent /authorize requests cannot both resolve the same request_uri; the store
|
||||
// enforces this with a conditional UPDATE, so the second GetPARSession returns ErrNotFound.
|
||||
func TestStoreGetPARSessionIsSingleUse(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
store := NewStore(db)
|
||||
|
||||
const (
|
||||
clientID = "par-client"
|
||||
requestURI = "urn:ietf:params:oauth:request_uri:single-use-test"
|
||||
)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "PAR Client"}).Error)
|
||||
|
||||
session := NewEmptySession()
|
||||
session.SetExpiresAt(fosite.PushedAuthorizeRequestContext, time.Now().UTC().Add(time.Minute))
|
||||
redirectURI, err := url.Parse("https://rp.example.com/callback")
|
||||
require.NoError(t, err)
|
||||
|
||||
request := &fosite.AuthorizeRequest{
|
||||
Request: fosite.Request{
|
||||
ID: "par-request",
|
||||
RequestedAt: time.Now().UTC(),
|
||||
Client: Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}},
|
||||
RequestedScope: fosite.Arguments{"openid"},
|
||||
Form: url.Values{},
|
||||
Session: session,
|
||||
},
|
||||
RedirectURI: redirectURI,
|
||||
ResponseTypes: fosite.Arguments{"code"},
|
||||
State: "state-value",
|
||||
}
|
||||
require.NoError(t, store.CreatePARSession(t.Context(), requestURI, request))
|
||||
|
||||
// First consumption resolves the stored request.
|
||||
first, err := store.GetPARSession(t.Context(), requestURI)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first)
|
||||
require.Equal(t, clientID, first.GetClient().GetID())
|
||||
|
||||
// A second consumption is rejected: the request_uri is single-use.
|
||||
_, err = store.GetPARSession(t.Context(), requestURI)
|
||||
require.ErrorIs(t, err, fosite.ErrNotFound)
|
||||
}
|
||||
|
||||
// TestStoreInvalidateAuthorizeCodeSessionIsAtomic verifies that an authorization code can
|
||||
// be invalidated only once (RFC 6749 §4.1.2). Invalidation is a conditional UPDATE guarded
|
||||
// on active = true, so a second concurrent token request that already read the code as
|
||||
// active fails closed (ErrNotFound) instead of minting a second token set from one code.
|
||||
func TestStoreInvalidateAuthorizeCodeSessionIsAtomic(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
store := NewStore(db)
|
||||
|
||||
const (
|
||||
clientID = "code-client"
|
||||
code = "auth-code-single-use"
|
||||
)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Code Client"}).Error)
|
||||
|
||||
session := NewEmptySession()
|
||||
session.SetExpiresAt(fosite.AuthorizeCode, time.Now().UTC().Add(time.Minute))
|
||||
request := &fosite.Request{
|
||||
ID: "code-request",
|
||||
RequestedAt: time.Now().UTC(),
|
||||
Client: Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}},
|
||||
RequestedScope: fosite.Arguments{"openid"},
|
||||
Form: url.Values{},
|
||||
Session: session,
|
||||
}
|
||||
require.NoError(t, store.CreateAuthorizeCodeSession(t.Context(), code, request))
|
||||
|
||||
// First invalidation wins.
|
||||
require.NoError(t, store.InvalidateAuthorizeCodeSession(t.Context(), code))
|
||||
|
||||
// A second invalidation of the now-inactive code fails closed: a racing token request
|
||||
// cannot proceed to issue a second set of tokens from the same code.
|
||||
require.ErrorIs(t, store.InvalidateAuthorizeCodeSession(t.Context(), code), fosite.ErrNotFound)
|
||||
|
||||
// Reads of the consumed code report it as invalidated so fosite triggers reuse handling.
|
||||
_, err := store.GetAuthorizeCodeSession(t.Context(), code, nil)
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidatedAuthorizeCode)
|
||||
}
|
||||
|
||||
func TestStoreRevokeSessionsByIDTokenHintRevokesMatchingFositeSessions(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
store := NewStore(db)
|
||||
|
||||
const (
|
||||
userID = "test-user-123"
|
||||
clientID = "test-client-456"
|
||||
otherClientID = "other-client-789"
|
||||
idTokenJTI = "matching-id-token-jti"
|
||||
otherIDTokenJTI = "other-id-token-jti"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: otherClientID},
|
||||
Name: "Other Client",
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "matching-refresh", "matching-access", newTestRequester("matching-request", clientID, userID, idTokenJTI)))
|
||||
require.NoError(t, store.CreateAccessTokenSession(t.Context(), "matching-access", newTestRequester("matching-request", clientID, userID, idTokenJTI)))
|
||||
require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "same-client-different-session", "same-client-different-access", newTestRequester("same-client-request", clientID, userID, otherIDTokenJTI)))
|
||||
require.NoError(t, store.CreateAccessTokenSession(t.Context(), "same-client-different-access", newTestRequester("same-client-request", clientID, userID, otherIDTokenJTI)))
|
||||
require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "other-client-same-jti", "other-client-access", newTestRequester("other-client-request", otherClientID, userID, idTokenJTI)))
|
||||
require.NoError(t, store.CreateAccessTokenSession(t.Context(), "other-client-access", newTestRequester("other-client-request", otherClientID, userID, idTokenJTI)))
|
||||
|
||||
require.NoError(t, store.RevokeSessionsByIDTokenHint(t.Context(), userID, clientID, idTokenJTI))
|
||||
|
||||
var sessions []OAuth2Session
|
||||
require.NoError(t, db.Order("key").Find(&sessions).Error)
|
||||
|
||||
activeRefreshByKey := map[string]bool{}
|
||||
accessKeys := map[string]bool{}
|
||||
for _, session := range sessions {
|
||||
switch session.Kind {
|
||||
case sessionKindRefreshToken:
|
||||
activeRefreshByKey[session.Key] = session.Active
|
||||
case sessionKindAccessToken:
|
||||
accessKeys[session.Key] = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.False(t, activeRefreshByKey["matching-refresh"])
|
||||
assert.True(t, activeRefreshByKey["same-client-different-session"])
|
||||
assert.True(t, activeRefreshByKey["other-client-same-jti"])
|
||||
assert.False(t, accessKeys["matching-access"])
|
||||
assert.True(t, accessKeys["same-client-different-access"])
|
||||
assert.True(t, accessKeys["other-client-access"])
|
||||
}
|
||||
|
||||
func TestStoreRevokeSessionsByIDTokenHintSkipsSessionsWithoutMatchingJTI(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
store := NewStore(db)
|
||||
|
||||
const (
|
||||
userID = "test-user-123"
|
||||
clientID = "test-client-456"
|
||||
otherClientID = "other-client-789"
|
||||
)
|
||||
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: otherClientID},
|
||||
Name: "Other Client",
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "same-client-refresh", "same-client-access", newTestRequester("same-client-request", clientID, userID, "")))
|
||||
require.NoError(t, store.CreateAccessTokenSession(t.Context(), "same-client-access", newTestRequester("same-client-request", clientID, userID, "")))
|
||||
require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "other-client-refresh", "other-client-access", newTestRequester("other-client-request", otherClientID, userID, "")))
|
||||
require.NoError(t, store.CreateAccessTokenSession(t.Context(), "other-client-access", newTestRequester("other-client-request", otherClientID, userID, "")))
|
||||
|
||||
require.NoError(t, store.RevokeSessionsByIDTokenHint(t.Context(), userID, clientID, "missing-from-stored-session"))
|
||||
|
||||
var sessions []OAuth2Session
|
||||
require.NoError(t, db.Order("key").Find(&sessions).Error)
|
||||
|
||||
activeRefreshByKey := map[string]bool{}
|
||||
accessKeys := map[string]bool{}
|
||||
for _, session := range sessions {
|
||||
switch session.Kind {
|
||||
case sessionKindRefreshToken:
|
||||
activeRefreshByKey[session.Key] = session.Active
|
||||
case sessionKindAccessToken:
|
||||
accessKeys[session.Key] = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, activeRefreshByKey["same-client-refresh"])
|
||||
assert.True(t, activeRefreshByKey["other-client-refresh"])
|
||||
assert.True(t, accessKeys["same-client-access"])
|
||||
assert.True(t, accessKeys["other-client-access"])
|
||||
}
|
||||
|
||||
func newTestRequester(requestID, clientID, subject, idTokenJTI string) fosite.Requester {
|
||||
session := NewEmptySession()
|
||||
session.Subject = subject
|
||||
session.Claims = &fositejwt.IDTokenClaims{
|
||||
JTI: idTokenJTI,
|
||||
RequestedAt: time.Now().UTC(),
|
||||
Extra: map[string]any{},
|
||||
}
|
||||
|
||||
return &fosite.Request{
|
||||
ID: requestID,
|
||||
RequestedAt: time.Now().UTC(),
|
||||
Client: Client{
|
||||
OidcClient: model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
},
|
||||
},
|
||||
GrantedScope: fosite.Arguments{"openid"},
|
||||
Form: map[string][]string{},
|
||||
Session: session,
|
||||
}
|
||||
}
|
||||
77
backend/internal/oidc/token_handler.go
Normal file
77
backend/internal/oidc/token_handler.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
)
|
||||
|
||||
type tokenHandler struct {
|
||||
provider fosite.OAuth2Provider
|
||||
claimsService *ClaimsService
|
||||
}
|
||||
|
||||
func newTokenHandler(provider fosite.OAuth2Provider, claimsService *ClaimsService) *tokenHandler {
|
||||
return &tokenHandler{
|
||||
provider: provider,
|
||||
claimsService: claimsService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *tokenHandler) token(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// For grants that continue an existing session (authorization code, refresh token),
|
||||
// fosite restores the stored session over this empty one.
|
||||
session := NewEmptySession()
|
||||
|
||||
accessRequest, err := h.provider.NewAccessRequest(ctx, c.Request, session)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to create access request", "error", err)
|
||||
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
requestSession, ok := accessRequest.GetSession().(*Session)
|
||||
if !ok {
|
||||
slog.ErrorContext(ctx, "Failed to handle token request: session must be *oidc.Session")
|
||||
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, fosite.ErrServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if client, ok := accessRequest.GetClient().(Client); ok {
|
||||
// Re-validate the resource owner on every user-bound grant.
|
||||
if err := h.claimsService.ValidateUserAccess(ctx, requestSession.Subject, client); err != nil {
|
||||
slog.WarnContext(ctx, "Rejected token request: user no longer allowed to access client", "error", err.Error())
|
||||
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Bind every issued JWT access token to the requesting client so it always carries an aud claim.
|
||||
accessRequest.GrantAudience(client.GetID())
|
||||
}
|
||||
|
||||
if err := h.claimsService.applyIDTokenClaims(ctx, requestSession, accessRequest.GetGrantedScopes()); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to apply ID token claims", "error", err)
|
||||
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// The client credentials grant has no resource owner, so no subject is ever set. Assign a
|
||||
// stable synthetic subject so the issued JWT access token still carries a subclaim.
|
||||
if requestSession.Subject == "" {
|
||||
if client, ok := accessRequest.GetClient().(Client); ok && accessRequest.GetGrantTypes().Has(string(fosite.GrantTypeClientCredentials)) {
|
||||
requestSession.Subject = "client-" + client.GetID()
|
||||
}
|
||||
}
|
||||
|
||||
response, err := h.provider.NewAccessResponse(ctx, accessRequest)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to create access response", "error", err)
|
||||
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.provider.WriteAccessResponse(ctx, c.Writer, accessRequest, response)
|
||||
}
|
||||
243
backend/internal/oidc/token_handler_test.go
Normal file
243
backend/internal/oidc/token_handler_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
"github.com/ory/fosite/compose"
|
||||
fositejwt "github.com/ory/fosite/token/jwt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
// TestTokenHandlerClientCredentialsGrant guards two regressions in one flow:
|
||||
// - the resource-owner re-validation must be skipped for client_credentials (which has
|
||||
// no user / empty subject), otherwise the grant is rejected with invalid_grant; and
|
||||
// - every issued JWT access token must carry an `aud` claim bound to the client
|
||||
// (RFC 9068 §2.2), which fosite only emits when the granted audience is non-empty.
|
||||
//
|
||||
// The provider-level tests bypass tokenHandler.token by calling NewAccessResponse directly,
|
||||
// so this drives the real HTTP handler with confidential-client Basic auth.
|
||||
func TestTokenHandlerClientCredentialsGrant(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
const (
|
||||
baseURL = "https://issuer.example.com"
|
||||
secret = "test-secret"
|
||||
clientID = "cc-client"
|
||||
clientPlain = "cc-secret-value"
|
||||
)
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Client Credentials Client",
|
||||
Secret: string(hashed),
|
||||
IsPublic: false,
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: key}, Config{
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: secret,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil))
|
||||
|
||||
form := url.Values{"grant_type": {"client_credentials"}}
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(clientID, clientPlain)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
handler.token(c)
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.NotEmpty(t, body["access_token"], "client_credentials must issue a token, got error: %v", body["error"])
|
||||
|
||||
claims := decodeJWTPart(t, body["access_token"].(string), 1)
|
||||
require.Contains(t, jwtAudience(claims), clientID, "access token must be audience-bound to the client")
|
||||
}
|
||||
|
||||
// jwtAudience normalizes the `aud` claim (string or []string) into a slice.
|
||||
func jwtAudience(claims map[string]any) []string {
|
||||
switch aud := claims["aud"].(type) {
|
||||
case string:
|
||||
return []string{aud}
|
||||
case []any:
|
||||
out := make([]string, 0, len(aud))
|
||||
for _, a := range aud {
|
||||
if s, ok := a.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenHandlerRefreshGrantRevalidatesUser is the regression guard for the most
|
||||
// security-sensitive part of the fosite migration: fosite's refresh-token grant replays
|
||||
// the stored session without reloading the user, so the token handler must re-check the
|
||||
// resource owner on every refresh. Without that re-check, a user who is disabled or
|
||||
// removed from a group-restricted client after the initial login keeps minting fresh
|
||||
// access/ID tokens (and rotating the refresh token) until the 30-day refresh token
|
||||
// expires, defeating offboarding and incident response. This drives the real refresh grant
|
||||
// end to end through the HTTP handler.
|
||||
func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
const (
|
||||
baseURL = "https://issuer.example.com"
|
||||
secret = "test-secret"
|
||||
)
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
signer := testTokenSigner{key: key}
|
||||
|
||||
// mintRefreshToken stores an active refresh-token session for the user/client pair and
|
||||
// returns the opaque token. It mirrors how the e2e test service seeds refresh tokens:
|
||||
// the HMAC signature is derived from the same global secret the provider uses, so the
|
||||
// real refresh grant resolves it.
|
||||
mintRefreshToken := func(t *testing.T, db *gorm.DB, clientID, userID string) string {
|
||||
t.Helper()
|
||||
globalSecret, err := DeriveGlobalSecret(secret)
|
||||
require.NoError(t, err)
|
||||
strategy := compose.NewOAuth2HMACStrategy(&fosite.Config{
|
||||
GlobalSecret: globalSecret,
|
||||
RefreshTokenLifespan: 30 * 24 * time.Hour,
|
||||
})
|
||||
token, signature, err := strategy.GenerateRefreshToken(t.Context(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
session := NewEmptySession()
|
||||
session.Subject = userID
|
||||
session.Claims = &fositejwt.IDTokenClaims{
|
||||
Subject: userID,
|
||||
RequestedAt: now,
|
||||
AuthTime: now,
|
||||
Extra: map[string]any{},
|
||||
}
|
||||
session.SetExpiresAt(fosite.RefreshToken, now.Add(30*24*time.Hour))
|
||||
session.SetExpiresAt(fosite.AccessToken, now.Add(time.Hour))
|
||||
|
||||
request := fosite.NewRequest()
|
||||
request.ID = "refresh-req-" + userID
|
||||
request.RequestedAt = now
|
||||
request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, IsPublic: true}}
|
||||
request.RequestedScope = fosite.Arguments{"openid"}
|
||||
request.GrantedScope = fosite.Arguments{"openid"}
|
||||
request.RequestedAudience = fosite.Arguments{clientID}
|
||||
request.GrantedAudience = fosite.Arguments{clientID}
|
||||
request.Session = session
|
||||
|
||||
require.NoError(t, NewStore(db).CreateRefreshTokenSession(t.Context(), signature, "", request))
|
||||
return token
|
||||
}
|
||||
|
||||
doRefresh := func(t *testing.T, db *gorm.DB, clientID, refreshToken string) map[string]any {
|
||||
t.Helper()
|
||||
provider, err := newProvider(NewStore(db), nil, signer, Config{
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: secret,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil))
|
||||
|
||||
form := url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {refreshToken},
|
||||
"client_id": {clientID},
|
||||
}
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
handler.token(c)
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
return body
|
||||
}
|
||||
|
||||
createClient := func(t *testing.T, db *gorm.DB, client model.OidcClient) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Create(&client).Error)
|
||||
}
|
||||
|
||||
t.Run("enabled user receives rotated tokens", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
const clientID, userID = "client-ok", "user-ok"
|
||||
createClient(t, db, model.OidcClient{Base: model.Base{ID: clientID}, Name: "Client", IsPublic: true})
|
||||
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim"}).Error)
|
||||
|
||||
token := mintRefreshToken(t, db, clientID, userID)
|
||||
body := doRefresh(t, db, clientID, token)
|
||||
|
||||
require.NotEmpty(t, body["access_token"], "expected a new access token, got error: %v", body["error"])
|
||||
require.NotEmpty(t, body["refresh_token"])
|
||||
require.NotEqual(t, token, body["refresh_token"], "refresh token must be rotated")
|
||||
})
|
||||
|
||||
t.Run("disabled user is rejected on refresh", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
const clientID, userID = "client-disabled", "user-disabled"
|
||||
createClient(t, db, model.OidcClient{Base: model.Base{ID: clientID}, Name: "Client", IsPublic: true})
|
||||
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim", Disabled: true}).Error)
|
||||
|
||||
token := mintRefreshToken(t, db, clientID, userID)
|
||||
body := doRefresh(t, db, clientID, token)
|
||||
|
||||
require.Empty(t, body["access_token"])
|
||||
require.Equal(t, "invalid_grant", body["error"])
|
||||
})
|
||||
|
||||
t.Run("user removed from a group-restricted client is rejected on refresh", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
const clientID, userID = "client-restricted", "user-outsider"
|
||||
group := model.UserGroup{Base: model.Base{ID: "allowed-group"}, Name: "allowed", FriendlyName: "Allowed"}
|
||||
require.NoError(t, db.Create(&group).Error)
|
||||
createClient(t, db, model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Restricted",
|
||||
IsPublic: true,
|
||||
IsGroupRestricted: true,
|
||||
AllowedUserGroups: []model.UserGroup{group},
|
||||
})
|
||||
// User is not a member of the allowed group.
|
||||
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "outsider"}).Error)
|
||||
|
||||
token := mintRefreshToken(t, db, clientID, userID)
|
||||
body := doRefresh(t, db, clientID, token)
|
||||
|
||||
require.Empty(t, body["access_token"])
|
||||
require.Equal(t, "access_denied", body["error"])
|
||||
})
|
||||
}
|
||||
50
backend/internal/oidc/tx.go
Normal file
50
backend/internal/oidc/tx.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Transactions are carried in the context, every database access goes through dbFromContext.
|
||||
// Although storing transactions in the context is not preferred, fosite handlers don't allow passing a transaction
|
||||
// so we have to do it this way.
|
||||
|
||||
type txContextKey struct{}
|
||||
|
||||
func contextWithTx(ctx context.Context, tx *gorm.DB) context.Context {
|
||||
return context.WithValue(ctx, txContextKey{}, tx)
|
||||
}
|
||||
|
||||
func dbFromContext(ctx context.Context, fallback *gorm.DB) *gorm.DB {
|
||||
if tx, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok {
|
||||
return tx.WithContext(ctx)
|
||||
}
|
||||
return fallback.WithContext(ctx)
|
||||
}
|
||||
|
||||
// withTx runs fn inside a transaction, committing on nil error. Nested calls join the
|
||||
// outer transaction.
|
||||
func withTx(ctx context.Context, db *gorm.DB, fn func(ctx context.Context) error) error {
|
||||
if _, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok {
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
tx := db.WithContext(ctx).Begin()
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
defer func() {
|
||||
err := tx.Rollback().Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrInvalidTransaction) {
|
||||
slog.ErrorContext(ctx, "Failed to rollback transaction", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := fn(contextWithTx(ctx, tx)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit().Error
|
||||
}
|
||||
66
backend/internal/oidc/userinfo_handler.go
Normal file
66
backend/internal/oidc/userinfo_handler.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
)
|
||||
|
||||
type userInfoHandler struct {
|
||||
provider fosite.OAuth2Provider
|
||||
claimsService *ClaimsService
|
||||
}
|
||||
|
||||
func newUserInfoHandler(provider fosite.OAuth2Provider, claimsService *ClaimsService) *userInfoHandler {
|
||||
return &userInfoHandler{
|
||||
provider: provider,
|
||||
claimsService: claimsService,
|
||||
}
|
||||
}
|
||||
|
||||
// userInfo godoc
|
||||
// @Summary Get user information
|
||||
// @Description Get user information based on the access token
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} object "User claims based on requested scopes"
|
||||
// @Security OAuth2AccessToken
|
||||
// @Router /api/oidc/userinfo [get]
|
||||
func (h *userInfoHandler) userInfo(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
tokenType, accessRequest, err := h.provider.IntrospectToken(ctx, fosite.AccessTokenFromRequest(c.Request), fosite.AccessToken, NewEmptySession())
|
||||
if err != nil {
|
||||
writeUserInfoError(c, err)
|
||||
return
|
||||
}
|
||||
if tokenType != fosite.AccessToken {
|
||||
writeUserInfoError(c, fosite.ErrRequestUnauthorized.WithDescription("Only access tokens are allowed in the authorization header."))
|
||||
return
|
||||
}
|
||||
|
||||
session, ok := accessRequest.GetSession().(*Session)
|
||||
if !ok || session.GetSubject() == "" {
|
||||
writeUserInfoError(c, fosite.ErrRequestUnauthorized.WithDescription("The access token is invalid"))
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.claimsService.GetUserClaims(ctx, session.GetSubject(), accessRequest.GetGrantedScopes())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, claims)
|
||||
}
|
||||
|
||||
func writeUserInfoError(c *gin.Context, err error) {
|
||||
rfcErr := fosite.ErrorToRFC6749Error(err)
|
||||
if rfcErr.StatusCode() == http.StatusUnauthorized {
|
||||
c.Header("WWW-Authenticate", fmt.Sprintf(`Bearer error="%s", error_description="%s"`, rfcErr.ErrorField, rfcErr.GetDescription()))
|
||||
}
|
||||
|
||||
c.JSON(rfcErr.StatusCode(), rfcErr)
|
||||
}
|
||||
128
backend/internal/oidc/userinfo_handler_test.go
Normal file
128
backend/internal/oidc/userinfo_handler_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
// TestUserInfoHandler covers the userinfo endpoint, which returns user PII based purely on
|
||||
// a presented access token. It must reject a missing/garbage token and — importantly — a
|
||||
// token that carries no resource owner (e.g. a client_credentials token), so machine
|
||||
// tokens cannot be exchanged for a user's profile. A valid user access token returns the
|
||||
// claims for exactly the scopes it was granted.
|
||||
func TestUserInfoHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
const (
|
||||
baseURL = "https://issuer.example.com"
|
||||
userID = "user-1"
|
||||
clientID = "client-1"
|
||||
)
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
Username: "tim",
|
||||
FirstName: "Tim",
|
||||
LastName: "Cook",
|
||||
DisplayName: "Tim Cook",
|
||||
Email: stringPointer("tim@example.com"),
|
||||
EmailVerified: true,
|
||||
}).Error)
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: key}, Config{
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: "test-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := newUserInfoHandler(provider, newClaimsService(db, nil, baseURL, nil))
|
||||
|
||||
issueAccessToken := func(t *testing.T, requestID, subject string, scopes ...string) string {
|
||||
t.Helper()
|
||||
session := NewEmptySession()
|
||||
session.Subject = subject
|
||||
session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
request := fosite.NewAccessRequest(session)
|
||||
request.ID = requestID
|
||||
request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}
|
||||
request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
|
||||
request.RequestedScope = fosite.Arguments(scopes)
|
||||
request.GrantedScope = fosite.Arguments(scopes)
|
||||
request.RequestedAudience = fosite.Arguments{clientID}
|
||||
request.GrantedAudience = fosite.Arguments{clientID}
|
||||
|
||||
response, err := provider.NewAccessResponse(t.Context(), request)
|
||||
require.NoError(t, err)
|
||||
return response.GetAccessToken()
|
||||
}
|
||||
|
||||
call := func(t *testing.T, token string) (*httptest.ResponseRecorder, *gin.Context) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/oidc/userinfo", nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
handler.userInfo(c)
|
||||
return rec, c
|
||||
}
|
||||
|
||||
t.Run("valid user access token returns the granted claims", func(t *testing.T) {
|
||||
token := issueAccessToken(t, "req-valid", userID, "openid", "email")
|
||||
rec, c := call(t, token)
|
||||
|
||||
require.Empty(t, c.Errors)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var claims map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &claims))
|
||||
require.Equal(t, userID, claims["sub"])
|
||||
require.Equal(t, "tim@example.com", claims["email"])
|
||||
// profile was not granted, so profile claims must be absent
|
||||
require.NotContains(t, claims, "given_name")
|
||||
})
|
||||
|
||||
t.Run("missing access token is rejected", func(t *testing.T) {
|
||||
rec, c := call(t, "")
|
||||
require.Empty(t, c.Errors)
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error="request_unauthorized"`)
|
||||
})
|
||||
|
||||
t.Run("garbage access token is rejected", func(t *testing.T) {
|
||||
rec, c := call(t, "garbage.token.value")
|
||||
require.Empty(t, c.Errors)
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error=`)
|
||||
})
|
||||
|
||||
t.Run("token without a resource owner is rejected", func(t *testing.T) {
|
||||
// client_credentials-style token: valid, but no subject -> must not return PII.
|
||||
token := issueAccessToken(t, "req-no-subject", "", "openid")
|
||||
rec, c := call(t, token)
|
||||
require.Empty(t, c.Errors)
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error="request_unauthorized"`)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -515,515 +515,6 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
})
|
||||
|
||||
t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
userClaims := map[string]any{
|
||||
"sub": "user123",
|
||||
"name": "Test User",
|
||||
"email": "user@example.com",
|
||||
}
|
||||
const clientID = "test-client-123"
|
||||
|
||||
tokenString, jti, err := service.GenerateIDToken(userClaims, clientID, "", "")
|
||||
require.NoError(t, err, "Failed to generate ID token")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
assert.Regexp(t, uuidRegexPattern, jti, "Returned JWT ID is not a UUID")
|
||||
|
||||
claims, err := service.VerifyIdToken(tokenString, false)
|
||||
require.NoError(t, err, "Failed to verify generated ID token")
|
||||
|
||||
subject, ok := claims.Subject()
|
||||
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||
assert.Equal(t, "user123", subject, "Token subject should match user ID")
|
||||
audience, ok := claims.Audience()
|
||||
_ = assert.True(t, ok, "Audience not found in token") &&
|
||||
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
|
||||
issuer, ok := claims.Issuer()
|
||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||
jwtID, ok := claims.JwtID()
|
||||
_ = assert.True(t, ok, "JWT ID not found in token") &&
|
||||
assert.Regexp(t, uuidRegexPattern, jwtID, "JWT ID is not a UUID")
|
||||
assert.Equal(t, jti, jwtID, "Returned JWT ID should match token claim")
|
||||
|
||||
expectedExp := time.Now().Add(1 * time.Hour)
|
||||
expiration, ok := claims.Expiration()
|
||||
assert.True(t, ok, "Expiration not found in token")
|
||||
timeDiff := expectedExp.Sub(expiration).Minutes()
|
||||
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
|
||||
})
|
||||
|
||||
t.Run("can accept expired tokens if told so", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
userClaims := map[string]any{
|
||||
"sub": "user123",
|
||||
"name": "Test User",
|
||||
"email": "user@example.com",
|
||||
}
|
||||
const clientID = "test-client-123"
|
||||
|
||||
token, err := jwt.NewBuilder().
|
||||
Subject(userClaims["sub"].(string)).
|
||||
Issuer(service.envConfig.AppURL).
|
||||
Audience([]string{clientID}).
|
||||
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
||||
Expiration(time.Now().Add(-1 * time.Hour)).
|
||||
Build()
|
||||
require.NoError(t, err, "Failed to build token")
|
||||
|
||||
err = SetTokenType(token, IDTokenJWTType)
|
||||
require.NoError(t, err, "Failed to set token type")
|
||||
|
||||
for k, v := range userClaims {
|
||||
if k != "sub" {
|
||||
err = token.Set(k, v)
|
||||
require.NoError(t, err, "Failed to set claim")
|
||||
}
|
||||
}
|
||||
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
|
||||
require.NoError(t, err, "Failed to sign token")
|
||||
tokenString := string(signed)
|
||||
|
||||
_, err = service.VerifyIdToken(tokenString, false)
|
||||
require.Error(t, err, "Verification should fail with expired token when not allowing expired tokens")
|
||||
assert.Contains(t, err.Error(), "\"exp\" not satisfied", "Error message should indicate token verification failure")
|
||||
|
||||
claims, err := service.VerifyIdToken(tokenString, true)
|
||||
require.NoError(t, err, "Verification should succeed with expired token when allowing expired tokens")
|
||||
|
||||
subject, ok := claims.Subject()
|
||||
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||
assert.Equal(t, userClaims["sub"], subject, "Token subject should match user ID")
|
||||
issuer, ok := claims.Issuer()
|
||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||
})
|
||||
|
||||
t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
userClaims := map[string]any{
|
||||
"sub": "user456",
|
||||
"name": "Another User",
|
||||
}
|
||||
const clientID = "test-client-456"
|
||||
nonce := "random-nonce-value"
|
||||
|
||||
tokenString, _, err := service.GenerateIDToken(userClaims, clientID, nonce, "")
|
||||
require.NoError(t, err, "Failed to generate ID token with nonce")
|
||||
|
||||
publicKey, err := service.GetPublicJWK()
|
||||
require.NoError(t, err, "Failed to get public key")
|
||||
token, err := jwt.Parse([]byte(tokenString), jwt.WithKey(jwa.RS256(), publicKey))
|
||||
require.NoError(t, err, "Failed to parse token")
|
||||
|
||||
var tokenNonce string
|
||||
err = token.Get("nonce", &tokenNonce)
|
||||
require.NoError(t, err, "Failed to get claims")
|
||||
|
||||
assert.Equal(t, nonce, tokenNonce, "Token should contain the correct nonce")
|
||||
})
|
||||
|
||||
t.Run("fails verification with incorrect issuer", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
userClaims := map[string]any{
|
||||
"sub": "user789",
|
||||
}
|
||||
tokenString, _, err := service.GenerateIDToken(userClaims, "client-789", "", "")
|
||||
require.NoError(t, err, "Failed to generate ID token")
|
||||
|
||||
service.envConfig.AppURL = "https://wrong-issuer.com"
|
||||
|
||||
_, err = service.VerifyIdToken(tokenString, false)
|
||||
require.Error(t, err, "Verification should fail with incorrect issuer")
|
||||
assert.Contains(t, err.Error(), "\"iss\" not satisfied", "Error message should indicate token verification failure")
|
||||
})
|
||||
|
||||
t.Run("works with Ed25519 keys", func(t *testing.T) {
|
||||
db, envConfig := newTestDbAndEnv(t)
|
||||
origKeyID := createEdDSAKeyJWK(t, db, envConfig, mockConfig)
|
||||
service := initJwtService(t, db, mockConfig, envConfig)
|
||||
|
||||
loadedKeyID, ok := service.privateKey.KeyID()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
|
||||
|
||||
userClaims := map[string]any{
|
||||
"sub": "eddsauser456",
|
||||
"name": "EdDSA User",
|
||||
"email": "eddsauser@example.com",
|
||||
}
|
||||
const clientID = "eddsa-client-123"
|
||||
|
||||
tokenString, _, err := service.GenerateIDToken(userClaims, clientID, "", "")
|
||||
require.NoError(t, err, "Failed to generate ID token with key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
claims, err := service.VerifyIdToken(tokenString, false)
|
||||
require.NoError(t, err, "Failed to verify generated ID token with key")
|
||||
|
||||
subject, ok := claims.Subject()
|
||||
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||
assert.Equal(t, "eddsauser456", subject, "Token subject should match user ID")
|
||||
issuer, ok := claims.Issuer()
|
||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||
|
||||
publicKey, err := service.GetPublicJWK()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jwa.OKP().String(), publicKey.KeyType().String(), "Key type should be OKP")
|
||||
alg, ok := publicKey.Algorithm()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, jwa.EdDSA().String(), alg.String(), "Algorithm should be EdDSA")
|
||||
})
|
||||
|
||||
t.Run("works with P-256 keys", func(t *testing.T) {
|
||||
db, envConfig := newTestDbAndEnv(t)
|
||||
origKeyID := createECDSAKeyJWK(t, db, envConfig, mockConfig)
|
||||
service := initJwtService(t, db, mockConfig, envConfig)
|
||||
|
||||
loadedKeyID, ok := service.privateKey.KeyID()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
|
||||
|
||||
userClaims := map[string]any{
|
||||
"sub": "ecdsauser456",
|
||||
"email": "ecdsauser@example.com",
|
||||
}
|
||||
const clientID = "ecdsa-client-123"
|
||||
|
||||
tokenString, _, err := service.GenerateIDToken(userClaims, clientID, "", "")
|
||||
require.NoError(t, err, "Failed to generate ID token with key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
claims, err := service.VerifyIdToken(tokenString, false)
|
||||
require.NoError(t, err, "Failed to verify generated ID token with key")
|
||||
|
||||
subject, ok := claims.Subject()
|
||||
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||
assert.Equal(t, "ecdsauser456", subject, "Token subject should match user ID")
|
||||
issuer, ok := claims.Issuer()
|
||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||
|
||||
publicKey, err := service.GetPublicJWK()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jwa.EC().String(), publicKey.KeyType().String(), "Key type should be EC")
|
||||
alg, ok := publicKey.Algorithm()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, jwa.ES256().String(), alg.String(), "Algorithm should be ES256")
|
||||
})
|
||||
|
||||
t.Run("works with RSA-4096 keys", func(t *testing.T) {
|
||||
db, envConfig := newTestDbAndEnv(t)
|
||||
origKeyID := createRSA4096KeyJWK(t, db, envConfig, mockConfig)
|
||||
service := initJwtService(t, db, mockConfig, envConfig)
|
||||
|
||||
loadedKeyID, ok := service.privateKey.KeyID()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
|
||||
|
||||
userClaims := map[string]any{
|
||||
"sub": "rsauser456",
|
||||
"name": "RSA User",
|
||||
"email": "rsauser@example.com",
|
||||
}
|
||||
const clientID = "rsa-client-123"
|
||||
|
||||
tokenString, _, err := service.GenerateIDToken(userClaims, clientID, "", "")
|
||||
require.NoError(t, err, "Failed to generate ID token with key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
claims, err := service.VerifyIdToken(tokenString, false)
|
||||
require.NoError(t, err, "Failed to verify generated ID token with key")
|
||||
|
||||
subject, ok := claims.Subject()
|
||||
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||
assert.Equal(t, "rsauser456", subject, "Token subject should match user ID")
|
||||
issuer, ok := claims.Issuer()
|
||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
})
|
||||
|
||||
t.Run("generates and verifies OAuth access token with standard claims", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "user123"},
|
||||
Email: new("user@example.com"),
|
||||
}
|
||||
const clientID = "test-client-123"
|
||||
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
|
||||
require.NoError(t, err, "Failed to generate OAuth access token")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
claims, err := service.VerifyOAuthAccessToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated OAuth access token")
|
||||
|
||||
subject, ok := claims.Subject()
|
||||
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||
assert.Equal(t, user.ID, subject, "Token subject should match user ID")
|
||||
audience, ok := claims.Audience()
|
||||
_ = assert.True(t, ok, "Audience not found in token") &&
|
||||
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
|
||||
issuer, ok := claims.Issuer()
|
||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||
jwtID, ok := claims.JwtID()
|
||||
_ = assert.True(t, ok, "JWT ID not found in token") &&
|
||||
assert.Regexp(t, uuidRegexPattern, jwtID, "JWT ID is not a UUID")
|
||||
|
||||
expectedExp := time.Now().Add(1 * time.Hour)
|
||||
expiration, ok := claims.Expiration()
|
||||
assert.True(t, ok, "Expiration not found in token")
|
||||
timeDiff := expectedExp.Sub(expiration).Minutes()
|
||||
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
|
||||
})
|
||||
|
||||
t.Run("sets authentication method references claim when provided", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "oauth-amr-user"},
|
||||
}
|
||||
const clientID = "test-client-amr"
|
||||
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, AuthenticationMethodPhishingResistant)
|
||||
require.NoError(t, err, "Failed to generate OAuth access token")
|
||||
|
||||
claims, err := service.VerifyOAuthAccessToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated OAuth access token")
|
||||
|
||||
authenticationMethod, err := GetAuthenticationMethod(claims)
|
||||
_ = assert.NoError(t, err, "Failed to get amr claim") &&
|
||||
assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match")
|
||||
})
|
||||
|
||||
t.Run("fails verification for expired token", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
user := model.User{Base: model.Base{ID: "user456"}}
|
||||
const clientID = "test-client-456"
|
||||
|
||||
token, err := jwt.NewBuilder().
|
||||
Subject(user.ID).
|
||||
Expiration(time.Now().Add(-1 * time.Hour)).
|
||||
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
||||
Audience([]string{clientID}).
|
||||
Issuer(service.envConfig.AppURL).
|
||||
Build()
|
||||
require.NoError(t, err, "Failed to build token")
|
||||
|
||||
err = SetTokenType(token, OAuthAccessTokenJWTType)
|
||||
require.NoError(t, err, "Failed to set token type")
|
||||
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
|
||||
require.NoError(t, err, "Failed to sign token")
|
||||
|
||||
_, err = service.VerifyOAuthAccessToken(string(signed))
|
||||
require.Error(t, err, "Verification should fail with expired token")
|
||||
assert.Contains(t, err.Error(), "\"exp\" not satisfied", "Error message should indicate token verification failure")
|
||||
})
|
||||
|
||||
t.Run("fails verification with invalid signature", func(t *testing.T) {
|
||||
service1, _, _ := setupJwtService(t, mockConfig)
|
||||
service2, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
user := model.User{Base: model.Base{ID: "user789"}}
|
||||
const clientID = "test-client-789"
|
||||
|
||||
tokenString, err := service1.GenerateOAuthAccessToken(user, clientID, "")
|
||||
require.NoError(t, err, "Failed to generate OAuth access token")
|
||||
|
||||
_, err = service2.VerifyOAuthAccessToken(tokenString)
|
||||
require.Error(t, err, "Verification should fail with invalid signature")
|
||||
assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure")
|
||||
})
|
||||
|
||||
t.Run("works with Ed25519 keys", func(t *testing.T) {
|
||||
db, envConfig := newTestDbAndEnv(t)
|
||||
origKeyID := createEdDSAKeyJWK(t, db, envConfig, mockConfig)
|
||||
service := initJwtService(t, db, mockConfig, envConfig)
|
||||
|
||||
loadedKeyID, ok := service.privateKey.KeyID()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "eddsauser789"},
|
||||
Email: new("eddsaoauth@example.com"),
|
||||
}
|
||||
const clientID = "eddsa-oauth-client"
|
||||
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
|
||||
require.NoError(t, err, "Failed to generate OAuth access token with key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
claims, err := service.VerifyOAuthAccessToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
|
||||
|
||||
subject, ok := claims.Subject()
|
||||
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||
assert.Equal(t, user.ID, subject, "Token subject should match user ID")
|
||||
audience, ok := claims.Audience()
|
||||
_ = assert.True(t, ok, "Audience not found in token") &&
|
||||
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
|
||||
|
||||
publicKey, err := service.GetPublicJWK()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jwa.OKP().String(), publicKey.KeyType().String(), "Key type should be OKP")
|
||||
alg, ok := publicKey.Algorithm()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, jwa.EdDSA().String(), alg.String(), "Algorithm should be EdDSA")
|
||||
})
|
||||
|
||||
t.Run("works with ECDSA keys", func(t *testing.T) {
|
||||
db, envConfig := newTestDbAndEnv(t)
|
||||
origKeyID := createECDSAKeyJWK(t, db, envConfig, mockConfig)
|
||||
service := initJwtService(t, db, mockConfig, envConfig)
|
||||
|
||||
loadedKeyID, ok := service.privateKey.KeyID()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "ecdsauser789"},
|
||||
Email: new("ecdsaoauth@example.com"),
|
||||
}
|
||||
const clientID = "ecdsa-oauth-client"
|
||||
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
|
||||
require.NoError(t, err, "Failed to generate OAuth access token with key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
claims, err := service.VerifyOAuthAccessToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
|
||||
|
||||
subject, ok := claims.Subject()
|
||||
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||
assert.Equal(t, user.ID, subject, "Token subject should match user ID")
|
||||
audience, ok := claims.Audience()
|
||||
_ = assert.True(t, ok, "Audience not found in token") &&
|
||||
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
|
||||
|
||||
publicKey, err := service.GetPublicJWK()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jwa.EC().String(), publicKey.KeyType().String(), "Key type should be EC")
|
||||
alg, ok := publicKey.Algorithm()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, jwa.ES256().String(), alg.String(), "Algorithm should be ES256")
|
||||
})
|
||||
|
||||
t.Run("works with RSA keys", func(t *testing.T) {
|
||||
db, envConfig := newTestDbAndEnv(t)
|
||||
origKeyID := createRSA4096KeyJWK(t, db, envConfig, mockConfig)
|
||||
service := initJwtService(t, db, mockConfig, envConfig)
|
||||
|
||||
loadedKeyID, ok := service.privateKey.KeyID()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "rsauser789"},
|
||||
Email: new("rsaoauth@example.com"),
|
||||
}
|
||||
const clientID = "rsa-oauth-client"
|
||||
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
|
||||
require.NoError(t, err, "Failed to generate OAuth access token with key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
claims, err := service.VerifyOAuthAccessToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
|
||||
|
||||
subject, ok := claims.Subject()
|
||||
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||
assert.Equal(t, user.ID, subject, "Token subject should match user ID")
|
||||
audience, ok := claims.Audience()
|
||||
_ = assert.True(t, ok, "Audience not found in token") &&
|
||||
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
|
||||
|
||||
publicKey, err := service.GetPublicJWK()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jwa.RSA().String(), publicKey.KeyType().String(), "Key type should be RSA")
|
||||
alg, ok := publicKey.Algorithm()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, jwa.RS256().String(), alg.String(), "Algorithm should be RS256")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{})
|
||||
|
||||
t.Run("generates and verifies refresh token", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
const (
|
||||
userID = "user123"
|
||||
clientID = "client123"
|
||||
refreshToken = "rt-123"
|
||||
)
|
||||
|
||||
tokenString, err := service.GenerateOAuthRefreshToken(userID, clientID, refreshToken)
|
||||
require.NoError(t, err, "Failed to generate refresh token")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
resUser, resClient, resRT, err := service.VerifyOAuthRefreshToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated token")
|
||||
assert.Equal(t, userID, resUser, "Should return correct user ID")
|
||||
assert.Equal(t, clientID, resClient, "Should return correct client ID")
|
||||
assert.Equal(t, refreshToken, resRT, "Should return correct refresh token")
|
||||
})
|
||||
|
||||
t.Run("fails verification for expired token", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
token, err := jwt.NewBuilder().
|
||||
Subject("user789").
|
||||
Expiration(time.Now().Add(-1 * time.Hour)).
|
||||
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
||||
Audience([]string{"client123"}).
|
||||
Issuer(service.envConfig.AppURL).
|
||||
Build()
|
||||
require.NoError(t, err, "Failed to build token")
|
||||
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
|
||||
require.NoError(t, err, "Failed to sign token")
|
||||
|
||||
_, _, _, err = service.VerifyOAuthRefreshToken(string(signed))
|
||||
require.Error(t, err, "Verification should fail with expired token")
|
||||
assert.Contains(t, err.Error(), "\"exp\" not satisfied", "Error message should indicate token verification failure")
|
||||
})
|
||||
|
||||
t.Run("fails verification with invalid signature", func(t *testing.T) {
|
||||
service1, _, _ := setupJwtService(t, mockConfig)
|
||||
service2, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
tokenString, err := service1.GenerateOAuthRefreshToken("user789", "client123", "my-rt-123")
|
||||
require.NoError(t, err, "Failed to generate refresh token")
|
||||
|
||||
_, _, _, err = service2.VerifyOAuthRefreshToken(tokenString)
|
||||
require.Error(t, err, "Verification should fail with invalid signature")
|
||||
assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTokenTypeValidator(t *testing.T) {
|
||||
// Create a context for the validator function
|
||||
ctx := context.Background()
|
||||
@@ -1045,16 +536,16 @@ func TestTokenTypeValidator(t *testing.T) {
|
||||
t.Run("fails when token type doesn't match expected type", func(t *testing.T) {
|
||||
// Create a token with a different type
|
||||
token := jwt.New()
|
||||
err := token.Set(TokenTypeClaim, OAuthAccessTokenJWTType)
|
||||
err := token.Set(TokenTypeClaim, "other-token")
|
||||
require.NoError(t, err, "Failed to set token type claim")
|
||||
|
||||
// Create a validator function for a different expected type
|
||||
validator := TokenTypeValidator(IDTokenJWTType)
|
||||
validator := TokenTypeValidator(AccessTokenJWTType)
|
||||
|
||||
// Validate the token
|
||||
err = validator(ctx, token)
|
||||
require.Error(t, err, "Validator should reject token with non-matching type")
|
||||
assert.Contains(t, err.Error(), "invalid token type: expected id-token, got oauth-access-token")
|
||||
assert.Contains(t, err.Error(), "invalid token type: expected access-token, got other-token")
|
||||
})
|
||||
|
||||
t.Run("fails when token type claim is missing", func(t *testing.T) {
|
||||
@@ -1071,57 +562,6 @@ func TestTokenTypeValidator(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTokenType(t *testing.T) {
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{})
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
buildTokenForType := func(t *testing.T, typ string, setClaimsFn func(b *jwt.Builder)) string {
|
||||
t.Helper()
|
||||
|
||||
b := jwt.NewBuilder()
|
||||
b.Subject("user123")
|
||||
if setClaimsFn != nil {
|
||||
setClaimsFn(b)
|
||||
}
|
||||
|
||||
token, err := b.Build()
|
||||
require.NoError(t, err, "Failed to build token")
|
||||
|
||||
err = SetTokenType(token, typ)
|
||||
require.NoError(t, err, "Failed to set token type")
|
||||
|
||||
alg, _ := service.privateKey.Algorithm()
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(alg, service.privateKey))
|
||||
require.NoError(t, err, "Failed to sign token")
|
||||
|
||||
return string(signed)
|
||||
}
|
||||
|
||||
t.Run("correctly identifies access tokens", func(t *testing.T) {
|
||||
tokenString := buildTokenForType(t, AccessTokenJWTType, nil)
|
||||
|
||||
tokenType, _, err := service.GetTokenType(tokenString)
|
||||
require.NoError(t, err, "GetTokenType should not return an error")
|
||||
assert.Equal(t, AccessTokenJWTType, tokenType, "Should identify access token type")
|
||||
})
|
||||
|
||||
t.Run("correctly identifies ID tokens", func(t *testing.T) {
|
||||
tokenString := buildTokenForType(t, IDTokenJWTType, nil)
|
||||
|
||||
tokenType, _, err := service.GetTokenType(tokenString)
|
||||
require.NoError(t, err, "GetTokenType should not return an error")
|
||||
assert.Equal(t, IDTokenJWTType, tokenType, "Should identify ID token type")
|
||||
})
|
||||
|
||||
t.Run("fails when token type claim is missing", func(t *testing.T) {
|
||||
tokenString := buildTokenForType(t, "", nil)
|
||||
|
||||
_, _, err := service.GetTokenType(tokenString)
|
||||
require.Error(t, err, "GetTokenType should return an error for tokens without type claim")
|
||||
assert.Contains(t, err.Error(), "failed to get token type claim", "Error message should indicate missing token type claim")
|
||||
})
|
||||
}
|
||||
|
||||
func importKey(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, privateKeyRaw any) string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
67
backend/internal/utils/csp.go
Normal file
67
backend/internal/utils/csp.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const cspNonceContextKey = "csp_nonce"
|
||||
|
||||
// GetCSPNonce returns the CSP nonce generated for this request, if any.
|
||||
func GetCSPNonce(c *gin.Context) string {
|
||||
if v, ok := c.Get(cspNonceContextKey); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetCSPNonce stores a per-request CSP nonce so handlers can reference it in
|
||||
// Content-Security-Policy headers they emit themselves.
|
||||
func SetCSPNonce(c *gin.Context, nonce string) {
|
||||
c.Set(cspNonceContextKey, nonce)
|
||||
}
|
||||
|
||||
func BuildCSP(nonce string, formActionExtra ...string) string {
|
||||
formAction := "'self'"
|
||||
scriptSrc := "script-src 'self'"
|
||||
if nonce != "" {
|
||||
scriptSrc += " 'nonce-" + nonce + "'"
|
||||
}
|
||||
|
||||
if len(formActionExtra) > 0 {
|
||||
b := strings.Builder{}
|
||||
|
||||
for _, extra := range formActionExtra {
|
||||
if extra != "" {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(extra)
|
||||
}
|
||||
}
|
||||
|
||||
formAction += b.String()
|
||||
}
|
||||
|
||||
return "default-src 'self'; " +
|
||||
"base-uri 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"form-action " + formAction + "; " +
|
||||
"img-src * blob:;" +
|
||||
"font-src 'self'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
scriptSrc
|
||||
}
|
||||
|
||||
// GenerateCSPNonce returns a random base64 nonce for use in a CSP header.
|
||||
func GenerateCSPNonce() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "" // if generation fails, return empty; policy will omit nonce
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -55,7 +55,6 @@
|
||||
"sign_in_to": "Sign in to {name}",
|
||||
"account_selection_signin_confirmation": "Do you want to use the following account to continue to <b>{name}</b>?",
|
||||
"use_a_different_account": "Use a different account",
|
||||
"client_not_found": "Client not found",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",
|
||||
"email": "Email",
|
||||
@@ -290,7 +289,7 @@
|
||||
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
|
||||
"par": "PAR",
|
||||
"requires_pushed_authorization_requests": "Requires Pushed Authorization Requests",
|
||||
"requires_pushed_authorization_requests_description": "Requires clients to use the PAR endpoint (/api/oidc/par) to pre-register authorization parameters before initiating the flow. Not available for public clients.",
|
||||
"requires_pushed_authorization_requests_description": "Requires clients to use the PAR endpoint to pre-register authorization parameters before initiating the flow.",
|
||||
"name_logo": "{name} logo",
|
||||
"change_logo": "Change Logo",
|
||||
"upload_logo": "Upload Logo",
|
||||
@@ -529,5 +528,7 @@
|
||||
"email_verification_sent": "Verification email sent successfully.",
|
||||
"emails_verified_by_default": "Emails verified by default",
|
||||
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.",
|
||||
"user_has_no_passkeys_yet": "This user has no passkeys yet."
|
||||
"user_has_no_passkeys_yet": "This user has no passkeys yet.",
|
||||
"replay_protection": "Replay Protection",
|
||||
"replay_protection_description": "If enabled the provided token can only be used once. If your provider uses the same token multiple times, you may need to disable this option."
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import ModeSwitcher from './mode-switcher.svelte';
|
||||
|
||||
const authUrls = [
|
||||
/^\/authorize$/,
|
||||
/^\/interaction$/,
|
||||
/^\/device$/,
|
||||
/^\/login(?:\/.*)?$/,
|
||||
/^\/logout$/,
|
||||
|
||||
@@ -4,21 +4,21 @@
|
||||
import { LucideMail, LucideUser, LucideUsers } from '@lucide/svelte';
|
||||
import ScopeItem from './scope-item.svelte';
|
||||
|
||||
let { scope }: { scope: string } = $props();
|
||||
let { scopes }: { scopes: string[] } = $props();
|
||||
</script>
|
||||
|
||||
<Item.Group data-testid="scopes">
|
||||
{#if scope!.includes('email')}
|
||||
{#if scopes.includes('email')}
|
||||
<ScopeItem icon={LucideMail} name={m.email()} description={m.view_your_email_address()} />
|
||||
{/if}
|
||||
{#if scope!.includes('profile')}
|
||||
{#if scopes.includes('profile')}
|
||||
<ScopeItem
|
||||
icon={LucideUser}
|
||||
name={m.profile()}
|
||||
description={m.view_your_profile_information()}
|
||||
/>
|
||||
{/if}
|
||||
{#if scope!.includes('groups')}
|
||||
{#if scopes.includes('groups')}
|
||||
<ScopeItem
|
||||
icon={LucideUsers}
|
||||
name={m.groups()}
|
||||
|
||||
@@ -1,80 +1,33 @@
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import type {
|
||||
AccessibleOidcClient,
|
||||
AuthorizeCallbackResponse,
|
||||
AuthorizeResponse,
|
||||
CompleteInteractionResponse,
|
||||
InteractionSession,
|
||||
InteractionStep,
|
||||
OidcClient,
|
||||
OidcClientCreate,
|
||||
OidcClientMetaData,
|
||||
OidcClientUpdate,
|
||||
OidcClientWithAllowedUserGroups,
|
||||
OidcClientWithAllowedUserGroupsCount,
|
||||
OidcDeviceCodeInfo,
|
||||
OidcAuthorizeRequestInfo
|
||||
OidcDeviceCodeInfo
|
||||
} from '$lib/types/oidc.type';
|
||||
import type { ScimServiceProvider } from '$lib/types/scim.type';
|
||||
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
||||
import APIService from './api-service';
|
||||
|
||||
class OidcService extends APIService {
|
||||
authorize = async (
|
||||
clientId: string,
|
||||
scope: string,
|
||||
callbackURL: string,
|
||||
nonce?: string,
|
||||
codeChallenge?: string,
|
||||
codeChallengeMethod?: string,
|
||||
reauthenticationToken?: string,
|
||||
responseMode?: string,
|
||||
prompt?: string,
|
||||
requestURI?: string
|
||||
) => {
|
||||
const res = await this.api.post('/oidc/authorize', {
|
||||
scope,
|
||||
nonce,
|
||||
callbackURL,
|
||||
clientId,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
reauthenticationToken,
|
||||
responseMode,
|
||||
prompt,
|
||||
requestURI
|
||||
});
|
||||
|
||||
return res.data as AuthorizeResponse;
|
||||
getAuthorizeInteraction = async (id: string) => {
|
||||
const { data } = await this.api.get<InteractionSession>(`/oidc/interactions/${id}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
resolveAuthorizeCallbackURL = async (
|
||||
clientId: string,
|
||||
callbackURL: string,
|
||||
requestURI?: string
|
||||
) => {
|
||||
const res = await this.api.post('/oidc/authorize/callback-url', {
|
||||
clientId,
|
||||
callbackURL,
|
||||
requestURI
|
||||
});
|
||||
|
||||
return res.data as AuthorizeCallbackResponse;
|
||||
};
|
||||
|
||||
isAuthorizationRequired = async (clientId: string, scope: string, requestURI?: string) => {
|
||||
const res = await this.api.post('/oidc/authorization-required', {
|
||||
scope,
|
||||
clientId,
|
||||
requestURI
|
||||
});
|
||||
|
||||
return res.data as { authorizationRequired: boolean; scope: string };
|
||||
};
|
||||
|
||||
getParRequestInfo = async (clientId: string, requestURI: string) => {
|
||||
const res = await this.api.get('/oidc/par-request-info', {
|
||||
params: { client_id: clientId, request_uri: requestURI }
|
||||
});
|
||||
|
||||
return res.data as OidcAuthorizeRequestInfo;
|
||||
completeAuthorizeInteractionStep = async (id: string, step: InteractionStep) => {
|
||||
const { data } = await this.api.post<CompleteInteractionResponse>(
|
||||
`/oidc/interactions/${id}/complete`,
|
||||
{ step }
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
listClients = async (options?: ListRequestOptions) => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -127,7 +127,7 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
||||
return fieldSchema.minLength !== null && fieldSchema.minLength > 0;
|
||||
}
|
||||
|
||||
// Handle unions like callbackUrlSchema
|
||||
// Handle unions
|
||||
if (fieldSchema instanceof z.ZodUnion) {
|
||||
return !fieldSchema.def.options.some((o: any) => {
|
||||
return o.def.type == 'optional';
|
||||
|
||||
@@ -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/');
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ export const callbackUrlSchema = z
|
||||
.nonempty()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (/^(javascript|data):/i.test(val)) {
|
||||
return false;
|
||||
}
|
||||
if (val.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import FormattedMessage from '$lib/components/formatted-message.svelte';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import ScopeList from '$lib/components/scope-list.svelte';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import OidcService from '$lib/services/oidc-service';
|
||||
import WebAuthnService from '$lib/services/webauthn-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||
import { getAxiosErrorMessage, getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { startAuthentication, type AuthenticationResponseJSON } from '@simplewebauthn/browser';
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import type { PageProps } from './$types';
|
||||
import ClientProviderImages from './components/client-provider-images.svelte';
|
||||
|
||||
const webauthnService = new WebAuthnService();
|
||||
const oidService = new OidcService();
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let {
|
||||
client,
|
||||
callbackURL,
|
||||
nonce,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
authorizeState,
|
||||
prompt,
|
||||
responseMode,
|
||||
requestURI
|
||||
} = data;
|
||||
let scope = $state(data.scope);
|
||||
let isLoading = $state(false);
|
||||
let success = $state(false);
|
||||
let errorMessage: string | null = $state(null);
|
||||
let authorizationRequired = $state(false);
|
||||
let authorizationConfirmed = $state(false);
|
||||
let accountSelectionRequired = $state(false);
|
||||
let userSignedInAt: Date | undefined;
|
||||
|
||||
const fullName = $derived.by(() => {
|
||||
if (!$userStore) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($userStore.displayName) {
|
||||
return $userStore.displayName;
|
||||
}
|
||||
|
||||
return [$userStore.firstName, $userStore.lastName].filter(Boolean).join(' ').trim();
|
||||
});
|
||||
const primaryName = $derived(fullName || $userStore?.email || '');
|
||||
|
||||
// Parse prompt parameter once (space-delimited per OIDC spec)
|
||||
const promptValues = prompt ? prompt.split(' ') : [];
|
||||
const hasPromptNone = promptValues.includes('none');
|
||||
const hasPromptConsent = promptValues.includes('consent');
|
||||
const hasPromptLogin = promptValues.includes('login');
|
||||
const hasPromptSelectAccount = promptValues.includes('select_account');
|
||||
|
||||
onMount(() => {
|
||||
void handleInitialAuthorization();
|
||||
});
|
||||
|
||||
async function handleInitialAuthorization() {
|
||||
// Conflicting prompt values - none can't be combined with any interactive prompt
|
||||
if (hasPromptNone && (hasPromptConsent || hasPromptLogin || hasPromptSelectAccount)) {
|
||||
await redirectWithError('interaction_required');
|
||||
return;
|
||||
}
|
||||
|
||||
// If prompt=none and user is not signed in, redirect immediately with login_required
|
||||
if (hasPromptNone && !$userStore) {
|
||||
await redirectWithError('login_required');
|
||||
return;
|
||||
}
|
||||
|
||||
// prompt=select_account: if the user is already signed in, pause so they can
|
||||
// confirm the current account before proceeding. If they're not signed in,
|
||||
// the normal login flow below is selection enough.
|
||||
if (hasPromptSelectAccount && $userStore) {
|
||||
accountSelectionRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($userStore) {
|
||||
await authorize();
|
||||
}
|
||||
}
|
||||
|
||||
async function useDifferentAccount() {
|
||||
try {
|
||||
await webauthnService.logout();
|
||||
} finally {
|
||||
await invalidateAll();
|
||||
}
|
||||
}
|
||||
|
||||
async function authorize() {
|
||||
isLoading = true;
|
||||
|
||||
let authResponse: AuthenticationResponseJSON | undefined;
|
||||
|
||||
try {
|
||||
if (!$userStore?.id) {
|
||||
const loginOptions = await webauthnService.getLoginOptions();
|
||||
authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||
const user = await webauthnService.finishLogin(authResponse);
|
||||
userStore.setUser(user);
|
||||
userSignedInAt = new Date();
|
||||
}
|
||||
|
||||
if (!authorizationConfirmed) {
|
||||
const authRequired = await oidService.isAuthorizationRequired(
|
||||
client!.id,
|
||||
scope,
|
||||
requestURI
|
||||
);
|
||||
authorizationRequired = authRequired.authorizationRequired;
|
||||
|
||||
if (requestURI) {
|
||||
scope = authRequired.scope;
|
||||
}
|
||||
|
||||
// If prompt=consent, always show consent UI
|
||||
if (hasPromptConsent) {
|
||||
authorizationRequired = true;
|
||||
}
|
||||
|
||||
// If prompt=none and consent required, redirect with error
|
||||
if (hasPromptNone && authorizationRequired) {
|
||||
await redirectWithError('consent_required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (authorizationRequired) {
|
||||
isLoading = false;
|
||||
authorizationConfirmed = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let reauthToken: string | undefined;
|
||||
if (client?.requiresReauthentication || hasPromptLogin) {
|
||||
let authResponse;
|
||||
const signedInRecently =
|
||||
userSignedInAt && userSignedInAt.getTime() > Date.now() - 60 * 1000;
|
||||
if (!signedInRecently) {
|
||||
const loginOptions = await webauthnService.getLoginOptions();
|
||||
authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||
}
|
||||
reauthToken = await webauthnService.reauthenticate(authResponse);
|
||||
}
|
||||
|
||||
const result = await oidService.authorize(
|
||||
client!.id,
|
||||
scope,
|
||||
callbackURL,
|
||||
nonce,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
reauthToken,
|
||||
responseMode,
|
||||
prompt,
|
||||
requestURI
|
||||
);
|
||||
|
||||
// Check if backend returned a redirect error
|
||||
if (result.requiresRedirect && result.error) {
|
||||
if (hasPromptNone) {
|
||||
await redirectWithError(result.error, result.callbackURL);
|
||||
} else {
|
||||
errorMessage = result.error;
|
||||
isLoading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
onSuccess(result.code!, result.callbackURL!, result.issuer!);
|
||||
} catch (e) {
|
||||
errorMessage = getWebauthnErrorMessage(e);
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function redirectWithError(error: string, validatedCallbackURL?: string) {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const safeCallbackURL =
|
||||
validatedCallbackURL ||
|
||||
(await oidService.resolveAuthorizeCallbackURL(client!.id, callbackURL, requestURI))
|
||||
.callbackURL;
|
||||
const redirectURL = new URL(safeCallbackURL);
|
||||
if (redirectURL.protocol == 'javascript:' || redirectURL.protocol == 'data:') {
|
||||
throw new Error('Invalid redirect URL protocol');
|
||||
}
|
||||
|
||||
window.location.href = createRedirectURL(safeCallbackURL, {
|
||||
error,
|
||||
state: authorizeState
|
||||
});
|
||||
} catch (e) {
|
||||
errorMessage = getAxiosErrorMessage(e);
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSuccess(code: string, callbackURL: string, issuer: string) {
|
||||
const redirectURL = new URL(callbackURL);
|
||||
if (redirectURL.protocol == 'javascript:' || redirectURL.protocol == 'data:') {
|
||||
throw new Error('Invalid redirect URL protocol');
|
||||
}
|
||||
|
||||
success = true;
|
||||
setTimeout(() => {
|
||||
if (responseMode === 'form_post') {
|
||||
// Create a hidden form and submit it via POST
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = callbackURL;
|
||||
|
||||
// Add code parameter
|
||||
const codeInput = document.createElement('input');
|
||||
codeInput.type = 'hidden';
|
||||
codeInput.name = 'code';
|
||||
codeInput.value = code;
|
||||
form.appendChild(codeInput);
|
||||
|
||||
// Add state parameter
|
||||
if (authorizeState) {
|
||||
const stateInput = document.createElement('input');
|
||||
stateInput.type = 'hidden';
|
||||
stateInput.name = 'state';
|
||||
stateInput.value = authorizeState;
|
||||
form.appendChild(stateInput);
|
||||
}
|
||||
|
||||
// Add issuer parameter
|
||||
const issInput = document.createElement('input');
|
||||
issInput.type = 'hidden';
|
||||
issInput.name = 'iss';
|
||||
issInput.value = issuer;
|
||||
form.appendChild(issInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
} else {
|
||||
window.location.href = createRedirectURL(callbackURL, {
|
||||
code,
|
||||
state: authorizeState,
|
||||
iss: issuer
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function createRedirectURL(url: string, params: Record<string, string>) {
|
||||
const redirectURL = new URL(url);
|
||||
const responseParams =
|
||||
responseMode === 'fragment'
|
||||
? new URLSearchParams(redirectURL.hash.slice(1))
|
||||
: redirectURL.searchParams;
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) {
|
||||
responseParams.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (responseMode === 'fragment') {
|
||||
redirectURL.hash = responseParams.toString();
|
||||
}
|
||||
|
||||
return redirectURL.toString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.sign_in_to({ name: client.name })}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if client == null}
|
||||
<p>{m.client_not_found()}</p>
|
||||
{:else}
|
||||
<SignInWrapper showAlternativeSignInMethodButton={$userStore == null}>
|
||||
<ClientProviderImages {client} {success} error={!!errorMessage} />
|
||||
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
|
||||
{m.sign_in_to({ name: client.name })}
|
||||
</h1>
|
||||
{#if errorMessage}
|
||||
<p class="text-muted-foreground mt-2 mb-10">
|
||||
{errorMessage}.
|
||||
</p>
|
||||
{/if}
|
||||
{#if authorizationRequired}
|
||||
<div class="w-full max-w-md" transition:slide={{ duration: 300 }}>
|
||||
<Card.Root class="mt-6 mb-10">
|
||||
<Card.Header>
|
||||
<p class="text-muted-foreground text-start">
|
||||
<FormattedMessage
|
||||
m={m.client_wants_to_access_the_following_information({ client: client.name })}
|
||||
/>
|
||||
</p>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<ScopeList {scope} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{:else if accountSelectionRequired && $userStore && !errorMessage}
|
||||
<div transition:slide={{ duration: 300 }} class="flex flex-col items-center">
|
||||
<p class="text-muted-foreground mt-2 mb-8">
|
||||
<FormattedMessage m={m.account_selection_signin_confirmation({ name: client.name })} />
|
||||
</p>
|
||||
<Card.Root class="mb-2 py-4 w-sm" data-testid="account-selection">
|
||||
<Card.Content class="flex items-center gap-4">
|
||||
<Avatar.Root class="size-11 shrink-0">
|
||||
<Avatar.Image src={cachedProfilePicture.getUrl($userStore.id)} />
|
||||
</Avatar.Root>
|
||||
<div class="flex min-w-0 flex-col text-start">
|
||||
<p class="truncate text-base leading-tight font-medium">
|
||||
{primaryName}
|
||||
</p>
|
||||
{#if fullName && $userStore.email}
|
||||
<p class="text-muted-foreground mt-1 truncate text-sm leading-tight">
|
||||
{$userStore.email}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<div class="mb-10 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground text-xs transition-colors hover:underline"
|
||||
onclick={useDifferentAccount}
|
||||
>
|
||||
{m.use_a_different_account()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !authorizationRequired && !errorMessage}
|
||||
<p class="text-muted-foreground mt-2 mb-10">
|
||||
<FormattedMessage
|
||||
m={m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
|
||||
client: client.name,
|
||||
appName: $appConfigStore.appName
|
||||
})}
|
||||
/>
|
||||
</p>
|
||||
{/if}
|
||||
<!-- Flex flow is reversed so the sign in button, which has auto-focus, is the first one in the DOM, for a11y -->
|
||||
<div class="flex w-full max-w-md flex-row-reverse gap-2">
|
||||
{#if !errorMessage}
|
||||
<Button class="flex-1" {isLoading} onclick={authorize} autofocus={true}>
|
||||
{m.sign_in()}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button class="flex-1" onclick={() => (errorMessage = null)}>
|
||||
{m.try_again()}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button href={document.referrer || '/'} class="flex-1" variant="secondary">
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
</div>
|
||||
</SignInWrapper>
|
||||
{/if}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -8,9 +8,10 @@
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import OIDCService from '$lib/services/oidc-service';
|
||||
import WebAuthnService from '$lib/services/webauthn-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type';
|
||||
import { getAxiosErrorMessage } from '$lib/utils/error-util';
|
||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -29,6 +30,8 @@
|
||||
let success = $state(false);
|
||||
let errorMessage: string | null = $state(null);
|
||||
let authorizationRequired = $state(false);
|
||||
let reauthenticationRequired = $state(false);
|
||||
let reauthenticated = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (data.code && $userStore) {
|
||||
@@ -56,15 +59,36 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.reauthenticationRequired && !reauthenticationRequired && !authorizationRequired) {
|
||||
reauthenticationRequired = true;
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.reauthenticationRequired && !reauthenticated) {
|
||||
await reauthenticate();
|
||||
reauthenticated = true;
|
||||
}
|
||||
|
||||
await oidcService.verifyDeviceCode(userCode);
|
||||
|
||||
success = true;
|
||||
} catch (e) {
|
||||
errorMessage = getAxiosErrorMessage(e);
|
||||
errorMessage = getWebauthnErrorMessage(e);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reauthenticate() {
|
||||
try {
|
||||
await webauthnService.reauthenticate();
|
||||
} catch {
|
||||
const loginOptions = await webauthnService.getLoginOptions();
|
||||
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||
await webauthnService.reauthenticate(authResponse);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -86,6 +110,15 @@
|
||||
</p>
|
||||
{:else if success}
|
||||
<p class="text-muted-foreground mt-2">{m.the_device_has_been_authorized()}</p>
|
||||
{:else if reauthenticationRequired && deviceInfo?.client}
|
||||
<p class="text-muted-foreground mt-2">
|
||||
<FormattedMessage
|
||||
m={m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
|
||||
client: deviceInfo.client.name,
|
||||
appName: $appConfigStore.appName
|
||||
})}
|
||||
/>
|
||||
</p>
|
||||
{:else if authorizationRequired}
|
||||
<div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
|
||||
<Card.Root class="mt-6">
|
||||
@@ -99,7 +132,7 @@
|
||||
</p>
|
||||
</Card.Header>
|
||||
<Card.Content data-testid="scopes">
|
||||
<ScopeList scope={deviceInfo!.scope} />
|
||||
<ScopeList scopes={deviceInfo!.scope} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
197
frontend/src/routes/interaction/+page.svelte
Normal file
197
frontend/src/routes/interaction/+page.svelte
Normal file
@@ -0,0 +1,197 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import FormattedMessage from '$lib/components/formatted-message.svelte';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import ScopeList from '$lib/components/scope-list.svelte';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import OidcService from '$lib/services/oidc-service';
|
||||
import WebAuthnService from '$lib/services/webauthn-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { InteractionStep } from '$lib/types/oidc.type';
|
||||
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { slide } from 'svelte/transition';
|
||||
import ClientProviderImages from '../authorize/components/client-provider-images.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
const webauthnService = new WebAuthnService();
|
||||
const oidcService = new OidcService();
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let interactionSession = $state(data.interactionSession);
|
||||
let isLoading = $state(false);
|
||||
let success = $state(false);
|
||||
let errorMessage: string | null = $state(null);
|
||||
let currentStep = $derived(interactionSession.currentStep);
|
||||
|
||||
const fullName = $derived.by(() => {
|
||||
if (!$userStore) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($userStore.displayName) {
|
||||
return $userStore.displayName;
|
||||
}
|
||||
|
||||
return [$userStore.firstName, $userStore.lastName].filter(Boolean).join(' ').trim();
|
||||
});
|
||||
const primaryName = $derived(fullName || $userStore?.email || '');
|
||||
|
||||
async function useDifferentAccount() {
|
||||
await webauthnService.logout();
|
||||
await completeInteraction('select_account', true);
|
||||
userStore.clearUser();
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
async function handlePipeline() {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
try {
|
||||
if (!$userStore) {
|
||||
await authenticate();
|
||||
}
|
||||
|
||||
if (currentStep === 'reauthenticate') {
|
||||
await reauthenticate();
|
||||
}
|
||||
|
||||
await completeInteraction(currentStep!);
|
||||
} catch (e) {
|
||||
success = false;
|
||||
errorMessage = getWebauthnErrorMessage(e);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const loginOptions = await webauthnService.getLoginOptions();
|
||||
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||
const user = await webauthnService.finishLogin(authResponse);
|
||||
await userStore.setUser(user);
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
async function reauthenticate() {
|
||||
try {
|
||||
await webauthnService.reauthenticate();
|
||||
} catch {
|
||||
const loginOptions = await webauthnService.getLoginOptions();
|
||||
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||
await webauthnService.reauthenticate(authResponse);
|
||||
}
|
||||
}
|
||||
|
||||
async function completeInteraction(step: InteractionStep, skipRedirect = false) {
|
||||
const result = await oidcService.completeAuthorizeInteractionStep(interactionSession.id, step);
|
||||
if (result.interaction) {
|
||||
interactionSession = result.interaction;
|
||||
if (interactionSession.currentStep == 'reauthenticate') {
|
||||
await handlePipeline();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.redirectUrl && !skipRedirect) {
|
||||
success = true;
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
window.location.href = result.redirectUrl;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.sign_in_to({ name: interactionSession.client.name })}</title>
|
||||
</svelte:head>
|
||||
|
||||
<SignInWrapper showAlternativeSignInMethodButton={$userStore == null}>
|
||||
<ClientProviderImages client={interactionSession.client} {success} error={!!errorMessage} />
|
||||
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
|
||||
{m.sign_in_to({ name: interactionSession.client.name })}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-2 mb-10">
|
||||
{#if errorMessage}
|
||||
{errorMessage}.
|
||||
{:else if currentStep == 'select_account' && $userStore}
|
||||
<FormattedMessage
|
||||
m={m.account_selection_signin_confirmation({ name: interactionSession.client.name })}
|
||||
/>
|
||||
{:else}
|
||||
<FormattedMessage
|
||||
m={m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
|
||||
client: interactionSession.client.name,
|
||||
appName: $appConfigStore.appName
|
||||
})}
|
||||
/>
|
||||
{/if}
|
||||
</p>
|
||||
{#if !$userStore || errorMessage}
|
||||
<!-- Return nothing -->
|
||||
{:else if currentStep === 'select_account'}
|
||||
<div
|
||||
transition:slide={{ duration: 300 }}
|
||||
class="flex flex-col items-center"
|
||||
data-testid="account-selection"
|
||||
>
|
||||
{#if $userStore}
|
||||
<Card.Root class="mb-2 py-4 w-sm">
|
||||
<Card.Content class="flex items-center gap-4">
|
||||
<Avatar.Root class="size-11 shrink-0">
|
||||
<Avatar.Image src={cachedProfilePicture.getUrl($userStore.id)} />
|
||||
</Avatar.Root>
|
||||
<div class="flex min-w-0 flex-col text-start">
|
||||
<p class="truncate text-base leading-tight font-medium">
|
||||
{primaryName}
|
||||
</p>
|
||||
{#if fullName && $userStore.email}
|
||||
<p class="text-muted-foreground mt-1 truncate text-sm leading-tight">
|
||||
{$userStore.email}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
<div class="mb-10 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground text-xs transition-colors hover:underline"
|
||||
onclick={useDifferentAccount}
|
||||
>
|
||||
{m.use_a_different_account()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if currentStep === 'consent'}
|
||||
<div class="w-full max-w-md" transition:slide={{ duration: 300 }}>
|
||||
<Card.Root class="mt-6 mb-10">
|
||||
<Card.Header>
|
||||
<p class="text-muted-foreground text-start">
|
||||
<FormattedMessage
|
||||
m={m.client_wants_to_access_the_following_information({
|
||||
client: interactionSession.client.name
|
||||
})}
|
||||
/>
|
||||
</p>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<ScopeList scopes={interactionSession.scopes} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex w-full max-w-md flex-row-reverse gap-2">
|
||||
<Button class="flex-1" {isLoading} onclick={handlePipeline} autofocus={true}>
|
||||
{errorMessage ? m.try_again() : m.sign_in()}
|
||||
</Button>
|
||||
<Button class="flex-1" variant="secondary" href={document.referrer || '/'}>
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
</div>
|
||||
</SignInWrapper>
|
||||
16
frontend/src/routes/interaction/+page.ts
Normal file
16
frontend/src/routes/interaction/+page.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import OidcService from '$lib/services/oidc-service';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
const interactionSessionId = url.searchParams.get('interaction');
|
||||
if (!interactionSessionId) {
|
||||
error(400, 'Missing authorize interaction');
|
||||
}
|
||||
|
||||
const oidcService = new OidcService();
|
||||
const interactionSession = await oidcService.getAuthorizeInteraction(interactionSessionId);
|
||||
return {
|
||||
interactionSession
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
@@ -30,7 +31,8 @@
|
||||
issuer: '',
|
||||
subject: '',
|
||||
audience: '',
|
||||
jwks: ''
|
||||
jwks: '',
|
||||
replayProtection: true
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -39,10 +41,10 @@
|
||||
federatedIdentities = federatedIdentities.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function updateFederatedIdentity(
|
||||
function updateFederatedIdentity<K extends keyof OidcClientFederatedIdentity>(
|
||||
index: number,
|
||||
field: keyof OidcClientFederatedIdentity,
|
||||
value: string
|
||||
field: K,
|
||||
value: OidcClientFederatedIdentity[K]
|
||||
) {
|
||||
federatedIdentities[index] = {
|
||||
...federatedIdentities[index],
|
||||
@@ -80,7 +82,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<Field.Field>
|
||||
<Field.Label required for="issuer-{i}">Issuer</Field.Label>
|
||||
<Input
|
||||
@@ -136,6 +138,13 @@
|
||||
<Field.Error>{getFieldError(i, 'jwks')}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<SwitchWithLabel
|
||||
id="replay-protection-{i}"
|
||||
label={m.replay_protection()}
|
||||
description={m.replay_protection_description()}
|
||||
checked={identity.replayProtection}
|
||||
onCheckedChange={(checked) => updateFederatedIdentity(i, 'replayProtection', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -86,7 +86,8 @@
|
||||
issuer: z.url(),
|
||||
subject: z.string().optional(),
|
||||
audience: z.string().optional(),
|
||||
jwks: z.url().optional().or(z.literal(''))
|
||||
jwks: z.url().optional().or(z.literal('')),
|
||||
replayProtection: z.boolean().default(true)
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -208,7 +209,6 @@
|
||||
onCheckedChange={(v) => {
|
||||
if (v) {
|
||||
$inputs.pkceEnabled.value = true;
|
||||
$inputs.requiresPushedAuthorizationRequests.value = false;
|
||||
}
|
||||
}}
|
||||
bind:checked={$inputs.isPublic.value}
|
||||
@@ -278,7 +278,6 @@
|
||||
id="requires-par"
|
||||
label={m.requires_pushed_authorization_requests()}
|
||||
description={m.requires_pushed_authorization_requests_description()}
|
||||
disabled={$inputs.isPublic.value}
|
||||
bind:checked={$inputs.requiresPushedAuthorizationRequests.value}
|
||||
/>
|
||||
{#if mode == 'create'}
|
||||
|
||||
@@ -41,6 +41,9 @@ export default defineConfig((mode) => {
|
||||
},
|
||||
'/.well-known': {
|
||||
target: process.env.DEVELOPMENT_BACKEND_URL || 'http://localhost:1411'
|
||||
},
|
||||
'^/authorize(?:\\?.*)?$': {
|
||||
target: process.env.DEVELOPMENT_BACKEND_URL || 'http://localhost:1411'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,27 +28,27 @@ export const oidcClients = {
|
||||
nextcloud: {
|
||||
id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
|
||||
name: 'Nextcloud',
|
||||
callbackUrl: 'http://nextcloud/auth/callback',
|
||||
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback',
|
||||
callbackUrl: 'http://nextcloud.localhost/auth/callback',
|
||||
logoutCallbackUrl: 'http://nextcloud.localhost/auth/logout/callback',
|
||||
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY',
|
||||
launchURL: 'https://nextcloud.local'
|
||||
},
|
||||
immich: {
|
||||
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
|
||||
name: 'Immich',
|
||||
callbackUrl: 'http://immich/auth/callback',
|
||||
callbackUrl: 'http://immich.localhost/auth/callback',
|
||||
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
|
||||
},
|
||||
tailscale: {
|
||||
id: '7c21a609-96b5-4011-9900-272b8d31a9d1',
|
||||
name: 'Tailscale',
|
||||
callbackUrl: 'http://tailscale/auth/callback',
|
||||
callbackUrl: 'http://tailscale.localhost/auth/callback',
|
||||
secret: 'n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo'
|
||||
},
|
||||
federated: {
|
||||
id: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
|
||||
name: 'Federated',
|
||||
callbackUrl: 'http://federated/auth/callback',
|
||||
callbackUrl: 'http://federated.localhost/auth/callback',
|
||||
federatedJWT: {
|
||||
issuer: 'https://external-idp.local',
|
||||
audience: 'api://PocketID',
|
||||
@@ -59,19 +59,19 @@ export const oidcClients = {
|
||||
scim: {
|
||||
id: 'c46d2090-37a0-4f2b-8748-6aa53b0c1afa',
|
||||
name: 'SCIM Client',
|
||||
callbackUrl: 'http://scim.client/auth/callback',
|
||||
callbackUrl: 'http://scimclient.localhost/auth/callback',
|
||||
secret: 'nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn'
|
||||
},
|
||||
pingvinShare: {
|
||||
name: 'Pingvin Share',
|
||||
callbackUrl: 'http://pingvin.share/auth/callback',
|
||||
secondCallbackUrl: 'http://pingvin.share/auth/callback2',
|
||||
callbackUrl: 'http://pingvin-share.localhost/auth/callback',
|
||||
secondCallbackUrl: 'http://pingvin-share.localhost/auth/callback2',
|
||||
launchURL: 'https://pingvin-share.local'
|
||||
},
|
||||
parClient: {
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef0000000001',
|
||||
name: 'PAR Test Client',
|
||||
callbackUrl: 'http://par-client/auth/callback',
|
||||
callbackUrl: 'http://par-client.localhost/auth/callback',
|
||||
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260601154900,
|
||||
"version": 20260607120000,
|
||||
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
|
||||
"tables": {
|
||||
"api_keys": [
|
||||
@@ -39,37 +39,9 @@
|
||||
"value": "7d/5hl7diJ2rnFL14hEAQf9tzpu29aqXQ8jpJ2iqqKUNFZpdOkEpud0CmRv4H3r8yyk2u/Gqqj9klSy58DJkYXGF5PAYgLyoBIb7L3JXWRbxg4cQ3QJCug13l2OTmpAKoVc+rmX8c3j3h1sNqyJ+7Ql5sS0jSeyiYgIsFNCdnK5alBDyvtcpe/QDpklmP4JCeVpvmf2rLGplk3g5UO5ydJ8UiDXxfDmi+gF6NKJvrGnnah8Ar3G/x88z+tTJtp0DIQFwxXwUM2XZqzEVGm8K2r0w5o9/Keh6bBBaiuH2C78ZOaijGV3DovhR+e9J0cYUYGwT42MZMx9fSWQ/lvWGGnf+Uq3MXJfjWSREfhkp8KTQwR9F7+dnVJWswOEk7jPR8I7hCWTMxJyvaFX3wgAXIVmhrgXZQQbYOqTt56IoqUl0xOJku8dA8opg2UcLlmmuOh6+hfkXKsiiS/H/9c1BVIGj1fCOiT6IePh4wKKSTbwJnPD5EKmdJpgTsUpjcDnXQKY4ReO0UpdRdKxwRDDLeQuG6j+ljGxR9GPudCU9Nmci6rFVI6n5LWYkQxBA1O73RpmXRZPDzntDfpXMEonkmSvOoxaCK2Id7CRKMdqvR0kEouwnhk5WSFtsfi3sA0pkXzPFxwZeWM8vFtbffZOZzXaOhxCOfcj1NClZohlZhyc4jvkxmrpY7PSaAzih0AmHI7y0LYFi6fZu/K4EheVa1+KF55nWZ8ARikHMWKAKkyExkTak7xyN884TDmzURRaPlQg4jzQte5WMNjAG/hlHibdMBNvgwiYd49ZxteJ8ABdbiXVRl+2JGbdjl2ubpQZwOn7bJKlqO56bIwsZ+e4+pXsuOGdBahkHrUjtMEmH3DZbGc6CJLbcmdhdpApLQRRcLAazxJhzAwJ47FRYsHsj57LnYNvmcKdIxw8rxCdLUuzz95uw0T3ankEO5J9sjem+HMEuKdwXK1UcuOn2rjR8Sd/BuvQmeso27dFbPXqXYNS90Ml45YyTvcKSiopD181oZR703TFUSpR7dsiqROMr+p/2jN9h6a8WbQ8xpksyclaQByY/M77AssbXnG6wfhRsntNIINCZLbBnjXOyz6ZHIC5K4tSTdcnWaiYPeRPQmnw9UUvHAcNU2yMWsy0eU377yDS0WstTxOdQutTdkczl8kv5Lo26JiEK7mSIuRK19ffF9Zz8FG8+eKv5zdyIPjyQRDYBysUoDv5huKe2eoxJu/MWS2Pql/ZtUGeD6Ozm3mCvh0vQ9ceagBkY6Ocm3du0ziAKP29Ri0mjg4DizVorbLzsh+EQH/s2Pi9MnjUZDlEmuLl2Xfp7/w4j/8u0N0tVR70VDFuGdKpTjFY3vS8EJrPtyMTM51x1D9rb8gIql8aR/rJw4YF+huxg1mv5n6+tGVqg5msbPmF12eJijP4lkmaRwIpLW5pJTtaDkUj7uOeu1mm4k+Dt5nh0/0jPHzrv6bcTCcbV7UjMHDoTXXqEpFAAJ66rHR7zdAJu+YKsnTIZyLmOpcowq7LL8G9qTvV0OSpyQWUIavRSgbDHFqEqRs+JU94jAzkq8nCY5MTd9m5sIv9InfdT3k+pwpsE/FKge8nghFLtbUrafGkzTky8SE2druvVcIvbfXMfLIKRUYjJgnWc0gQzF5J6pzXM7D2r/RG6JDzASqjlbURq6v9bhNerlOVdMujWKEEVcKWIzlbt4RkihRjM8AUqIZQOyicGQ+4yfIjAHw5viuABONYs3OIWULnFqJxdvS9rNKhfxSjIq9cfqyzevq2xrRoMXEonobh6M3bD2Vang8OAeVeD1OXWPERi4pepCYFS9RJ/Xa/UWxptsqSNuGcb3fAzQSmLpXLGdWRoKXvSe7EYgc0bGcLOjSTu5RURKo+EF9i4KT9EJauf6VXw5dTf/CCIJRXE1bWzXhSCFYntohYhX2ldOCDYpi/jFBC6Vtkw0ud3/xq8Nmhd5gUk+SpngByCZH3Pm3H+jvlbMpiqkDkm1v74hDX13Xhrcw2eWyuqKBVoRCCniUvwpYNbGvBfjC6Hcizv0Aybciwj+4nybt5EPoEUm6S6Gs7fG7QpPdvrzpAxX70MlmdkF/gwyuhbEeJhLK+WL7qAsN5CvHPzVbsIf90x+nGTtMJPgpxVr0tJMj+vprXV4WxutfARBiOnqe58MhA857sd+MzKBgKnoLOBRTiC3qc/0/ULwbG2HCCD7nmwzz7M4nUuMvo8rgS7z0BF68OClT8X3JwSXbL5Wg=="
|
||||
}
|
||||
],
|
||||
"oidc_authorization_codes": [
|
||||
{
|
||||
"client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
"authentication_method": "phr",
|
||||
"code": "auth-code",
|
||||
"code_challenge": null,
|
||||
"code_challenge_method_sha256": null,
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"expires_at": "2025-11-25T13:39:02Z",
|
||||
"id": "6bdd221e-d9f7-4e3d-92c0-4be125802ba2",
|
||||
"nonce": "nonce",
|
||||
"scope": "openid profile",
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
},
|
||||
{
|
||||
"client_id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
||||
"authentication_method": "phr",
|
||||
"code": "federated",
|
||||
"code_challenge": null,
|
||||
"code_challenge_method_sha256": null,
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"expires_at": "2025-11-25T13:39:02Z",
|
||||
"id": "37e914bd-ff2c-4653-8cd8-550f0213e430",
|
||||
"nonce": "nonce",
|
||||
"scope": "openid profile",
|
||||
"user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036"
|
||||
}
|
||||
],
|
||||
"oidc_clients": [
|
||||
{
|
||||
"callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkL2F1dGgvY2FsbGJhY2siXQ==",
|
||||
"callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
"credentials": "e30=",
|
||||
@@ -79,7 +51,7 @@
|
||||
"is_group_restricted": false,
|
||||
"is_public": false,
|
||||
"launch_url": "https://nextcloud.local",
|
||||
"logout_callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=",
|
||||
"logout_callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkLmxvY2FsaG9zdC9hdXRoL2xvZ291dC9jYWxsYmFjayJd",
|
||||
"name": "Nextcloud",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
@@ -87,7 +59,7 @@
|
||||
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC"
|
||||
},
|
||||
{
|
||||
"callback_urls": "WyJodHRwOi8vaW1taWNoL2F1dGgvY2FsbGJhY2siXQ==",
|
||||
"callback_urls": "WyJodHRwOi8vaW1taWNoLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
||||
"credentials": "e30=",
|
||||
@@ -105,7 +77,7 @@
|
||||
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
|
||||
},
|
||||
{
|
||||
"callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlL2F1dGgvY2FsbGJhY2siXQ==",
|
||||
"callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
"credentials": "e30=",
|
||||
@@ -115,7 +87,7 @@
|
||||
"is_group_restricted": true,
|
||||
"is_public": false,
|
||||
"launch_url": null,
|
||||
"logout_callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=",
|
||||
"logout_callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2xvZ291dC9jYWxsYmFjayJd",
|
||||
"name": "Tailscale",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
@@ -123,7 +95,7 @@
|
||||
"secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a"
|
||||
},
|
||||
{
|
||||
"callback_urls": "WyJodHRwOi8vZmVkZXJhdGVkL2F1dGgvY2FsbGJhY2siXQ==",
|
||||
"callback_urls": "WyJodHRwOi8vZmVkZXJhdGVkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
||||
"credentials": "eyJmZWRlcmF0ZWRJZGVudGl0aWVzIjpbeyJpc3N1ZXIiOiJodHRwczovL2V4dGVybmFsLWlkcC5sb2NhbCIsInN1YmplY3QiOiJjNDgyMzJmZi1mZjY1LTQ1ZWQtYWU5Ni03YWZhOGE5YjQ0M2IiLCJhdWRpZW5jZSI6ImFwaTovL1BvY2tldElEIiwiandrcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6MTQxMS9hcGkvZXh0ZXJuYWxpZHAvandrcy5qc29uIn1dfQ==",
|
||||
@@ -141,7 +113,7 @@
|
||||
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
|
||||
},
|
||||
{
|
||||
"callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC9hdXRoL2NhbGxiYWNrIl0=",
|
||||
"callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
"dark_image_type": null,
|
||||
@@ -158,7 +130,7 @@
|
||||
"secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK"
|
||||
},
|
||||
{
|
||||
"callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC9hdXRoL2NhbGxiYWNrIl0=",
|
||||
"callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
"credentials": "e30=",
|
||||
@@ -190,19 +162,6 @@
|
||||
"user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368"
|
||||
}
|
||||
],
|
||||
"oidc_refresh_tokens": [
|
||||
{
|
||||
"client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
"authentication_method": "phr",
|
||||
"id_token_jti": "dd75f6f6-ce0a-44b7-a645-7de390ccd2fa",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"expires_at": "2025-11-26T12:39:02Z",
|
||||
"id": "4928604e-e689-410c-9b25-5b9b6db9e46e",
|
||||
"scope": "openid profile email",
|
||||
"token": "fef6e2e37eb990f0bd7abd48a41d530c54b6a1f139b556e35e62475e6f4cb38d",
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
}
|
||||
],
|
||||
"one_time_access_tokens": [
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -273,25 +232,25 @@
|
||||
{
|
||||
"client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
"last_used_at": "2025-08-01T13:00:00Z",
|
||||
"scope": "openid profile email",
|
||||
"scope": "WyJvcGVuaWQiLCJwcm9maWxlIiwiZW1haWwiXQ==",
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
},
|
||||
{
|
||||
"client_id": "7c21a609-96b5-4011-9900-272b8d31a9d1",
|
||||
"last_used_at": "2025-08-10T14:00:00Z",
|
||||
"scope": "openid profile email",
|
||||
"scope": "WyJvcGVuaWQiLCJwcm9maWxlIiwiZW1haWwiXQ==",
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
},
|
||||
{
|
||||
"client_id": "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
|
||||
"last_used_at": "2024-01-01T00:00:00Z",
|
||||
"scope": "openid profile email",
|
||||
"scope": "WyJvcGVuaWQiLCJwcm9maWxlIiwiZW1haWwiXQ==",
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
},
|
||||
{
|
||||
"client_id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
||||
"last_used_at": "2025-08-12T12:00:00Z",
|
||||
"scope": "openid profile email",
|
||||
"scope": "WyJvcGVuaWQiLCJwcm9maWxlIiwiZW1haWwiXQ==",
|
||||
"user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import test, { expect, type Page, type Request } from '@playwright/test';
|
||||
import test, { expect, type APIRequestContext, type Page, type Request } from '@playwright/test';
|
||||
import { oidcClients, refreshTokens, users } from '../data';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
import { generateIdToken, generateOauthAccessToken } from '../utils/jwt.util';
|
||||
import { generateIdToken } from '../utils/jwt.util';
|
||||
import * as oidcUtil from '../utils/oidc.util';
|
||||
import passkeyUtil from '../utils/passkey.util';
|
||||
|
||||
test.beforeEach(async () => await cleanupBackend());
|
||||
|
||||
async function generateSeededOauthAccessToken(
|
||||
request: APIRequestContext,
|
||||
userId: string,
|
||||
clientId: string,
|
||||
expired = false
|
||||
) {
|
||||
return request
|
||||
.post('/api/test/accesstoken', {
|
||||
data: {
|
||||
client: clientId,
|
||||
expired,
|
||||
user: userId
|
||||
}
|
||||
})
|
||||
.then((r) => r.text());
|
||||
}
|
||||
|
||||
test('Authorize existing client', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
@@ -59,6 +76,7 @@ test('Authorize new client while not signed in', async ({ page }) => {
|
||||
test('Authorize new client fails with user group not allowed', async ({ page }) => {
|
||||
const oidcClient = oidcClients.immich;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
@@ -67,11 +85,15 @@ test('Authorize new client fails with user group not allowed', async ({ page })
|
||||
|
||||
await expectScopes(page, ['Email', 'Profile']);
|
||||
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
await expect(page.getByRole('paragraph').first()).toHaveText(
|
||||
"You're not allowed to access this service."
|
||||
const callbackUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
|
||||
page.getByRole('button', { name: 'Sign in' }).click()
|
||||
);
|
||||
|
||||
expect(callbackUrl.searchParams.get('error')).toBe('access_denied');
|
||||
expect(callbackUrl.searchParams.get('error_description')).toContain(
|
||||
'You are not allowed to access this service.'
|
||||
);
|
||||
expect(callbackUrl.searchParams.get('state')).toBe(urlParams.get('state'));
|
||||
});
|
||||
|
||||
function createUrlParams(oidcClient: { id: string; callbackUrl: string }) {
|
||||
@@ -117,6 +139,60 @@ test('End session with id token hint redirects to callback URL', async ({ page }
|
||||
);
|
||||
});
|
||||
|
||||
test('End session with id token hint redirects to callback URL without UI session', async ({
|
||||
page
|
||||
}) => {
|
||||
const client = oidcClients.nextcloud;
|
||||
const idToken = await generateIdToken(
|
||||
'fe81c12a-7336-4aee-bebc-d901a873bf48',
|
||||
users.tim,
|
||||
client.id
|
||||
);
|
||||
await page.context().clearCookies();
|
||||
|
||||
const callbackUrl = await expectCallbackRedirect(page, client.logoutCallbackUrl, () =>
|
||||
page.goto(
|
||||
`/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}&state=logout-state`
|
||||
)
|
||||
);
|
||||
expect(callbackUrl.searchParams.get('state')).toBe('logout-state');
|
||||
});
|
||||
|
||||
test('End session ignores an unregistered post_logout_redirect_uri', async ({ page }) => {
|
||||
// A post_logout_redirect_uri the client never registered must not be honored, otherwise
|
||||
// RP-initiated logout becomes an open redirect. The handler falls back to the in-app
|
||||
// logout confirmation instead of bouncing the user to the attacker URL.
|
||||
const client = oidcClients.nextcloud;
|
||||
const idToken = await generateIdToken(
|
||||
'fe81c12a-7336-4aee-bebc-d901a873bf48',
|
||||
users.tim,
|
||||
client.id
|
||||
);
|
||||
|
||||
await page.goto(
|
||||
`/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=http://evil.localhost/steal`
|
||||
);
|
||||
|
||||
await expect(page).toHaveURL('/logout');
|
||||
});
|
||||
|
||||
test('End session rejects an id token hint with a mismatched client_id', async ({ page }) => {
|
||||
const client = oidcClients.nextcloud;
|
||||
const idToken = await generateIdToken(
|
||||
'fe81c12a-7336-4aee-bebc-d901a873bf48',
|
||||
users.tim,
|
||||
client.id
|
||||
);
|
||||
|
||||
// The explicit client_id contradicts the audience baked into the id_token_hint, so the
|
||||
// logout must not be auto-confirmed against another client's callback.
|
||||
await page.goto(
|
||||
`/api/oidc/end-session?id_token_hint=${idToken}&client_id=${oidcClients.immich.id}`
|
||||
);
|
||||
|
||||
await expect(page).toHaveURL('/logout');
|
||||
});
|
||||
|
||||
test('Successfully refresh tokens with valid refresh token', async ({ request }) => {
|
||||
const { token, clientId, userId } = refreshTokens.filter((token) => !token.expired)[0];
|
||||
const clientSecret = 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY';
|
||||
@@ -150,8 +226,9 @@ test('Successfully refresh tokens with valid refresh token', async ({ request })
|
||||
expect(tokenData.access_token).toBeDefined();
|
||||
expect(tokenData.refresh_token).toBeDefined();
|
||||
expect(tokenData.id_token).toBeDefined();
|
||||
expect(tokenData.token_type).toBe('Bearer');
|
||||
expect(tokenData.expires_in).toBe(3600);
|
||||
expect(tokenData.token_type).toBe('bearer');
|
||||
expect(tokenData.expires_in).toBeGreaterThanOrEqual(3598);
|
||||
expect(tokenData.expires_in).toBeLessThanOrEqual(3600);
|
||||
|
||||
// The new refresh token should be different from the old one
|
||||
expect(tokenData.refresh_token).not.toBe(token);
|
||||
@@ -264,7 +341,11 @@ test('Using refresh token invalidates it for future use', async ({ request }) =>
|
||||
|
||||
test.describe('Introspection endpoint', () => {
|
||||
test('fails without client credentials', async ({ request }) => {
|
||||
const validAccessToken = await generateOauthAccessToken(users.tim, oidcClients.nextcloud.id);
|
||||
const validAccessToken = await generateSeededOauthAccessToken(
|
||||
request,
|
||||
users.tim.id,
|
||||
oidcClients.nextcloud.id
|
||||
);
|
||||
const introspectionResponse = await request.post('/api/oidc/introspect', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
@@ -273,12 +354,15 @@ test.describe('Introspection endpoint', () => {
|
||||
token: validAccessToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(introspectionResponse.status()).toBe(400);
|
||||
expect(introspectionResponse.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('succeeds with client credentials', async ({ request, baseURL }) => {
|
||||
const validAccessToken = await generateOauthAccessToken(users.tim, oidcClients.nextcloud.id);
|
||||
const validAccessToken = await generateSeededOauthAccessToken(
|
||||
request,
|
||||
users.tim.id,
|
||||
oidcClients.nextcloud.id
|
||||
);
|
||||
const introspectionResponse = await request.post('/api/oidc/introspect', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -292,18 +376,20 @@ test.describe('Introspection endpoint', () => {
|
||||
token: validAccessToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(introspectionResponse.status()).toBe(200);
|
||||
const introspectionBody = await introspectionResponse.json();
|
||||
expect(introspectionBody.active).toBe(true);
|
||||
expect(introspectionBody.token_type).toBe('access_token');
|
||||
expect(introspectionBody.iss).toBe(baseURL);
|
||||
expect(introspectionBody.sub).toBe(users.tim.id);
|
||||
expect(introspectionBody.aud).toStrictEqual([oidcClients.nextcloud.id]);
|
||||
});
|
||||
|
||||
test('succeeds with federated client credentials', async ({ page, request, baseURL }) => {
|
||||
const validAccessToken = await generateOauthAccessToken(users.tim, oidcClients.federated.id);
|
||||
const validAccessToken = await generateSeededOauthAccessToken(
|
||||
request,
|
||||
users.tim.id,
|
||||
oidcClients.federated.id
|
||||
);
|
||||
const clientAssertion = await oidcUtil.getClientAssertion(
|
||||
page,
|
||||
oidcClients.federated.federatedJWT
|
||||
@@ -322,14 +408,17 @@ test.describe('Introspection endpoint', () => {
|
||||
expect(introspectionResponse.status()).toBe(200);
|
||||
const introspectionBody = await introspectionResponse.json();
|
||||
expect(introspectionBody.active).toBe(true);
|
||||
expect(introspectionBody.token_type).toBe('access_token');
|
||||
expect(introspectionBody.iss).toBe(baseURL);
|
||||
expect(introspectionBody.sub).toBe(users.tim.id);
|
||||
expect(introspectionBody.aud).toStrictEqual([oidcClients.federated.id]);
|
||||
});
|
||||
|
||||
test('fails with client credentials for wrong app', async ({ request }) => {
|
||||
const validAccessToken = await generateOauthAccessToken(users.tim, oidcClients.nextcloud.id);
|
||||
const validAccessToken = await generateSeededOauthAccessToken(
|
||||
request,
|
||||
users.tim.id,
|
||||
oidcClients.nextcloud.id
|
||||
);
|
||||
const introspectionResponse = await request.post('/api/oidc/introspect', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -342,11 +431,16 @@ test.describe('Introspection endpoint', () => {
|
||||
}
|
||||
});
|
||||
|
||||
expect(introspectionResponse.status()).toBe(400);
|
||||
const introspectionBody = await introspectionResponse.json();
|
||||
expect(introspectionBody.active).toBe(false);
|
||||
});
|
||||
|
||||
test('fails with federated credentials for wrong app', async ({ page, request }) => {
|
||||
const validAccessToken = await generateOauthAccessToken(users.tim, oidcClients.nextcloud.id);
|
||||
const validAccessToken = await generateSeededOauthAccessToken(
|
||||
request,
|
||||
users.tim.id,
|
||||
oidcClients.nextcloud.id
|
||||
);
|
||||
const clientAssertion = await oidcUtil.getClientAssertion(
|
||||
page,
|
||||
oidcClients.federated.federatedJWT
|
||||
@@ -362,7 +456,9 @@ test.describe('Introspection endpoint', () => {
|
||||
}
|
||||
});
|
||||
|
||||
expect(introspectionResponse.status()).toBe(400);
|
||||
expect(introspectionResponse.status()).toBe(200);
|
||||
const introspectionBody = await introspectionResponse.json();
|
||||
expect(introspectionBody.active).toBe(false);
|
||||
});
|
||||
|
||||
test('non-expired refresh_token can be verified', async ({ request }) => {
|
||||
@@ -396,7 +492,6 @@ test.describe('Introspection endpoint', () => {
|
||||
expect(introspectionResponse.status()).toBe(200);
|
||||
const introspectionBody = await introspectionResponse.json();
|
||||
expect(introspectionBody.active).toBe(true);
|
||||
expect(introspectionBody.token_type).toBe('refresh_token');
|
||||
});
|
||||
|
||||
test('expired refresh_token can be verified', async ({ request }) => {
|
||||
@@ -433,21 +528,65 @@ test.describe('Introspection endpoint', () => {
|
||||
});
|
||||
|
||||
test("expired access_token can't be verified", async ({ request }) => {
|
||||
const expiredAccessToken = await generateOauthAccessToken(
|
||||
users.tim,
|
||||
const expiredAccessToken = await generateSeededOauthAccessToken(
|
||||
request,
|
||||
users.tim.id,
|
||||
oidcClients.nextcloud.id,
|
||||
true
|
||||
);
|
||||
const introspectionResponse = await request.post('/api/oidc/introspect', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization:
|
||||
'Basic ' +
|
||||
Buffer.from(`${oidcClients.nextcloud.id}:${oidcClients.nextcloud.secret}`).toString(
|
||||
'base64'
|
||||
)
|
||||
},
|
||||
form: {
|
||||
token: expiredAccessToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(introspectionResponse.status()).toBe(400);
|
||||
expect(introspectionResponse.status()).toBe(200);
|
||||
const introspectionBody = await introspectionResponse.json();
|
||||
expect(introspectionBody.active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Userinfo endpoint', () => {
|
||||
test('returns the claims for the granted scopes', async ({ request, baseURL }) => {
|
||||
const accessToken = await generateSeededOauthAccessToken(
|
||||
request,
|
||||
users.tim.id,
|
||||
oidcClients.nextcloud.id
|
||||
);
|
||||
|
||||
const res = await request.get('/api/oidc/userinfo', {
|
||||
headers: { Authorization: 'Bearer ' + accessToken }
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.sub).toBe(users.tim.id);
|
||||
expect(body.email).toBe(users.tim.email);
|
||||
expect(body.given_name).toBe(users.tim.firstname);
|
||||
expect(body.family_name).toBe(users.tim.lastname);
|
||||
expect(body.preferred_username).toBe(users.tim.username);
|
||||
// The picture claim must point at this instance, not leak an arbitrary host.
|
||||
expect(body.picture).toBe(`${baseURL}/api/users/${users.tim.id}/profile-picture.png`);
|
||||
});
|
||||
|
||||
test('fails without an access token', async ({ request }) => {
|
||||
const res = await request.get('/api/oidc/userinfo');
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('fails with an invalid access token', async ({ request }) => {
|
||||
const res = await request.get('/api/oidc/userinfo', {
|
||||
headers: { Authorization: 'Bearer not-a-real-token' }
|
||||
});
|
||||
expect(res.status()).not.toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -455,7 +594,7 @@ test('Authorize new client with device authorization flow', async ({ page }) =>
|
||||
const client = oidcClients.immich;
|
||||
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
|
||||
|
||||
await page.goto(`/device?code=${userCode}`);
|
||||
await page.goto(`/device?user_code=${userCode}`);
|
||||
|
||||
await expectScopes(page, ['Email', 'Profile']);
|
||||
|
||||
@@ -473,7 +612,7 @@ test('Authorize new client with device authorization flow while not signed in',
|
||||
const client = oidcClients.immich;
|
||||
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
|
||||
|
||||
await page.goto(`/device?code=${userCode}`);
|
||||
await page.goto(`/device?user_code=${userCode}`);
|
||||
|
||||
await (await passkeyUtil.init(page)).addPasskey();
|
||||
await page.getByRole('button', { name: 'Authorize' }).click();
|
||||
@@ -491,7 +630,7 @@ test('Authorize existing client with device authorization flow', async ({ page }
|
||||
const client = oidcClients.nextcloud;
|
||||
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
|
||||
|
||||
await page.goto(`/device?code=${userCode}`);
|
||||
await page.goto(`/device?user_code=${userCode}`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' })
|
||||
@@ -505,7 +644,7 @@ test('Authorize existing client with device authorization flow while not signed
|
||||
const client = oidcClients.nextcloud;
|
||||
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
|
||||
|
||||
await page.goto(`/device?code=${userCode}`);
|
||||
await page.goto(`/device?user_code=${userCode}`);
|
||||
|
||||
await (await passkeyUtil.init(page)).addPasskey();
|
||||
await page.getByRole('button', { name: 'Authorize' }).click();
|
||||
@@ -515,8 +654,53 @@ test('Authorize existing client with device authorization flow while not signed
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Device authorization flow forces reauthentication when client requires it', async ({
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
const client = oidcClients.nextcloud;
|
||||
await request.put(`/api/oidc/clients/${client.id}`, {
|
||||
data: {
|
||||
name: client.name,
|
||||
callbackURLs: [client.callbackUrl],
|
||||
logoutCallbackURLs: [client.logoutCallbackUrl],
|
||||
isPublic: false,
|
||||
pkceEnabled: false,
|
||||
requiresReauthentication: true,
|
||||
requiresPushedAuthorizationRequests: false,
|
||||
credentials: { federatedIdentities: [] },
|
||||
isGroupRestricted: false
|
||||
}
|
||||
});
|
||||
|
||||
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
|
||||
const directVerify = await page.request.post(`/api/oidc/device/verify?code=${userCode}`);
|
||||
expect(directVerify.status()).toBe(401);
|
||||
|
||||
let reauthCalled = false;
|
||||
await page.route('/api/webauthn/login/start', async (route) => {
|
||||
reauthCalled = true;
|
||||
await route.continue();
|
||||
});
|
||||
await page.route('/api/webauthn/reauthenticate', async (route) => {
|
||||
reauthCalled = true;
|
||||
await route.continue();
|
||||
});
|
||||
await (await passkeyUtil.init(page)).addPasskey();
|
||||
|
||||
await page.goto(`/device?user_code=${userCode}`);
|
||||
await expect(page.getByText('Do you want to sign in to Nextcloud')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Authorize' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' })
|
||||
).toBeVisible();
|
||||
expect(reauthCalled).toBe(true);
|
||||
});
|
||||
|
||||
test('Authorize client with device authorization flow with invalid code', async ({ page }) => {
|
||||
await page.goto('/device?code=invalid-code');
|
||||
await page.goto('/device?user_code=invalid-code');
|
||||
|
||||
await expect(
|
||||
page.getByRole('paragraph').filter({ hasText: 'Invalid device code.' })
|
||||
@@ -530,7 +714,7 @@ test('Authorize new client with device authorization with user group not allowed
|
||||
const client = oidcClients.immich;
|
||||
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
|
||||
|
||||
await page.goto(`/device?code=${userCode}`);
|
||||
await page.goto(`/device?user_code=${userCode}`);
|
||||
|
||||
await (await passkeyUtil.init(page)).addPasskey('craig');
|
||||
await page.getByRole('button', { name: 'Authorize' }).click();
|
||||
@@ -556,36 +740,61 @@ test('Federated identity fails with invalid client assertion', async ({ page })
|
||||
client_assertion: 'not-an-assertion'
|
||||
});
|
||||
|
||||
expect(res?.error).toBe('Invalid client assertion');
|
||||
expect(res?.error).toBe('invalid_client');
|
||||
});
|
||||
|
||||
test('Authorize existing client with federated identity', async ({ page }) => {
|
||||
const client = oidcClients.federated;
|
||||
const clientAssertion = await oidcUtil.getClientAssertion(page, client.federatedJWT);
|
||||
const urlParams = createUrlParams(client);
|
||||
|
||||
const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, async () => {
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
const signInButton = page.getByRole('button', { name: /sign in/i });
|
||||
await expect(signInButton).toBeVisible();
|
||||
await signInButton.click();
|
||||
});
|
||||
const code = callbackUrl.searchParams.get('code');
|
||||
expect(code).toBeTruthy();
|
||||
|
||||
const res = await oidcUtil.exchangeCode(page, {
|
||||
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: client.callbackUrl,
|
||||
code: client.accessCodes[0],
|
||||
code: code!,
|
||||
client_id: client.id,
|
||||
client_assertion: clientAssertion
|
||||
});
|
||||
|
||||
expect(res.access_token).not.toBeNull;
|
||||
expect(res.expires_in).not.toBeNull;
|
||||
expect(res.token_type).toBe('Bearer');
|
||||
expect(res.access_token).not.toBeNull();
|
||||
expect(res.expires_in).not.toBeNull();
|
||||
expect(res.token_type).toBe('bearer');
|
||||
});
|
||||
|
||||
test('Forces reauthentication when client requires it', async ({ page, request }) => {
|
||||
let webauthnStartCalled = false;
|
||||
let reauthCalled = false;
|
||||
await page.route('/api/webauthn/login/start', async (route) => {
|
||||
webauthnStartCalled = true;
|
||||
reauthCalled = true;
|
||||
await route.continue();
|
||||
});
|
||||
await page.route('/api/webauthn/reauthenticate', async (route) => {
|
||||
reauthCalled = true;
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await request.put(`/api/oidc/clients/${oidcClients.nextcloud.id}`, {
|
||||
data: { ...oidcClients.nextcloud, requiresReauthentication: true }
|
||||
data: {
|
||||
name: oidcClients.nextcloud.name,
|
||||
callbackURLs: [oidcClients.nextcloud.callbackUrl],
|
||||
logoutCallbackURLs: [oidcClients.nextcloud.logoutCallbackUrl],
|
||||
isPublic: false,
|
||||
pkceEnabled: false,
|
||||
requiresReauthentication: true,
|
||||
requiresPushedAuthorizationRequests: false,
|
||||
credentials: { federatedIdentities: [] },
|
||||
isGroupRestricted: false
|
||||
}
|
||||
});
|
||||
|
||||
await (await passkeyUtil.init(page)).addPasskey();
|
||||
@@ -594,9 +803,11 @@ test('Forces reauthentication when client requires it', async ({ page, request }
|
||||
await expectCallbackRedirect(page, oidcClients.nextcloud.callbackUrl, async () => {
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
await expect(page.getByTestId('scopes')).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
});
|
||||
|
||||
expect(webauthnStartCalled).toBe(true);
|
||||
expect(reauthCalled).toBe(true);
|
||||
});
|
||||
|
||||
test('Authorize existing client while not signed in with response_mode=form_post', async ({
|
||||
@@ -607,13 +818,12 @@ test('Authorize existing client while not signed in with response_mode=form_post
|
||||
urlParams.set('response_mode', 'form_post');
|
||||
await page.context().clearCookies();
|
||||
|
||||
const formPostRequestPromise = waitForFormPostRequest(page, oidcClient.callbackUrl);
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
await (await passkeyUtil.init(page)).addPasskey();
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
await expectFormPostRequest(formPostRequestPromise);
|
||||
await expectFormPostResponse(page, oidcClient.callbackUrl);
|
||||
});
|
||||
|
||||
test('Authorize existing client with response_mode=form_post', async ({ page }) => {
|
||||
@@ -621,10 +831,9 @@ test('Authorize existing client with response_mode=form_post', async ({ page })
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('response_mode', 'form_post');
|
||||
|
||||
const formPostRequestPromise = waitForFormPostRequest(page, oidcClient.callbackUrl);
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
await expectFormPostRequest(formPostRequestPromise);
|
||||
await expectFormPostResponse(page, oidcClient.callbackUrl);
|
||||
});
|
||||
|
||||
test('Authorize existing client with response_mode=fragment', async ({ page }) => {
|
||||
@@ -643,19 +852,27 @@ test('Authorize existing client with response_mode=fragment', async ({ page }) =
|
||||
expect(fragmentParams.get('iss')).toBeTruthy();
|
||||
});
|
||||
|
||||
function waitForFormPostRequest(page: Page, callbackUrl: string): Promise<Request> {
|
||||
return page.waitForRequest(
|
||||
(request) => request.method() === 'POST' && request.url() === callbackUrl
|
||||
async function expectFormPostResponse(
|
||||
page: Page,
|
||||
callbackUrl: string,
|
||||
expectedState = 'nXx-6Qr-owc1SHBa'
|
||||
): Promise<URLSearchParams> {
|
||||
const form = page.locator('form[method="post"]');
|
||||
await expect(form).toHaveAttribute('action', callbackUrl);
|
||||
const formData = new URLSearchParams(
|
||||
await form.locator('input[type="hidden"]').evaluateAll((inputs) => {
|
||||
const params = new URLSearchParams();
|
||||
for (const input of inputs) {
|
||||
params.append(input.name, input.value);
|
||||
}
|
||||
return params.toString();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function expectFormPostRequest(formPostRequestPromise: Promise<Request>) {
|
||||
const request = await formPostRequestPromise;
|
||||
const formData = new URLSearchParams(request.postData() ?? '');
|
||||
|
||||
expect(formData.get('code')).toBeTruthy();
|
||||
expect(formData.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
expect(formData.get('state')).toBe(expectedState);
|
||||
expect(formData.get('iss')).toBeTruthy();
|
||||
return formData;
|
||||
}
|
||||
|
||||
test.describe('OIDC prompt parameter', () => {
|
||||
@@ -691,9 +908,10 @@ test.describe('OIDC prompt parameter', () => {
|
||||
await route.fulfill({ status: 200, body: 'attacker' });
|
||||
});
|
||||
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
const response = await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
await expect(page.getByText(/Invalid callback URL/i)).toBeVisible();
|
||||
expect(response?.status()).toBe(400);
|
||||
await expect(page.locator('body')).toContainText('invalid_request');
|
||||
expect(attackerRedirected).toBe(false);
|
||||
});
|
||||
|
||||
@@ -744,9 +962,10 @@ test.describe('OIDC prompt parameter', () => {
|
||||
await route.fulfill({ status: 200, body: 'attacker' });
|
||||
});
|
||||
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
const response = await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
await expect(page.getByText(/Invalid callback URL/i)).toBeVisible();
|
||||
expect(response?.status()).toBe(400);
|
||||
await expect(page.locator('body')).toContainText('invalid_request');
|
||||
expect(attackerRedirected).toBe(false);
|
||||
});
|
||||
|
||||
@@ -785,11 +1004,17 @@ test.describe('OIDC prompt parameter', () => {
|
||||
reauthCalled = true;
|
||||
await route.continue();
|
||||
});
|
||||
await page.route('/api/webauthn/reauthenticate', async (route) => {
|
||||
reauthCalled = true;
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await (await passkeyUtil.init(page)).addPasskey();
|
||||
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
|
||||
page.goto(`/authorize?${urlParams.toString()}`)
|
||||
);
|
||||
await expectCallbackRedirect(page, oidcClient.callbackUrl, async () => {
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
});
|
||||
|
||||
expect(reauthCalled).toBe(true);
|
||||
});
|
||||
@@ -824,13 +1049,14 @@ test.describe('OIDC prompt parameter', () => {
|
||||
(await passkeyUtil.init(page)).addPasskey('craig');
|
||||
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await expect(page.getByText('Do you want to sign in to Nextcloud')).toBeVisible();
|
||||
|
||||
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
|
||||
page.getByRole('button', { name: 'Sign In' }).click()
|
||||
page.getByRole('button', { name: /sign in/i }).click()
|
||||
);
|
||||
});
|
||||
|
||||
test('prompt=none with prompt=consent returns interaction_required', async ({ page }) => {
|
||||
test('prompt=none with prompt=consent returns invalid_request', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('prompt', 'none consent');
|
||||
@@ -840,11 +1066,11 @@ test.describe('OIDC prompt parameter', () => {
|
||||
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
|
||||
);
|
||||
|
||||
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
|
||||
expect(redirectUrl.searchParams.get('error')).toBe('invalid_request');
|
||||
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
});
|
||||
|
||||
test('prompt=none with prompt=login returns interaction_required', async ({ page }) => {
|
||||
test('prompt=none with prompt=login returns invalid_request', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('prompt', 'none login');
|
||||
@@ -854,11 +1080,11 @@ test.describe('OIDC prompt parameter', () => {
|
||||
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
|
||||
);
|
||||
|
||||
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
|
||||
expect(redirectUrl.searchParams.get('error')).toBe('invalid_request');
|
||||
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
});
|
||||
|
||||
test('prompt=none with prompt=select_account returns interaction_required', async ({ page }) => {
|
||||
test('prompt=none with prompt=select_account returns invalid_request', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('prompt', 'none select_account');
|
||||
@@ -868,20 +1094,23 @@ test.describe('OIDC prompt parameter', () => {
|
||||
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
|
||||
);
|
||||
|
||||
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
|
||||
expect(redirectUrl.searchParams.get('error')).toBe('invalid_request');
|
||||
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForCallbackURL(page: Page, callbackUrl: string): Promise<URL> {
|
||||
const expectedUrl = new URL(callbackUrl);
|
||||
const isCallbackURL = (url: URL) =>
|
||||
url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname;
|
||||
|
||||
const isCallbackURL = callbackURLMatcher(callbackUrl);
|
||||
const callbackRequest = await page.waitForRequest((request) =>
|
||||
isCallbackURL(new URL(request.url()))
|
||||
);
|
||||
await page.waitForURL(isCallbackURL, { waitUntil: 'commit' }).catch(() => {});
|
||||
|
||||
const redirectURL = await callbackRedirectURL(callbackRequest, isCallbackURL);
|
||||
if (redirectURL) {
|
||||
return redirectURL;
|
||||
}
|
||||
|
||||
await page.waitForURL(isCallbackURL, { waitUntil: 'commit', timeout: 5000 }).catch(() => {});
|
||||
|
||||
const currentURL = new URL(page.url());
|
||||
if (isCallbackURL(currentURL)) {
|
||||
@@ -891,6 +1120,29 @@ async function waitForCallbackURL(page: Page, callbackUrl: string): Promise<URL>
|
||||
return new URL(callbackRequest.url());
|
||||
}
|
||||
|
||||
function callbackURLMatcher(callbackUrl: string): (url: URL) => boolean {
|
||||
const expectedUrl = new URL(callbackUrl);
|
||||
return (url) => url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname;
|
||||
}
|
||||
|
||||
async function callbackRedirectURL(
|
||||
request: Request,
|
||||
isCallbackURL: (url: URL) => boolean
|
||||
): Promise<URL | null> {
|
||||
const redirectResponse = await request.redirectedFrom()?.response();
|
||||
if (!redirectResponse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const redirectLocation = redirectResponse.headers().location;
|
||||
if (!redirectLocation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const redirectURL = new URL(redirectLocation, redirectResponse.url());
|
||||
return isCallbackURL(redirectURL) ? redirectURL : null;
|
||||
}
|
||||
|
||||
async function expectCallbackRedirect(
|
||||
page: Page,
|
||||
callbackUrl: string,
|
||||
@@ -908,14 +1160,14 @@ async function expectCallbackRedirect(
|
||||
await actionPromise;
|
||||
return callbackURL;
|
||||
} finally {
|
||||
await page.unroute(callbackRouteMatcher);
|
||||
if (!page.isClosed()) {
|
||||
await page.unroute(callbackRouteMatcher).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function routeCallbackPage(page: Page, callbackUrl: string): Promise<(url: URL) => boolean> {
|
||||
const expectedUrl = new URL(callbackUrl);
|
||||
const callbackRouteMatcher = (url: URL) =>
|
||||
url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname;
|
||||
const callbackRouteMatcher = callbackURLMatcher(callbackUrl);
|
||||
|
||||
await page.route(callbackRouteMatcher, async (route) => {
|
||||
await route.fulfill({
|
||||
@@ -977,34 +1229,10 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
|
||||
redirect_uri: client.callbackUrl
|
||||
});
|
||||
expect(tokenResult.access_token).toBeTruthy();
|
||||
expect(tokenResult.token_type).toBe('Bearer');
|
||||
expect(tokenResult.token_type).toBe('bearer');
|
||||
expect(tokenResult.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('par-request-info resolves the stored request parameters', async ({ page }) => {
|
||||
const state = 'par-info-state-9f3a';
|
||||
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: client.callbackUrl,
|
||||
scope: 'openid profile',
|
||||
state,
|
||||
responseMode: 'form_post'
|
||||
});
|
||||
expect(parResult.request_uri).toBeDefined();
|
||||
|
||||
const res = await page.request.get('/api/oidc/par-request-info', {
|
||||
params: { client_id: client.id, request_uri: parResult.request_uri! }
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.scope).toBe('openid profile');
|
||||
expect(body.redirectURI).toBe(client.callbackUrl);
|
||||
expect(body.state).toBe(state);
|
||||
expect(body.responseMode).toBe('form_post');
|
||||
});
|
||||
|
||||
test('PAR full flow carries the resolved state into the callback redirect', async ({ page }) => {
|
||||
const state = 'par-flow-state-7b21';
|
||||
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
@@ -1021,11 +1249,9 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
|
||||
request_uri: parResult.request_uri!
|
||||
});
|
||||
|
||||
const formPostRequestPromise = waitForFormPostRequest(page, client.callbackUrl);
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
const request = await formPostRequestPromise;
|
||||
const formData = new URLSearchParams(request.postData() ?? '');
|
||||
const formData = await expectFormPostResponse(page, client.callbackUrl, state);
|
||||
expect(formData.get('code')).toBeTruthy();
|
||||
expect(formData.get('state')).toBe(state);
|
||||
});
|
||||
@@ -1038,7 +1264,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: client.callbackUrl,
|
||||
scope: 'openid profile'
|
||||
scope: 'openid profile groups'
|
||||
});
|
||||
expect(parResult.request_uri).toBeDefined();
|
||||
|
||||
@@ -1049,7 +1275,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
// Consent screen with the requested scope (resolved from the PAR) must be shown
|
||||
await expectScopes(page, ['Profile']);
|
||||
await expectScopes(page, ['Groups']);
|
||||
|
||||
// Confirming proceeds with the authorization
|
||||
await expectCallbackRedirect(page, client.callbackUrl, () =>
|
||||
@@ -1076,19 +1302,13 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
|
||||
);
|
||||
expect(firstCallbackUrl.searchParams.get('code')).toBeTruthy();
|
||||
|
||||
// Second use of the same request_uri should fail
|
||||
// Use the authorize API directly (requires auth cookie which we have)
|
||||
const response = await page.request.post('/api/oidc/authorize', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
clientID: client.id,
|
||||
requestURI: parResult.request_uri
|
||||
}
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
await expect(page.getByText('invalid_request_uri')).toBeVisible();
|
||||
});
|
||||
|
||||
test('PAR endpoint rejects request without client credentials', async ({ page }) => {
|
||||
test('PAR endpoint rejects confidential client request without client credentials', async ({
|
||||
page
|
||||
}) => {
|
||||
const result = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
// no clientSecret
|
||||
@@ -1099,8 +1319,8 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
|
||||
expect(result.request_uri).toBeUndefined();
|
||||
});
|
||||
|
||||
test('PAR endpoint rejects public client', async ({ page }) => {
|
||||
// The parClient is confidential — test by setting isPublic via admin API first
|
||||
test('PAR endpoint accepts public client with PKCE', async ({ page }) => {
|
||||
// The PAR test client is confidential by default, so make it public first.
|
||||
await page.request.put(`/api/oidc/clients/${client.id}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
@@ -1118,12 +1338,13 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
|
||||
|
||||
const result = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: client.callbackUrl
|
||||
redirectUri: client.callbackUrl,
|
||||
codeChallenge: 'K2-ltc83acc4h0c9w6ESC_rEMTJ3bww-uCHaoeK1t8U',
|
||||
codeChallengeMethod: 'S256'
|
||||
});
|
||||
|
||||
expect(result.error).toBe('Pushed authorization requests are not supported for public clients');
|
||||
expect(result.request_uri).toBeUndefined();
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.request_uri).toBeDefined();
|
||||
});
|
||||
|
||||
test('PAR endpoint rejects invalid redirect_uri at push time', async ({ page }) => {
|
||||
@@ -1157,16 +1378,13 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Attempt a normal authorization (without request_uri)
|
||||
const response = await page.request.post('/api/oidc/authorize', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
clientID: client.id,
|
||||
scope: 'openid profile',
|
||||
callbackURL: client.callbackUrl
|
||||
}
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
const urlParams = createUrlParams(client);
|
||||
urlParams.set('scope', 'openid profile');
|
||||
|
||||
const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () =>
|
||||
page.goto(`/authorize?${urlParams.toString()}`)
|
||||
);
|
||||
expect(callbackUrl.searchParams.get('error')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Admin UI: PAR toggle persists after save', async ({ page }) => {
|
||||
|
||||
@@ -7,26 +7,14 @@ export async function interceptCallbackRedirect(
|
||||
timeoutMs: number = 10000
|
||||
): Promise<URL> {
|
||||
const routeMatcher = (url: URL) => url.pathname === callbackPath;
|
||||
const callbackPromise = page
|
||||
.waitForRequest((request) => routeMatcher(new URL(request.url())), { timeout: timeoutMs })
|
||||
.then((request) => new URL(request.url()));
|
||||
|
||||
const callbackPromise = new Promise<URL>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`Timed out waiting for redirect to ${callbackPath}`)),
|
||||
timeoutMs
|
||||
);
|
||||
|
||||
page.route(routeMatcher, async (route) => {
|
||||
clearTimeout(timer);
|
||||
resolve(new URL(route.request().url()));
|
||||
await route.abort();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await action();
|
||||
return await callbackPromise;
|
||||
} finally {
|
||||
await page.unroute(routeMatcher);
|
||||
}
|
||||
const actionPromise = action().catch((error) => error);
|
||||
const callbackUrl = await callbackPromise;
|
||||
await actionPromise;
|
||||
return callbackUrl;
|
||||
}
|
||||
|
||||
export async function getUserCode(
|
||||
@@ -86,7 +74,8 @@ export async function pushAuthorizationRequest(
|
||||
const form: Record<string, string> = {
|
||||
client_id: params.clientId,
|
||||
response_type: params.responseType ?? 'code',
|
||||
scope: params.scope ?? 'openid profile email'
|
||||
scope: params.scope ?? 'openid profile email',
|
||||
state: params.state ?? 'nXx-6Qr-owc1SHBa'
|
||||
};
|
||||
if (params.redirectUri) form.redirect_uri = params.redirectUri;
|
||||
if (params.clientSecret) form.client_secret = params.clientSecret;
|
||||
|
||||
Reference in New Issue
Block a user