diff --git a/.env.example b/.env.example index eeccdb9..1b97885 100644 --- a/.env.example +++ b/.env.example @@ -7,10 +7,12 @@ 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 -# Optional metadata for frontend OIDC integrations later: -OIDC_AUTH_URL= -OIDC_TOKEN_URL= -OIDC_CLIENT_ID= -OIDC_REDIRECT_URL= -OIDC_LOGOUT_URL= +# 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= diff --git a/README.md b/README.md index edf8717..0e77038 100644 --- a/README.md +++ b/README.md @@ -32,17 +32,20 @@ In this mode, protected routes always authenticate as: OIDC variables are not required when `DEV_MODE=true`. ## Auth flow -API and protected pages require `Authorization: Bearer ` when `DEV_MODE=false`. - -For quick local testing, set in browser console: -```js -localStorage.setItem("access_token", ""); -``` +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) ## 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 14ad172..2c615d4 100644 --- a/auth.go +++ b/auth.go @@ -2,14 +2,17 @@ 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" @@ -21,6 +24,11 @@ type contextKey string const userContextKey contextKey = "auth_user" +const ( + sessionCookieName = "session_token" + stateCookieName = "oidc_state" +) + type User struct { Sub string Email string @@ -28,11 +36,17 @@ type User struct { } type Auth struct { - issuer string - audience string - jwksURL string - devMode bool - db *sql.DB + 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 @@ -41,12 +55,18 @@ type Auth struct { func NewAuth(cfg Config) (*Auth, error) { a := &Auth{ - issuer: cfg.OIDCIssuer, - audience: cfg.OIDCAudience, - jwksURL: cfg.OIDCJWKSURL, - devMode: cfg.DevMode, - keyByKid: map[string]*rsa.PublicKey{}, - lastSyncAt: time.Time{}, + 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 @@ -61,12 +81,114 @@ 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", @@ -74,9 +196,14 @@ func (a *Auth) RequireAuth(next http.Handler) http.Handler { Name: "Demo User", } } else { - user, err = a.authenticateRequest(r) + 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.Error(w, "unauthorized", http.StatusUnauthorized) + http.Redirect(w, r, "/", http.StatusFound) return } } @@ -106,19 +233,50 @@ func MustUserFromContext(ctx context.Context) User { return User{} } -func (a *Auth) authenticateRequest(r *http.Request) (User, error) { - token := extractBearer(r.Header.Get("Authorization")) - if token == "" { - return User{}, errors.New("missing token") +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(r.Context(), kid) + key, err := a.lookupKey(context.Background(), kid) if err != nil { return nil, err } @@ -132,7 +290,6 @@ func (a *Auth) authenticateRequest(r *http.Request) (User, error) { if !ok { return User{}, errors.New("invalid claims") } - if iss, _ := claims["iss"].(string); iss != a.issuer { return User{}, errors.New("invalid issuer") } @@ -189,7 +346,6 @@ func (a *Auth) refreshKeys(ctx context.Context) error { return err } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { return fmt.Errorf("jwks request failed: %s", resp.Status) } @@ -212,7 +368,6 @@ func (a *Auth) refreshKeys(ctx context.Context) error { if len(newKeys) == 0 { return errors.New("no usable jwks keys") } - a.mu.Lock() a.keyByKid = newKeys a.lastSyncAt = time.Now() @@ -240,17 +395,6 @@ func rsaKeyFromJWK(nB64, eB64 string) (*rsa.PublicKey, error) { return &rsa.PublicKey{N: n, E: e}, nil } -func extractBearer(value string) string { - if value == "" { - return "" - } - parts := strings.SplitN(value, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - return "" - } - return strings.TrimSpace(parts[1]) -} - func audienceMatches(audValue any, expected string) bool { switch v := audValue.(type) { case string: @@ -265,3 +409,12 @@ func audienceMatches(audValue any, expected string) bool { } 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 4df253e..e9282bb 100644 --- a/config.go +++ b/config.go @@ -19,6 +19,7 @@ type Config struct { OIDCAuthURL string OIDCTokenURL string OIDCClientID string + OIDCClientSecret string OIDCRedirect string OIDCLogoutURL string } @@ -37,6 +38,7 @@ func LoadConfig() (Config, error) { 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"), } @@ -47,6 +49,9 @@ func LoadConfig() (Config, error) { 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 21586da..c7f5094 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ type App struct { auth *Auth templates *template.Template storageDir string + devMode bool } type Skill struct { @@ -83,11 +84,15 @@ func main() { auth: auth, templates: tmpl, storageDir: cfg.StorageDir, + devMode: cfg.DevMode, } app.auth.WithDB(db) 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))) @@ -133,7 +138,8 @@ func (a *App) handleHome(w http.ResponseWriter, r *http.Request) { return } a.render(w, "home.html", map[string]any{ - "Title": "LLM Skill Marketplace", + "Title": "LLM Skill Marketplace", + "DevMode": a.devMode, }) } diff --git a/static/app.js b/static/app.js index 194d6bc..83a5d06 100644 --- a/static/app.js +++ b/static/app.js @@ -1,24 +1,11 @@ (function () { - function getToken() { - return localStorage.getItem("access_token") || ""; - } - - function authHeaders(extra) { - var headers = extra || {}; - var token = getToken(); - if (token) { - headers.Authorization = "Bearer " + token; - } - return headers; - } - async function loadSkills() { var grid = document.getElementById("skill-grid"); if (!grid) return; - var res = await fetch("/api/skills", { headers: authHeaders() }); + var res = await fetch("/api/skills", { credentials: "include" }); if (!res.ok) { - grid.innerHTML = '

