From 528778753ca3b7107ca7783cf75c9eaed1f0e30e Mon Sep 17 00:00:00 2001 From: Derek Wright Date: Thu, 18 Dec 2025 08:37:22 -0500 Subject: [PATCH] add initial auth0 module --- auth/auth0/README.md | 144 ++++++++++++++++++++++ auth/auth0/auth0.go | 89 +++++++++++++ auth/auth0/authenticator/authenticator.go | 84 +++++++++++++ auth/auth0/authenticator/errors.go | 9 ++ auth/auth0/errors.go | 6 + auth/auth0/handlers.go | 127 +++++++++++++++++++ auth/auth0/handlers_test.go | 95 ++++++++++++++ auth/auth0/middleware.go | 42 +++++++ auth/auth0/routes.go | 11 ++ go.mod | 3 + 10 files changed, 610 insertions(+) create mode 100644 auth/auth0/README.md create mode 100644 auth/auth0/auth0.go create mode 100644 auth/auth0/authenticator/authenticator.go create mode 100644 auth/auth0/authenticator/errors.go create mode 100644 auth/auth0/errors.go create mode 100644 auth/auth0/handlers.go create mode 100644 auth/auth0/handlers_test.go create mode 100644 auth/auth0/middleware.go create mode 100644 auth/auth0/routes.go create mode 100644 go.mod diff --git a/auth/auth0/README.md b/auth/auth0/README.md new file mode 100644 index 0000000..687c3ce --- /dev/null +++ b/auth/auth0/README.md @@ -0,0 +1,144 @@ +# Auth0 + +A clean, secure, and reusable Auth0 authentication module for Go web applications using **Chi** router. + +This module provides everything you need to add Auth0-based login/logout to your Chi-based application: + +- `/login` — Redirects user to Auth0 Universal Login +- `/callback` — Handles Auth0 redirect, verifies ID token, stores user profile & access token in session +- `/logout` — Clears session and redirects to Auth0 logout (full single sign-out) +- Authentication middleware — Protects routes, redirects unauthenticated users to login +- `CurrentUser(r *http.Request)` helper — Retrieve authenticated user claims in handlers + +## Features + +- Secure OAuth2/OIDC flow with state validation and CSRF protection +- Clean functional options pattern for dependency injection +- Easy-to-use middleware for protected routes + +## Installation + +```bash +go get github.com/derekmwright/go-web/auth/auth0 +``` + +(Replace with your actual repo path when published) + +## Usage + +```go +package main + +import ( + "log" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/yourusername/go-auth0-chi" + // your session manager, e.g. gorilla/sessions, scollett/chi-sessions, etc. +) + +func main() { + r := chi.NewRouter() + + // Your session manager (must implement auth0.SessionManager interface) + sessionManager := NewYourSessionManager() // e.g. cookie store + + // Your logger (zap, zerolog, etc. — must implement auth0.Logger) + logger := NewYourLogger() + + // Initialize the Auth0 module + registerRoutes, requireAuth, err := auth0.New( + auth0.WithLogger(logger), + auth0.WithSessions(sessionManager), + ) + if err != nil { + log.Fatal(err) + } + + // Mount the auth routes (usually under root or /auth) + registerRoutes(r) + + // Public routes + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Home page — public")) + }) + + // Protected routes + r.Group(func(r chi.Router) { + r.Use(requireAuth) // ← enforces authentication + + r.Get("/dashboard", func(w http.ResponseWriter, r *http.Request) { + user := auth0.CurrentUser(r) + // user is map[string]any with Auth0 claims (sub, name, email, etc.) + w.Write([]byte("Welcome to the dashboard!")) + }) + + r.Get("/profile", func(w http.ResponseWriter, r *http.Request) { + profile := auth0.CurrentUser(r).(map[string]any) + // Render profile... + }) + }) + + log.Println("Server starting on :8080") + http.ListenAndServe(":8080", r) +} +``` + +## Routes Added + +When you call `registerRoutes(r)`, the following routes are registered: + +| Route | Method | Purpose | +|-------------|--------|---------| +| `/login` | GET | Initiates login: generates state, stores in session, redirects to Auth0 | +| `/callback` | GET | Auth0 redirect URI: validates state, exchanges code, verifies ID token, stores user & access token in session, redirects to `/` | +| `/logout` | GET | Clears session and redirects to Auth0 `/v2/logout` with proper `returnTo` and `client_id` (full SSO logout) | + +You can mount these under a subrouter if preferred: + +```go +authRouter := chi.NewRouter() +registerRoutes(authRouter) +r.Mount("/auth", authRouter) // → /auth/login, /auth/callback, etc. +``` + +## Required Environment Variables + +The module reads Auth0 configuration from environment variables: + +```env +AUTH0_DOMAIN=your-tenant.auth0.com +AUTH0_CLIENT_ID=your-client-id +AUTH0_CLIENT_SECRET=your-client-secret +AUTH0_REDIRECT_URI=http://localhost:8080/callback +``` + +Make sure `AUTH0_REDIRECT_URI` is listed in your Auth0 Application → **Allowed Callback URLs**. + +Also add your post-logout URL (e.g. `http://localhost:8080/`) to **Allowed Logout URLs** in the Auth0 dashboard. + +## Dependencies Injected + +You must provide: + +- `Logger` — with `Debug`, `Info`, `Error` methods (easy to adapt zap, zerolog, log/slog, etc.) +- `SessionManager` — with `Get(ctx, key)` and `Put(ctx, key, value)` (compatible with gorilla/sessions, etc.) + +## Session Storage + +The module stores: + +- `"user"` → `map[string]any` with decoded ID token claims (sub, name, email, picture, etc.) +- `"access_token"` → raw access token string (useful for calling APIs) + +You can extend this as needed in your own handlers. + +## Testing + +The module is designed for easy testing — all dependencies are interfaces. See the `_test.go` files for examples using mocks. + +## License + +MIT + diff --git a/auth/auth0/auth0.go b/auth/auth0/auth0.go new file mode 100644 index 0000000..8caba22 --- /dev/null +++ b/auth/auth0/auth0.go @@ -0,0 +1,89 @@ +package auth0 + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/go-chi/chi/v5" + + "dstar/auth0/authenticator" +) + +type Logger interface { + Debug(msg string, args ...any) + Info(msg string, args ...any) + Error(msg string, args ...any) +} + +type SessionManager interface { + Get(ctx context.Context, key string) any + Put(ctx context.Context, key string, value any) error +} + +type Config struct { + Logger Logger + Sessions SessionManager +} + +type Option func(deps *Config) + +func WithLogger(l Logger) Option { + return func(cfg *Config) { + cfg.Logger = l + } +} + +func WithSessions(s SessionManager) Option { + return func(cfg *Config) { + cfg.Sessions = s + } +} + +type deps struct { + auth *authenticator.Authenticator + logoutBase *url.URL + log Logger + sessions SessionManager +} + +func New(opts ...Option) (func(chi.Router), Middleware, error) { + cfg := Config{} + + for _, opt := range opts { + opt(&cfg) + } + + if cfg.Logger == nil { + return nil, nil, ErrNilLogger + } + if cfg.Sessions == nil { + return nil, nil, ErrNilSessions + } + + auth, err := authenticator.New() + if err != nil { + return nil, nil, err + } + + logoutURL, err := url.Parse(auth.LogoutURL) + if err != nil { + return nil, nil, fmt.Errorf("unable to parse logout URL: %w", err) + } + + d := &deps{ + log: cfg.Logger, + logoutBase: logoutURL, + sessions: cfg.Sessions, + auth: auth, + } + + mw := func(next http.Handler) http.Handler { + return authenticatedMiddleware(d, next) + } + + return func(r chi.Router) { + Register(r, d) + }, mw, nil +} diff --git a/auth/auth0/authenticator/authenticator.go b/auth/auth0/authenticator/authenticator.go new file mode 100644 index 0000000..f0482a3 --- /dev/null +++ b/auth/auth0/authenticator/authenticator.go @@ -0,0 +1,84 @@ +package authenticator + +import ( + "context" + "os" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// Config defines required configuration values for Auth0. +// +// * Values are read from the environment. +// They cannot be overridden or set from code. +type Config struct { + Domain string + ClientID string + ClientSecret string + RedirectURI string +} + +type Authenticator struct { + *oidc.Provider + oauth2.Config + LogoutURL string +} + +func New() (*Authenticator, error) { + cfg := Config{ + Domain: os.Getenv("AUTH0_DOMAIN"), + ClientID: os.Getenv("AUTH0_CLIENT_ID"), + ClientSecret: os.Getenv("AUTH0_CLIENT_SECRET"), + RedirectURI: os.Getenv("AUTH0_REDIRECT_URI"), + } + + if cfg.Domain == "" { + return nil, ErrEmptyDomain + } + + if cfg.ClientID == "" { + return nil, ErrEmptyClientID + } + + if cfg.ClientSecret == "" { + return nil, ErrEmptyClientSecret + } + + if cfg.RedirectURI == "" { + return nil, ErrEmptyRedirectURI + } + + provider, err := oidc.NewProvider( + context.Background(), + "https://"+cfg.Domain+"/", + ) + if err != nil { + return nil, err + } + + return &Authenticator{ + Provider: provider, + Config: oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURI, + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + Endpoint: provider.Endpoint(), + }, + LogoutURL: "https://" + cfg.Domain + "/v2/logout", + }, nil +} + +func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) { + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return nil, ErrNoIDToken + } + + oidcConfig := &oidc.Config{ + ClientID: a.ClientID, + } + + return a.Verifier(oidcConfig).Verify(ctx, rawIDToken) +} diff --git a/auth/auth0/authenticator/errors.go b/auth/auth0/authenticator/errors.go new file mode 100644 index 0000000..a0519ba --- /dev/null +++ b/auth/auth0/authenticator/errors.go @@ -0,0 +1,9 @@ +package authenticator + +import "fmt" + +var ErrEmptyDomain = fmt.Errorf("domain cannot be empty") +var ErrEmptyClientID = fmt.Errorf("client id cannot be empty") +var ErrEmptyClientSecret = fmt.Errorf("client secret cannot be empty") +var ErrEmptyRedirectURI = fmt.Errorf("redirect uri cannot be empty") +var ErrNoIDToken = fmt.Errorf("no id_token field in oauth2 token") diff --git a/auth/auth0/errors.go b/auth/auth0/errors.go new file mode 100644 index 0000000..7dcbfef --- /dev/null +++ b/auth/auth0/errors.go @@ -0,0 +1,6 @@ +package auth0 + +import "errors" + +var ErrNilLogger = errors.New("logger cannot be nil") +var ErrNilSessions = errors.New("sessions cannot be nil") diff --git a/auth/auth0/handlers.go b/auth/auth0/handlers.go new file mode 100644 index 0000000..af6b188 --- /dev/null +++ b/auth/auth0/handlers.go @@ -0,0 +1,127 @@ +package auth0 + +import ( + "context" + "crypto/rand" + "encoding/base64" + "net/http" + "net/url" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +const StateKey = "state" + +func generateRandomState() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(b), err +} + +func HandleLogin(deps *deps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + state, err := generateRandomState() + if err != nil { + deps.log.Error("unable to generate random state", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + deps.log.Info("generated state", "state", state) + + if err = deps.sessions.Put(r.Context(), StateKey, state); err != nil { + deps.log.Error("unable to store state in session", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, deps.auth.AuthCodeURL(state), http.StatusFound) + } +} + +func HandleLogout(deps *deps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := deps.sessions.Put(r.Context(), "user", nil); err != nil { + deps.log.Error("unable to remove user from session", "error", err) + } + + if err := deps.sessions.Put(r.Context(), StateKey, nil); err != nil { + deps.log.Error("unable to remove state from session", "error", err) + } + + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + returnTo := scheme + "://" + r.Host + + logout := deps.logoutBase.ResolveReference(&url.URL{}) + q := logout.Query() + q.Add("returnTo", returnTo) + q.Add("client_id", deps.auth.ClientID) + logout.RawQuery = q.Encode() + + http.Redirect(w, r, logout.String(), http.StatusFound) + } +} + +type Authenticator interface { + Exchange(context.Context, string) (*oauth2.Token, error) + VerifyIDToken(context.Context, *oauth2.Token) (*oidc.IDToken, error) +} + +func HandleCallback(deps *deps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, ok := deps.sessions.Get(r.Context(), StateKey).(string) + if !ok { + deps.log.Error("no state found in session") + http.Error(w, "no state found in session", http.StatusInternalServerError) + return + } + + if err := deps.sessions.Put(r.Context(), StateKey, nil); err != nil { + deps.log.Error("unable to remove state from session", "error", err) + } + + token, err := deps.auth.Exchange(r.Context(), r.URL.Query().Get("code")) + if err != nil { + deps.log.Error("unable to exchange auth code for token", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + idToken, err := deps.auth.VerifyIDToken(r.Context(), token) + if err != nil { + deps.log.Error("unable to verify ID token", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var profile map[string]any + if err = idToken.Claims(&profile); err != nil { + deps.log.Error("unable to decode ID token claims", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err = deps.sessions.Put(r.Context(), "user", profile); err != nil { + deps.log.Error("unable to store user profile in session", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err = deps.sessions.Put(r.Context(), "access_token", token.AccessToken); err != nil { + deps.log.Error("unable to store access token in session", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/", http.StatusFound) + } +} diff --git a/auth/auth0/handlers_test.go b/auth/auth0/handlers_test.go new file mode 100644 index 0000000..db38a02 --- /dev/null +++ b/auth/auth0/handlers_test.go @@ -0,0 +1,95 @@ +package auth0 + +import ( + "context" + "net/http/httptest" + "strings" + "sync" + "testing" + + "golang.org/x/oauth2" + + "dstar/auth0/authenticator" +) + +type mockLogger struct{} + +func (m *mockLogger) Debug(msg string, args ...any) {} +func (m *mockLogger) Info(msg string, args ...any) {} +func (m *mockLogger) Error(msg string, args ...any) {} + +type mockSessionManager struct { + store map[string]any + mu sync.RWMutex +} + +func (m *mockSessionManager) Get(ctx context.Context, key string) any { + m.mu.RLock() + defer m.mu.RUnlock() + return m.store[key] +} + +func (m *mockSessionManager) Put(ctx context.Context, key string, value any) error { + m.mu.Lock() + defer m.mu.Unlock() + if value == nil { + delete(m.store, key) + } else { + m.store[key] = value + } + return nil +} + +func TestHandleLogic(t *testing.T) { + tests := []struct { + name string + existingState any + wantRedirect string + wantSessionKey string + wantSessionValue string + }{ + { + name: "generates and stores state", + wantRedirect: "https://", + wantSessionKey: StateKey, + wantSessionValue: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockSessions := &mockSessionManager{ + store: make(map[string]any), + } + if tt.existingState != nil { + mockSessions.store[StateKey] = tt.existingState + } + + d := &deps{ + log: &mockLogger{}, + sessions: mockSessions, + auth: &authenticator.Authenticator{ + Config: oauth2.Config{ + Endpoint: oauth2.Endpoint{ + AuthURL: "https://test.auth0.com/authorize", + }, + }, + }, + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/login", nil) + + HandleLogin(d)(rr, req) + + if !strings.HasPrefix(rr.Header().Get("Location"), "https://test.auth0.com/authorize") { + t.Errorf("wrong redirect: %s", rr.Header().Get("Location")) + } + + state := mockSessions.store[StateKey].(string) + if len(state) == 0 { + t.Fatal("state not stored or empty") + } + }) + } +} diff --git a/auth/auth0/middleware.go b/auth/auth0/middleware.go new file mode 100644 index 0000000..9cf476e --- /dev/null +++ b/auth/auth0/middleware.go @@ -0,0 +1,42 @@ +package auth0 + +import ( + "context" + "net/http" +) + +type userContextKey struct{} + +type Middleware func(http.Handler) http.Handler + +func authenticatedMiddleware(deps *deps, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sessionUser := deps.sessions.Get(r.Context(), "user") + if sessionUser == nil { + state, err := generateRandomState() + if err != nil { + deps.log.Error("unable to generate random state", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err = deps.sessions.Put(r.Context(), StateKey, state); err != nil { + deps.log.Error("unable to store state in session", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + loginURL := deps.auth.AuthCodeURL(state) + http.Redirect(w, r, loginURL, http.StatusFound) + + return + } + + ctx := context.WithValue(r.Context(), userContextKey{}, sessionUser) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func CurrentUser(r *http.Request) any { + return r.Context().Value(userContextKey{}) +} diff --git a/auth/auth0/routes.go b/auth/auth0/routes.go new file mode 100644 index 0000000..7f1d67b --- /dev/null +++ b/auth/auth0/routes.go @@ -0,0 +1,11 @@ +package auth0 + +import ( + "github.com/go-chi/chi/v5" +) + +func Register(r chi.Router, deps *deps) { + r.Get("/login", HandleLogin(deps)) + r.Get("/callback", HandleCallback(deps)) + r.Get("/logout", HandleLogout(deps)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..806857a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.citc.tech/citc/web + +go 1.25.5