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'saudis the OIDCclient_id(per RFC 7519, which is fine for our use). - JWKS auto-refresh.
keyfunc/v3re-fetches the JWKS on key rotation; you don't need to manage that. WithExpirationRequiredis the safety net for unsigned-out sessions — a token withoutexpwon'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:
- Add a column:
ALTER TABLE users ADD COLUMN logto_sub text UNIQUE;. - On every successful
/mecall, iflogto_subis null, set it from the verified claim. New users created entirely via OIDC get inserted on first sight. - The magic-link path stays live until the last user with
logto_sub IS NULLhas signed in via OIDC at least once. - 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
- PR dimkk/controlum#3 — the actual code
- SSO + manage users tutorial — same flow walked from the OpenBitum operator's perspective
- API reference — endpoints used by the Management API recipes