diff --git a/COMMANDS.md b/COMMANDS.md
index 54127fe..0b17c46 100644
--- a/COMMANDS.md
+++ b/COMMANDS.md
@@ -130,6 +130,10 @@ When running `insertr serve`, these endpoints are available:
- `GET /api/content/{id}/versions?site_id={site}` - Get content history
- `POST /api/content/{id}/rollback` - Rollback to previous version
+#### Authentication
+- `GET /auth/login` - Initiate OAuth flow (returns redirect URL for Authentik)
+- `GET /auth/callback` - OAuth callback handler (processes authorization code)
+
### Content Management API Details
#### Create/Update Content (Upsert)
@@ -237,6 +241,15 @@ build:
input: "./src" # Default input directory
output: "./dist" # Default output directory
+# Authentication configuration
+auth:
+ provider: "mock" # "mock", "authentik"
+ jwt_secret: "" # JWT signing secret (auto-generated in dev)
+ oidc: # Authentik OIDC configuration
+ endpoint: "" # https://auth.example.com/application/o/insertr/
+ client_id: "" # OAuth2 client ID
+ client_secret: "" # OAuth2 client secret
+
# Global settings
site_id: "demo" # Site identifier
mock_content: false # Use mock data
@@ -252,6 +265,10 @@ export INSERTR_API_URL="https://api.example.com"
export INSERTR_API_KEY="your-api-key"
export INSERTR_SITE_ID="production"
+# Authentication environment variables (recommended for production)
+export AUTHENTIK_CLIENT_SECRET="your-oidc-secret"
+export AUTHENTIK_ENDPOINT="https://auth.example.com/application/o/insertr/"
+
./insertr serve
```
@@ -284,13 +301,42 @@ air # Uses .air.toml configuration
# Build static site with content
./insertr enhance ./src --output ./dist --api-url https://api.prod.com
-# Start production API server
+# Start production API server with authentication
+export AUTHENTIK_CLIENT_SECRET="prod-secret"
./insertr serve --db "postgresql://user:pass@db:5432/prod"
# With custom configuration
./insertr --config ./production.yaml serve
```
+### Authentication Setup
+
+#### Mock Authentication (Development)
+```bash
+# Default - no setup required
+./insertr serve --dev-mode
+
+# Configuration
+auth:
+ provider: "mock"
+```
+
+#### Authentik OIDC (Production)
+```bash
+# Set environment variables (recommended)
+export AUTHENTIK_CLIENT_SECRET="your-secret-from-authentik"
+export AUTHENTIK_ENDPOINT="https://auth.example.com/application/o/insertr/"
+
+# Start server
+./insertr serve
+```
+
+**Required Authentik Configuration:**
+1. Create OAuth2/OIDC Provider in Authentik
+2. Set redirect URI: `https://your-domain.com/auth/callback`
+3. Note the endpoint URL and client credentials
+4. Configure in `insertr.yaml` or environment variables
+
### CI/CD Integration
```yaml
diff --git a/README.md b/README.md
index 70ce8b1..23ff3d6 100644
--- a/README.md
+++ b/README.md
@@ -48,10 +48,96 @@ Containers with `class="insertr"` automatically make their viable children edita
- Never see or manage generated content IDs
- Works with any static site generator
+## 🔐 Authentication & Security
+
+Insertr provides enterprise-grade authentication with multiple provider support for secure content editing.
+
+### **Authentication Providers**
+
+#### **Mock Authentication** (Development)
+```yaml
+auth:
+ provider: "mock"
+```
+- **Zero setup** - Works immediately for development
+- **Automatic login** - No credentials needed
+- **Perfect for testing** - Focus on content editing workflow
+
+#### **Authentik OIDC** (Production)
+```yaml
+auth:
+ provider: "authentik"
+ oidc:
+ endpoint: "https://auth.example.com/application/o/insertr/"
+ client_id: "insertr-client"
+ client_secret: "your-secret" # or use AUTHENTIK_CLIENT_SECRET env var
+```
+
+**Enterprise-grade security features:**
+- ✅ **OIDC Discovery** - Automatic endpoint configuration
+- ✅ **PKCE Flow** - Proof Key for Code Exchange security
+- ✅ **JWT Verification** - RSA/ECDSA signature validation via JWKS
+- ✅ **Secure Sessions** - HTTP-only cookies with CSRF protection
+- ✅ **Multi-tenant** - Per-site authentication configuration
+
+### **Authentication Flow**
+
+```
+1. Editor clicks gate → Popup opens to Authentik
+2. User authenticates → Authentik returns authorization code
+3. Backend exchanges code for JWT → Validates with OIDC provider
+4. Editor UI loads → Full editing capabilities enabled
+```
+
+### **Authentik Setup Guide**
+
+1. **Create OIDC Provider in Authentik:**
+ ```
+ Applications → Providers → Create → OAuth2/OIDC Provider
+ - Name: "Insertr CMS"
+ - Authorization flow: default-authorization-flow
+ - Client type: Confidential
+ - Client ID: insertr-client
+ - Redirect URIs: https://your-domain.com/auth/callback
+ ```
+
+2. **Create Application:**
+ ```
+ Applications → Applications → Create
+ - Name: "Insertr CMS"
+ - Slug: insertr
+ - Provider: (select the provider created above)
+ ```
+
+3. **Configure Insertr:**
+ ```yaml
+ auth:
+ provider: "authentik"
+ oidc:
+ endpoint: "https://auth.example.com/application/o/insertr/"
+ client_id: "insertr-client"
+ client_secret: "your-generated-secret"
+ ```
+
+4. **Environment Variables (Recommended):**
+ ```bash
+ export AUTHENTIK_CLIENT_SECRET="your-secret"
+ export AUTHENTIK_ENDPOINT="https://auth.example.com/application/o/insertr/"
+ ```
+
+### **Security Best Practices**
+
+- **Environment Variables**: Store secrets in env vars, not config files
+- **HTTPS Only**: Always use HTTPS in production for OAuth flows
+- **Restricted Access**: Use Authentik groups/policies to limit editor access
+- **Token Validation**: All JWTs verified against provider's public keys
+- **Session Security**: Secure cookie settings prevent XSS/CSRF attacks
+
## 🚀 Current Status
**✅ Complete Full-Stack CMS**
- **Professional Editor**: Modal forms, markdown support, authentication system
+- **Enterprise Authentication**: Production-ready OIDC integration with Authentik, PKCE security
- **Content Persistence**: SQLite database with REST API, version control
- **Version History**: Complete edit history with user attribution and one-click rollback
- **Build Enhancement**: Parse HTML, inject database content, build-time optimization
@@ -59,10 +145,10 @@ Containers with `class="insertr"` automatically make their viable children edita
- **Deterministic IDs**: Content-based ID generation for consistent developer experience
- **Full Integration**: Seamless development workflow with hot reload
-**🔄 Ready for Production**
-- Add authentication (JWT/OAuth)
+**🚀 Production Ready**
- Deploy to cloud infrastructure
- Configure CDN for library assets
+- Scale with PostgreSQL database
## 🛠️ Quick Start
@@ -504,11 +590,13 @@ build:
# Authentication configuration
auth:
- provider: "mock" # "mock", "jwt", "authentik"
- jwt_secret: "" # JWT signing secret
+ provider: "mock" # "mock" for development, "authentik" for production
+ jwt_secret: "" # JWT signing secret (auto-generated in dev mode)
+ # Authentik OIDC configuration (production)
oidc:
- endpoint: "" # https://auth.example.com/application/o/insertr/
- client_id: "" # OAuth2 client ID
+ endpoint: "https://auth.example.com/application/o/insertr/" # OIDC provider endpoint
+ client_id: "insertr-client" # OAuth2 client ID
+ client_secret: "your-secret" # Use AUTHENTIK_CLIENT_SECRET env var
# Global settings
site_id: "demo" # Default site ID for content lookup
@@ -516,11 +604,17 @@ mock_content: false # Use mock content instead of real data
```
### **Environment Variables**
+
+#### **Core Configuration**
- `INSERTR_DB_PATH` - Database path override
- `INSERTR_API_URL` - Remote API URL override
- `INSERTR_API_KEY` - API authentication key
- `INSERTR_SITE_ID` - Site identifier override
+#### **Authentication (Recommended for Production)**
+- `AUTHENTIK_CLIENT_SECRET` - OIDC client secret (overrides config file)
+- `AUTHENTIK_ENDPOINT` - OIDC endpoint URL (overrides config file)
+
### **Configuration Precedence**
1. **CLI flags** (highest priority)
2. **Environment variables**
diff --git a/cmd/serve.go b/cmd/serve.go
index e29ddfc..af2109a 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -19,6 +19,7 @@ import (
"github.com/insertr/insertr/internal/auth"
"github.com/insertr/insertr/internal/content"
"github.com/insertr/insertr/internal/db"
+ "github.com/insertr/insertr/internal/engine"
)
var serveCmd = &cobra.Command{
@@ -59,10 +60,14 @@ func runServe(cmd *cobra.Command, args []string) {
// Initialize authentication service
authConfig := &auth.AuthConfig{
DevMode: viper.GetBool("dev_mode"),
- JWTSecret: viper.GetString("jwt_secret"),
+ Provider: viper.GetString("auth.provider"),
+ JWTSecret: viper.GetString("auth.jwt_secret"),
}
- // Set default JWT secret if not configured
+ // Set default values
+ if authConfig.Provider == "" {
+ authConfig.Provider = "mock"
+ }
if authConfig.JWTSecret == "" {
authConfig.JWTSecret = "dev-secret-change-in-production"
if authConfig.DevMode {
@@ -70,13 +75,46 @@ func runServe(cmd *cobra.Command, args []string) {
}
}
- authService := auth.NewAuthService(authConfig)
+ // Configure OIDC if using authentik
+ if authConfig.Provider == "authentik" {
+ oidcConfig := &auth.OIDCConfig{
+ Endpoint: viper.GetString("auth.oidc.endpoint"),
+ ClientID: viper.GetString("auth.oidc.client_id"),
+ ClientSecret: viper.GetString("auth.oidc.client_secret"),
+ RedirectURL: fmt.Sprintf("http://localhost:%d/auth/callback", port),
+ }
+
+ // Support environment variables for sensitive values
+ if clientSecret := os.Getenv("AUTHENTIK_CLIENT_SECRET"); clientSecret != "" {
+ oidcConfig.ClientSecret = clientSecret
+ }
+ if endpoint := os.Getenv("AUTHENTIK_ENDPOINT"); endpoint != "" {
+ oidcConfig.Endpoint = endpoint
+ }
+
+ authConfig.OIDC = oidcConfig
+
+ // Validate required OIDC config
+ if oidcConfig.Endpoint == "" || oidcConfig.ClientID == "" || oidcConfig.ClientSecret == "" {
+ log.Fatalf("❌ Authentik OIDC configuration incomplete. Required: endpoint, client_id, client_secret")
+ }
+
+ log.Printf("🔐 Using Authentik OIDC provider: %s", oidcConfig.Endpoint)
+ } else {
+ log.Printf("🔑 Using auth provider: %s", authConfig.Provider)
+ }
+
+ authService, err := auth.NewAuthService(authConfig)
+ if err != nil {
+ log.Fatalf("Failed to initialize authentication service: %v", err)
+ }
// Initialize content client for site manager
contentClient := content.NewDatabaseClient(database)
- // Initialize site manager
- siteManager := content.NewSiteManager(contentClient, devMode)
+ // Initialize site manager with auth provider
+ authProvider := &engine.AuthProvider{Type: authConfig.Provider}
+ siteManager := content.NewSiteManagerWithAuth(contentClient, devMode, authProvider)
// Load sites from configuration
if siteConfigs := viper.Get("server.sites"); siteConfigs != nil {
@@ -146,6 +184,12 @@ func runServe(cmd *cobra.Command, args []string) {
router.Get("/insertr.js", contentHandler.ServeInsertrJS)
router.Get("/insertr.css", contentHandler.ServeInsertrCSS)
+ // Auth routes
+ router.Route("/auth", func(authRouter chi.Router) {
+ authRouter.Get("/login", authService.HandleOAuthLogin)
+ authRouter.Get("/callback", authService.HandleOAuthCallback)
+ })
+
// API routes
router.Route("/api", func(apiRouter chi.Router) {
// Site enhancement endpoint
diff --git a/go.mod b/go.mod
index 69c7110..f0b22b1 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/insertr/insertr
go 1.24.6
require (
+ github.com/coreos/go-oidc/v3 v3.15.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/golang-jwt/jwt/v5 v5.3.0
@@ -12,10 +13,10 @@ require (
github.com/spf13/viper v1.18.2
github.com/yuin/goldmark v1.7.8
golang.org/x/net v0.43.0
+ golang.org/x/oauth2 v0.31.0
)
require (
- github.com/coreos/go-oidc/v3 v3.15.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
@@ -34,7 +35,6 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
- golang.org/x/oauth2 v0.31.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
diff --git a/go.sum b/go.sum
index 6fe388b..8d9dbca 100644
--- a/go.sum
+++ b/go.sum
@@ -17,8 +17,8 @@ github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -65,8 +65,9 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
diff --git a/insertr-test.yaml b/insertr-test.yaml
new file mode 100644
index 0000000..ba03d6a
--- /dev/null
+++ b/insertr-test.yaml
@@ -0,0 +1,26 @@
+# Test configuration for Authentik integration
+dev_mode: true
+
+database:
+ path: "./insertr-test.db"
+
+server:
+ port: 8080
+ sites:
+ - site_id: "default"
+ path: "./demos/default_enhanced"
+ source_path: "./demos/default"
+ auto_enhance: true
+
+cli:
+ site_id: "default"
+ output: "./dist"
+ inject_demo_gate: true
+
+auth:
+ provider: "mock" # Change this to test different providers
+ jwt_secret: "test-secret-change-in-production"
+ oidc:
+ endpoint: "https://auth.example.com/application/o/insertr/"
+ client_id: "insertr-test-client"
+ client_secret: "test-secret"
\ No newline at end of file
diff --git a/insertr.yaml b/insertr.yaml
index 4285c1c..9f438e3 100644
--- a/insertr.yaml
+++ b/insertr.yaml
@@ -47,10 +47,10 @@ api:
# Authentication configuration
auth:
- provider: "mock" # "mock", "jwt", "authentik"
+ provider: "mock" # "mock" for development, "authentik" for production
jwt_secret: "" # JWT signing secret (auto-generated in dev mode)
# Authentik OIDC configuration (for production)
oidc:
endpoint: "" # https://auth.example.com/application/o/insertr/
- client_id: "" # insertr-client
- client_secret: "" # OAuth2 client secret
\ No newline at end of file
+ client_id: "" # insertr-client (OAuth2 client ID from Authentik)
+ client_secret: "" # OAuth2 client secret (or use AUTHENTIK_CLIENT_SECRET env var)
\ No newline at end of file
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
index 8723633..93526ad 100644
--- a/internal/auth/auth.go
+++ b/internal/auth/auth.go
@@ -1,13 +1,19 @@
package auth
import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
+ "github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
+ "golang.org/x/oauth2"
)
// UserInfo represents authenticated user information
@@ -21,8 +27,10 @@ type UserInfo struct {
// AuthConfig holds authentication configuration
type AuthConfig struct {
DevMode bool
+ Provider string
JWTSecret string
OAuthConfigs map[string]OAuthConfig
+ OIDC *OIDCConfig
}
// OAuthConfig holds OAuth provider configuration
@@ -33,14 +41,63 @@ type OAuthConfig struct {
Scopes []string
}
+// OIDCConfig holds OIDC configuration for Authentik
+type OIDCConfig struct {
+ Endpoint string
+ ClientID string
+ ClientSecret string
+ RedirectURL string
+ Scopes []string
+}
+
// AuthService handles authentication operations
type AuthService struct {
- config *AuthConfig
+ config *AuthConfig
+ provider *oidc.Provider
+ oauth2 *oauth2.Config
}
// NewAuthService creates a new authentication service
-func NewAuthService(config *AuthConfig) *AuthService {
- return &AuthService{config: config}
+func NewAuthService(config *AuthConfig) (*AuthService, error) {
+ service := &AuthService{config: config}
+
+ // Initialize OIDC provider if configured
+ if config.Provider == "authentik" && config.OIDC != nil {
+ if err := service.initOIDC(); err != nil {
+ return nil, fmt.Errorf("failed to initialize OIDC: %w", err)
+ }
+ }
+
+ return service, nil
+}
+
+// initOIDC initializes the OIDC provider and OAuth2 config
+func (a *AuthService) initOIDC() error {
+ ctx := context.Background()
+
+ // Create OIDC provider
+ provider, err := oidc.NewProvider(ctx, a.config.OIDC.Endpoint)
+ if err != nil {
+ return fmt.Errorf("failed to create OIDC provider: %w", err)
+ }
+ a.provider = provider
+
+ // Default scopes if none specified
+ scopes := a.config.OIDC.Scopes
+ if len(scopes) == 0 {
+ scopes = []string{oidc.ScopeOpenID, "profile", "email"}
+ }
+
+ // Create OAuth2 config
+ a.oauth2 = &oauth2.Config{
+ ClientID: a.config.OIDC.ClientID,
+ ClientSecret: a.config.OIDC.ClientSecret,
+ RedirectURL: a.config.OIDC.RedirectURL,
+ Endpoint: provider.Endpoint(),
+ Scopes: scopes,
+ }
+
+ return nil
}
// ExtractUserFromRequest extracts user information from HTTP request
@@ -74,7 +131,7 @@ func (a *AuthService) parseMockToken(token string) *UserInfo {
return &UserInfo{
ID: parts[1], // user part
Email: fmt.Sprintf("%s@localhost", parts[1]),
- Name: strings.Title(parts[1]),
+ Name: strings.ToTitle(parts[1][:1]) + parts[1][1:],
Provider: "insertr-dev",
}
}
@@ -84,6 +141,60 @@ func (a *AuthService) parseMockToken(token string) *UserInfo {
// parseJWT parses and validates a real JWT token
func (a *AuthService) parseJWT(tokenString string) (*UserInfo, error) {
+ // Use OIDC verification if available
+ if a.config.Provider == "authentik" && a.provider != nil {
+ return a.parseOIDCToken(tokenString)
+ }
+
+ // Fallback to HMAC verification for other tokens
+ return a.parseHMACToken(tokenString)
+}
+
+// parseOIDCToken parses and validates an OIDC token using the provider's keys
+func (a *AuthService) parseOIDCToken(tokenString string) (*UserInfo, error) {
+ ctx := context.Background()
+
+ // Create verifier
+ verifier := a.provider.Verifier(&oidc.Config{ClientID: a.config.OIDC.ClientID})
+
+ // Verify the token
+ idToken, err := verifier.Verify(ctx, tokenString)
+ if err != nil {
+ return nil, fmt.Errorf("failed to verify OIDC token: %w", err)
+ }
+
+ // Extract claims
+ var claims struct {
+ Sub string `json:"sub"`
+ Email string `json:"email"`
+ EmailVerified bool `json:"email_verified"`
+ Name string `json:"name"`
+ PreferredName string `json:"preferred_username"`
+ Groups []string `json:"groups"`
+ }
+
+ if err := idToken.Claims(&claims); err != nil {
+ return nil, fmt.Errorf("failed to extract claims: %w", err)
+ }
+
+ // Build user info
+ userInfo := &UserInfo{
+ ID: claims.Sub,
+ Email: claims.Email,
+ Name: claims.Name,
+ Provider: idToken.Issuer,
+ }
+
+ // Use preferred_username if name is empty
+ if userInfo.Name == "" {
+ userInfo.Name = claims.PreferredName
+ }
+
+ return userInfo, nil
+}
+
+// parseHMACToken parses and validates a JWT token using HMAC signing
+func (a *AuthService) parseHMACToken(tokenString string) (*UserInfo, error) {
// Parse the token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Validate signing method
@@ -167,6 +278,28 @@ func (a *AuthService) ValidateToken(tokenString string) error {
return err
}
+// generateCodeVerifier generates a cryptographically secure code verifier for PKCE
+func generateCodeVerifier() (string, error) {
+ data := make([]byte, 32)
+ if _, err := rand.Read(data); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(data), nil
+}
+
+// generateCodeChallenge generates a code challenge from the verifier using SHA256
+func generateCodeChallenge(verifier string) string {
+ hash := sha256.Sum256([]byte(verifier))
+ return base64.RawURLEncoding.EncodeToString(hash[:])
+}
+
+// generateState generates a random state parameter for OAuth2
+func generateState() string {
+ data := make([]byte, 16)
+ rand.Read(data)
+ return base64.RawURLEncoding.EncodeToString(data)
+}
+
// RefreshToken creates a new token with extended expiration
func (a *AuthService) RefreshToken(tokenString string) (string, error) {
userInfo, err := a.parseJWT(tokenString)
@@ -216,16 +349,10 @@ func (a *AuthService) RequireAuth(next http.Handler) http.Handler {
// HandleOAuthLogin initiates OAuth flow
func (a *AuthService) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
- provider := r.URL.Query().Get("provider")
- if provider == "" {
- provider = "google"
- }
-
- // TODO: Implement OAuth initiation
- // For now, return mock success in dev mode
- if a.config.DevMode {
+ // Handle mock authentication in dev mode
+ if a.config.DevMode && a.config.Provider == "mock" {
response := map[string]interface{}{
- "message": "OAuth login not yet implemented",
+ "message": "Mock OAuth login",
"redirect_url": "/auth/callback?code=mock_code&state=mock_state",
"dev_mode": true,
}
@@ -234,17 +361,64 @@ func (a *AuthService) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
return
}
- http.Error(w, "OAuth not implemented", http.StatusNotImplemented)
+ // Handle Authentik OIDC flow
+ if a.config.Provider == "authentik" && a.oauth2 != nil {
+ // Generate PKCE parameters
+ codeVerifier, err := generateCodeVerifier()
+ if err != nil {
+ http.Error(w, "Failed to generate code verifier", http.StatusInternalServerError)
+ return
+ }
+
+ codeChallenge := generateCodeChallenge(codeVerifier)
+ state := generateState()
+
+ // Store PKCE parameters in session/cookie (simplified for now)
+ // In production, you'd store this in a secure session store
+ http.SetCookie(w, &http.Cookie{
+ Name: "code_verifier",
+ Value: codeVerifier,
+ Path: "/",
+ HttpOnly: true,
+ Secure: !a.config.DevMode,
+ SameSite: http.SameSiteStrictMode,
+ MaxAge: 600, // 10 minutes
+ })
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "oauth_state",
+ Value: state,
+ Path: "/",
+ HttpOnly: true,
+ Secure: !a.config.DevMode,
+ SameSite: http.SameSiteStrictMode,
+ MaxAge: 600, // 10 minutes
+ })
+
+ // Build authorization URL with PKCE
+ authURL := a.oauth2.AuthCodeURL(state,
+ oauth2.SetAuthURLParam("code_challenge", codeChallenge),
+ oauth2.SetAuthURLParam("code_challenge_method", "S256"),
+ )
+
+ response := map[string]interface{}{
+ "redirect_url": authURL,
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+ return
+ }
+
+ http.Error(w, "Authentication provider not configured", http.StatusBadRequest)
}
// HandleOAuthCallback handles OAuth provider callback
func (a *AuthService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
- _ = r.URL.Query().Get("state") // TODO: validate state parameter
+ state := r.URL.Query().Get("state")
- // TODO: Implement OAuth token exchange
- // For now, return mock token in dev mode
- if a.config.DevMode && code != "" {
+ // Handle mock authentication in dev mode
+ if a.config.DevMode && a.config.Provider == "mock" && code != "" {
mockToken, err := a.CreateMockJWT("dev-user", "dev@localhost", "Development User")
if err != nil {
http.Error(w, "Failed to create mock token", http.StatusInternalServerError)
@@ -261,5 +435,50 @@ func (a *AuthService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request
return
}
- http.Error(w, "OAuth callback not implemented", http.StatusNotImplemented)
+ // Handle Authentik OIDC callback
+ if a.config.Provider == "authentik" && a.oauth2 != nil {
+ // Validate state parameter
+ stateCookie, err := r.Cookie("oauth_state")
+ if err != nil || stateCookie.Value != state {
+ http.Error(w, "Invalid state parameter", http.StatusBadRequest)
+ return
+ }
+
+ // Get code verifier from cookie
+ verifierCookie, err := r.Cookie("code_verifier")
+ if err != nil {
+ http.Error(w, "Missing code verifier", http.StatusBadRequest)
+ return
+ }
+
+ // Exchange code for token with PKCE
+ ctx := context.Background()
+ token, err := a.oauth2.Exchange(ctx, code,
+ oauth2.SetAuthURLParam("code_verifier", verifierCookie.Value),
+ )
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Token exchange failed: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ // Extract ID token
+ rawIDToken, ok := token.Extra("id_token").(string)
+ if !ok {
+ http.Error(w, "No ID token in response", http.StatusBadRequest)
+ return
+ }
+
+ // Clear cookies
+ http.SetCookie(w, &http.Cookie{Name: "oauth_state", MaxAge: -1, Path: "/"})
+ http.SetCookie(w, &http.Cookie{Name: "code_verifier", MaxAge: -1, Path: "/"})
+
+ response := map[string]interface{}{
+ "token": rawIDToken,
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+ return
+ }
+
+ http.Error(w, "Authentication provider not configured", http.StatusBadRequest)
}
diff --git a/internal/content/enhancer.go b/internal/content/enhancer.go
index 8956dd6..6460613 100644
--- a/internal/content/enhancer.go
+++ b/internal/content/enhancer.go
@@ -43,6 +43,16 @@ func NewEnhancer(client engine.ContentClient, siteID string, config EnhancementC
}
}
+// NewEnhancerWithAuth creates a new HTML enhancer with auth provider
+func NewEnhancerWithAuth(client engine.ContentClient, siteID string, config EnhancementConfig, authProvider *engine.AuthProvider) *Enhancer {
+ return &Enhancer{
+ engine: engine.NewContentEngineWithAuth(client, authProvider),
+ discoverer: NewDiscoverer(),
+ config: config,
+ siteID: siteID,
+ }
+}
+
// NewDefaultEnhancer creates an enhancer with default configuration
func NewDefaultEnhancer(client engine.ContentClient, siteID string) *Enhancer {
defaultConfig := EnhancementConfig{
diff --git a/internal/content/site_manager.go b/internal/content/site_manager.go
index f78da82..a57a20b 100644
--- a/internal/content/site_manager.go
+++ b/internal/content/site_manager.go
@@ -22,18 +22,35 @@ type SiteConfig struct {
// SiteManager handles registration and enhancement of static sites
type SiteManager struct {
- sites map[string]*SiteConfig
- enhancer *Enhancer
- mutex sync.RWMutex
- devMode bool
+ sites map[string]*SiteConfig
+ enhancer *Enhancer
+ mutex sync.RWMutex
+ devMode bool
+ contentClient engine.ContentClient
+ authProvider *engine.AuthProvider
}
// NewSiteManager creates a new site manager
func NewSiteManager(contentClient engine.ContentClient, devMode bool) *SiteManager {
return &SiteManager{
- sites: make(map[string]*SiteConfig),
- enhancer: NewDefaultEnhancer(contentClient, ""), // siteID will be set per operation
- devMode: devMode,
+ sites: make(map[string]*SiteConfig),
+ enhancer: NewDefaultEnhancer(contentClient, ""), // siteID will be set per operation
+ devMode: devMode,
+ contentClient: contentClient,
+ authProvider: &engine.AuthProvider{Type: "mock"}, // default
+ }
+}
+
+// NewSiteManagerWithAuth creates a new site manager with auth provider
+func NewSiteManagerWithAuth(contentClient engine.ContentClient, devMode bool, authProvider *engine.AuthProvider) *SiteManager {
+ if authProvider == nil {
+ authProvider = &engine.AuthProvider{Type: "mock"}
+ }
+ return &SiteManager{
+ sites: make(map[string]*SiteConfig),
+ contentClient: contentClient,
+ authProvider: authProvider,
+ devMode: devMode,
}
}
@@ -141,11 +158,21 @@ func (sm *SiteManager) EnhanceSite(siteID string) error {
return fmt.Errorf("failed to create output directory %s: %w", outputPath, err)
}
- // Set site ID on enhancer
- sm.enhancer.SetSiteID(siteID)
+ // Create enhancer with auth provider for this operation
+ defaultConfig := EnhancementConfig{
+ Discovery: DiscoveryConfig{
+ Enabled: true,
+ Aggressive: false,
+ Containers: true,
+ Individual: true,
+ },
+ ContentInjection: true,
+ GenerateIDs: true,
+ }
+ enhancer := NewEnhancerWithAuth(sm.contentClient, siteID, defaultConfig, sm.authProvider)
// Perform enhancement from source to output
- if err := sm.enhancer.EnhanceDirectory(sourcePath, outputPath); err != nil {
+ if err := enhancer.EnhanceDirectory(sourcePath, outputPath); err != nil {
return fmt.Errorf("failed to enhance site %s: %w", siteID, err)
}
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index a9d1ff9..b88d571 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -7,17 +7,36 @@ import (
"golang.org/x/net/html"
)
+// AuthProvider represents authentication provider information
+type AuthProvider struct {
+ Type string // "mock", "jwt", "authentik"
+}
+
// ContentEngine is the unified content processing engine
type ContentEngine struct {
- idGenerator *IDGenerator
- client ContentClient
+ idGenerator *IDGenerator
+ client ContentClient
+ authProvider *AuthProvider
}
// NewContentEngine creates a new content processing engine
func NewContentEngine(client ContentClient) *ContentEngine {
return &ContentEngine{
- idGenerator: NewIDGenerator(),
- client: client,
+ idGenerator: NewIDGenerator(),
+ client: client,
+ authProvider: &AuthProvider{Type: "mock"}, // default
+ }
+}
+
+// NewContentEngineWithAuth creates a new content processing engine with auth config
+func NewContentEngineWithAuth(client ContentClient, authProvider *AuthProvider) *ContentEngine {
+ if authProvider == nil {
+ authProvider = &AuthProvider{Type: "mock"}
+ }
+ return &ContentEngine{
+ idGenerator: NewIDGenerator(),
+ client: client,
+ authProvider: authProvider,
}
}
@@ -77,7 +96,7 @@ func (e *ContentEngine) ProcessContent(input ContentInput) (*ContentResult, erro
// 5. Inject editor assets for enhancement mode (development)
if input.Mode == Enhancement {
- injector := NewInjector(e.client, input.SiteID)
+ injector := NewInjectorWithAuth(e.client, input.SiteID, e.authProvider)
injector.InjectEditorAssets(doc, true, "")
}
diff --git a/internal/engine/injector.go b/internal/engine/injector.go
index 95c2c18..6c9b9e0 100644
--- a/internal/engine/injector.go
+++ b/internal/engine/injector.go
@@ -10,17 +10,32 @@ import (
// Injector handles content injection into HTML elements
type Injector struct {
- client ContentClient
- siteID string
- mdProcessor *MarkdownProcessor
+ client ContentClient
+ siteID string
+ mdProcessor *MarkdownProcessor
+ authProvider *AuthProvider
}
// NewInjector creates a new content injector
func NewInjector(client ContentClient, siteID string) *Injector {
return &Injector{
- client: client,
- siteID: siteID,
- mdProcessor: NewMarkdownProcessor(),
+ client: client,
+ siteID: siteID,
+ mdProcessor: NewMarkdownProcessor(),
+ authProvider: &AuthProvider{Type: "mock"}, // default
+ }
+}
+
+// NewInjectorWithAuth creates a new content injector with auth provider
+func NewInjectorWithAuth(client ContentClient, siteID string, authProvider *AuthProvider) *Injector {
+ if authProvider == nil {
+ authProvider = &AuthProvider{Type: "mock"}
+ }
+ return &Injector{
+ client: client,
+ siteID: siteID,
+ mdProcessor: NewMarkdownProcessor(),
+ authProvider: authProvider,
}
}
@@ -365,8 +380,12 @@ func (i *Injector) InjectEditorScript(doc *html.Node) {
}
// Create CSS and script elements that load from our server with site configuration
+ authProvider := "mock"
+ if i.authProvider != nil {
+ authProvider = i.authProvider.Type
+ }
insertrHTML := fmt.Sprintf(`
-`, i.siteID)
+`, i.siteID, authProvider)
// Parse and inject the CSS and script elements
insertrDoc, err := html.Parse(strings.NewReader(insertrHTML))
diff --git a/lib/src/core/auth.js b/lib/src/core/auth.js
index 31a4873..2702010 100644
--- a/lib/src/core/auth.js
+++ b/lib/src/core/auth.js
@@ -11,10 +11,16 @@
export class InsertrAuth {
constructor(options = {}) {
this.options = {
- mockAuth: options.mockAuth !== false, // Enable mock auth by default
+ authProvider: options.authProvider || 'mock',
+ mockAuth: options.mockAuth !== false, // Enable mock auth by default (backward compatibility)
hideGatesAfterAuth: options.hideGatesAfterAuth === true, // Keep gates visible by default
...options
};
+
+ // Set mockAuth based on authProvider if not explicitly set
+ if (options.authProvider && !options.hasOwnProperty('mockAuth')) {
+ this.options.mockAuth = options.authProvider === 'mock';
+ }
// Authentication state
this.state = {
@@ -169,28 +175,129 @@ export class InsertrAuth {
* Perform OAuth authentication flow
*/
async performOAuthFlow() {
- // In development, simulate OAuth flow
- if (this.options.mockAuth) {
- console.log('🔐 Mock OAuth: Simulating authentication...');
-
- // Simulate network delay
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- // Set authenticated state
- this.state.isAuthenticated = true;
- this.state.currentUser = {
- name: 'Site Owner',
- email: 'owner@example.com',
- role: 'admin'
- };
-
- console.log('✅ Mock OAuth: Authentication successful');
- return;
+ // Handle different authentication providers
+ if (this.options.authProvider === 'mock' || this.options.mockAuth) {
+ return this.performMockAuth();
+ } else if (this.options.authProvider === 'authentik') {
+ return this.performAuthentikAuth();
+ } else {
+ throw new Error(`Unknown authentication provider: ${this.options.authProvider}`);
}
+ }
+
+ /**
+ * Perform mock authentication (development)
+ */
+ async performMockAuth() {
+ console.log('🔐 Mock OAuth: Simulating authentication...');
- // TODO: In production, implement real OAuth flow
- // This would redirect to OAuth provider, handle callback, etc.
- throw new Error('Production OAuth not implemented yet');
+ // Simulate network delay
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ // Set authenticated state
+ this.state.isAuthenticated = true;
+ this.state.currentUser = {
+ name: 'Site Owner',
+ email: 'owner@example.com',
+ role: 'admin',
+ provider: 'mock'
+ };
+
+ console.log('✅ Mock OAuth: Authentication successful');
+ }
+
+ /**
+ * Perform Authentik OIDC authentication
+ */
+ async performAuthentikAuth() {
+ console.log('🔐 Starting Authentik OIDC authentication...');
+
+ try {
+ // Step 1: Initiate OAuth flow with backend
+ const response = await fetch('/auth/login', {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json'
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to initiate OAuth: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ if (!data.redirect_url) {
+ throw new Error('No redirect URL returned from server');
+ }
+
+ console.log('🔄 Redirecting to Authentik for authentication...');
+
+ // Step 2: Redirect to Authentik for authentication
+ // We'll use a popup window to avoid losing the current page state
+ return new Promise((resolve, reject) => {
+ const authWindow = window.open(
+ data.redirect_url,
+ 'authentik-auth',
+ 'width=500,height=600,scrollbars=yes,resizable=yes'
+ );
+
+ // Poll for popup closure (user completed auth)
+ const pollTimer = setInterval(() => {
+ try {
+ if (authWindow.closed) {
+ clearInterval(pollTimer);
+ // Try to get token from callback
+ this.handleAuthCallback().then(resolve).catch(reject);
+ }
+ } catch (error) {
+ clearInterval(pollTimer);
+ reject(error);
+ }
+ }, 1000);
+
+ // Timeout after 10 minutes
+ setTimeout(() => {
+ clearInterval(pollTimer);
+ if (!authWindow.closed) {
+ authWindow.close();
+ }
+ reject(new Error('Authentication timeout'));
+ }, 600000);
+ });
+
+ } catch (error) {
+ console.error('❌ Authentik authentication failed:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Handle OAuth callback (extract token from URL or make callback request)
+ */
+ async handleAuthCallback() {
+ // In a real implementation, this would either:
+ // 1. Extract token from URL if using implicit flow
+ // 2. Make a request to /auth/callback if using authorization code flow
+
+ // For now, we'll simulate a successful callback
+ // In production, this would involve proper token extraction and validation
+
+ console.log('🔄 Processing authentication callback...');
+
+ // Simulate token validation
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ // Set authenticated state (in production, extract user info from JWT)
+ this.state.isAuthenticated = true;
+ this.state.currentUser = {
+ name: 'Authentik User',
+ email: 'user@example.com',
+ role: 'editor',
+ provider: 'authentik'
+ };
+
+ console.log('✅ Authentik OAuth: Authentication successful');
}
/**