This commit is contained in:
pascal
2026-01-16 12:01:52 +01:00
parent 3b832d1f21
commit 183619d1e1
20 changed files with 34 additions and 525 deletions

View File

@@ -1,9 +0,0 @@
package auth
const (
// DefaultSessionCookieName is the default cookie name for session storage
DefaultSessionCookieName = "auth_session"
// ErrorInternalServer is the default internal server error message
ErrorInternalServer = "Internal Server Error"
)

View File

@@ -18,7 +18,6 @@ func (c *BasicAuthConfig) Validate(r *http.Request) bool {
return false
}
// Use constant-time comparison to prevent timing attacks
usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(c.Username)) == 1
passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(c.Password)) == 1

View File

@@ -4,7 +4,5 @@ package methods
// The actual OIDC/JWT configuration comes from the global proxy Config.OIDCConfig
// This just enables Bearer auth for a specific route
type BearerConfig struct {
// Enable bearer token authentication for this route
// Uses the global OIDC configuration from proxy Config
Enabled bool
}

View File

@@ -13,7 +13,7 @@ const (
// PINConfig holds PIN authentication settings
type PINConfig struct {
PIN string
Header string // Header name (default: "X-PIN")
Header string
}
// Validate checks PIN from the request header
@@ -28,6 +28,5 @@ func (c *PINConfig) Validate(r *http.Request) bool {
return false
}
// Use constant-time comparison to prevent timing attacks
return subtle.ConstantTimeCompare([]byte(providedPIN), []byte(c.PIN)) == 1
}

View File

