diff --git a/.env.example b/.env.example index 1b97885..538e155 100644 --- a/.env.example +++ b/.env.example @@ -3,16 +3,9 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/skills_market?sslmode=d STORAGE_DIR=uploads DEV_MODE=true -OIDC_ISSUER=https://your-oidc-provider.example.com/ -OIDC_AUDIENCE=your-api-audience -OIDC_JWKS_URL=https://your-oidc-provider.example.com/.well-known/jwks.json - -# Required when DEV_MODE=false: -OIDC_AUTH_URL=https://your-oidc-provider.example.com/authorize -OIDC_TOKEN_URL=https://your-oidc-provider.example.com/oauth/token -OIDC_CLIENT_ID=your-client-id -OIDC_CLIENT_SECRET=your-client-secret -OIDC_REDIRECT_URL=http://localhost:8080/auth/callback - -# Optional: -OIDC_LOGOUT_URL= +# Forward auth mode (DEV_MODE=false): +# Deploy behind Authentik forward-auth and pass identity headers to this app. +# Expected headers include: +# - X-Authentik-Uid (required; fallback X-Forwarded-User) +# - X-Authentik-Email +# - X-Authentik-Name (fallback X-Forwarded-Preferred-Username) diff --git a/README.md b/README.md index 0e77038..293df90 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Go + Postgres marketplace where authenticated users can upload LLM skill files, - Backend: Go (`go run .`) - Frontend: vanilla HTML/CSS/JS - Database: Postgres -- Auth: external OIDC JWT validation via JWKS +- Auth: Authentik forward auth (identity headers) - Config: environment variables with `.env` support ## Run @@ -23,29 +23,24 @@ Go + Postgres marketplace where authenticated users can upload LLM skill files, 5. Open `http://localhost:8080`. ## Local dev mode (no auth setup) -Set `DEV_MODE=true` in `.env` (enabled by default in `.env.example`). +Set `DEV_MODE=true` in `.env`. In this mode, protected routes always authenticate as: - `sub`: `demo-user-001` - `email`: `demo@example.com` - `name`: `Demo User` -OIDC variables are not required when `DEV_MODE=true`. - -## Auth flow -When `DEV_MODE=false`, login is handled directly by the app: -- `GET /auth/login` redirects to your OIDC provider -- `GET /auth/callback` exchanges code for access token using `OIDC_CLIENT_ID` + `OIDC_CLIENT_SECRET` -- Access token is stored in secure HttpOnly session cookie -- Protected routes validate that token via JWKS -- `GET /auth/logout` clears the local session (and optionally redirects to provider logout URL) +## Forward auth flow (production) +When `DEV_MODE=false`, deploy this app behind Authentik forward auth. +The proxy/auth layer must validate authentication and pass identity headers to the app. +The app reads: +- `X-Authentik-Uid` (required; fallback `X-Forwarded-User`) +- `X-Authentik-Email` +- `X-Authentik-Name` (fallback `X-Forwarded-Preferred-Username`) ## Key routes - `GET /dashboard`: upload form - `GET /skills`: browse listings and buy - `GET /my-skills`: purchased skills and copy/download access -- `GET /auth/login`: start OIDC login -- `GET /auth/callback`: OIDC callback -- `GET /auth/logout`: logout - `POST /api/upload`: upload and list a skill - `POST /api/purchase`: buy a skill - `GET /api/my-skills`: owned purchased list diff --git a/auth.go b/auth.go index 2c615d4..82b44cc 100644 --- a/auth.go +++ b/auth.go @@ -2,33 +2,15 @@ package main import ( "context" - "crypto/rand" - "crypto/rsa" "database/sql" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "math/big" "net/http" - "net/url" "strings" - "sync" - "time" - - "github.com/golang-jwt/jwt/v5" ) type contextKey string const userContextKey contextKey = "auth_user" -const ( - sessionCookieName = "session_token" - stateCookieName = "oidc_state" -) - type User struct { Sub string Email string @@ -36,176 +18,24 @@ type User struct { } type Auth struct { - issuer string - audience string - jwksURL string - authURL string - tokenURL string - clientID string - clientSecret string - redirectURL string - logoutURL string - devMode bool - db *sql.DB - - mu sync.RWMutex - keyByKid map[string]*rsa.PublicKey - lastSyncAt time.Time + devMode bool + db *sql.DB } func NewAuth(cfg Config) (*Auth, error) { - a := &Auth{ - issuer: cfg.OIDCIssuer, - audience: cfg.OIDCAudience, - jwksURL: cfg.OIDCJWKSURL, - authURL: cfg.OIDCAuthURL, - tokenURL: cfg.OIDCTokenURL, - clientID: cfg.OIDCClientID, - clientSecret: cfg.OIDCClientSecret, - redirectURL: cfg.OIDCRedirect, - logoutURL: cfg.OIDCLogoutURL, - devMode: cfg.DevMode, - keyByKid: map[string]*rsa.PublicKey{}, - lastSyncAt: time.Time{}, - } - if a.devMode { - return a, nil - } - if err := a.refreshKeys(context.Background()); err != nil { - return nil, err - } - return a, nil + return &Auth{devMode: cfg.DevMode}, nil } func (a *Auth) WithDB(db *sql.DB) { a.db = db } -func (a *Auth) HandleLogin(w http.ResponseWriter, r *http.Request) { - if a.devMode { - http.Redirect(w, r, "/dashboard", http.StatusFound) - return - } - state, err := randomToken(24) - if err != nil { - http.Error(w, "failed to initialize login", http.StatusInternalServerError) - return - } - http.SetCookie(w, &http.Cookie{ - Name: stateCookieName, - Value: state, - Path: "/", - HttpOnly: true, - Secure: false, - SameSite: http.SameSiteLaxMode, - MaxAge: 600, - }) - - q := url.Values{} - q.Set("client_id", a.clientID) - q.Set("response_type", "code") - q.Set("scope", "openid profile email") - q.Set("redirect_uri", a.redirectURL) - q.Set("state", state) - http.Redirect(w, r, a.authURL+"?"+q.Encode(), http.StatusFound) -} - -func (a *Auth) HandleCallback(w http.ResponseWriter, r *http.Request) { - if a.devMode { - http.Redirect(w, r, "/dashboard", http.StatusFound) - return - } - - stateCookie, err := r.Cookie(stateCookieName) - if err != nil { - http.Error(w, "missing state cookie", http.StatusBadRequest) - return - } - stateParam := r.URL.Query().Get("state") - if stateParam == "" || stateParam != stateCookie.Value { - http.Error(w, "invalid state", http.StatusBadRequest) - return - } - - code := r.URL.Query().Get("code") - if code == "" { - http.Error(w, "missing authorization code", http.StatusBadRequest) - return - } - - token, err := a.exchangeCode(r.Context(), code) - if err != nil { - http.Error(w, "token exchange failed", http.StatusUnauthorized) - return - } - - if _, err := a.userFromToken(token); err != nil { - http.Error(w, "invalid access token", http.StatusUnauthorized) - return - } - - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: token, - Path: "/", - HttpOnly: true, - Secure: false, - SameSite: http.SameSiteLaxMode, - MaxAge: 3600, - }) - http.SetCookie(w, &http.Cookie{ - Name: stateCookieName, - Value: "", - Path: "/", - HttpOnly: true, - Secure: false, - SameSite: http.SameSiteLaxMode, - MaxAge: -1, - }) - http.Redirect(w, r, "/dashboard", http.StatusFound) -} - -func (a *Auth) HandleLogout(w http.ResponseWriter, r *http.Request) { - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: "", - Path: "/", - HttpOnly: true, - Secure: false, - SameSite: http.SameSiteLaxMode, - MaxAge: -1, - }) - if !a.devMode && a.logoutURL != "" { - http.Redirect(w, r, a.logoutURL, http.StatusFound) - return - } - http.Redirect(w, r, "/", http.StatusFound) -} - func (a *Auth) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var ( - user User - err error - ) - - if a.devMode { - user = User{ - Sub: "demo-user-001", - Email: "demo@example.com", - Name: "Demo User", - } - } else { - cookie, cErr := r.Cookie(sessionCookieName) - if cErr != nil || strings.TrimSpace(cookie.Value) == "" { - http.Redirect(w, r, "/", http.StatusFound) - return - } - user, err = a.userFromToken(cookie.Value) - if err != nil { - http.Redirect(w, r, "/", http.StatusFound) - return - } + user, ok := a.userFromRequest(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return } if a.db != nil { @@ -222,6 +52,33 @@ func (a *Auth) RequireAuth(next http.Handler) http.Handler { }) } +func (a *Auth) userFromRequest(r *http.Request) (User, bool) { + if a.devMode { + return User{Sub: "demo-user-001", Email: "demo@example.com", Name: "Demo User"}, true + } + + sub := strings.TrimSpace(r.Header.Get("X-Authentik-Uid")) + if sub == "" { + sub = strings.TrimSpace(r.Header.Get("X-Forwarded-User")) + } + email := strings.TrimSpace(r.Header.Get("X-Authentik-Email")) + name := strings.TrimSpace(r.Header.Get("X-Authentik-Name")) + if name == "" { + name = strings.TrimSpace(r.Header.Get("X-Forwarded-Preferred-Username")) + } + + if sub == "" { + return User{}, false + } + if email == "" { + email = sub + } + if name == "" { + name = email + } + return User{Sub: sub, Email: email, Name: name}, true +} + func MustUserFromContext(ctx context.Context) User { v := ctx.Value(userContextKey) if v == nil { @@ -232,189 +89,3 @@ func MustUserFromContext(ctx context.Context) User { } return User{} } - -func (a *Auth) exchangeCode(ctx context.Context, code string) (string, error) { - form := url.Values{} - form.Set("grant_type", "authorization_code") - form.Set("code", code) - form.Set("client_id", a.clientID) - form.Set("client_secret", a.clientSecret) - form.Set("redirect_uri", a.redirectURL) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.tokenURL, strings.NewReader(form.Encode())) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return "", fmt.Errorf("token endpoint failed: %s %s", resp.Status, string(body)) - } - - var tr struct { - AccessToken string `json:"access_token"` - } - if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { - return "", err - } - if tr.AccessToken == "" { - return "", errors.New("missing access_token") - } - return tr.AccessToken, nil -} - -func (a *Auth) userFromToken(token string) (User, error) { - parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256"})) - parsed, err := parser.Parse(token, func(t *jwt.Token) (any, error) { - kid, _ := t.Header["kid"].(string) - if kid == "" { - return nil, errors.New("missing kid") - } - key, err := a.lookupKey(context.Background(), kid) - if err != nil { - return nil, err - } - return key, nil - }) - if err != nil || !parsed.Valid { - return User{}, errors.New("invalid token") - } - - claims, ok := parsed.Claims.(jwt.MapClaims) - if !ok { - return User{}, errors.New("invalid claims") - } - if iss, _ := claims["iss"].(string); iss != a.issuer { - return User{}, errors.New("invalid issuer") - } - if !audienceMatches(claims["aud"], a.audience) { - return User{}, errors.New("invalid audience") - } - - sub, _ := claims["sub"].(string) - email, _ := claims["email"].(string) - name, _ := claims["name"].(string) - if sub == "" { - return User{}, errors.New("missing sub") - } - return User{Sub: sub, Email: email, Name: name}, nil -} - -func (a *Auth) lookupKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { - a.mu.RLock() - key := a.keyByKid[kid] - last := a.lastSyncAt - a.mu.RUnlock() - if key != nil { - return key, nil - } - if time.Since(last) > time.Minute { - if err := a.refreshKeys(ctx); err != nil { - return nil, err - } - a.mu.RLock() - defer a.mu.RUnlock() - if refreshed := a.keyByKid[kid]; refreshed != nil { - return refreshed, nil - } - } - return nil, fmt.Errorf("no key for kid %s", kid) -} - -type jwksDoc struct { - Keys []struct { - Kid string `json:"kid"` - Kty string `json:"kty"` - N string `json:"n"` - E string `json:"e"` - } `json:"keys"` -} - -func (a *Auth) refreshKeys(ctx context.Context) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.jwksURL, nil) - if err != nil { - return err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("jwks request failed: %s", resp.Status) - } - - var doc jwksDoc - if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { - return err - } - newKeys := map[string]*rsa.PublicKey{} - for _, k := range doc.Keys { - if k.Kty != "RSA" || k.Kid == "" || k.N == "" || k.E == "" { - continue - } - pub, err := rsaKeyFromJWK(k.N, k.E) - if err != nil { - continue - } - newKeys[k.Kid] = pub - } - if len(newKeys) == 0 { - return errors.New("no usable jwks keys") - } - a.mu.Lock() - a.keyByKid = newKeys - a.lastSyncAt = time.Now() - a.mu.Unlock() - return nil -} - -func rsaKeyFromJWK(nB64, eB64 string) (*rsa.PublicKey, error) { - nBytes, err := base64.RawURLEncoding.DecodeString(nB64) - if err != nil { - return nil, err - } - eBytes, err := base64.RawURLEncoding.DecodeString(eB64) - if err != nil { - return nil, err - } - n := new(big.Int).SetBytes(nBytes) - e := 0 - for _, b := range eBytes { - e = e<<8 + int(b) - } - if e == 0 { - return nil, errors.New("invalid exponent") - } - return &rsa.PublicKey{N: n, E: e}, nil -} - -func audienceMatches(audValue any, expected string) bool { - switch v := audValue.(type) { - case string: - return v == expected - case []any: - for _, item := range v { - s, ok := item.(string) - if ok && s == expected { - return true - } - } - } - return false -} - -func randomToken(n int) (string, error) { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b), nil -} - diff --git a/config.go b/config.go index e9282bb..18b74ad 100644 --- a/config.go +++ b/config.go @@ -9,49 +9,25 @@ import ( ) type Config struct { - Port string - DatabaseURL string - StorageDir string - DevMode bool - OIDCIssuer string - OIDCAudience string - OIDCJWKSURL string - OIDCAuthURL string - OIDCTokenURL string - OIDCClientID string - OIDCClientSecret string - OIDCRedirect string - OIDCLogoutURL string + Port string + DatabaseURL string + StorageDir string + DevMode bool } func LoadConfig() (Config, error) { _ = godotenv.Load() cfg := Config{ - Port: envOrDefault("PORT", "8080"), - DatabaseURL: os.Getenv("DATABASE_URL"), - StorageDir: envOrDefault("STORAGE_DIR", "uploads"), - DevMode: strings.EqualFold(envOrDefault("DEV_MODE", "false"), "true"), - OIDCIssuer: os.Getenv("OIDC_ISSUER"), - OIDCAudience: os.Getenv("OIDC_AUDIENCE"), - OIDCJWKSURL: os.Getenv("OIDC_JWKS_URL"), - OIDCAuthURL: os.Getenv("OIDC_AUTH_URL"), - OIDCTokenURL: os.Getenv("OIDC_TOKEN_URL"), - OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), - OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), - OIDCRedirect: os.Getenv("OIDC_REDIRECT_URL"), - OIDCLogoutURL: os.Getenv("OIDC_LOGOUT_URL"), + Port: envOrDefault("PORT", "8080"), + DatabaseURL: os.Getenv("DATABASE_URL"), + StorageDir: envOrDefault("STORAGE_DIR", "uploads"), + DevMode: strings.EqualFold(envOrDefault("DEV_MODE", "false"), "true"), } if cfg.DatabaseURL == "" { return Config{}, fmt.Errorf("DATABASE_URL is required") } - if !cfg.DevMode && (cfg.OIDCIssuer == "" || cfg.OIDCAudience == "" || cfg.OIDCJWKSURL == "") { - return Config{}, fmt.Errorf("OIDC_ISSUER, OIDC_AUDIENCE, and OIDC_JWKS_URL are required") - } - if !cfg.DevMode && (cfg.OIDCAuthURL == "" || cfg.OIDCTokenURL == "" || cfg.OIDCClientID == "" || cfg.OIDCClientSecret == "" || cfg.OIDCRedirect == "") { - return Config{}, fmt.Errorf("OIDC_AUTH_URL, OIDC_TOKEN_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URL are required when DEV_MODE=false") - } return cfg, nil } diff --git a/main.go b/main.go index c7f5094..b7fcb37 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "html/template" "io" "log" + "net" "net/http" "os" "path/filepath" @@ -90,9 +91,6 @@ func main() { mux := http.NewServeMux() mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) - mux.HandleFunc("/auth/login", app.auth.HandleLogin) - mux.HandleFunc("/auth/callback", app.auth.HandleCallback) - mux.HandleFunc("/auth/logout", app.auth.HandleLogout) mux.Handle("/uploads/", app.auth.RequireAuth(http.HandlerFunc(app.handleDownload))) mux.HandleFunc("/", app.handleHome) mux.Handle("/dashboard", app.auth.RequireAuth(http.HandlerFunc(app.handleDashboard))) @@ -127,11 +125,57 @@ func runMigrations(db *sql.DB) error { func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() - next.ServeHTTP(w, r) - log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) + lrw := &loggingResponseWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(lrw, r) + + ip := clientIP(r) + log.Printf("method=%s path=%s status=%d bytes=%d duration=%s ip=%s ua=%q", + r.Method, + r.URL.Path, + lrw.status, + lrw.bytes, + time.Since(start).Round(time.Millisecond), + ip, + r.UserAgent(), + ) }) } +// loggingResponseWriter captures status and response size for access logging. +type loggingResponseWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (w *loggingResponseWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +func (w *loggingResponseWriter) Write(p []byte) (int, error) { + n, err := w.ResponseWriter.Write(p) + w.bytes += n + return n, err +} + +func clientIP(r *http.Request) string { + if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" { + parts := strings.Split(xff, ",") + if len(parts) > 0 { + return strings.TrimSpace(parts[0]) + } + } + if xrip := strings.TrimSpace(r.Header.Get("X-Real-Ip")); xrip != "" { + return xrip + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil && host != "" { + return host + } + return r.RemoteAddr +} + func (a *App) handleHome(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) diff --git a/skills2 b/skills2 new file mode 100755 index 0000000..d3efa2a Binary files /dev/null and b/skills2 differ diff --git a/templates/dashboard.html b/templates/dashboard.html index d840bee..78f64fe 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -15,7 +15,7 @@
Browse Skills My Purchases - Logout + Logout
diff --git a/templates/home.html b/templates/home.html index 0390c09..0c9ecf8 100644 --- a/templates/home.html +++ b/templates/home.html @@ -13,7 +13,7 @@

Sell, buy, and reuse high quality LLM skills

Upload your prompt packages, automations, and reusable skill bundles. Other users can purchase them and copy directly into their own workspace.

- Sign In + Open Dashboard Open Dashboard
{{if .DevMode}} diff --git a/templates/my_skills.html b/templates/my_skills.html index 8540173..92fcc51 100644 --- a/templates/my_skills.html +++ b/templates/my_skills.html @@ -14,7 +14,7 @@
Back to Dashboard Browse More Skills - Logout + Logout
diff --git a/templates/skills.html b/templates/skills.html index d0004c3..af71f6a 100644 --- a/templates/skills.html +++ b/templates/skills.html @@ -14,7 +14,7 @@
Back to Dashboard My Purchases - Logout + Logout