big juicy update

This commit is contained in:
pavel 2026-05-12 19:08:00 +02:00
commit b58086263b
10 changed files with 106 additions and 427 deletions

View file

@ -3,16 +3,9 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/skills_market?sslmode=d
STORAGE_DIR=uploads STORAGE_DIR=uploads
DEV_MODE=true DEV_MODE=true
OIDC_ISSUER=https://your-oidc-provider.example.com/ # Forward auth mode (DEV_MODE=false):
OIDC_AUDIENCE=your-api-audience # Deploy behind Authentik forward-auth and pass identity headers to this app.
OIDC_JWKS_URL=https://your-oidc-provider.example.com/.well-known/jwks.json # Expected headers include:
# - X-Authentik-Uid (required; fallback X-Forwarded-User)
# Required when DEV_MODE=false: # - X-Authentik-Email
OIDC_AUTH_URL=https://your-oidc-provider.example.com/authorize # - X-Authentik-Name (fallback X-Forwarded-Preferred-Username)
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=

View file

@ -6,7 +6,7 @@ Go + Postgres marketplace where authenticated users can upload LLM skill files,
- Backend: Go (`go run .`) - Backend: Go (`go run .`)
- Frontend: vanilla HTML/CSS/JS - Frontend: vanilla HTML/CSS/JS
- Database: Postgres - Database: Postgres
- Auth: external OIDC JWT validation via JWKS - Auth: Authentik forward auth (identity headers)
- Config: environment variables with `.env` support - Config: environment variables with `.env` support
## Run ## Run
@ -23,29 +23,24 @@ Go + Postgres marketplace where authenticated users can upload LLM skill files,
5. Open `http://localhost:8080`. 5. Open `http://localhost:8080`.
## Local dev mode (no auth setup) ## 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: In this mode, protected routes always authenticate as:
- `sub`: `demo-user-001` - `sub`: `demo-user-001`
- `email`: `demo@example.com` - `email`: `demo@example.com`
- `name`: `Demo User` - `name`: `Demo User`
OIDC variables are not required when `DEV_MODE=true`. ## Forward auth flow (production)
When `DEV_MODE=false`, deploy this app behind Authentik forward auth.
## Auth flow The proxy/auth layer must validate authentication and pass identity headers to the app.
When `DEV_MODE=false`, login is handled directly by the app: The app reads:
- `GET /auth/login` redirects to your OIDC provider - `X-Authentik-Uid` (required; fallback `X-Forwarded-User`)
- `GET /auth/callback` exchanges code for access token using `OIDC_CLIENT_ID` + `OIDC_CLIENT_SECRET` - `X-Authentik-Email`
- Access token is stored in secure HttpOnly session cookie - `X-Authentik-Name` (fallback `X-Forwarded-Preferred-Username`)
- Protected routes validate that token via JWKS
- `GET /auth/logout` clears the local session (and optionally redirects to provider logout URL)
## Key routes ## Key routes
- `GET /dashboard`: upload form - `GET /dashboard`: upload form
- `GET /skills`: browse listings and buy - `GET /skills`: browse listings and buy
- `GET /my-skills`: purchased skills and copy/download access - `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/upload`: upload and list a skill
- `POST /api/purchase`: buy a skill - `POST /api/purchase`: buy a skill
- `GET /api/my-skills`: owned purchased list - `GET /api/my-skills`: owned purchased list

397
auth.go
View file

