Case study — Controlum integration

A walkthrough of how Controlum — a separate Next.js + Go SaaS — swapped its bundled magic-link auth for OpenBitum's Logto SSO. Same pattern fits any third-party app that wants OpenBitum as its identity provider.

What Controlum looks like

| Surface | Stack | Auth before | |---|---|---| | apps/web | Next.js 15 (App Router) | Cookie-backed session set by the Go API | | apps/api | Go 1.25, chi, pgx | Magic-link email flow → own HMAC-JWT in a cookie | | Shared | Postgres + Redis on Coolify | n/a |

The starting state shipped a /auth/request endpoint that emailed a one-time link, the buyer clicked, the Go API issued a session JWT. Functional but ties auth to the API, makes federation (Google, GitHub OAuth) painful, and forces every consumer of the user identity to round-trip through Controlum's Go service.

What we replaced it with

buyer browser
    │
    └── click "Sign in with OpenBitum" on app.controlum.pw
            │
            ▼
        Next.js: /api/logto/sign-in (route handler from @logto/next)
            │
            └── 307 → auth.openbitum.pw/oidc/auth?client_id=ajojkjea3pi0bgc88s6y2&…
                    │
                    └── login form (Logto-hosted) → callback
                            │
                            ▼
                        /api/logto/callback (route handler)
                        cookie set: logto:ajojkjea3pi0bgc88s6y2
                            │
                            └── 302 → /machines
                                    │
                                    └── Controlum web reads id_token from cookie
                                          via getLogtoContext() and forwards as
                                          `Authorization: Bearer <id_token>` to
                                          Go API.
                                              │
                                              ▼
                                          Go API: auth.OIDCConfig.Middleware
                                          verifies the JWT against
                                          auth.openbitum.pw/oidc/jwks
                                          (keyfunc/v3), populates request
                                          context with LogtoClaims{Sub, Email}.

End result: the Logto user's sub is the canonical user id in the Go API; magic-link cookie auth stays available as a transition path but new sign-ins go through OpenBitum SSO exclusively.

Provision two Logto applications for the third-party app

Controlum needs two Logto entries on auth.openbitum.pw:

| Use | Type | Why | |---|---|---| | Web OIDC sign-in | Traditional | The Next.js app needs a client_id + client_secret to drive the OIDC dance via @logto/next. | | Server-side user CRUD | MachineToMachine | The Go API needs to list / create / suspend users via the Logto Management API. Separate credentials so a leak of one doesn't compromise the other. |

# 1. Mint a Logto management-API token using the openbitum-glue
#    M2M client (operator-only — agent doesn't expose this).
TOKEN=$(curl -s -X POST https://auth.openbitum.pw/oidc/token \
  -u "$OPENBITUM_M2M_ID:$OPENBITUM_M2M_SECRET" \
  -d 'grant_type=client_credentials' \
  -d 'resource=https://default.logto.app/api' \
  -d 'scope=all' | jq -r .access_token)

# 2. Traditional Web app for sign-in.
curl -X POST https://auth.openbitum.pw/api/applications \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "name": "Controlum (prod)",
    "type": "Traditional",
    "oidcClientMetadata": {
      "redirectUris": [
        "https://app.controlum.pw/api/logto/callback",
        "http://localhost:10000/api/logto/callback"
      ],
      "postLogoutRedirectUris": [
        "https://app.controlum.pw", "http://localhost:10000"
      ]
    }
  }'

# 3. M2M for the Go API.
curl -X POST https://auth.openbitum.pw/api/applications \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "name": "Controlum (M2M)", "type": "MachineToMachine",
        "oidcClientMetadata": {"redirectUris":[],"postLogoutRedirectUris":[]} }'

# 4. Bind the M2M to the "Logto Management API access" role in the
#    default tenant. Role id is stable across installs.
psql -c "INSERT INTO applications_roles (tenant_id, id, application_id, role_id)
         SELECT 'default', substr(md5(random()::text),1,21), '<controlum-m2m-id>',
                'xpm34fm0d104kzyt3x4r7'
         ON CONFLICT DO NOTHING;"

For Controlum specifically the credentials worked out to:

OPENBITUM_LOGTO_APP_ID=ajojkjea3pi0bgc88s6y2
OPENBITUM_LOGTO_M2M_APP_ID=26pn8450gyt65g5xeipxo

