Merge remote-tracking branch 'origin/prototype/reverse-proxy' into prototype/reverse-proxy

This commit is contained in:
Eduard Gert
2026-02-04 15:11:33 +01:00
20 changed files with 769 additions and 410 deletions
+6 -2
View File
@@ -14,14 +14,18 @@ Proxy Authentication methods supported are:
- Simple PIN
- HTTP Basic Auth Username and Password
## Management Connection
## Management Connection and Authentication
The Proxy communicates with the Management server over a gRPC connection.
Proxies act as clients to the Management server, the following RPCs are used:
- Server-side streaming for proxied service updates.
- Client-side streaming for proxy logs.
## Authentication
To authenticate with the Management server, the proxy server uses Machine-to-Machine OAuth2.
If you are using the embedded IdP //TODO: explain how to get credentials.
Otherwise, create a new machine-to-machine profile in your IdP for proxy servers and set the relevant settings in the proxy's environment or flags (see below).
## User Authentication
When a request hits the Proxy, it looks up the permitted authentication methods for the Host domain.
If no authentication methods are registered for the Host domain, then no authentication will be applied (for fully public resources).
-5
View File
@@ -50,8 +50,3 @@ func (l Link) Authenticate(r *http.Request) (string, string) {
return "", linkFormId
}
func (l Link) Middleware(next http.Handler) http.Handler {
// TODO: handle magic link redirects, should be similar to OIDC.
return next
}
+1 -20
View File
@@ -52,12 +52,6 @@ type Scheme interface {
// be included in a UI template when prompting the user to authenticate.
// If the request is authenticated, then a user id should be returned.
Authenticate(*http.Request) (userid string, promptData string)
// Middleware is applied within the outer auth middleware, but they will
// be applied after authentication if no scheme has authenticated a
// request.
// If no scheme Middleware blocks the request processing, then the auth
// middleware will then present the user with the auth UI.
Middleware(http.Handler) http.Handler
}
type Middleware struct {
@@ -132,20 +126,7 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
methods[s.Type().String()] = promptData
}
// The handler is passed through the scheme middlewares,
// if none of them intercept the request, then this handler will
// be called and present the user with the authentication page.
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
web.ServeHTTP(w, r, map[string]any{"methods": methods})
}))
// No authentication succeeded. Apply the scheme handlers.
for _, s := range schemes {
handler = s.Middleware(handler)
}
// Run the unauthenticated request against the scheme handlers and the final UI handler.
handler.ServeHTTP(w, r)
web.ServeHTTP(w, r, map[string]any{"methods": methods})
})
}
+73 -174
View File
@@ -2,30 +2,27 @@ package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/netbirdio/netbird/shared/management/proto"
"google.golang.org/grpc"
"github.com/netbirdio/netbird/shared/auth/jwt"
)
const stateExpiration = 10 * time.Minute
type urlGenerator interface {
GetOIDCURL(context.Context, *proto.GetOIDCURLRequest, ...grpc.CallOption) (*proto.GetOIDCURLResponse, error)
}
const callbackPath = "/oauth/callback"
// OIDCConfig holds configuration for OIDC authentication
// OIDCConfig holds configuration for OIDC JWT verification
type OIDCConfig struct {
OIDCProviderURL string
OIDCClientID string
OIDCClientSecret string
OIDCScopes []string
DistributionGroups []string
Issuer string
Audiences []string
KeysLocation string
MaxTokenAgeSeconds int64
}
// oidcState stores CSRF state with expiration
@@ -36,50 +33,26 @@ type oidcState struct {
// OIDC implements the Scheme interface for JWT/OIDC authentication
type OIDC struct {
id, accountId, proxyURL string
verifier *oidc.IDTokenVerifier
oauthConfig *oauth2.Config
states map[string]*oidcState
statesMux sync.RWMutex
distributionGroups []string
id, accountId string
validator *jwt.Validator
maxTokenAgeSeconds int64
client urlGenerator
}
// NewOIDC creates a new OIDC authentication scheme
func NewOIDC(ctx context.Context, id, accountId, proxyURL string, cfg OIDCConfig) (*OIDC, error) {
if cfg.OIDCProviderURL == "" || cfg.OIDCClientID == "" {
return nil, fmt.Errorf("OIDC provider URL and client ID are required")
}
scopes := cfg.OIDCScopes
if len(scopes) == 0 {
scopes = []string{oidc.ScopeOpenID, "profile", "email"}
}
provider, err := oidc.NewProvider(ctx, cfg.OIDCProviderURL)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC provider: %w", err)
}
o := &OIDC{
func NewOIDC(client urlGenerator, id, accountId string, cfg OIDCConfig) *OIDC {
return &OIDC{
id: id,
accountId: accountId,
proxyURL: proxyURL,
verifier: provider.Verifier(&oidc.Config{
ClientID: cfg.OIDCClientID,
}),
oauthConfig: &oauth2.Config{
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret,
Endpoint: provider.Endpoint(),
Scopes: scopes,
},
states: make(map[string]*oidcState),
distributionGroups: cfg.DistributionGroups,
validator: jwt.NewValidator(
cfg.Issuer,
cfg.Audiences,
cfg.KeysLocation,
true,
),
maxTokenAgeSeconds: cfg.MaxTokenAgeSeconds,
client: client,
}
go o.cleanupStates()
return o, nil
}
func (*OIDC) Type() Method {
@@ -87,153 +60,79 @@ func (*OIDC) Type() Method {
}
func (o *OIDC) Authenticate(r *http.Request) (string, string) {
// Try Authorization: Bearer <token> header
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
if userID := o.validateToken(r.Context(), strings.TrimPrefix(auth, "Bearer ")); userID != "" {
return userID, ""
}
}
// Try _auth_token query parameter (from OIDC callback redirect)
if token := r.URL.Query().Get("_auth_token"); token != "" {
if token := r.URL.Query().Get("access_token"); token != "" {
if userID := o.validateToken(r.Context(), token); userID != "" {
return userID, ""
}
}
// If the request is not authenticated, return a redirect URL for the UI to
// route the user through if they select OIDC login.
b := make([]byte, 32)
_, _ = rand.Read(b)
state := base64.URLEncoding.EncodeToString(b)
redirectURL := &url.URL{
Scheme: "https",
Host: r.Host,
Path: r.URL.Path,
}
// TODO: this does not work if you are load balancing across multiple proxy servers.
o.statesMux.Lock()
o.states[state] = &oidcState{OriginalURL: fmt.Sprintf("https://%s%s", r.Host, r.URL), CreatedAt: time.Now()}
o.statesMux.Unlock()
return "", (&oauth2.Config{
ClientID: o.oauthConfig.ClientID,
ClientSecret: o.oauthConfig.ClientSecret,
Endpoint: o.oauthConfig.Endpoint,
RedirectURL: o.proxyURL + callbackPath,
Scopes: o.oauthConfig.Scopes,
}).AuthCodeURL(state)
}
// Middleware returns an http.Handler that handles OIDC callback and flow initiation.
func (o *OIDC) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Handle OIDC callback
if r.URL.Path == callbackPath {
o.handleCallback(w, r)
return
}
next.ServeHTTP(w, r)
res, err := o.client.GetOIDCURL(r.Context(), &proto.GetOIDCURLRequest{
Id: o.id,
AccountId: o.accountId,
RedirectUrl: redirectURL.String(),
})
if err != nil {
// TODO: log
return "", ""
}
return "", res.GetUrl()
}
// validateToken validates a JWT ID token and returns the user ID (subject)
// Returns empty string if token is invalid or user's groups don't appear
// in the distributionGroups.
// Returns empty string if token is invalid.
func (o *OIDC) validateToken(ctx context.Context, token string) string {
if o.verifier == nil {
if o.validator == nil {
return ""
}
idToken, err := o.verifier.Verify(ctx, token)
idToken, err := o.validator.ValidateAndParse(ctx, token)
if err != nil {
// TODO: log or return?
return ""
}
// If distribution groups are configured, check if user has access
if len(o.distributionGroups) > 0 {
var claims struct {
Groups []string `json:"groups"`
}
if err := idToken.Claims(&claims); err != nil {
// TODO: log or return?
return ""
}
allowed := make(map[string]struct{}, len(o.distributionGroups))
for _, g := range o.distributionGroups {
allowed[g] = struct{}{}
}
for _, g := range claims.Groups {
if _, ok := allowed[g]; ok {
return idToken.Subject
}
}
iat, err := idToken.Claims.GetIssuedAt()
if err != nil {
// TODO: log or return?
return ""
}
// Default deny
return ""
// If max token age is 0 skip this check.
if o.maxTokenAgeSeconds > 0 && time.Since(iat.Time).Seconds() > float64(o.maxTokenAgeSeconds) {
// TODO: log or return?
return ""
}
return extractUserID(idToken)
}
// handleCallback processes the OIDC callback
func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" || state == "" {
http.Error(w, "Invalid callback parameters", http.StatusBadRequest)
return
func extractUserID(token *gojwt.Token) string {
if token == nil {
return "unknown"
}
// Verify and consume state
o.statesMux.Lock()
st, ok := o.states[state]
if ok {
delete(o.states, state)
}
o.statesMux.Unlock()
claims, ok := token.Claims.(gojwt.MapClaims)
if !ok {
http.Error(w, "Invalid or expired state", http.StatusBadRequest)
return
return "unknown"
}
// Exchange code for token
token, err := o.oauthConfig.Exchange(r.Context(), code)
if err != nil {
http.Error(w, "Authentication failed", http.StatusUnauthorized)
return
}
// Prefer ID token if available
idToken := token.AccessToken
if id, ok := token.Extra("id_token").(string); ok && id != "" {
idToken = id
}
// Redirect back to original URL with token
origURL, err := url.Parse(st.OriginalURL)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
q := origURL.Query()
q.Set("_auth_token", idToken)
origURL.RawQuery = q.Encode()
http.Redirect(w, r, origURL.String(), http.StatusFound)
return getUserIDFromClaims(claims)
}
// cleanupStates periodically removes expired states
func (o *OIDC) cleanupStates() {
for range time.Tick(time.Minute) {
cutoff := time.Now().Add(-stateExpiration)
o.statesMux.Lock()
for k, v := range o.states {
if v.CreatedAt.Before(cutoff) {
delete(o.states, k)
}
}
o.statesMux.Unlock()
func getUserIDFromClaims(claims gojwt.MapClaims) string {
if sub, ok := claims["sub"].(string); ok && sub != "" {
return sub
}
if userID, ok := claims["user_id"].(string); ok && userID != "" {
return userID
}
if email, ok := claims["email"].(string); ok && email != "" {
return email
}
return "unknown"
}
+5 -4
View File
@@ -36,6 +36,11 @@ func (Password) Type() Method {
func (p Password) Authenticate(r *http.Request) (string, string) {
password := r.FormValue(passwordFormId)
if password == "" {
// This cannot be authenticated so not worth wasting time sending the request.
return "", passwordFormId
}
res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{
Id: p.id,
AccountId: p.accountId,
@@ -56,7 +61,3 @@ func (p Password) Authenticate(r *http.Request) (string, string) {
return "", passwordFormId
}
func (p Password) Middleware(next http.Handler) http.Handler {
return next
}
+5 -4
View File
@@ -36,6 +36,11 @@ func (Pin) Type() Method {
func (p Pin) Authenticate(r *http.Request) (string, string) {
pin := r.FormValue(pinFormId)
if pin == "" {
// This cannot be authenticated so not worth wasting time sending the request.
return "", pinFormId
}
res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{
Id: p.id,
AccountId: p.accountId,
@@ -56,7 +61,3 @@ func (p Pin) Authenticate(r *http.Request) (string, string) {
return "", pinFormId
}
func (p Pin) Middleware(next http.Handler) http.Handler {
return next
}
+6 -12
View File
@@ -376,18 +376,12 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
}
if mapping.GetAuth().GetOidc() != nil {
oidc := mapping.GetAuth().GetOidc()
scheme, err := auth.NewOIDC(ctx, mapping.GetId(), mapping.GetAccountId(), s.ProxyURL, auth.OIDCConfig{
OIDCProviderURL: s.OIDCEndpoint,
OIDCClientID: s.OIDCClientId,
OIDCClientSecret: s.OIDCClientSecret,
OIDCScopes: s.OIDCScopes,
DistributionGroups: oidc.GetDistributionGroups(),
})
if err != nil {
s.Logger.WithError(err).Error("Failed to create OIDC scheme")
} else {
schemes = append(schemes, scheme)
}
schemes = append(schemes, auth.NewOIDC(mgmtClient, mapping.GetId(), mapping.GetAccountId(), auth.OIDCConfig{
Issuer: oidc.GetIssuer(),
Audiences: oidc.GetAudiences(),
KeysLocation: oidc.GetKeysLocation(),
MaxTokenAgeSeconds: oidc.GetMaxTokenAge(),
}))
}
if mapping.GetAuth().GetLink() {
schemes = append(schemes, auth.NewLink(s.mgmtClient, mapping.GetId(), mapping.GetAccountId()))