mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-14 21:59:05 +02:00
259 lines
9.8 KiB
Go
259 lines
9.8 KiB
Go
package oidc
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
|
"github.com/pocket-id/pocket-id/backend/internal/authz"
|
|
"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"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type authorizationPARTestCase struct {
|
|
name string
|
|
method string
|
|
queryURI string
|
|
bodyURI []string
|
|
createPAR bool
|
|
wantCode bool
|
|
multipart bool
|
|
requestObject bool
|
|
malformed bool
|
|
}
|
|
|
|
// fakeSessionAuthenticator signs every request in as a regular user, optionally one who still has to verify their email
|
|
type fakeSessionAuthenticator struct {
|
|
unverified bool
|
|
}
|
|
|
|
func (fakeSessionAuthenticator) Kind() authz.PrincipalKind { return authz.KindSession }
|
|
|
|
func (fakeSessionAuthenticator) Present(*gin.Context) bool { return true }
|
|
|
|
func (a fakeSessionAuthenticator) Authenticate(*gin.Context) (*authz.Principal, error) {
|
|
principal := &authz.Principal{Kind: authz.KindSession, UserID: "test-user", Scopes: authz.UserScopes(false, authz.KindSession)}
|
|
if a.unverified {
|
|
principal.RestrictUnverified()
|
|
}
|
|
return principal, nil
|
|
}
|
|
|
|
// A browser that the authorization middleware refuses at the authorize endpoint must land on the interaction error page instead of a JSON error
|
|
func TestAuthorizationHandlerRedirectsAuthorizationErrors(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
handler := &authorizationHandler{}
|
|
|
|
serve := func(t *testing.T, authenticator authz.Authenticator) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
router := gin.New()
|
|
authz.NewMiddleware(authenticator).Router(router.Group("/")).Use(handler.redirectAuthorizationErrors).Optional().
|
|
GET("/authorize", authz.AccountAuthorize, func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
|
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/authorize", nil))
|
|
return recorder
|
|
}
|
|
|
|
t.Run("redirects a user who still has to verify their email", func(t *testing.T) {
|
|
recorder := serve(t, fakeSessionAuthenticator{unverified: true})
|
|
|
|
require.Equal(t, http.StatusFound, recorder.Code)
|
|
location, err := url.Parse(recorder.Header().Get("Location"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "/interaction/error", location.Path)
|
|
require.Equal(t, "You must verify your email address before you can access this service.", location.Query().Get("error"))
|
|
})
|
|
|
|
t.Run("leaves an authorized request to the authorize handler", func(t *testing.T) {
|
|
recorder := serve(t, fakeSessionAuthenticator{})
|
|
|
|
require.Equal(t, http.StatusNoContent, recorder.Code)
|
|
require.Empty(t, recorder.Header().Get("Location"))
|
|
})
|
|
}
|
|
|
|
func TestAuthorizationHandlerRequiresPAR(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
const fakeURI = parRequestURIPrefix + "missing"
|
|
for _, clientType := range []string{"public", "confidential"} {
|
|
t.Run(clientType, func(t *testing.T) {
|
|
for _, tt := range []authorizationPARTestCase{
|
|
{name: "missing request URI", method: http.MethodPost},
|
|
{name: "unknown PAR URI", method: http.MethodGet, queryURI: fakeURI},
|
|
{name: "empty body shadows PAR query", method: http.MethodPost, queryURI: fakeURI, bodyURI: []string{""}},
|
|
{name: "non-PAR body shadows PAR query", method: http.MethodPost, queryURI: fakeURI, bodyURI: []string{"not-a-par-uri"}},
|
|
{name: "valid PAR in query", method: http.MethodGet, queryURI: "stored", createPAR: true, wantCode: true},
|
|
{name: "valid PAR in body", method: http.MethodPost, bodyURI: []string{"stored"}, createPAR: true, wantCode: true},
|
|
{name: "valid body overrides invalid query", method: http.MethodPost, queryURI: fakeURI, bodyURI: []string{"stored"}, createPAR: true, wantCode: true},
|
|
{name: "empty body shadows stored PAR", method: http.MethodPost, queryURI: "stored", bodyURI: []string{""}, createPAR: true},
|
|
{name: "valid PAR in multipart body", method: http.MethodPost, bodyURI: []string{"stored"}, createPAR: true, wantCode: true, multipart: true},
|
|
{name: "request object cannot introduce PAR URI", method: http.MethodPost, requestObject: true},
|
|
{name: "malformed body", method: http.MethodPost, queryURI: fakeURI, malformed: true},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
testAuthorizationHandlerPAR(t, clientType, tt)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func testAuthorizationHandlerPAR(t *testing.T, clientType string, tt authorizationPARTestCase) {
|
|
t.Helper()
|
|
const (
|
|
baseURL = "https://issuer.example.com"
|
|
callbackURL = "https://client.example.com/callback"
|
|
clientID = "par-client"
|
|
userID = "test-user"
|
|
clientSecret = "test-client-secret"
|
|
fakeURI = parRequestURIPrefix + "missing"
|
|
)
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
|
|
// Use the real provider and an authenticated user so a bypass would issue an authorization code
|
|
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: "PAR client",
|
|
CallbackURLs: datatype.StringList{callbackURL},
|
|
IsPublic: clientType == "public",
|
|
PkceEnabled: true,
|
|
SkipConsent: true,
|
|
RequiresPushedAuthorizationRequests: true,
|
|
Credentials: testClientCredentials(clientSecret),
|
|
}).Error)
|
|
store := NewStore(db, nil)
|
|
provider, err := newProvider(store, nil, testTokenSigner{key: key}, Config{
|
|
BaseURL: baseURL, TokenBaseURL: baseURL, Secret: []byte("test-secret"),
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), nil, nil, nil)
|
|
handler := newAuthorizationHandler(provider, service)
|
|
params := url.Values{
|
|
"client_id": {clientID},
|
|
"response_type": {"code"},
|
|
"redirect_uri": {callbackURL},
|
|
"scope": {"openid"},
|
|
"state": {"state-with-enough-entropy"},
|
|
"prompt": {"none"},
|
|
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
|
|
"code_challenge_method": {"S256"},
|
|
}
|
|
if tt.requestObject {
|
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
|
|
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"request_uri":"` + fakeURI + `"}`))
|
|
params.Set("request", header+"."+payload+".")
|
|
}
|
|
|
|
// Create valid PAR sessions through Fosite to cover both accepted transports and consumption
|
|
var storedURI string
|
|
if tt.createPAR {
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/par", strings.NewReader(params.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
if clientType == "confidential" {
|
|
req.SetBasicAuth(clientID, clientSecret)
|
|
}
|
|
ar, err := provider.NewPushedAuthorizeRequest(t.Context(), req)
|
|
require.NoError(t, err)
|
|
response, err := provider.NewPushedAuthorizeResponse(t.Context(), ar, NewEmptySession())
|
|
require.NoError(t, err)
|
|
storedURI = response.GetRequestURI()
|
|
}
|
|
|
|
// Keep query and body parameters separate to exercise the same precedence as incoming HTTP requests
|
|
query := url.Values{}
|
|
if tt.queryURI != "" {
|
|
uri := tt.queryURI
|
|
if uri == "stored" {
|
|
uri = storedURI
|
|
}
|
|
query.Set("request_uri", uri)
|
|
}
|
|
for _, uri := range tt.bodyURI {
|
|
if uri == "stored" {
|
|
uri = storedURI
|
|
}
|
|
params.Add("request_uri", uri)
|
|
}
|
|
body := ""
|
|
contentType := "application/x-www-form-urlencoded"
|
|
if tt.method == http.MethodPost {
|
|
body = params.Encode()
|
|
} else {
|
|
for k, values := range params {
|
|
query[k] = values
|
|
}
|
|
}
|
|
if tt.multipart {
|
|
var buf bytes.Buffer
|
|
writer := multipart.NewWriter(&buf)
|
|
for k, values := range params {
|
|
for _, value := range values {
|
|
require.NoError(t, writer.WriteField(k, value))
|
|
}
|
|
}
|
|
require.NoError(t, writer.Close())
|
|
body = buf.String()
|
|
contentType = writer.FormDataContentType()
|
|
}
|
|
if tt.malformed {
|
|
body = "request_uri=%zz"
|
|
}
|
|
req := httptest.NewRequestWithContext(t.Context(), tt.method, "/authorize?"+query.Encode(), strings.NewReader(body))
|
|
req.Header.Set("Content-Type", contentType)
|
|
rec := httptest.NewRecorder()
|
|
router := gin.New()
|
|
router.Handle(tt.method, "/authorize", func(c *gin.Context) {
|
|
authz.SetPrincipal(c, &authz.Principal{
|
|
Kind: authz.KindSession,
|
|
UserID: userID,
|
|
Scopes: authz.UserScopes(false, authz.KindSession),
|
|
AuthenticationTime: time.Now().UTC().Add(-time.Minute),
|
|
})
|
|
handler.authorize(c)
|
|
})
|
|
router.ServeHTTP(rec, req)
|
|
|
|
// Rejections must neither return nor persist a code, while valid PAR must be consumed
|
|
location, err := url.Parse(rec.Header().Get("Location"))
|
|
require.NoError(t, err)
|
|
var codeCount int64
|
|
require.NoError(t, db.Model(&OAuth2Session{}).Where("kind = ?", sessionKindAuthorizeCode).Count(&codeCount).Error)
|
|
if tt.wantCode {
|
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
|
require.NotEmpty(t, location.Query().Get("code"), rec.Body.String())
|
|
require.Empty(t, location.Query().Get("error"))
|
|
require.EqualValues(t, 1, codeCount)
|
|
} else {
|
|
require.Empty(t, location.Query().Get("code"))
|
|
require.NotEmpty(t, location.Query().Get("error"))
|
|
require.Zero(t, codeCount)
|
|
}
|
|
if tt.createPAR {
|
|
var parCount int64
|
|
require.NoError(t, db.Model(&OAuth2Session{}).Where("kind = ? AND key = ? AND active = ?", sessionKindPAR, storedURI, true).Count(&parCount).Error)
|
|
if tt.wantCode {
|
|
require.Zero(t, parCount)
|
|
} else {
|
|
require.EqualValues(t, 1, parCount)
|
|
}
|
|
}
|
|
}
|