Skip to content

Commit 850311d

Browse files
authored
Merge pull request #108 from go-authgate/worktree-new
feat(middleware): add configurable CORS support for API endpoints
2 parents ea71681 + 0dc7320 commit 850311d

9 files changed

Lines changed: 290 additions & 1 deletion

File tree

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,15 @@ EXPIRED_TOKEN_CLEANUP_INTERVAL=1h # How often to run the cleanup (default:
232232
# - Increase AUDIT_SHUTDOWN_TIMEOUT (e.g., 30s) if audit buffer is large
233233
# - Keep close timeouts short (5s) to prevent hanging on shutdown
234234
# - All operations support cancellation with Ctrl+C during startup
235+
236+
# ============================================================
237+
# CORS (Cross-Origin Resource Sharing)
238+
# ============================================================
239+
# Enable CORS for /oauth/* API endpoints so SPA frontends (React, Vue, etc.)
240+
# can call token, introspect, and device code endpoints from a different origin.
241+
# Disabled by default — HTML page endpoints are never affected.
242+
# CORS_ENABLED=false
243+
# CORS_ALLOWED_ORIGINS=http://localhost:3000,https://app.example.com
244+
# CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS
245+
# CORS_ALLOWED_HEADERS=Origin,Content-Type,Authorization
246+
# CORS_MAX_AGE=12h

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ In addition to Device Code Flow, AuthGate supports Authorization Code Flow with
9797
- `internal/services/` - Business logic layer (user, device, authorization, token, client, audit services)
9898
- `internal/handlers/` - HTTP request handlers for all endpoints
9999
- `internal/models/` - GORM database models (User, OAuthApplication, UserAuthorization, DeviceCode, AuthorizationCode, AccessToken, OAuthConnection, AuditLog)
100-
- `internal/middleware/` - Gin middleware (auth, CSRF, rate limiting, metrics auth)
100+
- `internal/middleware/` - Gin middleware (auth, CSRF, rate limiting, metrics auth, CORS)
101101
- `internal/metrics/` - Prometheus metrics collection and caching (supports memory, Redis, Redis-aside)
102102
- `internal/cache/` - Cache implementations (memory, Redis, Redis-aside)
103103
- `internal/client/` - HTTP client with exponential backoff retry
@@ -261,6 +261,7 @@ Key configuration categories (see `.env.example` and `docs/CONFIGURATION.md` for
261261
- `ENABLE_RATE_LIMIT`, `RATE_LIMIT_STORE` (memory/redis) - Rate limiting
262262
- `ENABLE_AUDIT_LOGGING` - Comprehensive audit trails
263263
- `SESSION_FINGERPRINT` - Session security (User-Agent validation)
264+
- `CORS_ENABLED`, `CORS_ALLOWED_ORIGINS` - CORS for SPA frontends (applied to `/oauth/*` only)
264265

265266
**OAuth Providers**
266267

docs/CONFIGURATION.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This guide covers all configuration options for AuthGate, including environment
1414
- [HTTP Retry with Exponential Backoff](#http-retry-with-exponential-backoff)
1515
- [User Cache](#user-cache)
1616
- [Rate Limiting](#rate-limiting)
17+
- [CORS (Cross-Origin Resource Sharing)](#cors-cross-origin-resource-sharing)
1718

1819
---
1920

@@ -880,6 +881,43 @@ INTROSPECT_RATE_LIMIT=20
880881

881882
---
882883

884+
## CORS (Cross-Origin Resource Sharing)
885+
886+
When building a Single-Page Application (SPA) or mobile app that calls AuthGate's OAuth API endpoints from a different origin, you need to enable CORS. By default, CORS is **disabled** — enabling it only affects `/oauth/*` API endpoints (token, device code, introspect, revoke, userinfo). HTML page endpoints are never affected.
887+
888+
### Quick Start
889+
890+
```bash
891+
# .env
892+
CORS_ENABLED=true
893+
CORS_ALLOWED_ORIGINS=http://localhost:3000,https://app.example.com
894+
```
895+
896+
### Configuration
897+
898+
| Variable | Default | Description |
899+
|----------|---------|-------------|
900+
| `CORS_ENABLED` | `false` | Enable CORS for API endpoints |
901+
| `CORS_ALLOWED_ORIGINS` | _(none)_ | Comma-separated list of allowed origins |
902+
| `CORS_ALLOWED_METHODS` | `GET,POST,PUT,DELETE,OPTIONS` | Allowed HTTP methods |
903+
| `CORS_ALLOWED_HEADERS` | `Origin,Content-Type,Authorization` | Allowed request headers |
904+
| `CORS_MAX_AGE` | `12h` | How long browsers cache preflight responses |
905+
906+
### How It Works
907+
908+
- **Preflight requests** (`OPTIONS`) are handled automatically by the CORS middleware and return the appropriate `Access-Control-Allow-*` headers.
909+
- **Credentials** (`cookies`, `Authorization` header) are allowed — `Access-Control-Allow-Credentials: true` is set so token introspection and authenticated requests work from browser JS.
910+
- **Disallowed origins** receive a `403 Forbidden` response with no CORS headers.
911+
- **Same-origin requests** (no `Origin` header) are unaffected.
912+
913+
### Production Notes
914+
915+
- Only list origins you trust — avoid using `*` (wildcard) with credentials.
916+
- The CORS middleware is applied **only** to the `/oauth/*` route group, not to login pages, admin UI, or static assets.
917+
- For maximum security, set `CORS_ALLOWED_ORIGINS` to the exact origins of your frontend applications.
918+
919+
---
920+
883921
**Next Steps:**
884922

885923
- [Architecture Guide](ARCHITECTURE.md) - Understand the system design

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/appleboy/go-httpclient v0.10.0
99
github.com/appleboy/go-httpretry v0.11.0
1010
github.com/appleboy/graceful v1.3.0
11+
github.com/gin-contrib/cors v1.7.6
1112
github.com/gin-contrib/sessions v1.0.4
1213
github.com/gin-gonic/gin v1.12.0
1314
github.com/golang-jwt/jwt/v5 v5.3.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
6767
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
6868
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
6969
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
70+
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
71+
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
7072
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
7173
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
7274
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=

internal/bootstrap/router.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,14 @@ func setupAllRoutes(
156156

157157
// OAuth API routes (public, called by CLI)
158158
oauth := r.Group("/oauth")
159+
if cfg.CORSEnabled {
160+
if len(cfg.CORSAllowedOrigins) == 0 {
161+
log.Println(
162+
"WARNING: CORS is enabled but CORS_ALLOWED_ORIGINS is empty — all cross-origin requests will be rejected",
163+
)
164+
}
165+
oauth.Use(middleware.CORSMiddleware(cfg))
166+
}
159167
{
160168
oauth.POST("/device/code", rateLimiters.deviceCode, h.device.DeviceCodeRequest)
161169
oauth.POST("/token", rateLimiters.token, h.token.Token)

internal/config/config.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,13 @@ type Config struct {
201201
PKCERequired bool // Force PKCE for all public clients (default: false)
202202
ConsentRemember bool // Skip consent page if user already authorized same scope (default: true)
203203

204+
// CORS settings
205+
CORSEnabled bool // Enable CORS for API endpoints (default: false)
206+
CORSAllowedOrigins []string // Allowed origins (comma-separated via env, e.g. "http://localhost:3000")
207+
CORSAllowedMethods []string // Allowed HTTP methods (default: GET,POST,PUT,DELETE,OPTIONS)
208+
CORSAllowedHeaders []string // Allowed request headers (default: Origin,Content-Type,Authorization)
209+
CORSMaxAge time.Duration // Preflight cache duration (default: 12 hours)
210+
204211
// Bootstrap and shutdown timeout settings
205212
DBInitTimeout time.Duration // Database initialization timeout (default: 30s)
206213
RedisConnTimeout time.Duration // Redis connection timeout (default: 5s)
@@ -404,6 +411,19 @@ func Load() *Config {
404411
RedisCloseTimeout: getEnvDuration("REDIS_CLOSE_TIMEOUT", 5*time.Second),
405412
CacheCloseTimeout: getEnvDuration("CACHE_CLOSE_TIMEOUT", 5*time.Second),
406413
DBCloseTimeout: getEnvDuration("DB_CLOSE_TIMEOUT", 5*time.Second),
414+
415+
// CORS
416+
CORSEnabled: getEnvBool("CORS_ENABLED", false),
417+
CORSAllowedOrigins: getEnvSlice("CORS_ALLOWED_ORIGINS", nil),
418+
CORSAllowedMethods: getEnvSlice(
419+
"CORS_ALLOWED_METHODS",
420+
[]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
421+
),
422+
CORSAllowedHeaders: getEnvSlice(
423+
"CORS_ALLOWED_HEADERS",
424+
[]string{"Origin", "Content-Type", "Authorization"},
425+
),
426+
CORSMaxAge: getEnvDuration("CORS_MAX_AGE", 12*time.Hour),
407427
}
408428
}
409429

internal/middleware/cors.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package middleware
2+
3+
import (
4+
"time"
5+
6+
"github.com/go-authgate/authgate/internal/config"
7+
8+
"github.com/gin-contrib/cors"
9+
"github.com/gin-gonic/gin"
10+
)
11+
12+
// CORSMiddleware returns a CORS middleware configured from application settings.
13+
// It allows cross-origin requests from the configured origins to API endpoints.
14+
func CORSMiddleware(cfg *config.Config) gin.HandlerFunc {
15+
return cors.New(cors.Config{
16+
AllowOrigins: cfg.CORSAllowedOrigins,
17+
AllowMethods: cfg.CORSAllowedMethods,
18+
AllowHeaders: cfg.CORSAllowedHeaders,
19+
MaxAge: cfg.CORSMaxAge,
20+
21+
// Expose standard OAuth response headers to browser JS
22+
ExposeHeaders: []string{"Content-Length", "Content-Type"},
23+
24+
// Allow credentials (cookies, Authorization header) for token introspection
25+
AllowCredentials: true,
26+
})
27+
}
28+
29+
// NewCORSConfig creates a cors.Config from application settings for testing purposes.
30+
func NewCORSConfig(origins, methods, headers []string, maxAge time.Duration) cors.Config {
31+
return cors.Config{
32+
AllowOrigins: origins,
33+
AllowMethods: methods,
34+
AllowHeaders: headers,
35+
MaxAge: maxAge,
36+
ExposeHeaders: []string{"Content-Length", "Content-Type"},
37+
AllowCredentials: true,
38+
}
39+
}

internal/middleware/cors_test.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package middleware
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
"time"
8+
9+
"github.com/go-authgate/authgate/internal/config"
10+
11+
"github.com/gin-gonic/gin"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func setupCORSRouter(cfg *config.Config) *gin.Engine {
16+
gin.SetMode(gin.TestMode)
17+
r := gin.New()
18+
r.Use(CORSMiddleware(cfg))
19+
r.GET("/oauth/tokeninfo", func(c *gin.Context) {
20+
c.String(http.StatusOK, "ok")
21+
})
22+
r.POST("/oauth/token", func(c *gin.Context) {
23+
c.String(http.StatusOK, "ok")
24+
})
25+
r.OPTIONS("/oauth/token", func(c *gin.Context) {
26+
c.Status(http.StatusNoContent)
27+
})
28+
return r
29+
}
30+
31+
func defaultCORSConfig() *config.Config {
32+
return &config.Config{
33+
CORSEnabled: true,
34+
CORSAllowedOrigins: []string{"http://localhost:3000"},
35+
CORSAllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
36+
CORSAllowedHeaders: []string{"Origin", "Content-Type", "Authorization"},
37+
CORSMaxAge: 12 * time.Hour,
38+
}
39+
}
40+
41+
func TestCORS_AllowedOrigin(t *testing.T) {
42+
r := setupCORSRouter(defaultCORSConfig())
43+
44+
w := httptest.NewRecorder()
45+
req, _ := http.NewRequest(http.MethodGet, "/oauth/tokeninfo", nil)
46+
req.Header.Set("Origin", "http://localhost:3000")
47+
r.ServeHTTP(w, req)
48+
49+
assert.Equal(t, http.StatusOK, w.Code)
50+
assert.Equal(t, "http://localhost:3000", w.Header().Get("Access-Control-Allow-Origin"))
51+
assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials"))
52+
}
53+
54+
func TestCORS_DisallowedOrigin(t *testing.T) {
55+
r := setupCORSRouter(defaultCORSConfig())
56+
57+
w := httptest.NewRecorder()
58+
req, _ := http.NewRequest(http.MethodGet, "/oauth/tokeninfo", nil)
59+
req.Header.Set("Origin", "http://evil.com")
60+
r.ServeHTTP(w, req)
61+
62+
// gin-contrib/cors returns 403 for disallowed origins
63+
assert.Equal(t, http.StatusForbidden, w.Code)
64+
assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"))
65+
}
66+
67+
func TestCORS_PreflightRequest(t *testing.T) {
68+
r := setupCORSRouter(defaultCORSConfig())
69+
70+
w := httptest.NewRecorder()
71+
req, _ := http.NewRequest(http.MethodOptions, "/oauth/token", nil)
72+
req.Header.Set("Origin", "http://localhost:3000")
73+
req.Header.Set("Access-Control-Request-Method", "POST")
74+
req.Header.Set("Access-Control-Request-Headers", "Content-Type,Authorization")
75+
r.ServeHTTP(w, req)
76+
77+
assert.Equal(t, http.StatusNoContent, w.Code)
78+
assert.Equal(t, "http://localhost:3000", w.Header().Get("Access-Control-Allow-Origin"))
79+
assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "POST")
80+
assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "Content-Type")
81+
assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "Authorization")
82+
assert.NotEmpty(t, w.Header().Get("Access-Control-Max-Age"))
83+
}
84+
85+
func TestCORS_PreflightDisallowedOrigin(t *testing.T) {
86+
r := setupCORSRouter(defaultCORSConfig())
87+
88+
w := httptest.NewRecorder()
89+
req, _ := http.NewRequest(http.MethodOptions, "/oauth/token", nil)
90+
req.Header.Set("Origin", "http://evil.com")
91+
req.Header.Set("Access-Control-Request-Method", "POST")
92+
r.ServeHTTP(w, req)
93+
94+
assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"))
95+
}
96+
97+
func TestCORS_MultipleOrigins(t *testing.T) {
98+
cfg := defaultCORSConfig()
99+
cfg.CORSAllowedOrigins = []string{"http://localhost:3000", "https://app.example.com"}
100+
r := setupCORSRouter(cfg)
101+
102+
// First origin
103+
w := httptest.NewRecorder()
104+
req, _ := http.NewRequest(http.MethodGet, "/oauth/tokeninfo", nil)
105+
req.Header.Set("Origin", "http://localhost:3000")
106+
r.ServeHTTP(w, req)
107+
assert.Equal(t, "http://localhost:3000", w.Header().Get("Access-Control-Allow-Origin"))
108+
109+
// Second origin
110+
w = httptest.NewRecorder()
111+
req, _ = http.NewRequest(http.MethodGet, "/oauth/tokeninfo", nil)
112+
req.Header.Set("Origin", "https://app.example.com")
113+
r.ServeHTTP(w, req)
114+
assert.Equal(t, "https://app.example.com", w.Header().Get("Access-Control-Allow-Origin"))
115+
}
116+
117+
func TestCORS_NoOriginHeader(t *testing.T) {
118+
r := setupCORSRouter(defaultCORSConfig())
119+
120+
w := httptest.NewRecorder()
121+
req, _ := http.NewRequest(http.MethodGet, "/oauth/tokeninfo", nil)
122+
// No Origin header — same-origin request
123+
r.ServeHTTP(w, req)
124+
125+
assert.Equal(t, http.StatusOK, w.Code)
126+
assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"))
127+
}
128+
129+
func TestCORS_ExposeHeaders(t *testing.T) {
130+
r := setupCORSRouter(defaultCORSConfig())
131+
132+
w := httptest.NewRecorder()
133+
req, _ := http.NewRequest(http.MethodGet, "/oauth/tokeninfo", nil)
134+
req.Header.Set("Origin", "http://localhost:3000")
135+
r.ServeHTTP(w, req)
136+
137+
exposed := w.Header().Get("Access-Control-Expose-Headers")
138+
assert.Contains(t, exposed, "Content-Length")
139+
assert.Contains(t, exposed, "Content-Type")
140+
}
141+
142+
func TestCORS_POSTWithOrigin(t *testing.T) {
143+
r := setupCORSRouter(defaultCORSConfig())
144+
145+
w := httptest.NewRecorder()
146+
req, _ := http.NewRequest(http.MethodPost, "/oauth/token", nil)
147+
req.Header.Set("Origin", "http://localhost:3000")
148+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
149+
r.ServeHTTP(w, req)
150+
151+
assert.Equal(t, http.StatusOK, w.Code)
152+
assert.Equal(t, "http://localhost:3000", w.Header().Get("Access-Control-Allow-Origin"))
153+
}
154+
155+
func TestNewCORSConfig(t *testing.T) {
156+
cfg := NewCORSConfig(
157+
[]string{"http://localhost:3000"},
158+
[]string{"GET", "POST"},
159+
[]string{"Authorization"},
160+
1*time.Hour,
161+
)
162+
163+
assert.Equal(t, []string{"http://localhost:3000"}, cfg.AllowOrigins)
164+
assert.Equal(t, []string{"GET", "POST"}, cfg.AllowMethods)
165+
assert.Equal(t, []string{"Authorization"}, cfg.AllowHeaders)
166+
assert.Equal(t, 1*time.Hour, cfg.MaxAge)
167+
assert.True(t, cfg.AllowCredentials)
168+
}

0 commit comments

Comments
 (0)