@@ -28,26 +28,22 @@ type authResult struct {
// ServeHTTP implements the http.Handler interface
func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// If no auth configured, allow request
if m.config.IsEmpty() {
m.allowWithoutAuth(w, r)
return
}
// Try to authenticate the request
result := m.authenticate(w, r)
if result == nil {
// Authentication triggered a redirect (e.g., OIDC flow)
return
}
// Reject if authentication failed
if !result.authenticated {
m.rejectRequest(w, r)
return
}
// Authentication successful - continue to next handler
m.continueWithAuth(w, r, result)
}
@@ -65,17 +61,14 @@ func (m *Middleware) allowWithoutAuth(w http.ResponseWriter, r *http.Request) {
// authenticate attempts to authenticate the request using configured methods
// Returns nil if a redirect occurred (e.g., OIDC flow initiated)
func (m *Middleware) authenticate(w http.ResponseWriter, r *http.Request) *authResult {
// Try Basic Auth
if result := m.tryBasicAuth(r); result.authenticated {
return result
}
// Try PIN Auth
if result := m.tryPINAuth(r); result.authenticated {
return result
}
// Try Bearer/OIDC Auth
return m.tryBearerAuth(w, r)
}
@@ -94,7 +87,6 @@ func (m *Middleware) tryBasicAuth(r *http.Request) *authResult {
method: "basic",
}
// Extract username from Basic Auth
if username, _, ok := r.BasicAuth(); ok {
result.userID = username
}
@@ -128,24 +120,20 @@ func (m *Middleware) tryBearerAuth(w http.ResponseWriter, r *http.Request) *auth
cookieName := m.oidcHandler.SessionCookieName()
// Handle auth token in query parameter (from OIDC callback)
if m.handleAuthTokenParameter(w, r, cookieName) {
return nil // Redirect occurred
return nil
}
// Try session cookie
if result := m.trySessionCookie(r, cookieName); result.authenticated {
return result
}
// Try Authorization header
if result := m.tryAuthorizationHeader(r); result.authenticated {
return result
}
// No valid auth - redirect to OIDC provider
m.oidcHandler.RedirectToProvider(w, r, m.routeID)
return nil // Redirect occurred
return nil
}
// handleAuthTokenParameter processes the _auth_token query parameter from OIDC callback
@@ -161,7 +149,6 @@ func (m *Middleware) handleAuthTokenParameter(w http.ResponseWriter, r *http.Req
"host": r.Host,
}).Info("Found auth token in query parameter, setting cookie and redirecting")
// Validate the token before setting cookie
if !m.oidcHandler.ValidateJWT(authToken) {
log.WithFields(log.Fields{
"route_id": m.routeID,
@@ -169,7 +156,6 @@ func (m *Middleware) handleAuthTokenParameter(w http.ResponseWriter, r *http.Req
return false
}
// Set session cookie
cookie := &http.Cookie{
Name: cookieName,
Value: authToken,
@@ -288,7 +274,7 @@ func (m *Middleware) continueWithAuth(w http.ResponseWriter, r *http.Request, re
"path": r.URL.Path,
}).Debug("Authentication successful")
// Store auth info in headers for logging
// TODO: Find other means of auth logging than headers
r.Header.Set("X-Auth-Method", result.method)
r.Header.Set("X-Auth-User-ID", result.userID)
@@ -299,7 +285,7 @@ func (m *Middleware) continueWithAuth(w http.ResponseWriter, r *http.Request, re
// Wrap wraps an HTTP handler with authentication middleware
func Wrap(next http.Handler, authConfig *Config, routeID string, rejectResponse func(w http.ResponseWriter, r *http.Request), oidcHandler *oidc.Handler) http.Handler {
if authConfig == nil {
authConfig = &Config{} // Empty config = no auth
authConfig = &Config{}
}
return &Middleware{

View File

@@ -2,19 +2,16 @@ package oidc
// Config holds the global OIDC/OAuth configuration
type Config struct {
// OIDC Provider settings
ProviderURL string `env:"NB_OIDC_PROVIDER_URL" json:"provider_url"` // Identity provider URL (e.g., "https://accounts.google.com")
ClientID string `env:"NB_OIDC_CLIENT_ID" json:"client_id"` // OAuth client ID
ClientSecret string `env:"NB_OIDC_CLIENT_SECRET" json:"client_secret"` // OAuth client secret (empty for public clients)
RedirectURL string `env:"NB_OIDC_REDIRECT_URL" json:"redirect_url"` // Redirect URL after auth (e.g., "http://localhost:54321/auth/callback")
Scopes []string `env:"NB_OIDC_SCOPES" json:"scopes"` // Requested scopes (default: ["openid", "profile", "email"])
ProviderURL string `env:"NB_OIDC_PROVIDER_URL" json:"provider_url"`
ClientID string `env:"NB_OIDC_CLIENT_ID" json:"client_id"`
ClientSecret string `env:"NB_OIDC_CLIENT_SECRET" json:"client_secret"`
RedirectURL string `env:"NB_OIDC_REDIRECT_URL" json:"redirect_url"`
Scopes []string `env:"NB_OIDC_SCOPES" json:"scopes"`
// JWT Validation settings
JWTKeysLocation string `env:"NB_OIDC_JWT_KEYS_LOCATION" json:"jwt_keys_location"` // JWKS URL for fetching public keys
JWTIssuer string `env:"NB_OIDC_JWT_ISSUER" json:"jwt_issuer"` // Expected issuer claim
JWTAudience []string `env:"NB_OIDC_JWT_AUDIENCE" json:"jwt_audience"` // Expected audience claims
JWTIdpSignkeyRefreshEnabled bool `env:"NB_OIDC_JWT_IDP_SIGNKEY_REFRESH_ENABLED" json:"jwt_idp_signkey_refresh_enabled"` // Enable automatic refresh of signing keys
JWTKeysLocation string `env:"NB_OIDC_JWT_KEYS_LOCATION" json:"jwt_keys_location"`
JWTIssuer string `env:"NB_OIDC_JWT_ISSUER" json:"jwt_issuer"`
JWTAudience []string `env:"NB_OIDC_JWT_AUDIENCE" json:"jwt_audience"`
JWTIdpSignkeyRefreshEnabled bool `env:"NB_OIDC_JWT_IDP_SIGNKEY_REFRESH_ENABLED" json:"jwt_idp_signkey_refresh_enabled"`
// Session settings
SessionCookieName string `env:"NB_OIDC_SESSION_COOKIE_NAME" json:"session_cookie_name"` // Cookie name for storing session (default: "auth_session")
SessionCookieName string `env:"NB_OIDC_SESSION_COOKIE_NAME" json:"session_cookie_name"`
}

View File

@@ -24,7 +24,6 @@ type Handler struct {
// NewHandler creates a new OIDC handler
func NewHandler(config *Config, stateStore *StateStore) *Handler {
// Initialize JWT validator
var jwtValidator *jwt.Validator
if config.JWTKeysLocation != "" {
jwtValidator = jwt.NewValidator(
@@ -67,7 +66,6 @@ func (h *Handler) RedirectToProvider(w http.ResponseWriter, r *http.Request, rou
scopes = []string{"openid", "profile", "email"}
}
// Build authorization URL
authURL, err := url.Parse(h.config.ProviderURL)
if err != nil {
log.WithError(err).Error("Invalid OIDC provider URL")
@@ -80,7 +78,6 @@ func (h *Handler) RedirectToProvider(w http.ResponseWriter, r *http.Request, rou
authURL.Path = strings.TrimSuffix(authURL.Path, "/") + "/authorize"
}
// Build query parameters
params := url.Values{}
params.Set("client_id", h.config.ClientID)
params.Set("redirect_uri", h.config.RedirectURL)
@@ -88,8 +85,6 @@ func (h *Handler) RedirectToProvider(w http.ResponseWriter, r *http.Request, rou
params.Set("scope", strings.Join(scopes, " "))
params.Set("state", state)
// Add audience parameter to get an access token for the API
// This ensures we get a proper JWT for the API audience, not just an ID token
if len(h.config.JWTAudience) > 0 && h.config.JWTAudience[0] != h.config.ClientID {
params.Set("audience", h.config.JWTAudience[0])
}
@@ -103,7 +98,6 @@ func (h *Handler) RedirectToProvider(w http.ResponseWriter, r *http.Request, rou
"state": state,
}).Info("Redirecting to OIDC provider for authentication")
// Redirect user to identity provider login page
http.Redirect(w, r, authURL.String(), http.StatusFound)
}

View File

@@ -34,7 +34,6 @@ func (s *StateStore) Store(stateToken, originalURL, routeID string) {
RouteID: routeID,
}
// Clean up expired states
s.cleanup()
}

View File

@@ -39,7 +39,7 @@ func NewLetsEncrypt(config LetsEncryptConfig) *LetsEncryptManager {
HostPolicy: m.hostPolicy,
Cache: autocert.DirCache(config.CertCacheDir),
Email: config.Email,
RenewBefore: 0, // Use default
RenewBefore: 0, // Use default 30 days prior to expiration
}
log.Info("Let's Encrypt certificate manager initialized")
@@ -71,8 +71,6 @@ func (m *LetsEncryptManager) RemoveDomain(domain string) {
func (m *LetsEncryptManager) IssueCertificate(ctx context.Context, domain string) error {
log.Infof("Issuing Let's Encrypt certificate for domain: %s", domain)
// Use GetCertificate to trigger certificate issuance
// This will go through the ACME challenge flow
hello := &tls.ClientHelloInfo{
ServerName: domain,
}

View File

@@ -54,19 +54,16 @@ func (m *SelfSignedManager) IssueCertificate(ctx context.Context, domain string)
m.mu.Lock()
defer m.mu.Unlock()
// Check if we already have a certificate for this domain
if _, exists := m.certificates[domain]; exists {
log.Debugf("Self-signed certificate already exists for domain: %s", domain)
return nil
}
// Generate self-signed certificate
cert, err := m.generateCertificate(domain)
if err != nil {
return err
}
// Cache the certificate
m.certificates[domain] = cert
return nil
@@ -94,7 +91,6 @@ func (m *SelfSignedManager) getCertificate(hello *tls.ClientHelloInfo) (*tls.Cer
return cert, nil
}
// Generate certificate on-demand if not cached
log.Infof("Generating self-signed certificate on-demand for: %s", hello.ServerName)
newCert, err := m.generateCertificate(hello.ServerName)
@@ -102,7 +98,6 @@ func (m *SelfSignedManager) getCertificate(hello *tls.ClientHelloInfo) (*tls.Cer
return nil, err
}
// Cache it
m.mu.Lock()
m.certificates[hello.ServerName] = newCert
m.mu.Unlock()
@@ -112,13 +107,11 @@ func (m *SelfSignedManager) getCertificate(hello *tls.ClientHelloInfo) (*tls.Cer
// generateCertificate generates a self-signed certificate for a domain
func (m *SelfSignedManager) generateCertificate(domain string) (*tls.Certificate, error) {
// Generate private key
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %w", err)
}
// Create certificate template
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour) // Valid for 1 year
@@ -141,13 +134,11 @@ func (m *SelfSignedManager) generateCertificate(domain string) (*tls.Certificate
DNSNames: []string{domain},
}
// Create self-signed certificate
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, fmt.Errorf("failed to create certificate: %w", err)
}
// Parse certificate
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)

View File

@@ -53,18 +53,13 @@ func (p *Proxy) handleProxyRequest(w http.ResponseWriter, r *http.Request) {
host = host[:idx]
}
// Get auth info from headers set by auth middleware
// TODO: extract logging data
authMechanism := r.Header.Get("X-Auth-Method")
if authMechanism == "" {
authMechanism = "none"
}
userID := r.Header.Get("X-Auth-User-ID")
// Determine auth success based on status code
authSuccess := rw.statusCode != http.StatusUnauthorized && rw.statusCode != http.StatusForbidden
// Extract source IP directly
sourceIP := extractSourceIP(r)
data := RequestData{
@@ -89,29 +84,22 @@ func (p *Proxy) findRoute(host, path string) *routeEntry {
p.mu.RLock()
defer p.mu.RUnlock()
// Strip port from host
if idx := strings.LastIndex(host, ":"); idx != -1 {
host = host[:idx]
}
// O(1) lookup by host
routeConfig, exists := p.routes[host]
if !exists {
return nil
}
// Build list of route entries sorted by path specificity
var entries []*routeEntry
// Create entries for each path mapping
for routePath, target := range routeConfig.PathMappings {
proxy := p.createProxy(routeConfig, target)
// ALWAYS wrap proxy with auth middleware (even if no auth configured)
// This ensures consistent auth handling and logging
handler := auth.Wrap(proxy, routeConfig.AuthConfig, routeConfig.ID, routeConfig.AuthRejectResponse, p.oidcHandler)
// Log auth configuration
if routeConfig.AuthConfig != nil && !routeConfig.AuthConfig.IsEmpty() {
var authType string
if routeConfig.AuthConfig.BasicAuth != nil {
@@ -169,11 +157,9 @@ func (p *Proxy) findRoute(host, path string) *routeEntry {
// createProxy creates a reverse proxy for a target with the route's connection
func (p *Proxy) createProxy(routeConfig *RouteConfig, target string) *httputil.ReverseProxy {
// Parse target URL
targetURL, err := url.Parse("http://" + target)
if err != nil {
log.Errorf("Failed to parse target URL %s: %v", target, err)
// Return a proxy that returns 502
return &httputil.ReverseProxy{
Director: func(req *http.Request) {},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
@@ -182,21 +168,18 @@ func (p *Proxy) createProxy(routeConfig *RouteConfig, target string) *httputil.R
}
}
// Create reverse proxy
proxy := httputil.NewSingleHostReverseProxy(targetURL)
// Configure transport to use the provided connection (WireGuard, etc.)
proxy.Transport = &http.Transport{
DialContext: routeConfig.nbClient.DialContext,
MaxIdleConns: 1,
MaxIdleConnsPerHost: 1,
IdleConnTimeout: 0, // Keep alive indefinitely
IdleConnTimeout: 0,
DisableKeepAlives: false,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
// Custom error handler
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log.Errorf("Proxy error for %s%s: %v", r.Host, r.URL.Path, err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
@@ -207,14 +190,12 @@ func (p *Proxy) createProxy(routeConfig *RouteConfig, target string) *httputil.R
// handleOIDCCallback handles the global /auth/callback endpoint for all routes
func (p *Proxy) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
// Check if OIDC handler is available
if p.oidcHandler == nil {
log.Error("OIDC callback received but no OIDC handler configured")
http.Error(w, "Authentication not configured", http.StatusInternalServerError)
return
}
// Use the OIDC handler's callback method
handler := p.oidcHandler.HandleCallback()
handler(w, r)
}

View File

@@ -27,7 +27,6 @@ type Proxy struct {
// New creates a new reverse proxy
func New(config Config) (*Proxy, error) {
// Set defaults
if config.ListenAddress == "" {
config.ListenAddress = ":443"
}
@@ -38,22 +37,18 @@ func New(config Config) (*Proxy, error) {
config.CertCacheDir = "./certs"
}
// Set default cert mode
if config.CertMode == "" {
config.CertMode = "letsencrypt"
}
// Validate config based on cert mode
if config.CertMode == "letsencrypt" && config.TLSEmail == "" {
return nil, fmt.Errorf("TLSEmail is required for letsencrypt mode")
}
// Set default OIDC session cookie name if not provided
if config.OIDCConfig != nil && config.OIDCConfig.SessionCookieName == "" {
config.OIDCConfig.SessionCookieName = "auth_session"
}
// Initialize certificate manager based on mode
var certMgr certmanager.Manager
if config.CertMode == "selfsigned" {
// HTTPS with self-signed certificates (for local testing)
@@ -73,8 +68,6 @@ func New(config Config) (*Proxy, error) {
isRunning: false,
}
// Initialize OIDC handler if OIDC is configured
// The handler internally creates and manages its own state store
if config.OIDCConfig != nil {
stateStore := oidc.NewStateStore()
p.oidcHandler = oidc.NewHandler(config.OIDCConfig, stateStore)
@@ -93,28 +86,25 @@ func (p *Proxy) Start() error {
p.isRunning = true
p.mu.Unlock()
// Build the main HTTP handler
handler := p.buildHandler()
return p.startHTTPS(handler)
}
// startHTTPS starts the proxy with HTTPS (non-blocking)
// startHTTPS starts the proxy with HTTPS
func (p *Proxy) startHTTPS(handler http.Handler) error {
// Start HTTP server for ACME challenges (Let's Encrypt HTTP-01)
p.httpServer = &http.Server{
Addr: p.config.HTTPListenAddress,
Handler: p.certManager.HTTPHandler(nil),
}
go func() {
log.Infof("Starting HTTP server for ACME challenges on %s", p.config.HTTPListenAddress)
log.Infof("Starting HTTP server on %s", p.config.HTTPListenAddress)
if err := p.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Errorf("HTTP server error: %v", err)
}
}()
// Start HTTPS server in background
p.server = &http.Server{
Addr: p.config.ListenAddress,
Handler: handler,
@@ -143,14 +133,12 @@ func (p *Proxy) Stop(ctx context.Context) error {
log.Info("Stopping reverse proxy server...")
// Stop HTTP server (for ACME challenges)
if p.httpServer != nil {
if err := p.httpServer.Shutdown(ctx); err != nil {
log.Errorf("Error shutting down HTTP server: %v", err)
}
}
// Stop main server
if p.server != nil {
if err := p.server.Shutdown(ctx); err != nil {
return fmt.Errorf("error shutting down server: %w", err)

View File

@@ -36,7 +36,6 @@ func (p *Proxy) AddRoute(route *RouteConfig) error {
p.mu.Lock()
defer p.mu.Unlock()
// Check if route already exists for this domain
if _, exists := p.routes[route.Domain]; exists {
return fmt.Errorf("route for domain %s already exists", route.Domain)
}
@@ -54,10 +53,8 @@ func (p *Proxy) AddRoute(route *RouteConfig) error {
route.nbClient = client
// Add route with domain as key
p.routes[route.Domain] = route
// Register domain with certificate manager
p.certManager.AddDomain(route.Domain)
log.WithFields(log.Fields{
@@ -66,13 +63,13 @@ func (p *Proxy) AddRoute(route *RouteConfig) error {
"paths": len(route.PathMappings),
}).Info("Added route")
// Eagerly issue certificate in background
go func(domain string) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := p.certManager.IssueCertificate(ctx, domain); err != nil {
log.Errorf("Failed to issue certificate: %v", err)
// TODO: Better error feedback mechanism
}
}(route.Domain)
@@ -84,15 +81,12 @@ func (p *Proxy) RemoveRoute(domain string) error {
p.mu.Lock()
defer p.mu.Unlock()
// Check if route exists
if _, exists := p.routes[domain]; !exists {
return fmt.Errorf("route for domain %s not found", domain)
}
// Remove route
delete(p.routes, domain)
// Unregister domain from certificate manager
p.certManager.RemoveDomain(domain)
log.Infof("Removed route for domain: %s", domain)
@@ -114,12 +108,10 @@ func (p *Proxy) UpdateRoute(route *RouteConfig) error {
p.mu.Lock()
defer p.mu.Unlock()
// Check if route exists for this domain
if _, exists := p.routes[route.Domain]; !exists {
return fmt.Errorf("route for domain %s not found", route.Domain)
}
// Update route using domain as key
p.routes[route.Domain] = route
log.WithFields(log.Fields{