big juicy update
This commit is contained in:
parent
8fa8d04427
commit
b58086263b
10 changed files with 106 additions and 427 deletions
19
.env.example
19
.env.example
|
|
@ -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=
|
|
||||||
|
|
|
||||||
23
README.md
23
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
391
auth.go
391
auth.go
|
|
@ -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,177 +18,25 @@ type User struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
issuer string
|
|
||||||
audience string
|
|
||||||
jwksURL string
|
|
||||||
authURL string
|
|
||||||
tokenURL string
|
|
||||||
clientID string
|
|
||||||
clientSecret string
|
|
||||||
redirectURL string
|
|
||||||
logoutURL string
|
|
||||||
devMode bool
|
devMode bool
|
||||||
db *sql.DB
|
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)
|
||||||
)
|
|
||||||
|
|
||||||
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
|
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 {
|
||||||
_, _ = a.db.ExecContext(r.Context(), `
|
_, _ = a.db.ExecContext(r.Context(), `
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
24
config.go
24
config.go
|
|
@ -13,15 +13,6 @@ type Config struct {
|
||||||
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) {
|
||||||
|
|
@ -32,26 +23,11 @@ func LoadConfig() (Config, error) {
|
||||||
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
54
main.go
|
|
@ -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
BIN
skills2
Executable file
Binary file not shown.
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue