Skip to content

Commit

Permalink
feat: Add optional OIDC support
Browse files Browse the repository at this point in the history
This allows the API to trigger an OAuth workflow to create the JWT for authentication. For now the workflow is triggered by manually visiting `/api/login/oidc` on the frontend app until the UI repo is updated to add support.
  • Loading branch information
polds authored and yusing committed Jan 12, 2025
1 parent e10e6cf commit e759510
Show file tree
Hide file tree
Showing 10 changed files with 460 additions and 6 deletions.
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,19 @@ GODOXY_API_JWT_SECRET=
GODOXY_API_JWT_TOKEN_TTL=1h

# API/WebUI login credentials
# Important: If using OIDC authentication, the API_USER must match the username
# provided by the OIDC provider.
GODOXY_API_USER=admin
GODOXY_API_PASSWORD=password

# OIDC Configuration (optional)
# Uncomment and configure these values to enable OIDC authentication.
# GODOXY_OIDC_ISSUER_URL=https://accounts.google.com
# GODOXY_OIDC_CLIENT_ID=your-client-id
# GODOXY_OIDC_CLIENT_SECRET=your-client-secret
# Keep /api/auth/callback as the redirect URL, change the domain to match your setup.
# GODOXY_OIDC_REDIRECT_URL=https://your-domain/api/auth/callback

