JWT SSO
This commit is contained in:
parent
8dcf60c970
commit
c7bdb3b66e
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,28 @@
|
|||
<label for="active" class="text-sm font-medium text-gray-700">Active</label>
|
||||
</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----- ... -----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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
<label for="active" class="text-sm font-medium text-gray-700">Active</label>
|
||||
</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----- ... -----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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue