4.2 KiB
4.2 KiB
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
- User is logged into your application
- Your backend signs a JWT:
{email, name, iat, exp}with Ed25519 private key - User is redirected to
https://<tickets-host>/sso/<repo-slug>?token=<jwt> - Tickets system verifies JWT, creates/finds user, assigns to repo, creates session
- User lands on
/tickets/new— fully authenticated, no registration required
Setup
1. Generate an Ed25519 keypair
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
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
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
expshould be at most 5 minutes fromiat— shorter is betteremailis the unique user identifier — lowercased and trimmed server-sidenameupdates the user's display name on each SSO login if changed
Example payload:
{
"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
go get github.com/golang-jwt/jwt/v5