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

View file

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

397
auth.go
View file

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

View file

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

54
main.go
View file

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

BIN
skills2 Executable file

Binary file not shown.

View file

@ -15,7 +15,7 @@
<div class="actions">
<a class="btn primary" href="/skills">Browse Skills</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>
</section>
<section class="panel">

View file

@ -13,7 +13,7 @@
<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>
<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>
</div>
{{if .DevMode}}

View file

@ -14,7 +14,7 @@
<div class="actions">
<a class="btn" href="/dashboard">Back to Dashboard</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>
</section>
<section>

View file

@ -14,7 +14,7 @@
<div class="actions">
<a class="btn" href="/dashboard">Back to Dashboard</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>
</section>
<section>