refactor: tests (#731)

* tests: rework tests for context controller

* tests: add tests for health controller

* tests: add tests for oidc controller

* tests: use testify assert in context and health controller

* tests: add tests for user controller

* tests: add tests for resources controller

* tests: add well known controller tests

* test: add proxy controller tests

* chore: review comments

* chore: more review comments

* chore: cancel lockdown in testing

* tests: fix get cookie domain tests

* chore: add comment for testing passwords
This commit is contained in:
Stavros
2026-03-30 15:31:34 +03:00
committed by GitHub
parent f65df872f0
commit 5811218dbf
12 changed files with 1501 additions and 968 deletions

3
go.mod
View File

@@ -17,6 +17,7 @@ require (
github.com/mdp/qrterminal/v3 v3.2.1 github.com/mdp/qrterminal/v3 v3.2.1
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1
github.com/traefik/paerser v0.2.2 github.com/traefik/paerser v0.2.2
github.com/weppos/publicsuffix-go v0.50.3 github.com/weppos/publicsuffix-go v0.50.3
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
@@ -52,6 +53,7 @@ require (
github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
@@ -96,6 +98,7 @@ require (
github.com/opencontainers/image-spec v1.1.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect

View File

@@ -2,152 +2,131 @@ package controller_test
import ( import (
"encoding/json" "encoding/json"
"net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller" "github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/utils/tlog" "github.com/steveiliop56/tinyauth/internal/utils"
"github.com/stretchr/testify/assert"
"github.com/gin-gonic/gin"
"gotest.tools/v3/assert"
) )
var contextControllerCfg = controller.ContextControllerConfig{ func TestContextController(t *testing.T) {
Providers: []controller.Provider{ controllerConfig := controller.ContextControllerConfig{
Providers: []controller.Provider{
{
Name: "Local",
ID: "local",
OAuth: false,
},
},
Title: "Tinyauth",
AppURL: "https://tinyauth.example.com",
CookieDomain: "example.com",
ForgotPasswordMessage: "foo",
BackgroundImage: "/background.jpg",
OAuthAutoRedirect: "none",
WarningsEnabled: true,
}
tests := []struct {
description string
middlewares []gin.HandlerFunc
expected string
path string
}{
{ {
Name: "Local", description: "Ensure context controller returns app context",
ID: "local", middlewares: []gin.HandlerFunc{},
OAuth: false, path: "/api/context/app",
expected: func() string {
expectedAppContextResponse := controller.AppContextResponse{
Status: 200,
Message: "Success",
Providers: controllerConfig.Providers,
Title: controllerConfig.Title,
AppURL: controllerConfig.AppURL,
CookieDomain: controllerConfig.CookieDomain,
ForgotPasswordMessage: controllerConfig.ForgotPasswordMessage,
BackgroundImage: controllerConfig.BackgroundImage,
OAuthAutoRedirect: controllerConfig.OAuthAutoRedirect,
WarningsEnabled: controllerConfig.WarningsEnabled,
}
bytes, err := json.Marshal(expectedAppContextResponse)
assert.NoError(t, err)
return string(bytes)
}(),
}, },
{ {
Name: "Google", description: "Ensure user context returns 401 when unauthorized",
ID: "google", middlewares: []gin.HandlerFunc{},
OAuth: true, path: "/api/context/user",
expected: func() string {
expectedUserContextResponse := controller.UserContextResponse{
Status: 401,
Message: "Unauthorized",
}
bytes, err := json.Marshal(expectedUserContextResponse)
assert.NoError(t, err)
return string(bytes)
}(),
}, },
}, {
Title: "Test App", description: "Ensure user context returns when authorized",
AppURL: "http://localhost:8080", middlewares: []gin.HandlerFunc{
CookieDomain: "localhost", func(c *gin.Context) {
ForgotPasswordMessage: "Contact admin to reset your password.", c.Set("context", &config.UserContext{
BackgroundImage: "/assets/bg.jpg", Username: "johndoe",
OAuthAutoRedirect: "google", Name: "John Doe",
WarningsEnabled: true, Email: utils.CompileUserEmail("johndoe", controllerConfig.CookieDomain),
} Provider: "local",
IsLoggedIn: true,
var contextCtrlTestContext = config.UserContext{ })
Username: "testuser", },
Name: "testuser", },
Email: "test@example.com", path: "/api/context/user",
IsLoggedIn: true, expected: func() string {
IsBasicAuth: false, expectedUserContextResponse := controller.UserContextResponse{
OAuth: false, Status: 200,
Provider: "local", Message: "Success",
TotpPending: false, IsLoggedIn: true,
OAuthGroups: "", Username: "johndoe",
TotpEnabled: false, Name: "John Doe",
OAuthSub: "", Email: utils.CompileUserEmail("johndoe", controllerConfig.CookieDomain),
} Provider: "local",
}
func setupContextController(middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) { bytes, err := json.Marshal(expectedUserContextResponse)
tlog.NewSimpleLogger().Init() assert.NoError(t, err)
return string(bytes)
// Setup }(),
gin.SetMode(gin.TestMode)
router := gin.Default()
recorder := httptest.NewRecorder()
if middlewares != nil {
for _, m := range *middlewares {
router.Use(m)
}
}
group := router.Group("/api")
ctrl := controller.NewContextController(contextControllerCfg, group)
ctrl.SetupRoutes()
return router, recorder
}
func TestAppContextHandler(t *testing.T) {
expectedRes := controller.AppContextResponse{
Status: 200,
Message: "Success",
Providers: contextControllerCfg.Providers,
Title: contextControllerCfg.Title,
AppURL: contextControllerCfg.AppURL,
CookieDomain: contextControllerCfg.CookieDomain,
ForgotPasswordMessage: contextControllerCfg.ForgotPasswordMessage,
BackgroundImage: contextControllerCfg.BackgroundImage,
OAuthAutoRedirect: contextControllerCfg.OAuthAutoRedirect,
WarningsEnabled: contextControllerCfg.WarningsEnabled,
}
router, recorder := setupContextController(nil)
req := httptest.NewRequest("GET", "/api/context/app", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
var ctrlRes controller.AppContextResponse
err := json.Unmarshal(recorder.Body.Bytes(), &ctrlRes)
assert.NilError(t, err)
assert.DeepEqual(t, expectedRes, ctrlRes)
}
func TestUserContextHandler(t *testing.T) {
expectedRes := controller.UserContextResponse{
Status: 200,
Message: "Success",
IsLoggedIn: contextCtrlTestContext.IsLoggedIn,
Username: contextCtrlTestContext.Username,
Name: contextCtrlTestContext.Name,
Email: contextCtrlTestContext.Email,
Provider: contextCtrlTestContext.Provider,
OAuth: contextCtrlTestContext.OAuth,
TotpPending: contextCtrlTestContext.TotpPending,
OAuthName: contextCtrlTestContext.OAuthName,
}
// Test with context
router, recorder := setupContextController(&[]gin.HandlerFunc{
func(c *gin.Context) {
c.Set("context", &contextCtrlTestContext)
c.Next()
}, },
})
req := httptest.NewRequest("GET", "/api/context/user", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
var ctrlRes controller.UserContextResponse
err := json.Unmarshal(recorder.Body.Bytes(), &ctrlRes)
assert.NilError(t, err)
assert.DeepEqual(t, expectedRes, ctrlRes)
// Test no context
expectedRes = controller.UserContextResponse{
Status: 401,
Message: "Unauthorized",
IsLoggedIn: false,
} }
router, recorder = setupContextController(nil) for _, test := range tests {
req = httptest.NewRequest("GET", "/api/context/user", nil) t.Run(test.description, func(t *testing.T) {
router.ServeHTTP(recorder, req) router := gin.Default()
assert.Equal(t, 200, recorder.Code) for _, middleware := range test.middlewares {
router.Use(middleware)
}
err = json.Unmarshal(recorder.Body.Bytes(), &ctrlRes) group := router.Group("/api")
gin.SetMode(gin.TestMode)
assert.NilError(t, err) contextController := controller.NewContextController(controllerConfig, group)
assert.DeepEqual(t, expectedRes, ctrlRes) contextController.SetupRoutes()
recorder := httptest.NewRecorder()
request, err := http.NewRequest("GET", test.path, nil)
assert.NoError(t, err)
router.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, test.expected, recorder.Body.String())
})
}
} }

View File

@@ -19,7 +19,7 @@ func (controller *HealthController) SetupRoutes() {
func (controller *HealthController) healthHandler(c *gin.Context) { func (controller *HealthController) healthHandler(c *gin.Context) {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": "ok", "status": 200,
"message": "Healthy", "message": "Healthy",
}) })
} }

View File

@@ -0,0 +1,71 @@
package controller_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/stretchr/testify/assert"
)
func TestHealthController(t *testing.T) {
tests := []struct {
description string
path string
method string
expected string
}{
{
description: "Ensure health endpoint returns 200 OK",
path: "/api/healthz",
method: "GET",
expected: func() string {
expectedHealthResponse := map[string]any{
"status": 200,
"message": "Healthy",
}
bytes, err := json.Marshal(expectedHealthResponse)
assert.NoError(t, err)
return string(bytes)
}(),
},
{
description: "Ensure health endpoint returns 200 OK for HEAD request",
path: "/api/healthz",
method: "HEAD",
expected: func() string {
expectedHealthResponse := map[string]any{
"status": 200,
"message": "Healthy",
}
bytes, err := json.Marshal(expectedHealthResponse)
assert.NoError(t, err)
return string(bytes)
}(),
},
}
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
router := gin.Default()
group := router.Group("/api")
gin.SetMode(gin.TestMode)
healthController := controller.NewHealthController(group)
healthController.SetupRoutes()
recorder := httptest.NewRecorder()
request, err := http.NewRequest(test.method, test.path, nil)
assert.NoError(t, err)
router.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, test.expected, recorder.Body.String())
})
}
}

