forgejo-tickets/docs/SSO_INTEGRATION.md

152 lines
4.2 KiB
Markdown

# 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
```