152 lines
4.2 KiB
Markdown
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
|
|
```
|