diff --git a/README.md b/README.md index 3a55e94..59ca1a3 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,4 @@ The public server starts on `:8080` and the admin server on `:8081`. | [User Guide](docs/user-guide.md) | Registration, login, tickets, comments, email notifications | | [Admin Guide](docs/admin-guide.md) | Dashboard, user/ticket/repo management, Tailscale auth | | [Forgejo Integration](docs/forgejo-integration.md) | Issue sync, comment sync, webhooks, auto-close flow | +| [SSO Integration](docs/SSO_INTEGRATION.md) | JWT SSO with Ed25519 for auto-provisioning users from external apps | diff --git a/docs/SSO_INTEGRATION.md b/docs/SSO_INTEGRATION.md new file mode 100644 index 0000000..d548310 --- /dev/null +++ b/docs/SSO_INTEGRATION.md @@ -0,0 +1,151 @@ +# SSO Integration Guide (Go) + +## Overview + +Users of your application can be seamlessly redirected into the tickets system — auto-provisioned, auto-approved, and assigned to the relevant repo — via a single redirect with a signed JWT. + +Your backend signs a JWT with an Ed25519 private key. The tickets system stores the corresponding public key per repo and verifies incoming tokens. + +## Flow + +1. User is logged into your application +2. Your backend signs a JWT: `{email, name, iat, exp}` with Ed25519 private key +3. User is redirected to `https:///sso/?token=` +4. Tickets system verifies JWT, creates/finds user, assigns to repo, creates session +5. User lands on `/tickets/new` — fully authenticated, no registration required + +## Setup + +### 1. Generate an Ed25519 keypair + +```bash +openssl genpkey -algorithm ed25519 -out sso_private.pem +openssl pkey -in sso_private.pem -pubout -out sso_public.pem +``` + +### 2. Give the public key to the tickets admin + +Send `sso_public.pem` to the admin. They paste it into the repo's admin settings. They'll provide you with: + +- **Repo slug** — the URL path component (e.g. `billing-app`) +- **Base URL** — the tickets system host (e.g. `https://tickets.example.com`) + +The full SSO URL is: `https://tickets.example.com/sso/billing-app` + +### 3. Keep the private key secret + +Store `sso_private.pem` in your application's backend. Never expose it to the client. + +## Implementation + +### Load the private key at startup + +```go +import ( + "crypto/ed25519" + "crypto/x509" + "encoding/pem" + "os" +) + +func loadSSOPrivateKey(path string) (ed25519.PrivateKey, error) { + keyPEM, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + block, _ := pem.Decode(keyPEM) + if block == nil { + return nil, fmt.Errorf("no PEM block found in %s", path) + } + + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + + key, ok := parsed.(ed25519.PrivateKey) + if !ok { + return nil, fmt.Errorf("key is not Ed25519") + } + + return key, nil +} +``` + +### Sign a token and redirect + +```go +import ( + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// ssoPrivateKey is loaded once at startup via loadSSOPrivateKey() +// ssoBaseURL is e.g. "https://tickets.example.com" +// ssoRepoSlug is e.g. "billing-app" + +func handleReportBug(w http.ResponseWriter, r *http.Request) { + // Get current user from your own auth system + user := getCurrentUser(r) + + now := time.Now() + claims := jwt.MapClaims{ + "email": user.Email, + "name": user.Name, + "iat": now.Unix(), + "exp": now.Add(5 * time.Minute).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + signed, err := token.SignedString(ssoPrivateKey) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + redirectURL := ssoBaseURL + "/sso/" + ssoRepoSlug + "?token=" + signed + http.Redirect(w, r, redirectURL, http.StatusSeeOther) +} +``` + +## JWT Format + +| Claim | Type | Required | Description | +|---------|--------|----------|------------------------------------| +| `email` | string | yes | User's email address | +| `name` | string | yes | User's display name | +| `iat` | number | yes | Issued-at timestamp (unix seconds) | +| `exp` | number | yes | Expiration timestamp (max 5 min) | + +- **Algorithm must be EdDSA** (Ed25519) — the server rejects all other algorithms +- **`exp` should be at most 5 minutes** from `iat` — shorter is better +- `email` is the unique user identifier — lowercased and trimmed server-side +- `name` updates the user's display name on each SSO login if changed + +Example payload: +```json +{ + "email": "alice@example.com", + "name": "Alice Smith", + "iat": 1700000000, + "exp": 1700000300 +} +``` + +## Error Responses + +| Status | Meaning | +|--------|----------------------------------------------------------------| +| 400 | Missing token, missing claims, or SSO not configured for repo | +| 401 | Invalid signature, wrong algorithm, or expired token | +| 404 | Repo slug not found or repo is inactive | +| 500 | Server-side configuration error (bad PEM key in admin) | + +## Dependency + +```bash +go get github.com/golang-jwt/jwt/v5 +``` diff --git a/docs/admin-guide.md b/docs/admin-guide.md index f30d330..d8c9e0d 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -80,6 +80,7 @@ Repos map customer-facing "products" to Forgejo repositories. Each repo defines | Forgejo Repo | Name of the Forgejo repository | | Webhook Secret | Shared secret for HMAC-SHA256 webhook signature verification | | Active | Whether this product appears in the customer ticket creation form | +| SSO Public Key | Optional PEM-encoded Ed25519 public key to enable [JWT SSO](./SSO_INTEGRATION.md) for this repo | ### Edit Repo @@ -96,3 +97,13 @@ Each repo's webhook URL follows the pattern: ``` This URL should be configured in the Forgejo repository's webhook settings. See [Forgejo Integration](./forgejo-integration.md#webhook-setup) for detailed setup instructions. + +### SSO + +Each repo can optionally have an Ed25519 public key configured for JWT-based single sign-on. When set, external applications can redirect authenticated users to: + +``` +{BASE_URL}/sso/{slug}?token={jwt} +``` + +The user is auto-provisioned (verified, approved, assigned to the repo) and redirected to the new ticket form. See [SSO Integration](./SSO_INTEGRATION.md) for the full integration guide. diff --git a/docs/architecture.md b/docs/architecture.md index dff050a..c9885f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,6 +41,7 @@ internal/ oauth.go # OAuth login/callback for Google, Microsoft, Apple tickets.go # Ticket list, create, detail, add comment webhook.go # Forgejo webhook receiver (issue close -> ticket close) + sso.go # JWT SSO handler: Ed25519 token verification, user auto-provisioning admin/ routes.go # Admin router setup and route registration auth.go # Tailscale whois-based authentication middleware @@ -101,7 +102,7 @@ Request -> Template render (injects User, CSRFToken, Flash into PageData) ``` -The webhook endpoint (`POST /webhooks/forgejo/:repoSlug`) sits outside the CSRF group since it authenticates via HMAC signature. +The webhook endpoint (`POST /webhooks/forgejo/:repoSlug`) and the SSO endpoint (`GET /sso/:slug`) sit outside the CSRF group since they authenticate via HMAC signature and JWT signature respectively. See [SSO Integration](./SSO_INTEGRATION.md) for the JWT flow. ### Admin Server diff --git a/internal/handlers/admin/repos.go b/internal/handlers/admin/repos.go index ee9d56e..22f2bc8 100644 --- a/internal/handlers/admin/repos.go +++ b/internal/handlers/admin/repos.go @@ -73,6 +73,10 @@ func (h *RepoHandler) Create(c *gin.Context) { Active: active, } + if ssoKey := strings.TrimSpace(c.PostForm("sso_public_key")); ssoKey != "" { + repo.SSOPublicKey = &ssoKey + } + if err := h.deps.DB.Create(&repo).Error; err != nil { log.Error().Err(err).Msg("create repo error") h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/new", map[string]interface{}{ @@ -121,12 +125,18 @@ func (h *RepoHandler) Update(c *gin.Context) { forgejoRepo := strings.TrimSpace(c.PostForm("forgejo_repo")) active := c.PostForm("active") == "on" + var ssoPublicKey *string + if ssoKey := strings.TrimSpace(c.PostForm("sso_public_key")); ssoKey != "" { + ssoPublicKey = &ssoKey + } + if err := h.deps.DB.Model(&models.Repo{}).Where("id = ?", repoID).Updates(map[string]interface{}{ - "name": name, - "slug": slug, - "forgejo_owner": forgejoOwner, - "forgejo_repo": forgejoRepo, - "active": active, + "name": name, + "slug": slug, + "forgejo_owner": forgejoOwner, + "forgejo_repo": forgejoRepo, + "active": active, + "sso_public_key": ssoPublicKey, }).Error; err != nil { log.Error().Err(err).Msg("update repo error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to update repo") diff --git a/internal/handlers/public/routes.go b/internal/handlers/public/routes.go index 85a90d0..81514d6 100644 --- a/internal/handlers/public/routes.go +++ b/internal/handlers/public/routes.go @@ -45,6 +45,9 @@ func NewRouter(deps Dependencies) *gin.Engine { webhookHandler := &WebhookHandler{deps: deps} r.POST("/webhooks/forgejo/:repoSlug", webhookHandler.HandleForgejoWebhook) + ssoHandler := &SSOHandler{deps: deps} + r.GET("/sso/:slug", ssoHandler.HandleSSO) + csrf := r.Group("/") csrf.Use(csrfMiddleware) { diff --git a/internal/handlers/public/sso.go b/internal/handlers/public/sso.go new file mode 100644 index 0000000..940a315 --- /dev/null +++ b/internal/handlers/public/sso.go @@ -0,0 +1,156 @@ +package public + +import ( + "crypto/ed25519" + "crypto/x509" + "encoding/pem" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/mattnite/forgejo-tickets/internal/models" + "github.com/rs/zerolog/log" +) + +type SSOClaims struct { + Email string `json:"email"` + Name string `json:"name"` + jwt.RegisteredClaims +} + +type SSOHandler struct { + deps Dependencies +} + +func (h *SSOHandler) HandleSSO(c *gin.Context) { + slug := c.Param("slug") + token := c.Query("token") + + if token == "" { + c.String(http.StatusBadRequest, "missing token") + return + } + + // Look up repo by slug + var repo models.Repo + if err := h.deps.DB.First(&repo, "slug = ?", slug).Error; err != nil { + c.String(http.StatusNotFound, "repo not found") + return + } + + if !repo.Active { + c.String(http.StatusNotFound, "repo not found") + return + } + + if repo.SSOPublicKey == nil || strings.TrimSpace(*repo.SSOPublicKey) == "" { + c.String(http.StatusBadRequest, "SSO not configured for this repo") + return + } + + // Parse PEM public key + pubKey, err := parseEd25519PublicKey(*repo.SSOPublicKey) + if err != nil { + log.Error().Err(err).Str("repo", slug).Msg("failed to parse SSO public key") + c.String(http.StatusInternalServerError, "SSO configuration error") + return + } + + // Parse and verify JWT + claims := &SSOClaims{} + parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) { + // Enforce EdDSA algorithm to prevent algorithm confusion attacks + if t.Method != jwt.SigningMethodEdDSA { + return nil, jwt.ErrSignatureInvalid + } + return pubKey, nil + }) + if err != nil || !parsed.Valid { + c.String(http.StatusUnauthorized, "invalid or expired token") + return + } + + // Validate claims + email := strings.ToLower(strings.TrimSpace(claims.Email)) + name := strings.TrimSpace(claims.Name) + + if email == "" || name == "" { + c.String(http.StatusBadRequest, "missing email or name in token") + return + } + + // Find or create user + var user models.User + result := h.deps.DB.Where("email = ?", email).First(&user) + if result.Error != nil { + // User doesn't exist — create + user = models.User{ + Email: email, + Name: name, + EmailVerified: true, + Approved: true, + } + if err := h.deps.DB.Create(&user).Error; err != nil { + // Race condition: another request may have created the user + if err2 := h.deps.DB.Where("email = ?", email).First(&user).Error; err2 != nil { + log.Error().Err(err).Msg("SSO: failed to create user") + c.String(http.StatusInternalServerError, "failed to create user") + return + } + } + } + + // Update existing user if needed + updates := map[string]interface{}{} + if user.Name != name { + updates["name"] = name + } + if !user.EmailVerified { + updates["email_verified"] = true + } + if !user.Approved { + updates["approved"] = true + } + if len(updates) > 0 { + h.deps.DB.Model(&user).Updates(updates) + } + + // Assign user to repo if not already + var count int64 + h.deps.DB.Model(&models.UserRepo{}).Where("user_id = ? AND repo_id = ?", user.ID, repo.ID).Count(&count) + if count == 0 { + userRepo := models.UserRepo{UserID: user.ID, RepoID: repo.ID} + if err := h.deps.DB.Create(&userRepo).Error; err != nil { + log.Warn().Err(err).Msg("SSO: failed to create user-repo association (may already exist)") + } + } + + // Create session + if err := h.deps.Auth.CreateSession(c.Request, c.Writer, user.ID); err != nil { + log.Error().Err(err).Msg("SSO: failed to create session") + c.String(http.StatusInternalServerError, "failed to create session") + return + } + + c.Redirect(http.StatusSeeOther, "/tickets/new") +} + +func parseEd25519PublicKey(pemStr string) (ed25519.PublicKey, error) { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return nil, jwt.ErrSignatureInvalid + } + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + + edKey, ok := pub.(ed25519.PublicKey) + if !ok { + return nil, jwt.ErrSignatureInvalid + } + + return edKey, nil +} diff --git a/internal/models/models.go b/internal/models/models.go index 6d4185e..fa1274b 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -62,6 +62,7 @@ type Repo struct { WebhookVerified bool `gorm:"not null;default:false" json:"webhook_verified"` WebhookVerifiedAt *time.Time `json:"webhook_verified_at"` Active bool `gorm:"not null;default:true" json:"active"` + SSOPublicKey *string `json:"sso_public_key"` CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` } diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go index a15f381..924a595 100644 --- a/internal/templates/funcs.go +++ b/internal/templates/funcs.go @@ -73,6 +73,12 @@ func templateFuncs() template.FuncMap { } return s[:n] + "..." }, + "deref": func(s *string) string { + if s == nil { + return "" + } + return *s + }, "dict": func(values ...interface{}) map[string]interface{} { dict := make(map[string]interface{}) for i := 0; i < len(values)-1; i += 2 { diff --git a/web/templates/pages/admin/repos/edit.html b/web/templates/pages/admin/repos/edit.html index 95709a1..161a056 100644 --- a/web/templates/pages/admin/repos/edit.html +++ b/web/templates/pages/admin/repos/edit.html @@ -73,6 +73,28 @@ +
+ + +

Optional. Paste the PEM-encoded Ed25519 public key to enable JWT SSO for this repo. Leave empty to disable.

+
+ + {{if and .Repo.SSOPublicKey (ne (deref .Repo.SSOPublicKey) "")}} +
+
+ + + +

SSO enabled

+
+

SSO URL: + {{.BaseURL}}/sso/{{.Repo.Slug}} +

+
+ {{end}} + diff --git a/web/templates/pages/admin/repos/new.html b/web/templates/pages/admin/repos/new.html index 10a98e2..6e30f7e 100644 --- a/web/templates/pages/admin/repos/new.html +++ b/web/templates/pages/admin/repos/new.html @@ -56,6 +56,14 @@ +
+ + +

Optional. Paste the PEM-encoded Ed25519 public key to enable JWT SSO for this repo.

+
+