(Secrets are operator-only — they're attached to the PR description, not the repo.)

Wire @logto/next into the Next.js app

cd apps/web
pnpm add @logto/next

Two files: a lazy-init config helper, and a catch-all route handler.

// apps/web/lib/logto.ts
import { LogtoNextConfig, UserScope } from "@logto/next";

const baseUrl =
  process.env.OPENBITUM_LOGTO_BASE_URL ??
  (process.env.NODE_ENV === "production"
    ? "https://app.controlum.pw"
    : "http://localhost:10000");

export const logtoConfig: LogtoNextConfig = {
  endpoint:
    process.env.OPENBITUM_LOGTO_ENDPOINT ?? "https://auth.openbitum.pw",
  appId: process.env.OPENBITUM_LOGTO_APP_ID ?? "ajojkjea3pi0bgc88s6y2",
  appSecret:
    process.env.OPENBITUM_LOGTO_APP_SECRET ?? "MUST-BE-SET-AT-RUNTIME",
  baseUrl,
  cookieSecret:
    process.env.OPENBITUM_LOGTO_COOKIE_SECRET ??
    "controlum-cookie-secret-32-chars-minimum!",
  cookieSecure: baseUrl.startsWith("https://"),
  scopes: [UserScope.Profile, UserScope.Email],
};
// apps/web/app/api/logto/[...logto]/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { signIn, signOut, handleSignIn } from "@logto/next/server-actions";
import { logtoConfig } from "@/lib/logto";

export const dynamic = "force-dynamic";

export const GET = async (request: NextRequest, ctx: { params: Promise<{ logto: string[] }> }) => {
  const { logto } = await ctx.params;
  const action = logto?.[0];
  const baseUrl = logtoConfig.baseUrl.replace(/\/$/, "");
  if (action === "sign-in")   { await signIn(logtoConfig, `${baseUrl}/api/logto/callback`);          return NextResponse.redirect(new URL("/", baseUrl)); }
  if (action === "sign-up")   { await signIn(logtoConfig, `${baseUrl}/api/logto/callback`, "signUp"); return NextResponse.redirect(new URL("/", baseUrl)); }
  if (action === "sign-out")  { await signOut(logtoConfig, baseUrl);                                 return NextResponse.redirect(new URL("/", baseUrl)); }
  if (action === "callback")  {
    const callbackUrl = new URL(`${baseUrl}/api/logto/callback${request.nextUrl.search}`);
    await handleSignIn(logtoConfig, callbackUrl);
    return NextResponse.redirect(new URL("/machines", baseUrl));   // Controlum post-login home
  }
  return NextResponse.json({ error: "unknown_action" }, { status: 404 });
};

Replace the magic-link LoginScreen with one CTA

// apps/web/app/page.tsx — only the relevant snippet
function LoginScreen() {
  return (
    <main className="flex min-h-screen items-center justify-center p-6">
      <div className="w-full max-w-sm text-center space-y-6">
        <h1 className="text-2xl font-semibold">Sign in to Controlum</h1>
        <p className="text-sm text-muted-foreground">
          You'll be redirected to <code>auth.openbitum.pw</code> to sign in,
          then back here.
        </p>
        <a href="/api/logto/sign-in" data-testid="signin">
          <Button size="lg" className="w-full">Sign in with OpenBitum</Button>
        </a>
      </div>
    </main>
  );
}

Forward id_token to the Go API

The web app reads the id_token from the @logto/next cookie session and forwards it as a Bearer token on outbound API calls. Suggested pattern (server component or server action):

import LogtoClient from "@logto/next/server-actions";
import { logtoConfig } from "@/lib/logto";

const getIdToken = async (): Promise<string | null> => {
  const client = new LogtoClient(logtoConfig);
  const node = await client.createNodeClient({ ignoreCookieChange: true });
  return (await node.getIdToken()) ?? null;
};

// in your API client wrapper
const token = await getIdToken();
const res = await fetch(`${API_URL}/me`, {
  headers: token ? { Authorization: `Bearer ${token}` } : {},
});

Go-side JWKS-aware middleware

cd apps/api
go get github.com/MicahParks/keyfunc/v3@latest
go mod tidy
// apps/api/internal/auth/oidc.go
package auth

import (
    "context"; "encoding/json"; "errors"; "fmt"; "net/http"; "strings"
    "github.com/MicahParks/keyfunc/v3"
    "github.com/golang-jwt/jwt/v5"
)

type LogtoClaims struct {
    Sub   string `json:"sub"`
    Email string `json:"email"`
    jwt.RegisteredClaims
}

type OIDCConfig struct {
    JWKSURL, Issuer string
    jwks            keyfunc.Keyfunc
}

func NewOIDC(ctx context.Context, jwksURL, issuer string) (*OIDCConfig, error) {
    if jwksURL == "" || issuer == "" {
        return nil, errors.New("OIDC requires JWKSURL and Issuer")
    }
    k, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
    if err != nil { return nil, fmt.Errorf("OIDC keyfunc: %w", err) }
    return &OIDCConfig{JWKSURL: jwksURL, Issuer: issuer, jwks: k}, nil
}

func (c *OIDCConfig) VerifyBearer(r *http.Request) (*LogtoClaims, error) {
    auth := r.Header.Get("Authorization")
    if !strings.HasPrefix(auth, "Bearer ") {
        return nil, errors.New("missing Bearer token")
    }
    raw := strings.TrimPrefix(auth, "Bearer ")
    var claims LogtoClaims
    t, err := jwt.ParseWithClaims(raw, &claims, c.jwks.Keyfunc,
        jwt.WithIssuer(c.Issuer),
        jwt.WithExpirationRequired(),
    )
    if err != nil || !t.Valid { return nil, err }
    return &claims, nil
}

type ctxKey struct{}
var oidcClaimsKey = ctxKey{}

func (c *OIDCConfig) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        claims, err := c.VerifyBearer(r)
        if err != nil {
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusUnauthorized)
            _ = json.NewEncoder(w).Encode(map[string]string{"error":"unauthorized"})
            return
        }
        ctx := context.WithValue(r.Context(), oidcClaimsKey, claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func ClaimsFrom(r *http.Request) *LogtoClaims {
    v, _ := r.Context().Value(oidcClaimsKey).(*LogtoClaims)
    return v
}

Notes on the validation:

  • Issuer is the trust anchor. jwt.WithIssuer(c.Issuer) is the only claim check that matters — the JWKS pins the keypair, the issuer pins the tenant. Aud check is relaxed because the id_token's aud is the OIDC client_id (per RFC 7519, which is fine for our use).
  • JWKS auto-refresh. keyfunc/v3 re-fetches the JWKS on key rotation; you don't need to manage that.
  • WithExpirationRequired is the safety net for unsigned-out sessions — a token without exp won't validate even if signed.

Wire into cmd/api/main.go

import "github.com/controlum/api/internal/auth"

oidc, err := auth.NewOIDC(ctx,
    mustEnv("OPENBITUM_LOGTO_JWKS_URL"),  // https://auth.openbitum.pw/oidc/jwks
    mustEnv("OPENBITUM_LOGTO_ISSUER"),    // https://auth.openbitum.pw/oidc
)
if err != nil { log.Fatal(err) }

// Pure-OIDC: /me only takes OIDC tokens (no magic-link cookie).
mux.Method("GET", "/me", oidc.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    c := auth.ClaimsFrom(r)
    if c == nil { http.Error(w, "unauthorized", 401); return }
    _ = json.NewEncoder(w).Encode(map[string]string{
        "email": c.Email, "sub": c.Sub,
    })
})))

