This commit is contained in:
Matthew Knight 2026-02-15 09:12:19 -08:00
parent 8dcf60c970
commit c7bdb3b66e
No known key found for this signature in database
11 changed files with 376 additions and 6 deletions

View File

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

151
docs/SSO_INTEGRATION.md Normal file
View File

@ -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://<tickets-host>/sso/<repo-slug>?token=<jwt>`
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
```

View File

@ -80,6 +80,7 @@ Repos map customer-facing "products" to Forgejo repositories. Each repo defines
| Forgejo Repo | Name of the Forgejo repository | | Forgejo Repo | Name of the Forgejo repository |
| Webhook Secret | Shared secret for HMAC-SHA256 webhook signature verification | | Webhook Secret | Shared secret for HMAC-SHA256 webhook signature verification |
| Active | Whether this product appears in the customer ticket creation form | | 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 ### 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. 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.

View File

@ -41,6 +41,7 @@ internal/
oauth.go # OAuth login/callback for Google, Microsoft, Apple oauth.go # OAuth login/callback for Google, Microsoft, Apple
tickets.go # Ticket list, create, detail, add comment tickets.go # Ticket list, create, detail, add comment
webhook.go # Forgejo webhook receiver (issue close -> ticket close) webhook.go # Forgejo webhook receiver (issue close -> ticket close)
sso.go # JWT SSO handler: Ed25519 token verification, user auto-provisioning
admin/ admin/
routes.go # Admin router setup and route registration routes.go # Admin router setup and route registration
auth.go # Tailscale whois-based authentication middleware auth.go # Tailscale whois-based authentication middleware
@ -101,7 +102,7 @@ Request
-> Template render (injects User, CSRFToken, Flash into PageData) -> 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 ### Admin Server

View File

@ -73,6 +73,10 @@ func (h *RepoHandler) Create(c *gin.Context) {
Active: active, 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 { if err := h.deps.DB.Create(&repo).Error; err != nil {
log.Error().Err(err).Msg("create repo error") log.Error().Err(err).Msg("create repo error")
h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/new", map[string]interface{}{ 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")) forgejoRepo := strings.TrimSpace(c.PostForm("forgejo_repo"))
active := c.PostForm("active") == "on" 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{}{ if err := h.deps.DB.Model(&models.Repo{}).Where("id = ?", repoID).Updates(map[string]interface{}{
"name": name, "name": name,
"slug": slug, "slug": slug,
"forgejo_owner": forgejoOwner, "forgejo_owner": forgejoOwner,
"forgejo_repo": forgejoRepo, "forgejo_repo": forgejoRepo,
"active": active, "active": active,
"sso_public_key": ssoPublicKey,
}).Error; err != nil { }).Error; err != nil {
log.Error().Err(err).Msg("update repo error") log.Error().Err(err).Msg("update repo error")
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to update repo") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to update repo")

View File

@ -45,6 +45,9 @@ func NewRouter(deps Dependencies) *gin.Engine {
webhookHandler := &WebhookHandler{deps: deps} webhookHandler := &WebhookHandler{deps: deps}
r.POST("/webhooks/forgejo/:repoSlug", webhookHandler.HandleForgejoWebhook) r.POST("/webhooks/forgejo/:repoSlug", webhookHandler.HandleForgejoWebhook)
ssoHandler := &SSOHandler{deps: deps}
r.GET("/sso/:slug", ssoHandler.HandleSSO)
csrf := r.Group("/") csrf := r.Group("/")
csrf.Use(csrfMiddleware) csrf.Use(csrfMiddleware)
{ {

View File

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

View File

@ -62,6 +62,7 @@ type Repo struct {
WebhookVerified bool `gorm:"not null;default:false" json:"webhook_verified"` WebhookVerified bool `gorm:"not null;default:false" json:"webhook_verified"`
WebhookVerifiedAt *time.Time `json:"webhook_verified_at"` WebhookVerifiedAt *time.Time `json:"webhook_verified_at"`
Active bool `gorm:"not null;default:true" json:"active"` 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"` CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
} }

View File

@ -73,6 +73,12 @@ func templateFuncs() template.FuncMap {
} }
return s[:n] + "..." return s[:n] + "..."
}, },
"deref": func(s *string) string {
if s == nil {
return ""
}
return *s
},
"dict": func(values ...interface{}) map[string]interface{} { "dict": func(values ...interface{}) map[string]interface{} {
dict := make(map[string]interface{}) dict := make(map[string]interface{})
for i := 0; i < len(values)-1; i += 2 { for i := 0; i < len(values)-1; i += 2 {

View File

@ -73,6 +73,28 @@
<label for="active" class="text-sm font-medium text-gray-700">Active</label> <label for="active" class="text-sm font-medium text-gray-700">Active</label>
</div> </div>
<div>
<label for="sso_public_key" class="block text-sm font-medium text-gray-700">SSO Public Key (Ed25519 PEM)</label>
<textarea name="sso_public_key" id="sso_public_key" rows="5"
placeholder="-----BEGIN PUBLIC KEY-----&#10;...&#10;-----END PUBLIC KEY-----"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono text-xs">{{deref .Repo.SSOPublicKey}}</textarea>
<p class="mt-1 text-xs text-gray-500">Optional. Paste the PEM-encoded Ed25519 public key to enable JWT SSO for this repo. Leave empty to disable.</p>
</div>
{{if and .Repo.SSOPublicKey (ne (deref .Repo.SSOPublicKey) "")}}
<div class="rounded-md bg-green-50 p-4 ring-1 ring-green-200">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm font-semibold text-green-800">SSO enabled</p>
</div>
<p class="mt-1 text-xs text-green-600">SSO URL:
<code class="bg-green-100 px-1 py-0.5 rounded select-all">{{.BaseURL}}/sso/{{.Repo.Slug}}</code>
</p>
</div>
{{end}}
<button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Save Changes</button> <button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Save Changes</button>
</form> </form>
</div> </div>

View File

@ -56,6 +56,14 @@
<label for="active" class="text-sm font-medium text-gray-700">Active</label> <label for="active" class="text-sm font-medium text-gray-700">Active</label>
</div> </div>
<div>
<label for="sso_public_key" class="block text-sm font-medium text-gray-700">SSO Public Key (Ed25519 PEM)</label>
<textarea name="sso_public_key" id="sso_public_key" rows="5"
placeholder="-----BEGIN PUBLIC KEY-----&#10;...&#10;-----END PUBLIC KEY-----"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono text-xs"></textarea>
<p class="mt-1 text-xs text-gray-500">Optional. Paste the PEM-encoded Ed25519 public key to enable JWT SSO for this repo.</p>
</div>
<button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Add Repo</button> <button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Add Repo</button>
</form> </form>
</div> </div>