@ -2,33 +2,15 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"crypto/rsa"
"database/sql" "database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http" "net/http"
"net/url"
"strings" "strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
) )
type contextKey string type contextKey string
const userContextKey contextKey = "auth_user" const userContextKey contextKey = "auth_user"
const (
sessionCookieName = "session_token"
stateCookieName = "oidc_state"
)
type User struct { type User struct {
Sub string Sub string
Email string Email string
@ -36,176 +18,24 @@ type User struct {
} }
type Auth struct { type Auth struct {
issuer string devMode bool
audience string db *sql.DB
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
} }
func NewAuth(cfg Config) (*Auth, error) { func NewAuth(cfg Config) (*Auth, error) {
a := &Auth{ return &Auth{devMode: cfg.DevMode}, nil
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
} }
func (a *Auth) WithDB(db *sql.DB) { func (a *Auth) WithDB(db *sql.DB) {
a.db = 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 { func (a *Auth) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ( user, ok := a.userFromRequest(r)
user User if !ok {
err error http.Error(w, "unauthorized", http.StatusUnauthorized)
) return
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
}
} }
if a.db != nil { 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 { func MustUserFromContext(ctx context.Context) User {
v := ctx.Value(userContextKey) v := ctx.Value(userContextKey)
if v == nil { if v == nil {
@ -232,189 +89,3 @@ func MustUserFromContext(ctx context.Context) User {
} }
return 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
}

View file

@ -9,49 +9,25 @@ import (
) )
type Config struct { type Config struct {
Port string Port string
DatabaseURL string DatabaseURL string
StorageDir string StorageDir string
DevMode bool DevMode bool
OIDCIssuer string
OIDCAudience string
OIDCJWKSURL string
OIDCAuthURL string
OIDCTokenURL string
OIDCClientID string
OIDCClientSecret string
OIDCRedirect string
OIDCLogoutURL string
} }
func LoadConfig() (Config, error) { func LoadConfig() (Config, error) {
_ = godotenv.Load() _ = godotenv.Load()
cfg := Config{ cfg := Config{
Port: envOrDefault("PORT", "8080"), Port: envOrDefault("PORT", "8080"),
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
StorageDir: envOrDefault("STORAGE_DIR", "uploads"), StorageDir: envOrDefault("STORAGE_DIR", "uploads"),
DevMode: strings.EqualFold(envOrDefault("DEV_MODE", "false"), "true"), 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"),
} }
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {
return Config{}, fmt.Errorf("DATABASE_URL is required") 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 return cfg, nil
} }

54
main.go
View file

@ -8,6 +8,7 @@ import (
"html/template" "html/template"
"io" "io"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -90,9 +91,6 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) 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.Handle("/uploads/", app.auth.RequireAuth(http.HandlerFunc(app.handleDownload)))
mux.HandleFunc("/", app.handleHome) mux.HandleFunc("/", app.handleHome)
mux.Handle("/dashboard", app.auth.RequireAuth(http.HandlerFunc(app.handleDashboard))) 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 { func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now() start := time.Now()
next.ServeHTTP(w, r) lrw := &loggingResponseWriter{ResponseWriter: w, status: http.StatusOK}
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) 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) { func (a *App) handleHome(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { if r.URL.Path != "/" {
http.NotFound(w, r) http.NotFound(w, r)

BIN
skills2 Executable file

Binary file not shown.

View file

@ -15,7 +15,7 @@
<div class="actions"> <div class="actions">
<a class="btn primary" href="/skills">Browse Skills</a> <a class="btn primary" href="/skills">Browse Skills</a>
<a class="btn" href="/my-skills">My Purchases</a> <a class="btn" href="/my-skills">My Purchases</a>
<a class="btn" href="/auth/logout">Logout</a> <a class="btn" href="/">Logout</a>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel">

View file

@ -13,7 +13,7 @@
<h1>Sell, buy, and reuse high quality LLM skills</h1> <h1>Sell, buy, and reuse high quality LLM skills</h1>
<p class="lead">Upload your prompt packages, automations, and reusable skill bundles. Other users can purchase them and copy directly into their own workspace.</p> <p class="lead">Upload your prompt packages, automations, and reusable skill bundles. Other users can purchase them and copy directly into their own workspace.</p>
<div class="actions"> <div class="actions">
<a class="btn primary" href="/auth/login">Sign In</a> <a class="btn primary" href="/dashboard">Open Dashboard</a>
<a class="btn" href="/dashboard">Open Dashboard</a> <a class="btn" href="/dashboard">Open Dashboard</a>
</div> </div>
{{if .DevMode}} {{if .DevMode}}

View file

@ -14,7 +14,7 @@
<div class="actions"> <div class="actions">
<a class="btn" href="/dashboard">Back to Dashboard</a> <a class="btn" href="/dashboard">Back to Dashboard</a>
<a class="btn" href="/skills">Browse More Skills</a> <a class="btn" href="/skills">Browse More Skills</a>
<a class="btn" href="/auth/logout">Logout</a> <a class="btn" href="/">Logout</a>
</div> </div>
</section> </section>
<section> <section>

View file

@ -14,7 +14,7 @@
<div class="actions"> <div class="actions">
<a class="btn" href="/dashboard">Back to Dashboard</a> <a class="btn" href="/dashboard">Back to Dashboard</a>
<a class="btn" href="/my-skills">My Purchases</a> <a class="btn" href="/my-skills">My Purchases</a>
<a class="btn" href="/auth/logout">Logout</a> <a class="btn" href="/">Logout</a>
</div> </div>
</section> </section>
<section> <section>