Set `access_token` in localStorage with a valid OIDC JWT.

'; + grid.innerHTML = '

Sign in first to browse skills.

'; return; } @@ -49,7 +36,8 @@ var res = await fetch("/api/purchase", { method: "POST", - headers: authHeaders({ "Content-Type": "application/x-www-form-urlencoded" }), + credentials: "include", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString() }); @@ -64,9 +52,9 @@ var grid = document.getElementById("my-skill-grid"); if (!grid) return; - var res = await fetch("/api/my-skills", { headers: authHeaders() }); + var res = await fetch("/api/my-skills", { credentials: "include" }); if (!res.ok) { - grid.innerHTML = '

Set `access_token` in localStorage with a valid OIDC JWT.

'; + grid.innerHTML = '

Sign in first to view your purchased skills.

'; return; } @@ -107,7 +95,7 @@ var data = new FormData(form); var res = await fetch("/api/upload", { method: "POST", - headers: authHeaders(), + credentials: "include", body: data }); if (!res.ok) { @@ -132,4 +120,3 @@ loadSkills(); loadMySkills(); })(); - diff --git a/templates/dashboard.html b/templates/dashboard.html index 7c6d263..d840bee 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -15,6 +15,7 @@
@@ -32,4 +33,3 @@ - diff --git a/templates/home.html b/templates/home.html index 5a0a1c4..0390c09 100644 --- a/templates/home.html +++ b/templates/home.html @@ -13,11 +13,15 @@

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.

-

Authentication uses your external OIDC provider token via Authorization header.

+ {{if .DevMode}} +

Dev mode is active. Authentication uses the demo user automatically.

+ {{else}} +

Authentication is handled by your OIDC provider using secure session cookies.

+ {{end}}
- diff --git a/templates/my_skills.html b/templates/my_skills.html index 4922194..8540173 100644 --- a/templates/my_skills.html +++ b/templates/my_skills.html @@ -14,6 +14,7 @@
@@ -23,4 +24,3 @@ - diff --git a/templates/skills.html b/templates/skills.html index c65feb8..d0004c3 100644 --- a/templates/skills.html +++ b/templates/skills.html @@ -14,6 +14,7 @@
@@ -23,4 +24,3 @@ -