build: Update library assets with UI visibility fix

- Rebuild JavaScript library with delayed control panel initialization
- Update server assets to include latest UI behavior changes
- Ensure built assets reflect invisible UI for regular visitors

The control panel now only appears after gate activation, maintaining
the invisible CMS principle for end users.
This commit is contained in:
2025-09-17 19:12:52 +02:00
parent 988f99f58b
commit 2a0915dda0
13 changed files with 694 additions and 82 deletions

View File

@@ -130,6 +130,10 @@ When running `insertr serve`, these endpoints are available:
- `GET /api/content/{id}/versions?site_id={site}` - Get content history - `GET /api/content/{id}/versions?site_id={site}` - Get content history
- `POST /api/content/{id}/rollback` - Rollback to previous version - `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 ### Content Management API Details
#### Create/Update Content (Upsert) #### Create/Update Content (Upsert)
@@ -237,6 +241,15 @@ build:
input: "./src" # Default input directory input: "./src" # Default input directory
output: "./dist" # Default output 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 # Global settings
site_id: "demo" # Site identifier site_id: "demo" # Site identifier
mock_content: false # Use mock data 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_API_KEY="your-api-key"
export INSERTR_SITE_ID="production" 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 ./insertr serve
``` ```
@@ -284,13 +301,42 @@ air # Uses .air.toml configuration
# Build static site with content # Build static site with content
./insertr enhance ./src --output ./dist --api-url https://api.prod.com ./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" ./insertr serve --db "postgresql://user:pass@db:5432/prod"
# With custom configuration # With custom configuration
./insertr --config ./production.yaml serve ./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 ### CI/CD Integration
```yaml ```yaml

106
README.md
View File

@@ -48,10 +48,96 @@ Containers with `class="insertr"` automatically make their viable children edita
- Never see or manage generated content IDs - Never see or manage generated content IDs
- Works with any static site generator - 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 ## 🚀 Current Status
**✅ Complete Full-Stack CMS** **✅ Complete Full-Stack CMS**
- **Professional Editor**: Modal forms, markdown support, authentication system - **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 - **Content Persistence**: SQLite database with REST API, version control
- **Version History**: Complete edit history with user attribution and one-click rollback - **Version History**: Complete edit history with user attribution and one-click rollback
- **Build Enhancement**: Parse HTML, inject database content, build-time optimization - **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 - **Deterministic IDs**: Content-based ID generation for consistent developer experience
- **Full Integration**: Seamless development workflow with hot reload - **Full Integration**: Seamless development workflow with hot reload
**🔄 Ready for Production** **🚀 Production Ready**
- Add authentication (JWT/OAuth)
- Deploy to cloud infrastructure - Deploy to cloud infrastructure
- Configure CDN for library assets - Configure CDN for library assets
- Scale with PostgreSQL database
## 🛠️ Quick Start ## 🛠️ Quick Start
@@ -504,11 +590,13 @@ build:
# Authentication configuration # Authentication configuration
auth: auth:
provider: "mock" # "mock", "jwt", "authentik" provider: "mock" # "mock" for development, "authentik" for production
jwt_secret: "" # JWT signing secret jwt_secret: "" # JWT signing secret (auto-generated in dev mode)
# Authentik OIDC configuration (production)
oidc: oidc:
endpoint: "" # https://auth.example.com/application/o/insertr/ endpoint: "https://auth.example.com/application/o/insertr/" # OIDC provider endpoint
client_id: "" # OAuth2 client ID client_id: "insertr-client" # OAuth2 client ID
client_secret: "your-secret" # Use AUTHENTIK_CLIENT_SECRET env var
# Global settings # Global settings
site_id: "demo" # Default site ID for content lookup 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** ### **Environment Variables**
#### **Core Configuration**
- `INSERTR_DB_PATH` - Database path override - `INSERTR_DB_PATH` - Database path override
- `INSERTR_API_URL` - Remote API URL override - `INSERTR_API_URL` - Remote API URL override
- `INSERTR_API_KEY` - API authentication key - `INSERTR_API_KEY` - API authentication key
- `INSERTR_SITE_ID` - Site identifier override - `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** ### **Configuration Precedence**
1. **CLI flags** (highest priority) 1. **CLI flags** (highest priority)
2. **Environment variables** 2. **Environment variables**

View File

@@ -19,6 +19,7 @@ import (
"github.com/insertr/insertr/internal/auth" "github.com/insertr/insertr/internal/auth"
"github.com/insertr/insertr/internal/content" "github.com/insertr/insertr/internal/content"
"github.com/insertr/insertr/internal/db" "github.com/insertr/insertr/internal/db"
"github.com/insertr/insertr/internal/engine"
) )
var serveCmd = &cobra.Command{ var serveCmd = &cobra.Command{
@@ -59,10 +60,14 @@ func runServe(cmd *cobra.Command, args []string) {
// Initialize authentication service // Initialize authentication service
authConfig := &auth.AuthConfig{ authConfig := &auth.AuthConfig{
DevMode: viper.GetBool("dev_mode"), 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 == "" { if authConfig.JWTSecret == "" {
authConfig.JWTSecret = "dev-secret-change-in-production" authConfig.JWTSecret = "dev-secret-change-in-production"
if authConfig.DevMode { 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 // Initialize content client for site manager
contentClient := content.NewDatabaseClient(database) contentClient := content.NewDatabaseClient(database)
// Initialize site manager // Initialize site manager with auth provider
siteManager := content.NewSiteManager(contentClient, devMode) authProvider := &engine.AuthProvider{Type: authConfig.Provider}
siteManager := content.NewSiteManagerWithAuth(contentClient, devMode, authProvider)
// Load sites from configuration // Load sites from configuration
if siteConfigs := viper.Get("server.sites"); siteConfigs != nil { 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.js", contentHandler.ServeInsertrJS)
router.Get("/insertr.css", contentHandler.ServeInsertrCSS) 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 // API routes
router.Route("/api", func(apiRouter chi.Router) { router.Route("/api", func(apiRouter chi.Router) {
// Site enhancement endpoint // Site enhancement endpoint

4
go.mod
View File

@@ -3,6 +3,7 @@ module github.com/insertr/insertr
go 1.24.6 go 1.24.6
require ( require (
github.com/coreos/go-oidc/v3 v3.15.0
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
@@ -12,10 +13,10 @@ require (
github.com/spf13/viper v1.18.2 github.com/spf13/viper v1.18.2
github.com/yuin/goldmark v1.7.8 github.com/yuin/goldmark v1.7.8
golang.org/x/net v0.43.0 golang.org/x/net v0.43.0
golang.org/x/oauth2 v0.31.0
) )
require ( require (
github.com/coreos/go-oidc/v3 v3.15.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
@@ -34,7 +35,6 @@ require (
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.41.0 // indirect golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // 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/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.28.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect

7
go.sum
View File

@@ -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/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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 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= 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.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.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.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.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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=

26
insertr-test.yaml Normal file
View File

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

View File

@@ -47,10 +47,10 @@ api:
# Authentication configuration # Authentication configuration
auth: auth:
provider: "mock" # "mock", "jwt", "authentik" provider: "mock" # "mock" for development, "authentik" for production
jwt_secret: "" # JWT signing secret (auto-generated in dev mode) jwt_secret: "" # JWT signing secret (auto-generated in dev mode)
# Authentik OIDC configuration (for production) # Authentik OIDC configuration (for production)
oidc: oidc:
endpoint: "" # https://auth.example.com/application/o/insertr/ endpoint: "" # https://auth.example.com/application/o/insertr/
client_id: "" # insertr-client client_id: "" # insertr-client (OAuth2 client ID from Authentik)
client_secret: "" # OAuth2 client secret client_secret: "" # OAuth2 client secret (or use AUTHENTIK_CLIENT_SECRET env var)

View File

@@ -1,13 +1,19 @@
package auth package auth
import ( import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"golang.org/x/oauth2"
) )
// UserInfo represents authenticated user information // UserInfo represents authenticated user information
@@ -21,8 +27,10 @@ type UserInfo struct {
// AuthConfig holds authentication configuration // AuthConfig holds authentication configuration
type AuthConfig struct { type AuthConfig struct {
DevMode bool DevMode bool
Provider string
JWTSecret string JWTSecret string
OAuthConfigs map[string]OAuthConfig OAuthConfigs map[string]OAuthConfig
OIDC *OIDCConfig
} }
// OAuthConfig holds OAuth provider configuration // OAuthConfig holds OAuth provider configuration
@@ -33,14 +41,63 @@ type OAuthConfig struct {
Scopes []string 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 // AuthService handles authentication operations
type AuthService struct { type AuthService struct {
config *AuthConfig config *AuthConfig
provider *oidc.Provider
oauth2 *oauth2.Config
} }
// NewAuthService creates a new authentication service // NewAuthService creates a new authentication service
func NewAuthService(config *AuthConfig) *AuthService { func NewAuthService(config *AuthConfig) (*AuthService, error) {
return &AuthService{config: config} 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 // ExtractUserFromRequest extracts user information from HTTP request
@@ -74,7 +131,7 @@ func (a *AuthService) parseMockToken(token string) *UserInfo {
return &UserInfo{ return &UserInfo{
ID: parts[1], // user part ID: parts[1], // user part
Email: fmt.Sprintf("%s@localhost", parts[1]), Email: fmt.Sprintf("%s@localhost", parts[1]),
Name: strings.Title(parts[1]), Name: strings.ToTitle(parts[1][:1]) + parts[1][1:],
Provider: "insertr-dev", Provider: "insertr-dev",
} }
} }
@@ -84,6 +141,60 @@ func (a *AuthService) parseMockToken(token string) *UserInfo {
// parseJWT parses and validates a real JWT token // parseJWT parses and validates a real JWT token
func (a *AuthService) parseJWT(tokenString string) (*UserInfo, error) { 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 // Parse the token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Validate signing method // Validate signing method
@@ -167,6 +278,28 @@ func (a *AuthService) ValidateToken(tokenString string) error {
return err 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 // RefreshToken creates a new token with extended expiration
func (a *AuthService) RefreshToken(tokenString string) (string, error) { func (a *AuthService) RefreshToken(tokenString string) (string, error) {
userInfo, err := a.parseJWT(tokenString) userInfo, err := a.parseJWT(tokenString)
@@ -216,16 +349,10 @@ func (a *AuthService) RequireAuth(next http.Handler) http.Handler {
// HandleOAuthLogin initiates OAuth flow // HandleOAuthLogin initiates OAuth flow
func (a *AuthService) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) { func (a *AuthService) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider") // Handle mock authentication in dev mode
if provider == "" { if a.config.DevMode && a.config.Provider == "mock" {
provider = "google"
}
// TODO: Implement OAuth initiation
// For now, return mock success in dev mode
if a.config.DevMode {
response := map[string]interface{}{ response := map[string]interface{}{
"message": "OAuth login not yet implemented", "message": "Mock OAuth login",
"redirect_url": "/auth/callback?code=mock_code&state=mock_state", "redirect_url": "/auth/callback?code=mock_code&state=mock_state",
"dev_mode": true, "dev_mode": true,
} }
@@ -234,17 +361,64 @@ func (a *AuthService) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
return 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 // HandleOAuthCallback handles OAuth provider callback
func (a *AuthService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { func (a *AuthService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code") 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 // Handle mock authentication in dev mode
// For now, return mock token in dev mode if a.config.DevMode && a.config.Provider == "mock" && code != "" {
if a.config.DevMode && code != "" {
mockToken, err := a.CreateMockJWT("dev-user", "dev@localhost", "Development User") mockToken, err := a.CreateMockJWT("dev-user", "dev@localhost", "Development User")
if err != nil { if err != nil {
http.Error(w, "Failed to create mock token", http.StatusInternalServerError) 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 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)
} }

View File

@@ -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 // NewDefaultEnhancer creates an enhancer with default configuration
func NewDefaultEnhancer(client engine.ContentClient, siteID string) *Enhancer { func NewDefaultEnhancer(client engine.ContentClient, siteID string) *Enhancer {
defaultConfig := EnhancementConfig{ defaultConfig := EnhancementConfig{

View File

@@ -26,6 +26,8 @@ type SiteManager struct {
enhancer *Enhancer enhancer *Enhancer
mutex sync.RWMutex mutex sync.RWMutex
devMode bool devMode bool
contentClient engine.ContentClient
authProvider *engine.AuthProvider
} }
// NewSiteManager creates a new site manager // NewSiteManager creates a new site manager
@@ -34,6 +36,21 @@ func NewSiteManager(contentClient engine.ContentClient, devMode bool) *SiteManag
sites: make(map[string]*SiteConfig), sites: make(map[string]*SiteConfig),
enhancer: NewDefaultEnhancer(contentClient, ""), // siteID will be set per operation enhancer: NewDefaultEnhancer(contentClient, ""), // siteID will be set per operation
devMode: devMode, 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) return fmt.Errorf("failed to create output directory %s: %w", outputPath, err)
} }
// Set site ID on enhancer // Create enhancer with auth provider for this operation
sm.enhancer.SetSiteID(siteID) 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 // 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) return fmt.Errorf("failed to enhance site %s: %w", siteID, err)
} }

View File

@@ -7,10 +7,16 @@ import (
"golang.org/x/net/html" "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 // ContentEngine is the unified content processing engine
type ContentEngine struct { type ContentEngine struct {
idGenerator *IDGenerator idGenerator *IDGenerator
client ContentClient client ContentClient
authProvider *AuthProvider
} }
// NewContentEngine creates a new content processing engine // NewContentEngine creates a new content processing engine
@@ -18,6 +24,19 @@ func NewContentEngine(client ContentClient) *ContentEngine {
return &ContentEngine{ return &ContentEngine{
idGenerator: NewIDGenerator(), idGenerator: NewIDGenerator(),
client: client, 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) // 5. Inject editor assets for enhancement mode (development)
if input.Mode == Enhancement { if input.Mode == Enhancement {
injector := NewInjector(e.client, input.SiteID) injector := NewInjectorWithAuth(e.client, input.SiteID, e.authProvider)
injector.InjectEditorAssets(doc, true, "") injector.InjectEditorAssets(doc, true, "")
} }

View File

@@ -13,6 +13,7 @@ type Injector struct {
client ContentClient client ContentClient
siteID string siteID string
mdProcessor *MarkdownProcessor mdProcessor *MarkdownProcessor
authProvider *AuthProvider
} }
// NewInjector creates a new content injector // NewInjector creates a new content injector
@@ -21,6 +22,20 @@ func NewInjector(client ContentClient, siteID string) *Injector {
client: client, client: client,
siteID: siteID, siteID: siteID,
mdProcessor: NewMarkdownProcessor(), 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 // 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(`<link rel="stylesheet" href="http://localhost:8080/insertr.css" data-insertr-injected="true"> insertrHTML := fmt.Sprintf(`<link rel="stylesheet" href="http://localhost:8080/insertr.css" data-insertr-injected="true">
<script src="http://localhost:8080/insertr.js" data-insertr-injected="true" data-site-id="%s" data-api-endpoint="http://localhost:8080/api/content" data-mock-auth="true" data-debug="true"></script>`, i.siteID) <script src="http://localhost:8080/insertr.js" data-insertr-injected="true" data-site-id="%s" data-api-endpoint="http://localhost:8080/api/content" data-auth-provider="%s" data-debug="true"></script>`, i.siteID, authProvider)
// Parse and inject the CSS and script elements // Parse and inject the CSS and script elements
insertrDoc, err := html.Parse(strings.NewReader(insertrHTML)) insertrDoc, err := html.Parse(strings.NewReader(insertrHTML))

View File

@@ -11,11 +11,17 @@
export class InsertrAuth { export class InsertrAuth {
constructor(options = {}) { constructor(options = {}) {
this.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 hideGatesAfterAuth: options.hideGatesAfterAuth === true, // Keep gates visible by default
...options ...options
}; };
// Set mockAuth based on authProvider if not explicitly set
if (options.authProvider && !options.hasOwnProperty('mockAuth')) {
this.options.mockAuth = options.authProvider === 'mock';
}
// Authentication state // Authentication state
this.state = { this.state = {
isAuthenticated: false, isAuthenticated: false,
@@ -169,8 +175,20 @@ export class InsertrAuth {
* Perform OAuth authentication flow * Perform OAuth authentication flow
*/ */
async performOAuthFlow() { async performOAuthFlow() {
// In development, simulate OAuth flow // Handle different authentication providers
if (this.options.mockAuth) { 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...'); console.log('🔐 Mock OAuth: Simulating authentication...');
// Simulate network delay // Simulate network delay
@@ -181,16 +199,105 @@ export class InsertrAuth {
this.state.currentUser = { this.state.currentUser = {
name: 'Site Owner', name: 'Site Owner',
email: 'owner@example.com', email: 'owner@example.com',
role: 'admin' role: 'admin',
provider: 'mock'
}; };
console.log('✅ Mock OAuth: Authentication successful'); console.log('✅ Mock OAuth: Authentication successful');
return;
} }
// TODO: In production, implement real OAuth flow /**
// This would redirect to OAuth provider, handle callback, etc. * Perform Authentik OIDC authentication
throw new Error('Production OAuth not implemented yet'); */
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');
} }
/** /**