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

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

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
func NewDefaultEnhancer(client engine.ContentClient, siteID string) *Enhancer {
defaultConfig := EnhancementConfig{

View File

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

View File

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

View File

@@ -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(`<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
insertrDoc, err := html.Parse(strings.NewReader(insertrHTML))