View File

@@ -235,7 +235,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
if !ok { if !ok {
tlog.App.Error().Msg("Missing authorization header") tlog.App.Error().Msg("Missing authorization header")
c.Header("www-authenticate", "basic") c.Header("www-authenticate", `Basic realm="Tinyauth OIDC Token Endpoint"`)
c.JSON(400, gin.H{ c.JSON(400, gin.H{
"error": "invalid_client", "error": "invalid_client",
}) })

View File

@@ -2,10 +2,9 @@ package controller_test
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"path"
"strings" "strings"
"testing" "testing"
@@ -16,266 +15,456 @@ import (
"github.com/steveiliop56/tinyauth/internal/controller" "github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/repository" "github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/service" "github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils/tlog" "github.com/stretchr/testify/assert"
"gotest.tools/v3/assert" "github.com/stretchr/testify/require"
) )
var oidcServiceConfig = service.OIDCServiceConfig{
Clients: map[string]config.OIDCClientConfig{
"client1": {
ClientID: "some-client-id",
ClientSecret: "some-client-secret",
ClientSecretFile: "",
TrustedRedirectURIs: []string{
"https://example.com/oauth/callback",
},
Name: "Client 1",
},
},
PrivateKeyPath: "/tmp/tinyauth_oidc_key",
PublicKeyPath: "/tmp/tinyauth_oidc_key.pub",
Issuer: "https://example.com",
SessionExpiry: 3600,
}
var oidcCtrlTestContext = config.UserContext{
Username: "test",
Name: "Test",
Email: "test@example.com",
IsLoggedIn: true,
IsBasicAuth: false,
OAuth: false,
Provider: "ldap", // ldap in order to test the groups
TotpPending: false,
OAuthGroups: "",
TotpEnabled: false,
OAuthName: "",
OAuthSub: "",
LdapGroups: "test1,test2",
}
// Test is not amazing, but it will confirm the OIDC server works
func TestOIDCController(t *testing.T) { func TestOIDCController(t *testing.T) {
tlog.NewSimpleLogger().Init() tempDir := t.TempDir()
// Create an app instance oidcServiceCfg := service.OIDCServiceConfig{
app := bootstrap.NewBootstrapApp(config.Config{}) Clients: map[string]config.OIDCClientConfig{
"test": {
// Get db ClientID: "some-client-id",
db, err := app.SetupDatabase("/tmp/tinyauth.db") ClientSecret: "some-client-secret",
assert.NilError(t, err) TrustedRedirectURIs: []string{"https://test.example.com/callback"},
Name: "Test Client",
// Create queries },
queries := repository.New(db) },
PrivateKeyPath: path.Join(tempDir, "key.pem"),
// Create a new OIDC Servicee PublicKeyPath: path.Join(tempDir, "key.pub"),
oidcService := service.NewOIDCService(oidcServiceConfig, queries) Issuer: "https://tinyauth.example.com",
err = oidcService.Init() SessionExpiry: 500,
assert.NilError(t, err)
// Create test router
gin.SetMode(gin.TestMode)
router := gin.Default()
router.Use(func(c *gin.Context) {
c.Set("context", &oidcCtrlTestContext)
c.Next()
})
group := router.Group("/api")
// Register oidc controller
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{}, oidcService, group)
oidcController.SetupRoutes()
// Get redirect URL test
recorder := httptest.NewRecorder()
marshalled, err := json.Marshal(service.AuthorizeRequest{
Scope: "openid profile email groups",
ResponseType: "code",
ClientID: "some-client-id",
RedirectURI: "https://example.com/oauth/callback",
State: "some-state",
})
assert.NilError(t, err)
req, err := http.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(marshalled)))
assert.NilError(t, err)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
resJson := map[string]any{}
err = json.Unmarshal(recorder.Body.Bytes(), &resJson)
assert.NilError(t, err)
redirect_uri, ok := resJson["redirect_uri"].(string)
assert.Assert(t, ok)
u, err := url.Parse(redirect_uri)
assert.NilError(t, err)
m, err := url.ParseQuery(u.RawQuery)
assert.NilError(t, err)
assert.Equal(t, m["state"][0], "some-state")
code := m["code"][0]
// Exchange code for token
recorder = httptest.NewRecorder()
params, err := query.Values(controller.TokenRequest{
GrantType: "authorization_code",
Code: code,
RedirectURI: "https://example.com/oauth/callback",
})
assert.NilError(t, err)
req, err = http.NewRequest("POST", "/api/oidc/token", strings.NewReader(params.Encode()))
assert.NilError(t, err)
req.Header.Set("content-type", "application/x-www-form-urlencoded")
req.SetBasicAuth("some-client-id", "some-client-secret")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
resJson = map[string]any{}
err = json.Unmarshal(recorder.Body.Bytes(), &resJson)
assert.NilError(t, err)
accessToken, ok := resJson["access_token"].(string)
assert.Assert(t, ok)
_, ok = resJson["id_token"].(string)
assert.Assert(t, ok)
refreshToken, ok := resJson["refresh_token"].(string)
assert.Assert(t, ok)
expires_in, ok := resJson["expires_in"].(float64)
assert.Assert(t, ok)
assert.Equal(t, expires_in, float64(oidcServiceConfig.SessionExpiry))
// Ensure code is expired
recorder = httptest.NewRecorder()
params, err = query.Values(controller.TokenRequest{
GrantType: "authorization_code",
Code: code,
RedirectURI: "https://example.com/oauth/callback",
})
assert.NilError(t, err)
req, err = http.NewRequest("POST", "/api/oidc/token", strings.NewReader(params.Encode()))
assert.NilError(t, err)
req.Header.Set("content-type", "application/x-www-form-urlencoded")
req.SetBasicAuth("some-client-id", "some-client-secret")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
// Test userinfo
recorder = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/oidc/userinfo", nil)
assert.NilError(t, err)
req.Header.Set("authorization", fmt.Sprintf("Bearer %s", accessToken))
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
resJson = map[string]any{}
err = json.Unmarshal(recorder.Body.Bytes(), &resJson)
assert.NilError(t, err)
_, ok = resJson["sub"].(string)
assert.Assert(t, ok)
name, ok := resJson["name"].(string)
assert.Assert(t, ok)
assert.Equal(t, name, oidcCtrlTestContext.Name)
email, ok := resJson["email"].(string)
assert.Assert(t, ok)
assert.Equal(t, email, oidcCtrlTestContext.Email)
preferred_username, ok := resJson["preferred_username"].(string)
assert.Assert(t, ok)
assert.Equal(t, preferred_username, oidcCtrlTestContext.Username)
// Not sure why this is failing, will look into it later
igroups, ok := resJson["groups"].([]any)
assert.Assert(t, ok)
groups := make([]string, len(igroups))
for i, group := range igroups {
groups[i], ok = group.(string)
assert.Assert(t, ok)
} }
assert.DeepEqual(t, strings.Split(oidcCtrlTestContext.LdapGroups, ","), groups) controllerCfg := controller.OIDCControllerConfig{}
// Test refresh token simpleCtx := func(c *gin.Context) {
recorder = httptest.NewRecorder() c.Set("context", &config.UserContext{
Username: "test",
Name: "Test User",
Email: "test@example.com",
IsLoggedIn: true,
Provider: "local",
})
c.Next()
}
params, err = query.Values(controller.TokenRequest{ type testCase struct {
GrantType: "refresh_token", description string
RefreshToken: refreshToken, middlewares []gin.HandlerFunc
run func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder)
}
var tests []testCase
getTestByDescription := func(description string) (func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder), bool) {
for _, test := range tests {
if test.description == description {
return test.run, true
}
}
return nil, false
}
tests = []testCase{
{
description: "Ensure we can fetch the client",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/oidc/clients/some-client-id", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
},
},
{
description: "Ensure API fails on non-existent client ID",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/oidc/clients/non-existent-client-id", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 404, recorder.Code)
},
},
{
description: "Ensure authorize fails with empty context",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("POST", "/api/oidc/authorize", nil)
router.ServeHTTP(recorder, req)
var res map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &res)
assert.NoError(t, err)
assert.Equal(t, res["redirect_uri"], "https://tinyauth.example.com/error?error=User+is+not+logged+in+or+the+session+is+invalid")
},
},
{
description: "Ensure authorize fails with an invalid param",
middlewares: []gin.HandlerFunc{
simpleCtx,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
reqBody := service.AuthorizeRequest{
Scope: "openid",
ResponseType: "some_unsupported_response_type",
ClientID: "some-client-id",
RedirectURI: "https://test.example.com/callback",
State: "some-state",
Nonce: "some-nonce",
}
reqBodyBytes, err := json.Marshal(reqBody)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
var res map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &res)
assert.NoError(t, err)
assert.Equal(t, res["redirect_uri"], "https://test.example.com/callback?error=unsupported_response_type&error_description=Invalid+request+parameters&state=some-state")
},
},
{
description: "Ensure authorize succeeds with valid params",
middlewares: []gin.HandlerFunc{
simpleCtx,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
reqBody := service.AuthorizeRequest{
Scope: "openid",
ResponseType: "code",
ClientID: "some-client-id",
RedirectURI: "https://test.example.com/callback",
State: "some-state",
Nonce: "some-nonce",
}
reqBodyBytes, err := json.Marshal(reqBody)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
var res map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &res)
assert.NoError(t, err)
redirectURI := res["redirect_uri"].(string)
url, err := url.Parse(redirectURI)
assert.NoError(t, err)
queryParams := url.Query()
assert.Equal(t, queryParams.Get("state"), "some-state")
code := queryParams.Get("code")
assert.NotEmpty(t, code)
},
},
{
description: "Ensure token request fails with invalid grant",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
reqBody := controller.TokenRequest{
GrantType: "invalid_grant",
Code: "",
RedirectURI: "https://test.example.com/callback",
}
reqBodyEncoded, err := query.Values(reqBody)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(recorder, req)
var res map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &res)
assert.NoError(t, err)
assert.Equal(t, res["error"], "unsupported_grant_type")
},
},
{
description: "Ensure token endpoint accepts basic auth",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
reqBody := controller.TokenRequest{
GrantType: "authorization_code",
Code: "some-code",
RedirectURI: "https://test.example.com/callback",
}
reqBodyEncoded, err := query.Values(reqBody)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("some-client-id", "some-client-secret")
router.ServeHTTP(recorder, req)
assert.Empty(t, recorder.Header().Get("www-authenticate"))
},
},
{
description: "Ensure token endpoint accepts form auth",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", "some-code")
form.Set("redirect_uri", "https://test.example.com/callback")
form.Set("client_id", "some-client-id")
form.Set("client_secret", "some-client-secret")
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(recorder, req)
assert.Empty(t, recorder.Header().Get("www-authenticate"))
},
},
{
description: "Ensure token endpoint sets authenticate header when no auth is available",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
reqBody := controller.TokenRequest{
GrantType: "authorization_code",
Code: "some-code",
RedirectURI: "https://test.example.com/callback",
}
reqBodyEncoded, err := query.Values(reqBody)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(recorder, req)
authHeader := recorder.Header().Get("www-authenticate")
assert.Contains(t, authHeader, "Basic")
},
},
{
description: "Ensure we can get a token with a valid request",
middlewares: []gin.HandlerFunc{
simpleCtx,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
authorizeCodeTest, found := getTestByDescription("Ensure authorize succeeds with valid params")
assert.True(t, found, "Authorize test not found")
authorizeTestRecorder := httptest.NewRecorder()
authorizeCodeTest(t, router, authorizeTestRecorder)
var authorizeRes map[string]any
err := json.Unmarshal(authorizeTestRecorder.Body.Bytes(), &authorizeRes)
assert.NoError(t, err)
redirectURI := authorizeRes["redirect_uri"].(string)
url, err := url.Parse(redirectURI)
assert.NoError(t, err)
queryParams := url.Query()
code := queryParams.Get("code")
assert.NotEmpty(t, code)
reqBody := controller.TokenRequest{
GrantType: "authorization_code",
Code: code,
RedirectURI: "https://test.example.com/callback",
}
reqBodyEncoded, err := query.Values(reqBody)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("some-client-id", "some-client-secret")
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
},
},
{
description: "Ensure we can renew the access token with the refresh token",
middlewares: []gin.HandlerFunc{
simpleCtx,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
tokenTest, found := getTestByDescription("Ensure we can get a token with a valid request")
assert.True(t, found, "Token test not found")
tokenRecorder := httptest.NewRecorder()
tokenTest(t, router, tokenRecorder)
var tokenRes map[string]any
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
assert.NoError(t, err)
_, ok := tokenRes["refresh_token"]
assert.True(t, ok, "Expected refresh token in response")
refreshToken := tokenRes["refresh_token"].(string)
assert.NotEmpty(t, refreshToken)
reqBody := controller.TokenRequest{
GrantType: "refresh_token",
RefreshToken: refreshToken,
ClientID: "some-client-id",
ClientSecret: "some-client-secret",
}
reqBodyEncoded, err := query.Values(reqBody)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(recorder, req)
assert.NotEmpty(t, recorder.Header().Get("cache-control"))
assert.NotEmpty(t, recorder.Header().Get("pragma"))
assert.Equal(t, 200, recorder.Code)
var refreshRes map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &refreshRes)
assert.NoError(t, err)
_, ok = refreshRes["access_token"]
assert.True(t, ok, "Expected access token in refresh response")
assert.NotEqual(t, tokenRes["refresh_token"].(string), refreshRes["access_token"].(string))
assert.NotEqual(t, tokenRes["access_token"].(string), refreshRes["access_token"].(string))
},
},
{
description: "Ensure token endpoint deletes code after use",
middlewares: []gin.HandlerFunc{
simpleCtx,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
authorizeCodeTest, found := getTestByDescription("Ensure authorize succeeds with valid params")
assert.True(t, found, "Authorize test not found")
authorizeTestRecorder := httptest.NewRecorder()
authorizeCodeTest(t, router, authorizeTestRecorder)
var authorizeRes map[string]any
err := json.Unmarshal(authorizeTestRecorder.Body.Bytes(), &authorizeRes)
assert.NoError(t, err)
redirectURI := authorizeRes["redirect_uri"].(string)
url, err := url.Parse(redirectURI)
assert.NoError(t, err)
queryParams := url.Query()
code := queryParams.Get("code")
assert.NotEmpty(t, code)
reqBody := controller.TokenRequest{
GrantType: "authorization_code",
Code: code,
RedirectURI: "https://test.example.com/callback",
}
reqBodyEncoded, err := query.Values(reqBody)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("some-client-id", "some-client-secret")
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
// Try to use the same code again
secondReq := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
secondReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
secondReq.SetBasicAuth("some-client-id", "some-client-secret")
secondRecorder := httptest.NewRecorder()
router.ServeHTTP(secondRecorder, secondReq)
assert.Equal(t, 400, secondRecorder.Code)
var secondRes map[string]any
err = json.Unmarshal(secondRecorder.Body.Bytes(), &secondRes)
assert.NoError(t, err)
assert.Equal(t, secondRes["error"], "invalid_grant")
},
},
{
description: "Ensure userinfo forbids access with invalid access token",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/oidc/userinfo", nil)
req.Header.Set("Authorization", "Bearer invalid-access-token")
router.ServeHTTP(recorder, req)
assert.Equal(t, 401, recorder.Code)
},
},
{
description: "Ensure access token can be used to access protected resources",
middlewares: []gin.HandlerFunc{
simpleCtx,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
tokenTest, found := getTestByDescription("Ensure we can get a token with a valid request")
assert.True(t, found, "Token test not found")
tokenRecorder := httptest.NewRecorder()
tokenTest(t, router, tokenRecorder)
var tokenRes map[string]any
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
assert.NoError(t, err)
accessToken := tokenRes["access_token"].(string)
assert.NotEmpty(t, accessToken)
protectedReq := httptest.NewRequest("GET", "/api/oidc/userinfo", nil)
protectedReq.Header.Set("Authorization", "Bearer "+accessToken)
router.ServeHTTP(recorder, protectedReq)
assert.Equal(t, 200, recorder.Code)
var userInfoRes map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &userInfoRes)
assert.NoError(t, err)
_, ok := userInfoRes["sub"]
assert.True(t, ok, "Expected sub claim in userinfo response")
// We should not have an email claim since we didn't request it in the scope
_, ok = userInfoRes["email"]
assert.False(t, ok, "Did not expect email claim in userinfo response")
},
},
}
app := bootstrap.NewBootstrapApp(config.Config{})
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
require.NoError(t, err)
queries := repository.New(db)
oidcService := service.NewOIDCService(oidcServiceCfg, queries)
err = oidcService.Init()
require.NoError(t, err)
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
router := gin.Default()
for _, middleware := range test.middlewares {
router.Use(middleware)
}
group := router.Group("/api")
gin.SetMode(gin.TestMode)
oidcController := controller.NewOIDCController(controllerCfg, oidcService, group)
oidcController.SetupRoutes()
recorder := httptest.NewRecorder()
test.run(t, router, recorder)
})
}
t.Cleanup(func() {
err = db.Close()
require.NoError(t, err)
}) })
assert.NilError(t, err)
req, err = http.NewRequest("POST", "/api/oidc/token", strings.NewReader(params.Encode()))
assert.NilError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("some-client-id", "some-client-secret")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
resJson = map[string]any{}
err = json.Unmarshal(recorder.Body.Bytes(), &resJson)
assert.NilError(t, err)
newToken, ok := resJson["access_token"].(string)
assert.Assert(t, ok)
assert.Assert(t, newToken != accessToken)
// Ensure old token is invalid
recorder = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/oidc/userinfo", nil)
assert.NilError(t, err)
req.Header.Set("authorization", fmt.Sprintf("Bearer %s", accessToken))
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
// Test new token
recorder = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/oidc/userinfo", nil)
assert.NilError(t, err)
req.Header.Set("authorization", fmt.Sprintf("Bearer %s", newToken))
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
} }

