Merge branch 'main' into feat/k8s_gateways

This commit is contained in:
Stavros
2026-09-20 18:51:23 +03:00
37 changed files with 1597 additions and 979 deletions
+5
View File
@@ -18,3 +18,8 @@ type RedirectQuery struct {
RedirectURI string `url:"redirect_uri"`
LoginFor FrontendLoginFor `url:"login_for"`
}
type SimpleResponse struct {
Status int `json:"status"`
Message string `json:"message"`
}
+3 -1
View File
@@ -294,7 +294,9 @@ func (controller *OAuthController) getCookieDomain() string {
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
v := validators.NewDomainValidator(validators.DomainValidatorOptions{
WithPort: true,
WithPort: true,
WithScheme: true,
AllowedSchemes: []string{"https", "http"},
})
_, err := v.SafeHostname(controller.runtime.AppURL)
+101 -18
View File
@@ -1,6 +1,8 @@
package controller
import (
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
@@ -62,15 +64,19 @@ type ErrorScreen struct {
Error string `url:"error"`
}
type ClientRequest struct {
ClientID string `uri:"id" binding:"required"`
}
type ClientCredentials struct {
ClientID string
ClientSecret string
}
type SkipConsentRequest struct {
OIDCTicket string `form:"oidc_ticket" binding:"required"`
}
type SkipConsentResponse struct {
SkipConsent bool `json:"skipConsent"`
}
type AuthorizeScreenParams struct {
LoginFor FrontendLoginFor `url:"login_for"`
OIDCTicket string `url:"oidc_ticket"`
@@ -105,6 +111,7 @@ func NewOIDCController(i OIDCControllerInput) *OIDCController {
oidcGroup := i.RouterGroup.Group("/oidc")
oidcGroup.POST("/authorize-complete", controller.authorizeComplete)
oidcGroup.GET("/skip-consent", controller.skipConsent)
oidcGroup.POST("/token", controller.Token)
oidcGroup.GET("/userinfo", controller.Userinfo)
oidcGroup.POST("/userinfo", controller.Userinfo)
@@ -242,16 +249,6 @@ func (controller *OIDCController) authorize(c *gin.Context) {
}
}
if userContext != nil && userContext.Authenticated && values.OIDCPrompt != service.OIDCPromptLogin {
consent, err := controller.oidc.GetOIDCConsent(c, userContext.GetUsername(), req.ClientID)
if err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to get OIDC consent")
} else if consent != nil && scopesGranted(consent.Scope, req.Scope) {
values.OIDCPrompt = service.OIDCPromptNone
}
}
queries, err := query.Values(values)
if err != nil {
@@ -270,6 +267,90 @@ func (controller *OIDCController) authorize(c *gin.Context) {
c.Redirect(http.StatusFound, redirectUrl)
}
func (controller *OIDCController) skipConsent(c *gin.Context) {
c.Header("cache-control", "no-store")
c.Header("pragma", "no-cache")
if controller.oidc == nil {
c.JSON(500, SimpleResponse{
Status: 500,
Message: "OIDC not configured",
})
return
}
userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil {
if !errors.Is(err, model.ErrUserContextNotFound) {
controller.log.App.Warn().Err(err).Msg("Failed to get user context")
}
}
if err != nil || !userContext.Authenticated {
c.JSON(401, SimpleResponse{
Status: 401,
Message: "User not logged in",
})
return
}
var req SkipConsentRequest
err = c.BindQuery(&req)
if err != nil {
c.JSON(400, SimpleResponse{
Status: 400,
Message: "Bad request",
})
return
}
controller.log.App.Debug().Interface("req", req).Msg("Received skip consent request")
authorizeReq, ok := controller.oidc.GetAuthorizeRequestByTicket(req.OIDCTicket)
if !ok {
c.JSON(200, SkipConsentResponse{
SkipConsent: false,
})
return
}
controller.log.App.Debug().Str("client", authorizeReq.ClientID).Str("user", userContext.GetUsername()).Msg("User consented to OIDC")
if authorizeReq.Prompt == service.OIDCPromptLogin.String() {
c.JSON(200, SkipConsentResponse{
SkipConsent: false,
})
return
}
consent, err := controller.oidc.GetOIDCConsent(c, userContext.GetUsername(), authorizeReq.ClientID)
if err != nil || consent == nil {
if err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to get OIDC consent")
}
c.JSON(200, SkipConsentResponse{
SkipConsent: false,
})
return
}
if !scopesGranted(consent.Scope, authorizeReq.Scope) {
c.JSON(200, SkipConsentResponse{
SkipConsent: false,
})
return
}
c.JSON(200, SkipConsentResponse{
SkipConsent: true,
})
}
// The actual **internal** endpoint that actually creates the code and session.
// It is called by the frontend after the user has logged in and given consent.
func (controller *OIDCController) authorizeComplete(c *gin.Context) {
@@ -330,6 +411,9 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
return
}
// We no longer need the ticket
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)
// Get the client
client, ok := controller.oidc.GetClient(authorizeReq.ClientID)
@@ -343,9 +427,6 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
return
}
// We no longer need the ticket
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)
// Create the sub to find and delete old sessions
sub := controller.oidc.CreateSub(*userContext, authorizeReq.ClientID)
@@ -466,7 +547,9 @@ func (controller *OIDCController) Token(c *gin.Context) {
return
}
if client.ClientSecret != creds.ClientSecret {
clientSecretHash := sha256.Sum256([]byte(client.ClientSecret))
providedSecretHash := sha256.Sum256([]byte(creds.ClientSecret))
if subtle.ConstantTimeCompare(clientSecretHash[:], providedSecretHash[:]) != 1 {
controller.log.App.Warn().Str("clientId", creds.ClientID).Msg("Invalid client secret")
c.JSON(400, gin.H{
"error": "invalid_client",
+284 -72
View File
@@ -171,7 +171,7 @@ func TestOIDCController(t *testing.T) {
},
},
{
description: "Authorize skips the consent screen when all requested scopes were already granted",
description: "Authorize does not skip the consent screen even when consent was already granted",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
@@ -189,77 +189,9 @@ func TestOIDCController(t *testing.T) {
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
assert.Contains(t, location, "oidc_prompt=none")
},
},
{
description: "Authorize shows the consent screen when a new scope is requested",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "testuser", ClientID: "some-client-id",
Scope: "openid", CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
q := url.Values{}
q.Set("scope", "openid profile")
q.Set("response_type", "code")
q.Set("client_id", "some-client-id")
q.Set("redirect_uri", "https://test.example.com/callback")
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
assert.NotContains(t, location, "oidc_prompt=none")
},
},
{
description: "Authorize skips the consent screen for a subset of already granted scopes",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "testuser", ClientID: "some-client-id",
Scope: "openid profile email", CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
q := url.Values{}
q.Set("scope", "openid profile")
q.Set("response_type", "code")
q.Set("client_id", "some-client-id")
q.Set("redirect_uri", "https://test.example.com/callback")
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
assert.Contains(t, location, "oidc_prompt=none")
},
},
{
description: "Authorize shows the consent screen when no consent was granted yet",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
require.NoError(t, store.DeleteOIDCConsentByClientID(ctx, "some-client-id"))
q := url.Values{}
q.Set("scope", "openid profile")
q.Set("response_type", "code")
q.Set("client_id", "some-client-id")
q.Set("redirect_uri", "https://test.example.com/callback")
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
router.ServeHTTP(recorder, req)
// The consent skip check now happens after auth via the
// skip-consent endpoint, so authorize should never set
// prompt=none on its own.
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
@@ -304,6 +236,232 @@ func TestOIDCController(t *testing.T) {
},
},
// --- skip-consent ---
{
description: "Skip consent returns 500 when OIDC is not configured",
oidcDisabled: true,
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket=some-ticket", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusInternalServerError, recorder.Code)
},
},
{
description: "Skip consent returns 401 when the user context is missing",
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket=some-ticket", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Skip consent returns 401 when the user is not authenticated",
middlewares: []gin.HandlerFunc{
func(c *gin.Context) {
c.Set("context", &model.UserContext{
Authenticated: false,
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{Username: "testuser"},
},
})
},
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket=some-ticket", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Skip consent returns 400 when the ticket is missing",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/oidc/skip-consent", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
{
description: "Skip consent returns false when the ticket is unknown",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket=nonexistent-ticket", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var res SkipConsentResponse
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
assert.False(t, res.SkipConsent)
},
},
{
description: "Skip consent returns true when the authorize request has prompt none",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "testuser", ClientID: "some-client-id",
Scope: "openid profile", CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
Scope: "openid profile",
ResponseType: "code",
ClientID: "some-client-id",
RedirectURI: "https://test.example.com/callback",
Prompt: "none",
})
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket="+url.QueryEscape(ticket), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var res SkipConsentResponse
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
assert.True(t, res.SkipConsent)
},
},
{
description: "Skip consent returns false when no consent was granted yet",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
require.NoError(t, store.DeleteOIDCConsentByClientID(ctx, "some-client-id"))
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
Scope: "openid profile",
ResponseType: "code",
ClientID: "some-client-id",
RedirectURI: "https://test.example.com/callback",
})
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket="+url.QueryEscape(ticket), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var res SkipConsentResponse
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
assert.False(t, res.SkipConsent)
},
},
{
description: "Skip consent returns false when a new scope is requested",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "testuser", ClientID: "some-client-id",
Scope: "openid", CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
Scope: "openid profile",
ResponseType: "code",
ClientID: "some-client-id",
RedirectURI: "https://test.example.com/callback",
})
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket="+url.QueryEscape(ticket), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var res SkipConsentResponse
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
assert.False(t, res.SkipConsent)
},
},
{
description: "Skip consent returns true when all requested scopes were already granted",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "testuser", ClientID: "some-client-id",
Scope: "openid profile", CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
Scope: "openid profile",
ResponseType: "code",
ClientID: "some-client-id",
RedirectURI: "https://test.example.com/callback",
})
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket="+url.QueryEscape(ticket), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var res SkipConsentResponse
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
assert.True(t, res.SkipConsent)
},
},
{
description: "Skip consent returns true for a subset of already granted scopes",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "testuser", ClientID: "some-client-id",
Scope: "openid profile email", CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
Scope: "openid profile",
ResponseType: "code",
ClientID: "some-client-id",
RedirectURI: "https://test.example.com/callback",
})
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket="+url.QueryEscape(ticket), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var res SkipConsentResponse
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
assert.True(t, res.SkipConsent)
},
},
{
description: "Skip consent returns false when the consent belongs to another user",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
require.NoError(t, store.DeleteOIDCConsentByClientID(ctx, "some-client-id"))
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "otheruser", ClientID: "some-client-id",
Scope: "openid profile", CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
Scope: "openid profile",
ResponseType: "code",
ClientID: "some-client-id",
RedirectURI: "https://test.example.com/callback",
})
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket="+url.QueryEscape(ticket), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var res SkipConsentResponse
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
assert.False(t, res.SkipConsent)
},
},
// --- authorize-complete ---
{
description: "Should fail if oidc is disabled",
@@ -423,6 +581,60 @@ func TestOIDCController(t *testing.T) {
assert.Contains(t, redirectURI, "state=state-123")
},
},
{
description: "Authorize complete deletes the ticket on success",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
Scope: "openid",
ResponseType: "code",
ClientID: "some-client-id",
RedirectURI: "https://test.example.com/callback",
})
body, err := json.Marshal(AuthorizeCompleteRequest{Ticket: ticket})
require.NoError(t, err)
req := httptest.NewRequest("POST", "/api/oidc/authorize-complete", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
_, ok := oidcService.GetAuthorizeRequestByTicket(ticket)
assert.False(t, ok)
},
},
{
description: "Authorize complete deletes the ticket even when the client is unknown",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
Scope: "openid",
ResponseType: "code",
ClientID: "unknown-client",
RedirectURI: "https://test.example.com/callback",
})
body, err := json.Marshal(AuthorizeCompleteRequest{Ticket: ticket})
require.NoError(t, err)
req := httptest.NewRequest("POST", "/api/oidc/authorize-complete", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var res map[string]any
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
redirectURI, ok := res["redirect_uri"].(string)
require.True(t, ok)
assert.Contains(t, redirectURI, oidcService.GetIssuer()+"/error")
_, ok = oidcService.GetAuthorizeRequestByTicket(ticket)
assert.False(t, ok)
},
},
// --- token ---
{
+72 -16
View File
@@ -57,6 +57,7 @@ type ProxyContext struct {
type ProxyController struct {
log *logger.Logger
runtime *model.RuntimeConfig
config *model.Config
acls *service.AccessControlsService
auth *service.AuthService
policyEngine *service.PolicyEngine
@@ -67,6 +68,7 @@ type ProxyControllerInput struct {
Log *logger.Logger
RuntimeConfig *model.RuntimeConfig
Config *model.Config
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"`
ACLsService *service.AccessControlsService
AuthService *service.AuthService
@@ -77,6 +79,7 @@ func NewProxyController(i ProxyControllerInput) *ProxyController {
controller := &ProxyController{
log: i.Log,
runtime: i.RuntimeConfig,
config: i.Config,
acls: i.ACLsService,
auth: i.AuthService,
policyEngine: i.PolicyEngine,
@@ -465,6 +468,10 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont
// We get the path from the query string
path := c.Query("path")
if strings.TrimSpace(path) == "" {
return ProxyContext{}, errors.New("path not found")
}
// For envoy we need to support every method
method := c.Request.Method
@@ -477,14 +484,22 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont
}, nil
}
func (controller *ProxyController) determineAuthModules(proxy ProxyType) []AuthModuleType {
func (controller *ProxyController) determineAuthModules(proxy ProxyType, fallbacks bool) []AuthModuleType {
switch proxy {
case Traefik, Caddy:
return []AuthModuleType{ForwardAuth}
case Envoy:
return []AuthModuleType{ExtAuthz, ForwardAuth}
authModules := []AuthModuleType{ExtAuthz}
if fallbacks {
authModules = append(authModules, ForwardAuth)
}
return authModules
case Nginx:
return []AuthModuleType{AuthRequest, ForwardAuth}
authModules := []AuthModuleType{AuthRequest}
if fallbacks {
authModules = append(authModules, ForwardAuth)
}
return authModules
default:
return []AuthModuleType{}
}
@@ -514,6 +529,39 @@ func (controller *ProxyController) getContextFromAuthModule(c *gin.Context, modu
return ProxyContext{}, fmt.Errorf("unsupported auth module: %v", module)
}
func (controller *ProxyController) authModuleIdentifiersPresent(c *gin.Context, module AuthModuleType) bool {
switch module {
case ForwardAuth:
_, host := controller.getHeader(c, "x-forwarded-host")
_, uri := controller.getHeader(c, "x-forwarded-uri")
return host || uri
case AuthRequest:
_, ok := controller.getHeader(c, "x-original-url")
return ok
case ExtAuthz:
return strings.TrimSpace(c.Query("path")) != ""
default:
return false
}
}
func (controller *ProxyController) ensureNoMultipleAuthModules(c *gin.Context, authModules []AuthModuleType) error {
present := 0
for _, module := range authModules {
if controller.authModuleIdentifiersPresent(c, module) {
present++
}
}
if present > 1 {
controller.log.App.Warn().Msg("Request carries headers for multiple auth modules, possible spoofing attempt, denying")
return fmt.Errorf("conflicting auth module headers")
}
return nil
}
func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext, error) {
var req Proxy
@@ -530,28 +578,36 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
controller.log.App.Debug().Msgf("Determined proxy type: %v", proxy)
authModules := controller.determineAuthModules(proxy)
authModules := controller.determineAuthModules(proxy, !controller.config.Experimental.DisableAuthModuleFallback)
if len(authModules) == 0 {
return ProxyContext{}, fmt.Errorf("no auth modules supported for proxy: %v", req.Proxy)
}
var ctx ProxyContext
for _, module := range authModules {
controller.log.App.Debug().Msgf("Trying to get context from auth module %v", module)
ctx, err = controller.getContextFromAuthModule(c, module)
if err == nil {
controller.log.App.Debug().Msgf("Successfully got context from auth module %v", module)
break
}
controller.log.App.Debug().Msgf("Failed to get context from auth module %v: %v", module, err)
}
err = controller.ensureNoMultipleAuthModules(c, controller.determineAuthModules(proxy, true))
if err != nil {
return ProxyContext{}, err
}
var ctx *ProxyContext
for _, module := range authModules {
controller.log.App.Debug().Msgf("Trying to get context from auth module %v", module)
authModuleCtx, err := controller.getContextFromAuthModule(c, module)
if err != nil {
controller.log.App.Debug().Msgf("Failed to get context from auth module %v: %v", module, err)
continue
}
controller.log.App.Debug().Msgf("Successfully got context from auth module %v", module)
ctx = &authModuleCtx
break
}
if ctx == nil {
return ProxyContext{}, fmt.Errorf("failed to get context from any auth module")
}
// Parse the raw path to populate the cleaned path used for ACLs
upath, err := url.Parse(ctx.PathRaw)
@@ -577,5 +633,5 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
ctx.IsBrowser = isBrowser
ctx.ProxyType = proxy
return ctx, nil
return *ctx, nil
}
+30 -2
View File
@@ -213,7 +213,7 @@ func TestProxyController(t *testing.T) {
description: "Ensure forward auth fallback for envoy",
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 := httptest.NewRequest("HEAD", "/api/auth/envoy", nil)
req.Host = ""
req.Header.Set("x-forwarded-host", "test.example.com")
req.Header.Set("x-forwarded-proto", "https")
@@ -261,7 +261,7 @@ func TestProxyController(t *testing.T) {
description: "Ensure extauthz with envoy non browser returns json",
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 := httptest.NewRequest("HEAD", "/api/auth/envoy", nil)
req.Header.Set("x-forwarded-host", "test.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/hello")
@@ -877,6 +877,32 @@ func TestProxyController(t *testing.T) {
assert.Equal(t, "bar", recorder.Header().Get("x-foo"))
},
},
{
description: "Forward auth and auth request headers should fail for nginx",
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/nginx", nil)
req.Header.Set("x-forwarded-host", "foo.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/foo?bar=foo")
req.Header.Set("x-original-url", "https://foo.example.com/foo?bar=foo")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
{
description: "Forward auth and ext authz headers should fail for envoy",
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil)
req.Host = "foo.example.com"
req.Header.Set("x-forwarded-host", "foo.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/foo?bar=foo")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
}
store := memory.New()
@@ -892,6 +918,7 @@ func TestProxyController(t *testing.T) {
aclsService := service.NewAccessControlsService(service.AccessControlServiceInput{
Log: log,
Config: &cfg,
Runtime: &runtime,
LabelProvider: nil,
})
@@ -952,6 +979,7 @@ func TestProxyController(t *testing.T) {
NewProxyController(ProxyControllerInput{
Log: log,
RuntimeConfig: &runtime,
Config: &cfg,
RouterGroup: group,
ACLsService: aclsService,
AuthService: authService,
+23 -14
View File
@@ -53,7 +53,7 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
UI: UIConfig{
Title: "Tinyauth",
ForgotPasswordMessage: "You can change your password by changing the configuration.",
BackgroundImage: "/background.jpg",
BackgroundImage: "/background.webp",
WarningsEnabled: true,
},
LDAP: LDAPConfig{
@@ -239,7 +239,8 @@ type LogStreamConfig struct {
}
type ExperimentalConfig struct {
OAuthBridgeEnabled bool `description:"Enable the OAuth bridge, uses a new way to format OAuth user information." yaml:"oauthBridgeEnabled,omitempty"`
OAuthBridgeEnabled bool `description:"Enable the OAuth bridge, uses a new way to format OAuth user information." yaml:"oauthBridgeEnabled,omitempty"`
DisableAuthModuleFallback bool `description:"Disable the fallback to forward_auth modules when auth_request or ext_authz fail." yaml:"disableAuthModuleFallback,omitempty"`
}
type TailscaleConfig struct {
@@ -253,18 +254,26 @@ type TailscaleConfig struct {
// OAuth/OIDC config
type OAuthServiceConfig struct {
ClientID string `description:"OAuth client ID." yaml:"clientId,omitempty"`
ClientSecret string `description:"OAuth client secret." yaml:"clientSecret,omitempty"`
ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile,omitempty"`
Whitelist []string `description:"Comma-separated list of allowed OAuth domains for this provider." yaml:"whitelist,omitempty"`
WhitelistFile string `description:"Path to the OAuth whitelist file for this provider." yaml:"whitelistFile,omitempty"`
Scopes []string `description:"OAuth scopes." yaml:"scopes,omitempty"`
RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl,omitempty"`
AuthURL string `description:"OAuth authorization URL." yaml:"authUrl,omitempty"`
TokenURL string `description:"OAuth token URL." yaml:"tokenUrl,omitempty"`
UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl,omitempty"`
Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure,omitempty"`
Name string `description:"Provider name in UI." yaml:"name,omitempty"`
ClientID string `description:"OAuth client ID." yaml:"clientId,omitempty"`
ClientSecret string `description:"OAuth client secret." yaml:"clientSecret,omitempty"`
ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile,omitempty"`
Whitelist []string `description:"Comma-separated list of allowed OAuth domains for this provider." yaml:"whitelist,omitempty"`
WhitelistFile string `description:"Path to the OAuth whitelist file for this provider." yaml:"whitelistFile,omitempty"`
Scopes []string `description:"OAuth scopes." yaml:"scopes,omitempty"`
RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl,omitempty"`
AuthURL string `description:"OAuth authorization URL." yaml:"authUrl,omitempty"`
TokenURL string `description:"OAuth token URL." yaml:"tokenUrl,omitempty"`
UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl,omitempty"`
Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure,omitempty"`
Name string `description:"Provider name in UI." yaml:"name,omitempty"`
Claims OAuthServiceClaimsMap `description:"Map of claims to extract from the userinfo response." yaml:"claims,omitempty"`
}
type OAuthServiceClaimsMap struct {
Username string `description:"Username claim." yaml:"username,omitempty"`
Email string `description:"Email claim." yaml:"email,omitempty"`
Name string `description:"Name claim." yaml:"name,omitempty"`
Groups string `description:"Groups claim." yaml:"groups,omitempty"`
}
type OIDCClientConfig struct {
+40 -8
View File
@@ -2,11 +2,13 @@ package service
import (
"errors"
"fmt"
"net"
"strings"
"unicode"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig"
)
@@ -21,6 +23,7 @@ type LabelProvider interface {
type AccessControlsService struct {
log *logger.Logger
config *model.Config
runtime *model.RuntimeConfig
labelProvider LabelProvider
}
@@ -29,6 +32,7 @@ type AccessControlServiceInput struct {
Log *logger.Logger
Config *model.Config
Runtime *model.RuntimeConfig
LabelProvider LabelProvider `optional:"true"`
}
@@ -37,12 +41,38 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
return &AccessControlsService{
log: i.Log,
config: i.Config,
runtime: i.Runtime,
labelProvider: i.LabelProvider,
}
}
func (service *AccessControlsService) ensureAscii(str string) bool {
for i := 0; i < len(str); i++ {
if str[i] > unicode.MaxASCII {
return false
}
}
return true
}
func (service *AccessControlsService) normalizeDomain(domain string) string {
if host, _, err := net.SplitHostPort(domain); err == nil {
domain = host
}
domain = strings.TrimRight(domain, ".")
return strings.ToLower(domain)
}
func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) {
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
if !service.ensureAscii(domain) {
return nil, errors.New("domain contains non-ascii characters")
}
normalizedDomain := service.normalizeDomain(domain)
if !strings.HasSuffix(normalizedDomain, "."+service.runtime.CookieDomain) && normalizedDomain != service.runtime.CookieDomain {
return nil, fmt.Errorf("domain does not match cookie domain, expected %s (or a subdomain), got %s", service.runtime.CookieDomain, domain)
}
var domainMatch *model.App
var nameMatch *model.App
@@ -50,16 +80,18 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator
locatorFunc := func(name string, app *model.App) bool {
if app.Config.Domain != "" {
err := v.Validate(app.Config.Domain, domain)
if err == nil {
if !service.ensureAscii(app.Config.Domain) {
service.log.App.Warn().Str("name", name).Str("domain", app.Config.Domain).Msg("Domain contains non-ascii characters, skipping")
return false
}
if normalizedDomain == service.normalizeDomain(app.Config.Domain) {
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
domainMatch = app
return true
} else if !errors.Is(err, validators.ErrHostnameMismatch) {
service.log.App.Debug().Str("name", name).Err(err).Msg("Domain validation failed")
}
return false
}
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) {
if strings.HasPrefix(normalizedDomain, strings.ToLower(name+".")) {
service.log.App.Debug().Str("name", name).Msg("Found matching container by app name")
nameMatch = app
nameMatchedApps = append(nameMatchedApps, name)
@@ -83,7 +115,7 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator
}
if len(nameMatchedApps) > 1 {
service.log.App.Warn().Str("domain", domain).Strs("apps", nameMatchedApps).Msg("Multiple apps matched domain by name, app names must be unique, using last match")
return nil, fmt.Errorf("domain matched multiple apps by name prefix, use explicit domain config")
}
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name")
@@ -4,8 +4,10 @@ import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/test"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
@@ -34,14 +36,25 @@ func TestAccessControlsService(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
_, runtime := test.CreateTestConfigs(t)
tests := []struct {
name string
domain string
acls map[string]model.App
want *model.App
name string
domain string
acls map[string]model.App
want *model.App
errorFunc func(t *testing.T, e error)
}{
{
name: "returns ACLs for domain",
domain: "app.example.com",
acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "app.example.com"}},
},
want: &model.App{Config: model.AppConfig{Domain: "app.example.com"}},
},
{
name: "returns ACLs for root domain",
domain: "example.com",
acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "example.com"}},
@@ -65,20 +78,11 @@ func TestAccessControlsService(t *testing.T) {
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
},
{
name: "returns ACLs for non-ascii domain",
name: "returns error for non-ascii domain",
domain: "bücher.example.com",
acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "bücher.example.com"}},
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "domain contains non-ascii characters")
},
want: &model.App{Config: model.AppConfig{Domain: "bücher.example.com"}},
},
{
name: "returns ACLs for punycode domain and non-ascii config",
domain: "bücher.example.com",
acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
},
want: &model.App{Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
},
{
name: "returns ACLs with case-insensitive matching",
@@ -110,6 +114,33 @@ func TestAccessControlsService(t *testing.T) {
acls: map[string]model.App{},
want: nil,
},
{
name: "App in domain not matching with the cookie domain should return nothing with name matching",
domain: "foo.bad_example.com",
acls: map[string]model.App{
"foo": {
Path: model.AppPath{Allow: "/foo"},
},
},
want: nil,
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "domain does not match cookie domain")
},
},
{
name: "App in domain not matching with the cookie domain should return nothing with domain matching",
domain: "foo.bad_example.com",
acls: map[string]model.App{
"foo": {
Path: model.AppPath{Allow: "/foo"},
Config: model.AppConfig{Domain: "foo.bad_example.com"},
},
},
want: nil,
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "domain does not match cookie domain")
},
},
}
// run once for a mock provider
@@ -118,10 +149,17 @@ func TestAccessControlsService(t *testing.T) {
mock := newMockProvider(test.acls, false)
acls := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Runtime: &runtime,
Config: &model.Config{},
LabelProvider: mock,
})
app, err := acls.GetAccessControls(test.domain)
app, err := acls.getACLs(test.domain, func(locator func(name string, app *model.App) bool) error {
return mock.Lookup(test.domain, locator)
})
if test.errorFunc != nil {
test.errorFunc(t, err)
return
}
require.NoError(t, err)
require.Equal(t, test.want, app)
})
@@ -131,12 +169,17 @@ func TestAccessControlsService(t *testing.T) {
for _, test := range tests {
t.Run(test.name+"(staticACLs)", func(t *testing.T) {
acls := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Log: log,
Runtime: &runtime,
Config: &model.Config{
Apps: test.acls,
},
})
app, err := acls.lookupStaticACLs(test.domain)
if test.errorFunc != nil {
test.errorFunc(t, err)
return
}
require.NoError(t, err)
require.Equal(t, test.want, app)
})
@@ -146,16 +189,34 @@ func TestAccessControlsService(t *testing.T) {
mock := newMockProvider(map[string]model.App{}, true)
acls := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Runtime: &runtime,
Config: &model.Config{},
LabelProvider: mock,
})
_, err := acls.GetAccessControls("example.com")
require.Error(t, err)
_, err := acls.getACLs("example.com", func(locator func(name string, app *model.App) bool) error {
return mock.Lookup("example.com", locator)
})
assert.Error(t, err)
// get acls should return an error when multiple apps with the same domain exist
acls = NewAccessControlsService(AccessControlServiceInput{
Log: log,
Runtime: &runtime,
Config: &model.Config{
Apps: map[string]model.App{
"foo": {Path: model.AppPath{Allow: "/foo"}},
"foo.bar": {Path: model.AppPath{Allow: "/bar"}},
},
},
})
_, err = acls.GetAccessControls("foo.bar.example.com")
assert.ErrorContains(t, err, "domain matched multiple apps by name prefix, use explicit domain config")
// get access controls should get acls from
// static when static acls are configured
acls = NewAccessControlsService(AccessControlServiceInput{
Log: log,
Log: log,
Runtime: &runtime,
Config: &model.Config{
Apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
@@ -164,12 +225,12 @@ func TestAccessControlsService(t *testing.T) {
})
app, err := acls.GetAccessControls("foo.example.com")
require.NoError(t, err)
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "foo.example.com"}}, app)
assert.Equal(t, &model.App{Config: model.AppConfig{Domain: "foo.example.com"}}, app)
// should return nil for no apps
app, err = acls.GetAccessControls("bar.example.com")
require.NoError(t, err)
require.Nil(t, app)
assert.Nil(t, app)
// Should use label provider if available
mock = newMockProvider(map[string]model.App{
@@ -179,10 +240,11 @@ func TestAccessControlsService(t *testing.T) {
}, false)
acls = NewAccessControlsService(AccessControlServiceInput{
Log: log,
Runtime: &runtime,
Config: &model.Config{},
LabelProvider: mock,
})
app, err = acls.GetAccessControls("bar.example.com")
require.NoError(t, err)
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "bar.example.com"}}, app)
assert.Equal(t, &model.App{Config: model.AppConfig{Domain: "bar.example.com"}}, app)
}
+67 -15
View File
@@ -2,8 +2,12 @@ package service
import (
"context"
"errors"
"fmt"
"os"
"time"
"github.com/cenkalti/backoff/v5"
"github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
@@ -14,6 +18,10 @@ import (
"github.com/docker/docker/client"
)
var (
ErrPingFailed = fmt.Errorf("failed to ping docker")
)
type DockerService struct {
log *logger.Logger
client *client.Client
@@ -31,26 +39,54 @@ type DockerServiceInput struct {
}
func NewDockerService(i DockerServiceInput) (*DockerService, error) {
client, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return nil, err
}
client.NegotiateAPIVersion(i.Ctx)
_, err = client.Ping(i.Ctx)
if err != nil {
i.Log.App.Debug().Err(err).Msg("Docker not connected")
return nil, nil
}
service := &DockerService{
log: i.Log,
client: client,
context: i.Ctx,
}
service.log.App.Debug().Msg("Attempting to connect to Docker")
if os.Getenv("DOCKER_HOST") == "" {
cli, err := service.connect()
if err != nil {
if errors.Is(err, ErrPingFailed) {
service.log.App.Debug().Msg("Docker not connected")
return nil, nil
}
return nil, fmt.Errorf("failed to connect to docker: %w", err)
}
service.client = cli
} else {
exp := backoff.NewExponentialBackOff()
exp.InitialInterval = 3 * time.Second
exp.RandomizationFactor = 0.1
exp.Multiplier = 1.5
exp.Reset()
operation := func() (*client.Client, error) {
if service.client != nil {
service.client.Close()
}
cli, err := service.connect()
if err != nil {
return nil, err
}
return cli, nil
}
cli, err := backoff.Retry(service.context, operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))
if err != nil {
if errors.Is(err, ErrPingFailed) {
service.log.App.Debug().Msg("Docker not connected after retrying")
return nil, nil
}
return nil, fmt.Errorf("failed to connect to docker after retrying: %w", err)
}
service.client = cli
}
service.isConnected = true
service.log.App.Debug().Msg("Docker connected successfully")
@@ -59,6 +95,22 @@ func NewDockerService(i DockerServiceInput) (*DockerService, error) {
return service, nil
}
func (docker *DockerService) connect() (*client.Client, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, err
}
_, err = cli.Ping(docker.context)
if err != nil {
return nil, ErrPingFailed
}
return cli, nil
}
func (docker *DockerService) getContainers() ([]container.Summary, error) {
return docker.client.ContainerList(docker.context, container.ListOptions{})
}
+7 -3
View File
@@ -21,11 +21,15 @@ type GithubUserinfoResponse struct {
ID int `json:"id"`
}
func defaultExtractor(client *http.Client, ctx context.Context, url string) (*model.Claims, error) {
return simpleReq[model.Claims](client, ctx, url, nil)
func defaultExtractor(client *http.Client, ctx context.Context, url string, mapClaims MapClaims) (*model.Claims, error) {
claims, err := simpleReq[map[string]any](client, ctx, url, nil)
if err != nil {
return nil, err
}
return new(mapClaims(*claims)), nil
}
func githubExtractor(client *http.Client, ctx context.Context, _ string) (*model.Claims, error) {
func githubExtractor(client *http.Client, ctx context.Context, _ string, _ MapClaims) (*model.Claims, error) {
var user model.Claims
userInfo, err := simpleReq[GithubUserinfoResponse](client, ctx, "https://api.github.com/user", map[string]string{
+26 -2
View File
@@ -10,7 +10,8 @@ import (
"golang.org/x/oauth2"
)
type OAuthUserinfoExtractor func(client *http.Client, ctx context.Context, url string) (*model.Claims, error)
type MapClaims func(claims map[string]any) model.Claims
type OAuthUserinfoExtractor func(client *http.Client, ctx context.Context, url string, mapClaims MapClaims) (*model.Claims, error)
type OAuthService struct {
serviceCfg model.OAuthServiceConfig
@@ -81,7 +82,7 @@ func (s *OAuthService) GetToken(code string, verifier string) (*oauth2.Token, er
func (s *OAuthService) GetUserinfo(token *oauth2.Token) (*model.Claims, error) {
client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token))
return s.userinfoExtractor(client, s.ctx, s.serviceCfg.UserinfoURL)
return s.userinfoExtractor(client, s.ctx, s.serviceCfg.UserinfoURL, s.mapClaims)
}
func (s *OAuthService) GetConfig() model.OAuthServiceConfig {
@@ -97,3 +98,26 @@ func (s *OAuthService) UpdateConfig(config model.OAuthServiceConfig) {
s.config.Endpoint.TokenURL = config.TokenURL
s.config.RedirectURL = config.RedirectURL
}
func (s *OAuthService) mapClaims(claims map[string]any) model.Claims {
return model.Claims{
Sub: mapClaim[string]("sub", "", claims),
Name: mapClaim[string]("name", s.serviceCfg.Claims.Name, claims),
PreferredUsername: mapClaim[string]("preferred_username", s.serviceCfg.Claims.Username, claims),
Email: mapClaim[string]("email", s.serviceCfg.Claims.Email, claims),
Groups: mapClaim[any]("groups", s.serviceCfg.Claims.Groups, claims),
}
}
func mapClaim[T any](fallback, override string, kv map[string]any) T {
key := fallback
if override != "" {
key = override
}
v, ok := kv[key].(T)
if !ok {
var zero T
return zero
}
return v
}
+13 -1
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/subtle"
"crypto/x509"
"encoding/base64"
"encoding/json"
@@ -53,6 +54,17 @@ const (
OIDCPromptNone OIDCPrompt = "none"
)
func (p OIDCPrompt) String() string {
switch p {
case OIDCPromptLogin:
return "login"
case OIDCPromptNone:
return "none"
default:
return "login"
}
}
var SupportedPrompts = []string{string(OIDCPromptLogin), string(OIDCPromptNone)}
// This is not spec-compliant, the ID token SHOULD NOT contain user info claims but,
@@ -871,7 +883,7 @@ func (service *OIDCService) ValidatePKCE(codeChallenge string, codeVerifier stri
if codeChallenge == "" {
return true
}
return codeChallenge == service.hashAndEncodePKCE(codeVerifier)
return subtle.ConstantTimeCompare([]byte(codeChallenge), []byte(service.hashAndEncodePKCE(codeVerifier))) == 1
}
func (service *OIDCService) hashAndEncodePKCE(codeVerifier string) string {
+1 -1
View File
@@ -18,7 +18,7 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
UI: model.UIConfig{
Title: "Tinyauth Test",
ForgotPasswordMessage: "foo",
BackgroundImage: "/background.jpg",
BackgroundImage: "/background.webp",
WarningsEnabled: true,
},
OAuth: model.OAuthConfig{
+10 -1
View File
@@ -38,7 +38,16 @@ func SafeParseAppURL(str string) (string, error) {
return "", fmt.Errorf("ip addresses not allowed")
}
hostname, err = idna.Lookup.ToASCII(hostname)
i := idna.New(
idna.MapForLookup(),
idna.Transitional(false),
idna.BidiRule(),
idna.StrictDomainName(false),
idna.CheckHyphens(true),
idna.CheckJoiners(false),
)
hostname, err = i.ToASCII(hostname)
if err != nil {
return "", fmt.Errorf("failed to convert hostname to ascii: %w", err)
+8 -1
View File
@@ -43,6 +43,13 @@ func TestSafeParseAPPURL(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Underscores
appURL = "http://sub_tinyauth.app"
expected = "http://sub_tinyauth.app"
result, err = utils.SafeParseAppURL(appURL)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Lowercase
appURL = "HTTP://SUb.tinyAUth.aPP"
expected = "http://sub.tinyauth.app"
@@ -66,7 +73,7 @@ func TestSafeParseAPPURL(t *testing.T) {
assert.ErrorContains(t, err, "invalid url")
// Invalid punycode
appURL = "http://ab--cd.example.com"
appURL = "http://xn--h-kva.example.com"
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorContains(t, err, "failed to convert hostname to ascii")