Write flow popup tests

This commit is contained in:
Melon 2023-10-09 00:04:28 +01:00
parent f5f003298e
commit 1280c30c5e
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
5 changed files with 199 additions and 26 deletions

View File

@ -30,6 +30,17 @@ func NewManager(services []SsoConfig) (*Manager, error) {
return l, nil return l, nil
} }
func NewManagerForTests(services []WellKnownOIDC) *Manager {
l := &Manager{m: make(map[string]*WellKnownOIDC, len(services))}
for _, i := range services {
if !isValidNamespace.MatchString(i.Config.Namespace) {
panic("Invalid namespace in tests")
}
l.m[i.Config.Namespace] = &i
}
return l
}
func (l *Manager) CheckNamespace(namespace string) bool { func (l *Manager) CheckNamespace(namespace string) bool {
_, ok := l.m[namespace] _, ok := l.m[namespace]
return ok return ok

View File

@ -19,11 +19,13 @@ var httpGet = http.Get
type SsoConfig struct { type SsoConfig struct {
Addr utils.JsonUrl `json:"addr"` // https://login.example.com Addr utils.JsonUrl `json:"addr"` // https://login.example.com
Namespace string `json:"namespace"` // example.com Namespace string `json:"namespace"` // example.com
Client struct { Client SsoConfigClient `json:"client"`
}
type SsoConfigClient struct {
ID string `json:"id"` ID string `json:"id"`
Secret string `json:"secret"` Secret string `json:"secret"`
Scopes []string `json:"scopes"` Scopes []string `json:"scopes"`
} `json:"client"`
} }
func (s SsoConfig) FetchConfig() (*WellKnownOIDC, error) { func (s SsoConfig) FetchConfig() (*WellKnownOIDC, error) {

View File

@ -12,20 +12,20 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"log"
"net/http" "net/http"
"strings" "strings"
"time" "time"
) )
var uuidNewStringState = uuid.NewString
var uuidNewStringAti = uuid.NewString
var uuidNewStringRti = uuid.NewString
func (h *HttpServer) flowPopup(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { func (h *HttpServer) flowPopup(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
err := pages.FlowTemplates.Execute(rw, map[string]any{ pages.RenderPageTemplate(rw, "flow-popup", map[string]any{
"ServiceName": h.conf.ServiceName, "ServiceName": h.conf.ServiceName,
"Origin": req.URL.Query().Get("origin"), "Origin": req.URL.Query().Get("origin"),
}) })
if err != nil {
log.Printf("Failed to render page: %s\n", err)
}
} }
func (h *HttpServer) flowPopupPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { func (h *HttpServer) flowPopupPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
@ -43,7 +43,7 @@ func (h *HttpServer) flowPopupPost(rw http.ResponseWriter, req *http.Request, _
} }
// save state for use later // save state for use later
state := login.Config.Namespace + ":" + uuid.NewString() state := login.Config.Namespace + ":" + uuidNewStringState()
h.flowState.Set(state, flowStateData{ h.flowState.Set(state, flowStateData{
login, login,
targetOrigin, targetOrigin,
@ -117,7 +117,7 @@ func (h *HttpServer) flowCallback(rw http.ResponseWriter, req *http.Request, _ h
ps := claims.NewPermStorage() ps := claims.NewPermStorage()
nsSub := sub + "@" + v.sso.Config.Namespace nsSub := sub + "@" + v.sso.Config.Namespace
ati := uuid.NewString() ati := uuidNewStringAti()
accessToken, err := h.signer.GenerateJwt(nsSub, ati, jwt.ClaimStrings{aud}, 15*time.Minute, auth.AccessTokenClaims{ accessToken, err := h.signer.GenerateJwt(nsSub, ati, jwt.ClaimStrings{aud}, 15*time.Minute, auth.AccessTokenClaims{
Perms: ps, Perms: ps,
}) })
@ -126,13 +126,13 @@ func (h *HttpServer) flowCallback(rw http.ResponseWriter, req *http.Request, _ h
return return
} }
refreshToken, err := h.signer.GenerateJwt(nsSub, uuid.NewString(), jwt.ClaimStrings{aud}, 15*time.Minute, auth.RefreshTokenClaims{AccessTokenId: ati}) refreshToken, err := h.signer.GenerateJwt(nsSub, uuidNewStringRti(), jwt.ClaimStrings{aud}, 15*time.Minute, auth.RefreshTokenClaims{AccessTokenId: ati})
if err != nil { if err != nil {
http.Error(rw, "Error generating refresh token", http.StatusInternalServerError) http.Error(rw, "Error generating refresh token", http.StatusInternalServerError)
return return
} }
_ = pages.FlowTemplates.Execute(rw, map[string]any{ pages.RenderPageTemplate(rw, "flow-callback", map[string]any{
"ServiceName": h.conf.ServiceName, "ServiceName": h.conf.ServiceName,
"TargetOrigin": v.targetOrigin, "TargetOrigin": v.targetOrigin,
"TargetMessage": v3, "TargetMessage": v3,

View File

@ -1 +1,144 @@
package server package server
import (
"fmt"
"github.com/1f349/cache"
"github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/server/pages"
"github.com/1f349/lavender/utils"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"github.com/stretchr/testify/assert"
"golang.org/x/oauth2"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
const lavenderDomain = "http://localhost:0"
const clientAppDomain = "http://localhost:1"
const loginDomain = "http://localhost:2"
func init() {
err := pages.LoadPages("")
if err != nil {
panic(err)
}
}
func TestFlowPopup(t *testing.T) {
h := HttpServer{conf: Conf{ServiceName: "Test Service Name"}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/popup?"+url.Values{"origin": []string{clientAppDomain}}.Encode(), nil)
h.flowPopup(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<title>Test Service Name</title>
</head>
<body>
<header>
<h1>Test Service Name</h1>
</header>
<main>
<form method="POST" action="/popup">
<input type="hidden" name="origin" value="%s"/>
<div>
<label for="field_loginname">Login Name:</label>
<input type="text" name="loginname" id="field_loginname" required/>
</div>
<button type="submit">Continue</button>
</form>
</main>
</body>
</html>
`, clientAppDomain), rec.Body.String())
}
func TestFlowPopupPost(t *testing.T) {
manager := issuer.NewManagerForTests([]issuer.WellKnownOIDC{
{
Config: issuer.SsoConfig{
Addr: utils.JsonUrl{},
Namespace: "example.com",
Client: issuer.SsoConfigClient{
ID: "test-id",
Secret: "test-secret",
Scopes: []string{"openid"},
},
},
Issuer: "https://example.com",
AuthorizationEndpoint: loginDomain + "/authorize",
TokenEndpoint: loginDomain + "/token",
UserInfoEndpoint: loginDomain + "/userinfo",
ResponseTypesSupported: nil,
ScopesSupported: nil,
ClaimsSupported: nil,
GrantTypesSupported: nil,
OAuth2Config: oauth2.Config{
ClientID: "test-id",
ClientSecret: "test-secret",
Endpoint: oauth2.Endpoint{
AuthURL: loginDomain + "/authorize",
TokenURL: loginDomain + "/token",
AuthStyle: oauth2.AuthStyleInHeader,
},
Scopes: nil,
},
},
})
h := HttpServer{
r: nil,
conf: Conf{BaseUrl: lavenderDomain},
manager: manager,
flowState: cache.New[string, flowStateData](),
services: map[string]struct{}{
clientAppDomain: {},
},
}
// test no login service error
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/popup", strings.NewReader(url.Values{
"loginname": []string{"test@missing.example.com"},
"origin": []string{clientAppDomain},
}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
h.flowPopupPost(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Equal(t, "No login service defined for this username\n", rec.Body.String())
// test invalid target origin error
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/popup", strings.NewReader(url.Values{
"loginname": []string{"test@example.com"},
"origin": []string{"http://localhost:1010"},
}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
h.flowPopupPost(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Equal(t, "Invalid target origin\n", rec.Body.String())
// test successful request
nextState := uuid.NewString()
uuidNewStringState = func() string { return nextState }
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/popup", strings.NewReader(url.Values{
"loginname": []string{"test@example.com"},
"origin": []string{clientAppDomain},
}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
h.flowPopupPost(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusFound, rec.Code)
assert.Equal(t, "", rec.Body.String())
assert.Equal(t, loginDomain+"/authorize?"+url.Values{
"client_id": []string{"test-id"},
"login_name": []string{"test@example.com"},
"redirect_uri": []string{lavenderDomain + "/callback"},
"response_type": []string{"code"},
"state": []string{"example.com:" + nextState},
}.Encode(), rec.Header().Get("Location"))
}

View File

@ -5,24 +5,41 @@ import (
_ "embed" _ "embed"
"github.com/1f349/overlapfs" "github.com/1f349/overlapfs"
"html/template" "html/template"
"io"
"io/fs"
"log"
"os" "os"
"path/filepath" "path/filepath"
"sync"
) )
var ( var (
//go:embed *.go.html //go:embed *.go.html
flowPages embed.FS flowPages embed.FS
FlowTemplates *template.Template flowTemplates *template.Template
loadOnce sync.Once
) )
func LoadPages(wd string) error { func LoadPages(wd string) (err error) {
loadOnce.Do(func() {
var o fs.FS = flowPages
if wd != "" {
wwwDir := filepath.Join(wd, "www") wwwDir := filepath.Join(wd, "www")
err := os.Mkdir(wwwDir, os.ModePerm) err = os.Mkdir(wwwDir, os.ModePerm)
if err != nil { if err != nil {
return nil return
} }
wdFs := os.DirFS(wwwDir) wdFs := os.DirFS(wwwDir)
o := overlapfs.OverlapFS{A: flowPages, B: wdFs} o = overlapfs.OverlapFS{A: flowPages, B: wdFs}
FlowTemplates, err = template.ParseFS(o, "*.go.html") }
flowTemplates, err = template.ParseFS(o, "*.go.html")
})
return err return err
} }
func RenderPageTemplate(wr io.Writer, name string, data any) {
err := flowTemplates.ExecuteTemplate(wr, name+".go.html", data)
if err != nil {
log.Printf("Failed to render page: %s: %s\n", name, err)
}
}