View File

@@ -1,302 +1,373 @@
package controller_test package controller_test
import ( import (
"net/http"
"net/http/httptest" "net/http/httptest"
"path"
"testing" "testing"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/tinyauth/internal/bootstrap" "github.com/steveiliop56/tinyauth/internal/bootstrap"
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller" "github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/repository" "github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/service" "github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/stretchr/testify/assert"
"gotest.tools/v3/assert" "github.com/stretchr/testify/require"
) )
var loggedInCtx = config.UserContext{ func TestProxyController(t *testing.T) {
Username: "test", tempDir := t.TempDir()
Name: "Test",
Email: "test@example.com",
IsLoggedIn: true,
Provider: "local",
}
func setupProxyController(t *testing.T, middlewares []gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) { authServiceCfg := service.AuthServiceConfig{
// Setup
gin.SetMode(gin.TestMode)
router := gin.Default()
if len(middlewares) > 0 {
for _, m := range middlewares {
router.Use(m)
}
}
group := router.Group("/api")
recorder := httptest.NewRecorder()
// Mock app
app := bootstrap.NewBootstrapApp(config.Config{})
// Database
db, err := app.SetupDatabase(":memory:")
assert.NilError(t, err)
// Queries
queries := repository.New(db)
// Docker
dockerService := service.NewDockerService()
assert.NilError(t, dockerService.Init())
// Access controls
accessControlsService := service.NewAccessControlsService(dockerService, map[string]config.App{
"whoami": {
Path: config.AppPath{
Allow: "/allow",
},
},
})
assert.NilError(t, accessControlsService.Init())
// Auth service
authService := service.NewAuthService(service.AuthServiceConfig{
Users: []config.User{ Users: []config.User{
{ {
Username: "testuser", Username: "testuser",
Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
}, },
{ {
Username: "totpuser", Username: "totpuser",
Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
TotpSecret: "foo", TotpSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK",
}, },
}, },
OauthWhitelist: []string{}, SessionExpiry: 10, // 10 seconds, useful for testing
SessionExpiry: 3600, CookieDomain: "example.com",
SessionMaxLifetime: 0, LoginTimeout: 10, // 10 seconds, useful for testing
SecureCookie: false, LoginMaxRetries: 3,
CookieDomain: "localhost", SessionCookieName: "tinyauth-session",
LoginTimeout: 300, }
LoginMaxRetries: 3,
SessionCookieName: "tinyauth-session",
}, dockerService, nil, queries, &service.OAuthBrokerService{})
// Controller controllerCfg := controller.ProxyControllerConfig{
ctrl := controller.NewProxyController(controller.ProxyControllerConfig{ AppURL: "https://tinyauth.example.com",
AppURL: "http://tinyauth.example.com", }
}, group, accessControlsService, authService)
ctrl.SetupRoutes()
return router, recorder acls := map[string]config.App{
} "app_path_allow": {
Config: config.AppConfig{
// TODO: Needs tests for context middleware Domain: "path-allow.example.com",
},
func TestProxyHandler(t *testing.T) { Path: config.AppPath{
// Test logged out user traefik/caddy (forward_auth) Allow: "/allowed",
router, recorder := setupProxyController(t, nil) },
},
req, err := http.NewRequest("GET", "/api/auth/traefik", nil) "app_user_allow": {
assert.NilError(t, err) Config: config.AppConfig{
Domain: "user-allow.example.com",
req.Header.Set("x-forwarded-host", "whoami.example.com") },
req.Header.Set("x-forwarded-proto", "http") Users: config.AppUsers{
req.Header.Set("x-forwarded-uri", "/") Allow: "testuser",
},
router.ServeHTTP(recorder, req) },
assert.Equal(t, recorder.Code, http.StatusUnauthorized) "ip_bypass": {
Config: config.AppConfig{
// Test logged out user nginx (auth_request) Domain: "ip-bypass.example.com",
router, recorder = setupProxyController(t, nil) },
IP: config.AppIP{
req, err = http.NewRequest("GET", "/api/auth/nginx", nil) Bypass: []string{"10.10.10.10"},
assert.NilError(t, err) },
},
req.Header.Set("x-original-url", "http://whoami.example.com/") }
router.ServeHTTP(recorder, req) const browserUserAgent = `
assert.Equal(t, recorder.Code, http.StatusUnauthorized) Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36`
// Test logged out user envoy (ext_authz) simpleCtx := func(c *gin.Context) {
router, recorder = setupProxyController(t, nil) c.Set("context", &config.UserContext{
Username: "testuser",
req, err = http.NewRequest("GET", "/api/auth/envoy?path=/", nil) Name: "Testuser",
assert.NilError(t, err) Email: "testuser@example.com",
IsLoggedIn: true,
req.Host = "whoami.example.com" Provider: "local",
req.Header.Set("x-forwarded-proto", "http") })
c.Next()
router.ServeHTTP(recorder, req) }
assert.Equal(t, recorder.Code, http.StatusUnauthorized)
simpleCtxTotp := func(c *gin.Context) {
// Test logged in user traefik/caddy (forward_auth) c.Set("context", &config.UserContext{
router, recorder = setupProxyController(t, []gin.HandlerFunc{ Username: "totpuser",
func(c *gin.Context) { Name: "Totpuser",
c.Set("context", &loggedInCtx) Email: "totpuser@example.com",
c.Next() IsLoggedIn: true,
}, Provider: "local",
}) TotpEnabled: true,
})
req, err = http.NewRequest("GET", "/api/auth/traefik", nil) c.Next()
assert.NilError(t, err) }
req.Header.Set("x-forwarded-host", "whoami.example.com") type testCase struct {
req.Header.Set("x-forwarded-proto", "http") description string
req.Header.Set("x-forwarded-uri", "/") middlewares []gin.HandlerFunc
run func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder)
router.ServeHTTP(recorder, req) }
assert.Equal(t, recorder.Code, http.StatusOK)
tests := []testCase{
// Test logged in user nginx (auth_request) {
router, recorder = setupProxyController(t, []gin.HandlerFunc{ description: "Default forward auth should be detected and used",
func(c *gin.Context) { middlewares: []gin.HandlerFunc{},
c.Set("context", &loggedInCtx) run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
c.Next() req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
}, req.Header.Set("x-forwarded-host", "test.example.com")
}) req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
req, err = http.NewRequest("GET", "/api/auth/nginx", nil) req.Header.Set("user-agent", browserUserAgent)
assert.NilError(t, err) router.ServeHTTP(recorder, req)
req.Header.Set("x-original-url", "http://whoami.example.com/") assert.Equal(t, 307, recorder.Code)
location := recorder.Header().Get("Location")
router.ServeHTTP(recorder, req) assert.Contains(t, location, "https://tinyauth.example.com/login?redirect_uri=")
assert.Equal(t, recorder.Code, http.StatusOK) assert.Contains(t, location, "https%3A%2F%2Ftest.example.com%2F")
},
// Test logged in user envoy (ext_authz) },
router, recorder = setupProxyController(t, []gin.HandlerFunc{ {
func(c *gin.Context) { description: "Auth request (nginx) should be detected and used",
c.Set("context", &loggedInCtx) middlewares: []gin.HandlerFunc{},
c.Next() run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
}, req := httptest.NewRequest("GET", "/api/auth/nginx", nil)
}) req.Header.Set("x-original-url", "https://test.example.com/")
router.ServeHTTP(recorder, req)
req, err = http.NewRequest("GET", "/api/auth/envoy?path=/", nil) assert.Equal(t, 401, recorder.Code)
assert.NilError(t, err) },
},
req.Host = "whoami.example.com" {
req.Header.Set("x-forwarded-proto", "http") description: "Ext authz (envoy) should be detected and used",
middlewares: []gin.HandlerFunc{},
router.ServeHTTP(recorder, req) run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
assert.Equal(t, recorder.Code, http.StatusOK) req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil) // test a different method for envoy
req.Host = "test.example.com"
// Test ACL allow caddy/traefik (forward_auth) req.Header.Set("x-forwarded-proto", "https")
router, recorder = setupProxyController(t, nil) router.ServeHTTP(recorder, req)
assert.Equal(t, 401, recorder.Code)
req, err = http.NewRequest("GET", "/api/auth/traefik", nil) },
assert.NilError(t, err) },
{
req.Header.Set("x-forwarded-host", "whoami.example.com") description: "Ensure forward auth fallback for nginx",
req.Header.Set("x-forwarded-proto", "http") middlewares: []gin.HandlerFunc{},
req.Header.Set("x-forwarded-uri", "/allow") run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/nginx", nil)
router.ServeHTTP(recorder, req) req.Header.Set("x-forwarded-host", "test.example.com")
assert.Equal(t, recorder.Code, http.StatusOK) req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
// Test ACL allow nginx router.ServeHTTP(recorder, req)
router, recorder = setupProxyController(t, nil) assert.Equal(t, 401, recorder.Code)
},
req, err = http.NewRequest("GET", "/api/auth/nginx", nil) },
assert.NilError(t, err) {
description: "Ensure forward auth fallback for envoy",
req.Header.Set("x-original-url", "http://whoami.example.com/allow") middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
router.ServeHTTP(recorder, req) req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil)
assert.Equal(t, recorder.Code, http.StatusOK) req.Header.Set("x-forwarded-host", "test.example.com")
req.Header.Set("x-forwarded-proto", "https")
// Test ACL allow envoy req.Header.Set("x-forwarded-uri", "/hello")
router, recorder = setupProxyController(t, nil) router.ServeHTTP(recorder, req)
assert.Equal(t, 401, recorder.Code)
req, err = http.NewRequest("GET", "/api/auth/envoy?path=/allow", nil) },
assert.NilError(t, err) },
{
req.Host = "whoami.example.com" description: "Ensure normal authentication flow for forward auth",
req.Header.Set("x-forwarded-proto", "http") middlewares: []gin.HandlerFunc{
simpleCtx,
router.ServeHTTP(recorder, req) },
assert.Equal(t, recorder.Code, http.StatusOK) run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
// Test traefik/caddy (forward_auth) without required headers req.Header.Set("x-forwarded-host", "test.example.com")
router, recorder = setupProxyController(t, nil) req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
req, err = http.NewRequest("GET", "/api/auth/traefik", nil) router.ServeHTTP(recorder, req)
assert.NilError(t, err)
assert.Equal(t, 200, recorder.Code)
router.ServeHTTP(recorder, req) assert.Equal(t, "testuser", recorder.Header().Get("remote-user"))
assert.Equal(t, recorder.Code, http.StatusBadRequest) assert.Equal(t, "Testuser", recorder.Header().Get("remote-name"))
assert.Equal(t, "testuser@example.com", recorder.Header().Get("remote-email"))
// Test nginx (forward_auth) without required headers },
router, recorder = setupProxyController(t, nil) },
{
req, err = http.NewRequest("GET", "/api/auth/nginx", nil) description: "Ensure normal authentication flow for nginx auth request",
assert.NilError(t, err) middlewares: []gin.HandlerFunc{
simpleCtx,
router.ServeHTTP(recorder, req) },
assert.Equal(t, recorder.Code, http.StatusBadRequest) run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/nginx", nil)
// Test envoy (forward_auth) without required headers req.Header.Set("x-original-url", "https://test.example.com/")
router, recorder = setupProxyController(t, nil) router.ServeHTTP(recorder, req)
req, err = http.NewRequest("GET", "/api/auth/envoy", nil) assert.Equal(t, 200, recorder.Code)
assert.NilError(t, err) assert.Equal(t, "testuser", recorder.Header().Get("remote-user"))
assert.Equal(t, "Testuser", recorder.Header().Get("remote-name"))
router.ServeHTTP(recorder, req) assert.Equal(t, "testuser@example.com", recorder.Header().Get("remote-email"))
assert.Equal(t, recorder.Code, http.StatusBadRequest) },
},
// Test nginx (auth_request) with forward_auth fallback with ACLs {
router, recorder = setupProxyController(t, nil) description: "Ensure normal authentication flow for envoy ext authz",
middlewares: []gin.HandlerFunc{
req, err = http.NewRequest("GET", "/api/auth/nginx", nil) simpleCtx,
assert.NilError(t, err) },
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req.Header.Set("x-forwarded-host", "whoami.example.com") req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil)
req.Header.Set("x-forwarded-proto", "http") req.Host = "test.example.com"
req.Header.Set("x-forwarded-uri", "/allow") req.Header.Set("x-forwarded-proto", "https")
router.ServeHTTP(recorder, req)
router.ServeHTTP(recorder, req)
assert.Equal(t, recorder.Code, http.StatusOK) assert.Equal(t, 200, recorder.Code)
assert.Equal(t, "testuser", recorder.Header().Get("remote-user"))
// Test envoy (ext_authz) with forward_auth fallback with ACLs assert.Equal(t, "Testuser", recorder.Header().Get("remote-name"))
router, recorder = setupProxyController(t, nil) assert.Equal(t, "testuser@example.com", recorder.Header().Get("remote-email"))
},
req, err = http.NewRequest("GET", "/api/auth/envoy", nil) },
assert.NilError(t, err) {
description: "Ensure path allow ACL works on forward auth",
req.Header.Set("x-forwarded-host", "whoami.example.com") middlewares: []gin.HandlerFunc{},
req.Header.Set("x-forwarded-proto", "http") run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req.Header.Set("x-forwarded-uri", "/allow") req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-allow.example.com")
router.ServeHTTP(recorder, req) req.Header.Set("x-forwarded-proto", "https")
assert.Equal(t, recorder.Code, http.StatusOK) req.Header.Set("x-forwarded-uri", "/allowed")
router.ServeHTTP(recorder, req)
// Test envoy (ext_authz) with empty path assert.Equal(t, 200, recorder.Code)
router, recorder = setupProxyController(t, nil) },
},
req, err = http.NewRequest("GET", "/api/auth/envoy", nil) {
assert.NilError(t, err) description: "Ensure path allow ACL works on nginx auth request",
middlewares: []gin.HandlerFunc{},
req.Host = "whoami.example.com" run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req.Header.Set("x-forwarded-proto", "http") req := httptest.NewRequest("GET", "/api/auth/nginx", nil)
req.Header.Set("x-original-url", "https://path-allow.example.com/allowed")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, recorder.Code, http.StatusUnauthorized) assert.Equal(t, 200, recorder.Code)
},
// Ensure forward_auth fallback works with path (should ignore) },
router, recorder = setupProxyController(t, nil) {
description: "Ensure path allow ACL works on envoy ext authz",
req, err = http.NewRequest("GET", "/api/auth/traefik?path=/allow", nil) middlewares: []gin.HandlerFunc{},
assert.NilError(t, err) run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/allowed", nil)
req.Header.Set("x-forwarded-proto", "http") req.Host = "path-allow.example.com"
req.Header.Set("x-forwarded-host", "whoami.example.com") req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/allow") router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
router.ServeHTTP(recorder, req) },
assert.Equal(t, recorder.Code, http.StatusOK) },
{
description: "Ensure ip bypass ACL works on forward auth",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "ip-bypass.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
req.Header.Set("x-forwarded-for", "10.10.10.10")
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
},
},
{
description: "Ensure ip bypass ACL works on nginx auth request",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/nginx", nil)
req.Header.Set("x-original-url", "https://ip-bypass.example.com/")
req.Header.Set("x-forwarded-for", "10.10.10.10")
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
},
},
{
description: "Ensure ip bypass ACL works on envoy ext authz",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil)
req.Host = "ip-bypass.example.com"
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-for", "10.10.10.10")
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
},
},
{
description: "Ensure user allow ACL allows correct user (should allow testuser)",
middlewares: []gin.HandlerFunc{
simpleCtx,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "user-allow.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
},
},
{
description: "Ensure user allow ACL blocks incorrect user (should block totpuser)",
middlewares: []gin.HandlerFunc{
simpleCtxTotp,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "user-allow.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
router.ServeHTTP(recorder, req)
assert.Equal(t, 403, recorder.Code)
assert.Equal(t, "", recorder.Header().Get("remote-user"))
assert.Equal(t, "", recorder.Header().Get("remote-name"))
assert.Equal(t, "", recorder.Header().Get("remote-email"))
},
},
}
tlog.NewSimpleLogger().Init()
oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig)
app := bootstrap.NewBootstrapApp(config.Config{})
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
require.NoError(t, err)
queries := repository.New(db)
docker := service.NewDockerService()
err = docker.Init()
require.NoError(t, err)
ldap := service.NewLdapService(service.LdapServiceConfig{})
err = ldap.Init()
require.NoError(t, err)
broker := service.NewOAuthBrokerService(oauthBrokerCfgs)
err = broker.Init()
require.NoError(t, err)
authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker)
err = authService.Init()
require.NoError(t, err)
aclsService := service.NewAccessControlsService(docker, acls)
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
router := gin.Default()
for _, m := range test.middlewares {
router.Use(m)
}
group := router.Group("/api")
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
proxyController := controller.NewProxyController(controllerCfg, group, aclsService, authService)
proxyController.SetupRoutes()
test.run(t, router, recorder)
})
}
t.Cleanup(func() {
err = db.Close()
require.NoError(t, err)
})
} }