# Proxy listening address
GODOXY_HTTP_ADDR=:80
GODOXY_HTTPS_ADDR=:443
Expand Down
8 changes: 7 additions & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"github.com/yusing/go-proxy/internal"
"github.com/yusing/go-proxy/internal/api/v1/auth"
"github.com/yusing/go-proxy/internal/api/v1/query"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/config"
Expand Down Expand Up @@ -115,6 +116,11 @@ func main() {
cfg.Start()
config.WatchChanges()

// Initialize authentication providers
if err := auth.Initialize(); err != nil {
logging.Warn().Err(err).Msg("Failed to initialize authentication providers")
}

sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT)
signal.Notify(sig, syscall.SIGTERM)
Expand All @@ -123,7 +129,7 @@ func main() {
// wait for signal
<-sig

// grafully shutdown
// gracefully shutdown
logging.Info().Msg("shutting down")
_ = task.GracefulShutdown(time.Second * time.Duration(cfg.Value().TimeoutShutdown))
}
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.23.4
require (
github.com/PuerkitoBio/goquery v1.10.1
github.com/coder/websocket v1.8.12
github.com/coreos/go-oidc/v3 v3.12.0
github.com/docker/cli v27.4.1+incompatible
github.com/docker/docker v27.4.1+incompatible
github.com/fsnotify/fsnotify v1.8.0
Expand All @@ -19,6 +20,7 @@ require (
github.com/vincent-petithory/dataurl v1.0.0
golang.org/x/crypto v0.32.0
golang.org/x/net v0.34.0
golang.org/x/oauth2 v0.25.0
golang.org/x/text v0.21.0
golang.org/x/time v0.9.0
gopkg.in/yaml.v3 v3.0.1
Expand Down Expand Up @@ -70,7 +72,6 @@ require (
go.opentelemetry.io/otel/sdk v1.30.0 // indirect
go.opentelemetry.io/otel/trace v1.33.0 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/tools v0.29.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down
3 changes: 3 additions & 0 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ func NewHandler(cfg config.ConfigInstance) http.Handler {
mux.HandleFunc("GET", "/v1", v1.Index)
mux.HandleFunc("GET", "/v1/version", v1.GetVersion)
mux.HandleFunc("POST", "/v1/login", auth.LoginHandler)
mux.HandleFunc("GET", "/v1/login/method", auth.AuthMethodHandler)
mux.HandleFunc("GET", "/v1/login/oidc", auth.OIDCLoginHandler)
mux.HandleFunc("GET", "/v1/auth/callback", auth.OIDCCallbackHandler)
mux.HandleFunc("GET", "/v1/logout", auth.LogoutHandler)
mux.HandleFunc("POST", "/v1/logout", auth.LogoutHandler)
mux.HandleFunc("POST", "/v1/reload", useCfg(cfg, v1.Reload))
Expand Down
42 changes: 38 additions & 4 deletions internal/api/v1/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,39 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
U.HandleErr(w, r, err, http.StatusUnauthorized)
return
}
if err := setAuthenticatedCookie(w, r, creds.Username); err != nil {
U.HandleErr(w, r, err, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}

func AuthMethodHandler(w http.ResponseWriter, r *http.Request) {
switch {
case common.APIJWTSecret == nil:
U.WriteBody(w, []byte("skip"))
case common.OIDCIssuerURL != "":
U.WriteBody(w, []byte("oidc"))
case common.APIPasswordHash != nil:
U.WriteBody(w, []byte("password"))
default:
U.WriteBody(w, []byte("skip"))
}
w.WriteHeader(http.StatusOK)
}

func setAuthenticatedCookie(w http.ResponseWriter, r *http.Request, username string) error {
expiresAt := time.Now().Add(common.APIJWTTokenTTL)
claim := &Claims{
Username: creds.Username,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS512, claim)
tokenStr, err := token.SignedString(common.APIJWTSecret)
if err != nil {
U.HandleErr(w, r, err)
return
return err
}
http.SetCookie(w, &http.Cookie{
Name: "token",
Expand All @@ -73,7 +93,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
SameSite: http.SameSiteStrictMode,
Path: "/",
})
w.WriteHeader(http.StatusOK)
return nil
}

func LogoutHandler(w http.ResponseWriter, r *http.Request) {
Expand All @@ -89,6 +109,20 @@ func LogoutHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTemporaryRedirect)
}

// Initialize sets up authentication providers.
func Initialize() error {
// Initialize OIDC if configured.
if common.OIDCIssuerURL != "" {
return InitOIDC(
common.OIDCIssuerURL,
common.OIDCClientID,
common.OIDCClientSecret,
common.OIDCRedirectURL,
)
}
return nil
}

func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
if common.IsDebugSkipAuth || common.APIJWTSecret == nil {
return next
Expand Down
176 changes: 176 additions & 0 deletions internal/api/v1/auth/oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package auth

import (
"context"
"fmt"
"net/http"
"time"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
"golang.org/x/oauth2"
)

var (
oauthConfig *oauth2.Config
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
)

// InitOIDC initializes the OIDC provider
func InitOIDC(issuerURL, clientID, clientSecret, redirectURL string) error {
if issuerURL == "" {
return nil // OIDC not configured
}

provider, err := oidc.NewProvider(context.Background(), issuerURL)
if err != nil {
return fmt.Errorf("failed to initialize OIDC provider: %w", err)
}

oidcProvider = provider
oidcVerifier = provider.Verifier(&oidc.Config{
ClientID: clientID,
})

oauthConfig = &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}

return nil
}

// OIDCLoginHandler initiates the OIDC login flow
func OIDCLoginHandler(w http.ResponseWriter, r *http.Request) {
if oauthConfig == nil {
U.HandleErr(w, r, E.New("OIDC not configured"), http.StatusNotImplemented)
return
}

state := common.GenerateRandomString(32)
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: state,
MaxAge: 300,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Path: "/",
})

url := oauthConfig.AuthCodeURL(state)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

// OIDCCallbackHandler handles the OIDC callback
func OIDCCallbackHandler(w http.ResponseWriter, r *http.Request) {
if oauthConfig == nil {
U.HandleErr(w, r, E.New("OIDC not configured"), http.StatusNotImplemented)
return
}

// For testing purposes, skip provider verification
if common.IsTest {
handleTestCallback(w, r)
return
}

if oidcProvider == nil {
U.HandleErr(w, r, E.New("OIDC not configured"), http.StatusNotImplemented)
return
}

state, err := r.Cookie("oauth_state")
if err != nil {
U.HandleErr(w, r, E.New("missing state cookie"), http.StatusBadRequest)
return
}

if r.URL.Query().Get("state") != state.Value {
U.HandleErr(w, r, E.New("invalid oauth state"), http.StatusBadRequest)
return
}

code := r.URL.Query().Get("code")
oauth2Token, err := oauthConfig.Exchange(r.Context(), code)
if err != nil {
U.HandleErr(w, r, fmt.Errorf("failed to exchange token: %w", err), http.StatusInternalServerError)
return
}

rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
U.HandleErr(w, r, E.New("missing id_token"), http.StatusInternalServerError)
return
}

idToken, err := oidcVerifier.Verify(r.Context(), rawIDToken)
if err != nil {
U.HandleErr(w, r, fmt.Errorf("failed to verify ID token: %w", err), http.StatusInternalServerError)
return
}

var claims struct {
Email string `json:"email"`
Username string `json:"preferred_username"`
}
if err := idToken.Claims(&claims); err != nil {
U.HandleErr(w, r, fmt.Errorf("failed to parse claims: %w", err), http.StatusInternalServerError)
return
}

if err := setAuthenticatedCookie(w, r, claims.Username); err != nil {
U.HandleErr(w, r, err, http.StatusInternalServerError)
return
}

// Redirect to home page
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}

// handleTestCallback handles OIDC callback in test environment
func handleTestCallback(w http.ResponseWriter, r *http.Request) {
state, err := r.Cookie("oauth_state")
if err != nil {
U.HandleErr(w, r, E.New("missing state cookie"), http.StatusBadRequest)
return
}

if r.URL.Query().Get("state") != state.Value {
U.HandleErr(w, r, E.New("invalid oauth state"), http.StatusBadRequest)
return
}

// Create test JWT token
expiresAt := time.Now().Add(common.APIJWTTokenTTL)
jwtClaims := &Claims{
Username: "test-user",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
},
}

token := jwt.NewWithClaims(jwt.SigningMethodHS512, jwtClaims)
tokenStr, err := token.SignedString(common.APIJWTSecret)
if err != nil {
U.HandleErr(w, r, err, http.StatusInternalServerError)
return
}

http.SetCookie(w, &http.Cookie{
Name: "token",
Value: tokenStr,
Expires: expiresAt,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Path: "/",
})

http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
Loading

0 comments on commit e759510

Please sign in to comment.