forgejo-tickets/docs/SSO_INTEGRATION.md

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

  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

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
  • 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:

{
  "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