View File

@@ -3,57 +3,81 @@ package controller_test
import ( import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path"
"testing" "testing"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gotest.tools/v3/assert" "github.com/steveiliop56/tinyauth/internal/controller"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestResourcesHandler(t *testing.T) { func TestResourcesController(t *testing.T) {
// Setup tempDir := t.TempDir()
gin.SetMode(gin.TestMode)
router := gin.New()
group := router.Group("/")
ctrl := controller.NewResourcesController(controller.ResourcesControllerConfig{ resourcesControllerCfg := controller.ResourcesControllerConfig{
Path: "/tmp/tinyauth", Path: path.Join(tempDir, "resources"),
Enabled: true, Enabled: true,
}, group) }
ctrl.SetupRoutes()
// Create test data err := os.Mkdir(resourcesControllerCfg.Path, 0777)
err := os.Mkdir("/tmp/tinyauth", 0755) require.NoError(t, err)
assert.NilError(t, err)
defer os.RemoveAll("/tmp/tinyauth")
file, err := os.Create("/tmp/tinyauth/test.txt") type testCase struct {
assert.NilError(t, err) description string
run func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder)
}
_, err = file.WriteString("This is a test file.") tests := []testCase{
assert.NilError(t, err) {
file.Close() description: "Ensure resources endpoint returns 200 OK for existing file",
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/resources/testfile.txt", nil)
router.ServeHTTP(recorder, req)
// Test existing file assert.Equal(t, 200, recorder.Code)
req := httptest.NewRequest("GET", "/resources/test.txt", nil) assert.Equal(t, "This is a test file.", recorder.Body.String())
recorder := httptest.NewRecorder() },
router.ServeHTTP(recorder, req) },
{
description: "Ensure resources endpoint returns 404 Not Found for non-existing file",
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/resources/nonexistent.txt", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code) assert.Equal(t, 404, recorder.Code)
assert.Equal(t, "This is a test file.", recorder.Body.String()) },
},
{
description: "Ensure resources controller denies path traversal",
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/resources/../somefile.txt", nil)
router.ServeHTTP(recorder, req)
// Test non-existing file assert.Equal(t, 404, recorder.Code)
req = httptest.NewRequest("GET", "/resources/nonexistent.txt", nil) },
recorder = httptest.NewRecorder() },
router.ServeHTTP(recorder, req) }
assert.Equal(t, 404, recorder.Code) testFilePath := resourcesControllerCfg.Path + "/testfile.txt"
err = os.WriteFile(testFilePath, []byte("This is a test file."), 0777)
require.NoError(t, err)
// Test directory traversal attack testFilePathParent := tempDir + "/somefile.txt"
req = httptest.NewRequest("GET", "/resources/../etc/passwd", nil) err = os.WriteFile(testFilePathParent, []byte("This file should not be accessible."), 0777)
recorder = httptest.NewRecorder() require.NoError(t, err)
router.ServeHTTP(recorder, req)
assert.Equal(t, 404, recorder.Code) for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
router := gin.Default()
group := router.Group("/")
gin.SetMode(gin.TestMode)
resourcesController := controller.NewResourcesController(resourcesControllerCfg, group)
resourcesController.SetupRoutes()
recorder := httptest.NewRecorder()
test.run(t, router, recorder)
})
}
} }

