add initial auth0 module
This commit is contained in:
144
auth/auth0/README.md
Normal file
144
auth/auth0/README.md
Normal 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
89
auth/auth0/auth0.go
Normal 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
|
||||
}
|
||||
84
auth/auth0/authenticator/authenticator.go
Normal file
84
auth/auth0/authenticator/authenticator.go
Normal 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)
|
||||
}
|
||||
9
auth/auth0/authenticator/errors.go
Normal file
9
auth/auth0/authenticator/errors.go
Normal 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
6
auth/auth0/errors.go
Normal 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
127
auth/auth0/handlers.go
Normal 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)
|
||||
}
|
||||
}
|
||||
95
auth/auth0/handlers_test.go
Normal file
95
auth/auth0/handlers_test.go
Normal 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
42
auth/auth0/middleware.go
Normal 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
11
auth/auth0/routes.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user