add initial auth0 module

This commit is contained in:
2025-12-18 08:37:22 -05:00
commit 528778753c
10 changed files with 610 additions and 0 deletions

144
auth/auth0/README.md Normal file
View File

@@ -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

89
auth/auth0/auth0.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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")

6
auth/auth0/errors.go Normal file
View File

@@ -0,0 +1,6 @@
package auth0
import "errors"
var ErrNilLogger = errors.New("logger cannot be nil")
var ErrNilSessions = errors.New("sessions cannot be nil")

127
auth/auth0/handlers.go Normal file
View File

@@ -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)
}
}

View File

@@ -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")
}
})
}
}

42
auth/auth0/middleware.go Normal file
View File

@@ -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{})
}

11
auth/auth0/routes.go Normal file
View File

@@ -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))
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.citc.tech/citc/web
go 1.25.5