View File

@@ -2,305 +2,355 @@ package controller_test
import ( import (
"encoding/json" "encoding/json"
"net/http"
"net/http/httptest" "net/http/httptest"
"path"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp"
"github.com/steveiliop56/tinyauth/internal/bootstrap" "github.com/steveiliop56/tinyauth/internal/bootstrap"
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller" "github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/repository" "github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/service" "github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils/tlog" "github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/stretchr/testify/assert"
"github.com/gin-gonic/gin" "github.com/stretchr/testify/require"
"github.com/pquerna/otp/totp"
"gotest.tools/v3/assert"
) )
var cookieValue string func TestUserController(t *testing.T) {
var totpSecret = "6WFZXPEZRK5MZHHYAFW4DAOUYQMCASBJ" tempDir := t.TempDir()
func setupUserController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) { authServiceCfg := service.AuthServiceConfig{
tlog.NewSimpleLogger().Init()
// Setup
gin.SetMode(gin.TestMode)
router := gin.Default()
if middlewares != nil {
for _, m := range *middlewares {
router.Use(m)
}
}
group := router.Group("/api")
recorder := httptest.NewRecorder()
// Mock app
app := bootstrap.NewBootstrapApp(config.Config{})
// Database
db, err := app.SetupDatabase(":memory:")
assert.NilError(t, err)
// Queries
queries := repository.New(db)
// Auth service
authService := service.NewAuthService(service.AuthServiceConfig{
Users: []config.User{ Users: []config.User{
{ {
Username: "testuser", Username: "testuser",
Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
}, },
{ {
Username: "totpuser", Username: "totpuser",
Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
TotpSecret: totpSecret, TotpSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK",
}, },
}, },
OauthWhitelist: []string{}, SessionExpiry: 10, // 10 seconds, useful for testing
SessionExpiry: 3600, CookieDomain: "example.com",
SessionMaxLifetime: 0, LoginTimeout: 10, // 10 seconds, useful for testing
SecureCookie: false, LoginMaxRetries: 3,
CookieDomain: "localhost", SessionCookieName: "tinyauth-session",
LoginTimeout: 300,
LoginMaxRetries: 3,
SessionCookieName: "tinyauth-session",
}, nil, nil, queries, &service.OAuthBrokerService{})
// Controller
ctrl := controller.NewUserController(controller.UserControllerConfig{
CookieDomain: "localhost",
}, group, authService)
ctrl.SetupRoutes()
return router, recorder
}
func TestLoginHandler(t *testing.T) {
// Setup
router, recorder := setupUserController(t, nil)
loginReq := controller.LoginRequest{
Username: "testuser",
Password: "test",
} }
loginReqJson, err := json.Marshal(loginReq) userControllerCfg := controller.UserControllerConfig{
assert.NilError(t, err) CookieDomain: "example.com",
// Test
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqJson)))
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
cookie := recorder.Result().Cookies()[0]
assert.Equal(t, "tinyauth-session", cookie.Name)
assert.Assert(t, cookie.Value != "")
cookieValue = cookie.Value
// Test invalid credentials
loginReq = controller.LoginRequest{
Username: "testuser",
Password: "invalid",
} }
loginReqJson, err = json.Marshal(loginReq) type testCase struct {
assert.NilError(t, err) description string
middlewares []gin.HandlerFunc
recorder = httptest.NewRecorder() run func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder)
req = httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqJson)))
router.ServeHTTP(recorder, req)
assert.Equal(t, 401, recorder.Code)
// Test totp required
loginReq = controller.LoginRequest{
Username: "totpuser",
Password: "test",
} }
loginReqJson, err = json.Marshal(loginReq) tests := []testCase{
assert.NilError(t, err) {
description: "Should be able to login with valid credentials",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
loginReq := controller.LoginRequest{
Username: "testuser",
Password: "password",
}
loginReqBody, err := json.Marshal(loginReq)
assert.NoError(t, err)
recorder = httptest.NewRecorder() req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
req = httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqJson))) req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code) router.ServeHTTP(recorder, req)
loginResJson, err := json.Marshal(map[string]any{ assert.Equal(t, 200, recorder.Code)
"message": "TOTP required", assert.Len(t, recorder.Result().Cookies(), 1)
"status": 200,
"totpPending": true,
})
assert.NilError(t, err) cookie := recorder.Result().Cookies()[0]
assert.Equal(t, string(loginResJson), recorder.Body.String()) assert.Equal(t, "tinyauth-session", cookie.Name)
assert.True(t, cookie.HttpOnly)
// Test invalid json assert.Equal(t, "example.com", cookie.Domain)
recorder = httptest.NewRecorder() assert.Equal(t, 10, cookie.MaxAge)
req = httptest.NewRequest("POST", "/api/user/login", strings.NewReader("{invalid json}")) },
router.ServeHTTP(recorder, req)
assert.Equal(t, 400, recorder.Code)
// Test rate limiting
loginReq = controller.LoginRequest{
Username: "testuser",
Password: "invalid",
}
loginReqJson, err = json.Marshal(loginReq)
assert.NilError(t, err)
for range 5 {
recorder = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqJson)))
router.ServeHTTP(recorder, req)
}
assert.Equal(t, 429, recorder.Code)
}
func TestLogoutHandler(t *testing.T) {
// Setup
router, recorder := setupUserController(t, nil)
// Test
req := httptest.NewRequest("POST", "/api/user/logout", nil)
req.AddCookie(&http.Cookie{
Name: "tinyauth-session",
Value: cookieValue,
})
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
cookie := recorder.Result().Cookies()[0]
assert.Equal(t, "tinyauth-session", cookie.Name)
assert.Equal(t, "", cookie.Value)
assert.Equal(t, -1, cookie.MaxAge)
}
func TestTotpHandler(t *testing.T) {
// Setup
router, recorder := setupUserController(t, &[]gin.HandlerFunc{
func(c *gin.Context) {
c.Set("context", &config.UserContext{
Username: "totpuser",
Name: "totpuser",
Email: "totpuser@example.com",
IsLoggedIn: false,
OAuth: false,
Provider: "local",
TotpPending: true,
OAuthGroups: "",
TotpEnabled: true,
})
c.Next()
}, },
}) {
description: "Should reject login with invalid credentials",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
loginReq := controller.LoginRequest{
Username: "testuser",
Password: "wrongpassword",
}
loginReqBody, err := json.Marshal(loginReq)
assert.NoError(t, err)
// Test req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
code, err := totp.GenerateCode(totpSecret, time.Now()) req.Header.Set("Content-Type", "application/json")
assert.NilError(t, err) router.ServeHTTP(recorder, req)
totpReq := controller.TotpRequest{ assert.Equal(t, 401, recorder.Code)
Code: code, assert.Len(t, recorder.Result().Cookies(), 0)
} assert.Contains(t, recorder.Body.String(), "Unauthorized")
},
totpReqJson, err := json.Marshal(totpReq)
assert.NilError(t, err)
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqJson)))
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
cookie := recorder.Result().Cookies()[0]
assert.Equal(t, "tinyauth-session", cookie.Name)
assert.Assert(t, cookie.Value != "")
// Test invalid json
recorder = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/api/user/totp", strings.NewReader("{invalid json}"))
router.ServeHTTP(recorder, req)
assert.Equal(t, 400, recorder.Code)
// Test rate limiting
totpReq = controller.TotpRequest{
Code: "000000",
}
totpReqJson, err = json.Marshal(totpReq)
assert.NilError(t, err)
for range 5 {
recorder = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqJson)))
router.ServeHTTP(recorder, req)
}
assert.Equal(t, 429, recorder.Code)
// Test invalid code
router, recorder = setupUserController(t, &[]gin.HandlerFunc{
func(c *gin.Context) {
c.Set("context", &config.UserContext{
Username: "totpuser",
Name: "totpuser",
Email: "totpuser@example.com",
IsLoggedIn: false,
OAuth: false,
Provider: "local",
TotpPending: true,
OAuthGroups: "",
TotpEnabled: true,
})
c.Next()
}, },
}) {
description: "Should rate limit on 3 invalid attempts",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
loginReq := controller.LoginRequest{
Username: "testuser",
Password: "wrongpassword",
}
loginReqBody, err := json.Marshal(loginReq)
assert.NoError(t, err)
req = httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqJson))) for range 3 {
router.ServeHTTP(recorder, req) recorder := httptest.NewRecorder()
assert.Equal(t, 401, recorder.Code) req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
req.Header.Set("Content-Type", "application/json")
// Test no totp pending router.ServeHTTP(recorder, req)
router, recorder = setupUserController(t, &[]gin.HandlerFunc{
func(c *gin.Context) { assert.Equal(t, 401, recorder.Code)
c.Set("context", &config.UserContext{ assert.Len(t, recorder.Result().Cookies(), 0)
Username: "totpuser", assert.Contains(t, recorder.Body.String(), "Unauthorized")
Name: "totpuser", }
Email: "totpuser@example.com",
IsLoggedIn: false, // 4th attempt should be rate limited
OAuth: false, recorder = httptest.NewRecorder()
Provider: "local", req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
TotpPending: false, req.Header.Set("Content-Type", "application/json")
OAuthGroups: "",
TotpEnabled: false, router.ServeHTTP(recorder, req)
})
c.Next() assert.Equal(t, 429, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Too many failed login attempts.")
},
}, },
{
description: "Should not allow full login with totp",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
loginReq := controller.LoginRequest{
Username: "totpuser",
Password: "password",
}
loginReqBody, err := json.Marshal(loginReq)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
decodedBody := make(map[string]any)
err = json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
assert.NoError(t, err)
assert.Equal(t, decodedBody["totpPending"], true)
// should set the session cookie
assert.Len(t, recorder.Result().Cookies(), 1)
cookie := recorder.Result().Cookies()[0]
assert.Equal(t, "tinyauth-session", cookie.Name)
assert.True(t, cookie.HttpOnly)
assert.Equal(t, "example.com", cookie.Domain)
assert.Equal(t, 3600, cookie.MaxAge) // 1 hour, default for totp pending sessions
},
},
{
description: "Should be able to logout",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
// First login to get a session cookie
loginReq := controller.LoginRequest{
Username: "testuser",
Password: "password",
}
loginReqBody, err := json.Marshal(loginReq)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
assert.Len(t, recorder.Result().Cookies(), 1)
cookie := recorder.Result().Cookies()[0]
assert.Equal(t, "tinyauth-session", cookie.Name)
// Now logout using the session cookie
recorder = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/api/user/logout", nil)
req.AddCookie(cookie)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
assert.Len(t, recorder.Result().Cookies(), 1)
logoutCookie := recorder.Result().Cookies()[0]
assert.Equal(t, "tinyauth-session", logoutCookie.Name)
assert.Equal(t, "", logoutCookie.Value)
assert.Equal(t, -1, logoutCookie.MaxAge) // MaxAge -1 means delete cookie
},
},
{
description: "Should be able to login with totp",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now())
assert.NoError(t, err)
totpReq := controller.TotpRequest{
Code: code,
}
totpReqBody, err := json.Marshal(totpReq)
assert.NoError(t, err)
recorder = httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
assert.Len(t, recorder.Result().Cookies(), 1)
// should set a new session cookie with totp pending removed
totpCookie := recorder.Result().Cookies()[0]
assert.Equal(t, "tinyauth-session", totpCookie.Name)
assert.True(t, totpCookie.HttpOnly)
assert.Equal(t, "example.com", totpCookie.Domain)
assert.Equal(t, 10, totpCookie.MaxAge) // should use the regular session expiry time
},
},
{
description: "Totp should rate limit on multiple invalid attempts",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
for range 3 {
totpReq := controller.TotpRequest{
Code: "000000", // invalid code
}
totpReqBody, err := json.Marshal(totpReq)
assert.NoError(t, err)
recorder = httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, 401, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Unauthorized")
}
// 4th attempt should be rate limited
recorder = httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(`{"code":"000000"}`)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, 429, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Too many failed TOTP attempts.")
},
},
}
tlog.NewSimpleLogger().Init()
oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig)
app := bootstrap.NewBootstrapApp(config.Config{})
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
require.NoError(t, err)
queries := repository.New(db)
docker := service.NewDockerService()
err = docker.Init()
require.NoError(t, err)
ldap := service.NewLdapService(service.LdapServiceConfig{})
err = ldap.Init()
require.NoError(t, err)
broker := service.NewOAuthBrokerService(oauthBrokerCfgs)
err = broker.Init()
require.NoError(t, err)
authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker)
err = authService.Init()
require.NoError(t, err)
beforeEach := func() {
// Clear failed login attempts before each test
authService.ClearRateLimitsTestingOnly()
}
setTotpMiddlewareOverrides := []string{
"Should be able to login with totp",
"Totp should rate limit on multiple invalid attempts",
}
for _, test := range tests {
beforeEach()
t.Run(test.description, func(t *testing.T) {
router := gin.Default()
for _, middleware := range test.middlewares {
router.Use(middleware)
}
// Gin is stupid and doesn't allow setting a middleware after the groups
// so we need to do some stupid overrides here
if slices.Contains(setTotpMiddlewareOverrides, test.description) {
// Assuming the cookie is set, it should be picked up by the
// context middleware
router.Use(func(c *gin.Context) {
c.Set("context", &config.UserContext{
Username: "totpuser",
Name: "Totpuser",
Email: "totpuser@example.com",
Provider: "local",
TotpPending: true,
TotpEnabled: true,
})
})
}
group := router.Group("/api")
gin.SetMode(gin.TestMode)
userController := controller.NewUserController(userControllerCfg, group, authService)
userController.SetupRoutes()
recorder := httptest.NewRecorder()
test.run(t, router, recorder)
})
}
t.Cleanup(func() {
err = db.Close()
require.NoError(t, err)
}) })
req = httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqJson)))
router.ServeHTTP(recorder, req)
assert.Equal(t, 401, recorder.Code)
} }