// Mixed: accept either OIDC or legacy session JWT during migration.
mux.Method("GET", "/machines", chain(
    fallbackMiddleware(oidc.Middleware, legacy.SessionMiddleware),
    machinesHandler,
))

fallbackMiddleware is yours to write — try the OIDC chain first, fall back to local session if the request didn't carry a Bearer token.

Manage users from the Go backend (Management API)

For listing / creating / suspending users from the Go side:

// Pseudocode — flesh out with retries + token cache (1h TTL).
type LogtoAdmin struct {
    Endpoint, AppID, AppSecret string
    Token string
    TokenExpires time.Time
}

func (l *LogtoAdmin) ensureToken(ctx context.Context) error {
    if time.Now().Before(l.TokenExpires.Add(-2 * time.Minute)) { return nil }
    req, _ := http.NewRequestWithContext(ctx, "POST",
        l.Endpoint+"/oidc/token",
        strings.NewReader("grant_type=client_credentials"+
            "&resource=https://default.logto.app/api"+
            "&scope=all"))
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    req.SetBasicAuth(l.AppID, l.AppSecret)
    res, err := http.DefaultClient.Do(req)
    if err != nil { return err }
    var body struct {
        AccessToken string `json:"access_token"`
        ExpiresIn   int    `json:"expires_in"`
    }
    _ = json.NewDecoder(res.Body).Decode(&body)
    l.Token, l.TokenExpires = body.AccessToken, time.Now().Add(time.Duration(body.ExpiresIn) * time.Second)
    return nil
}

// l.ListUsers / l.GetByEmail / l.CreateUser / l.Suspend …
// all just call ${l.Endpoint}/api/users with Authorization: Bearer $l.Token.

The exact REST shape is documented on openapi.logto.io, or — easier — see SSO + manage users §"Manage your users" which has the curl recipes that the OpenBitum project itself uses.

Migrating existing magic-link users

The users table already has rows keyed by uuid + email. To bridge:

  1. Add a column: ALTER TABLE users ADD COLUMN logto_sub text UNIQUE;.
  2. On every successful /me call, if logto_sub is null, set it from the verified claim. New users created entirely via OIDC get inserted on first sight.
  3. The magic-link path stays live until the last user with logto_sub IS NULL has signed in via OIDC at least once.
  4. Drop the magic-link routes when the migration is settled.

Status of the live integration

| Concern | State | |---|---| | Logto Application "Controlum (prod)" — Traditional OIDC | ✅ provisioned (ajojkjea3pi0bgc88s6y2) | | Logto Application "Controlum (M2M)" — Management API access | ✅ provisioned, bound to default-tenant management role | | apps/web/lib/logto.ts + app/api/logto/[...logto]/route.ts | ✅ committed in feature/openbitum-sso | | app/page.tsx LoginScreen → OpenBitum CTA | ✅ committed | | apps/api/internal/auth/oidc.go + keyfunc/v3 in go.mod | ✅ committed | | cmd/api/main.go wiring + protected endpoints | ❓ next step — laid out in step 6 above, owner: Controlum agent / dev | | Magic-link → OIDC migration plan | ❓ planning sketch in step 8, real rollout owned by Controlum |

See also