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