View File

@@ -0,0 +1,129 @@
package controller_test
import (
"encoding/json"
"fmt"
"net/http/httptest"
"path"
"testing"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/tinyauth/internal/bootstrap"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWellKnownController(t *testing.T) {
tempDir := t.TempDir()
oidcServiceCfg := service.OIDCServiceConfig{
Clients: map[string]config.OIDCClientConfig{
"test": {
ClientID: "some-client-id",
ClientSecret: "some-client-secret",
TrustedRedirectURIs: []string{"https://test.example.com/callback"},
Name: "Test Client",
},
},
PrivateKeyPath: path.Join(tempDir, "key.pem"),
PublicKeyPath: path.Join(tempDir, "key.pub"),
Issuer: "https://tinyauth.example.com",
SessionExpiry: 500,
}
type testCase struct {
description string
run func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder)
}
tests := []testCase{
{
description: "Ensure well-known endpoint returns correct OIDC configuration",
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/.well-known/openid-configuration", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
res := controller.OpenIDConnectConfiguration{}
err := json.Unmarshal(recorder.Body.Bytes(), &res)
assert.NoError(t, err)
expected := controller.OpenIDConnectConfiguration{
Issuer: oidcServiceCfg.Issuer,
AuthorizationEndpoint: fmt.Sprintf("%s/authorize", oidcServiceCfg.Issuer),
TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", oidcServiceCfg.Issuer),
UserinfoEndpoint: fmt.Sprintf("%s/api/oidc/userinfo", oidcServiceCfg.Issuer),
JwksUri: fmt.Sprintf("%s/.well-known/jwks.json", oidcServiceCfg.Issuer),
ScopesSupported: service.SupportedScopes,
ResponseTypesSupported: service.SupportedResponseTypes,
GrantTypesSupported: service.SupportedGrantTypes,
SubjectTypesSupported: []string{"pairwise"},
IDTokenSigningAlgValuesSupported: []string{"RS256"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups"},
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
}
assert.Equal(t, expected, res)
},
},
{
description: "Ensure well-known endpoint returns correct JWKS",
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/.well-known/jwks.json", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
decodedBody := make(map[string]any)
err := json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
assert.NoError(t, err)
keys, ok := decodedBody["keys"].([]any)
assert.True(t, ok)
assert.Len(t, keys, 1)
keyData, ok := keys[0].(map[string]any)
assert.True(t, ok)
assert.Equal(t, "RSA", keyData["kty"])
assert.Equal(t, "sig", keyData["use"])
assert.Equal(t, "RS256", keyData["alg"])
},
},
}
app := bootstrap.NewBootstrapApp(config.Config{})
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
require.NoError(t, err)
queries := repository.New(db)
oidcService := service.NewOIDCService(oidcServiceCfg, queries)
err = oidcService.Init()
require.NoError(t, err)
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
router := gin.Default()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
wellKnownController := controller.NewWellKnownController(controller.WellKnownControllerConfig{}, oidcService, router)
wellKnownController.SetupRoutes()
test.run(t, router, recorder)
})
}
t.Cleanup(func() {
err = db.Close()
require.NoError(t, err)
})
}

View File

@@ -1,6 +1,7 @@
package service package service
import ( import (
"context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -78,6 +79,8 @@ type AuthService struct {
queries *repository.Queries queries *repository.Queries
oauthBroker *OAuthBrokerService oauthBroker *OAuthBrokerService
lockdown *Lockdown lockdown *Lockdown
lockdownCtx context.Context
lockdownCancelFunc context.CancelFunc
} }
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries, oauthBroker *OAuthBrokerService) *AuthService { func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries, oauthBroker *OAuthBrokerService) *AuthService {
@@ -770,6 +773,11 @@ func (auth *AuthService) ensureOAuthSessionLimit() {
} }
func (auth *AuthService) lockdownMode() { func (auth *AuthService) lockdownMode() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
auth.lockdownCtx = ctx
auth.lockdownCancelFunc = cancel
auth.loginMutex.Lock() auth.loginMutex.Lock()
tlog.App.Warn().Msg("Multiple login attempts detected, possibly DDOS attack. Activating temporary lockdown.") tlog.App.Warn().Msg("Multiple login attempts detected, possibly DDOS attack. Activating temporary lockdown.")
@@ -788,7 +796,12 @@ func (auth *AuthService) lockdownMode() {
auth.loginMutex.Unlock() auth.loginMutex.Unlock()
<-timer.C select {
case <-timer.C:
// Timer expired, end lockdown
case <-ctx.Done():
// Context cancelled, end lockdown
}
auth.loginMutex.Lock() auth.loginMutex.Lock()
@@ -796,3 +809,13 @@ func (auth *AuthService) lockdownMode() {
auth.lockdown = nil auth.lockdown = nil
auth.loginMutex.Unlock() auth.loginMutex.Unlock()
} }
// Function only used for testing - do not use in prod!
func (auth *AuthService) ClearRateLimitsTestingOnly() {
auth.loginMutex.Lock()
auth.loginAttempts = make(map[string]*LoginAttempt)
if auth.lockdown != nil {
auth.lockdownCancelFunc()
}
auth.loginMutex.Unlock()
}

View File

@@ -25,12 +25,6 @@ func TestGetRootDomain(t *testing.T) {
assert.NilError(t, err) assert.NilError(t, err)
assert.Equal(t, expected, result) assert.Equal(t, expected, result)
// Domain with no subdomain
domain = "http://tinyauth.app"
expected = "tinyauth.app"
_, err = utils.GetCookieDomain(domain)
assert.Error(t, err, "invalid app url, must be at least second level domain")
// Invalid domain (only TLD) // Invalid domain (only TLD)
domain = "com" domain = "com"
_, err = utils.GetCookieDomain(domain) _, err = utils.GetCookieDomain(domain)