Skip to content

Configure Logto OIDC (backend + frontend)

Auth is self-hosted Logto OIDC (Zitadel was migrated off). Login runs in the frontend via Auth.js (next-auth v5); the upstream access token never reaches the browser — it lives only on the encrypted server-side Auth.js JWT cookie, and core-gateway-consumer re-validates it on every single call by forwarding it as the gRPC bearer to core-identity /v1/auth/validate.

This page is the wiring recipe: set Logto up so the access token is a JWT (not opaque), point the frontend and core-identity at the same audience, and give core-identity a Management-API client so it can enrich profiles. For the why and the full flow diagram, see Identity & auth.

  1. Stand up Logto (infra — out of scope here): the self-hosted deployment plus its Postgres DB. The end-to-end console spec lives in soundverse-saas-2.0/docs/auth-logto-setup.md. Note the four OIDC endpoints Logto exposes off its base (call it <endpoint>):

    Purpose URL
    Issuer <endpoint>/oidc
    JWKS <endpoint>/oidc/jwks
    Userinfo <endpoint>/oidc/me
    End-session <endpoint>/oidc/session/end
  2. Register an API Resource in Logto. Its indicator is the value both AUTH_LOGTO_RESOURCE (frontend) and IDP_AUDIENCE (backend) will hold. When the frontend requests this resource, the issued access token becomes a JWT whose aud equals the indicator.

  3. Create the Traditional Web application the frontend uses. Register a redirect URI per Auth.js provider id — <origin>/api/auth/callback/logto, plus one for each social id (logto-google, logto-microsoft, logto-discord) — and a post-logout redirect URI for RP-initiated federated logout. Enable “Always issue refresh token” OFF; the frontend gets a refresh token via prompt=consent instead (see the trap below).

  4. Create a Machine-to-Machine (M2M) application for profile enrichment and grant it the Logto Management API access role. core-identity uses its client-credentials to read GET {endpoint}/api/users/{sub} — this is how email/name/avatar/phone are resolved (the userinfo endpoint can’t be used; see the trap below).

  5. Enable connectors and account linking. Turn on the Google / Microsoft / Discord social connectors and (for phone) the SMS connector, then enable each in the Sign-in Experiencedirect_sign_in silently falls back to Logto’s own login page if the connector isn’t enabled there. Turn on automatic account linking so one human with a verified email/phone is one Logto user across many identities.

  6. Wire the frontend env (soundverse-saas-2.0) — see the table below.

  7. Wire the core-identity env — see the table below.

  8. Verify. Sign in, then confirm the gateway accepts the bearer (a call to any /api/* returns data, not unauthenticated) and that a fresh identity.users row has a non-blank email. If the token is opaque you’ll see Not enough segments in core-identity logs — go back to steps 2 and 6.

The Auth.js config is split so the gate can run at the edge: auth.config.ts is edge-safe (declares the Logto provider(s), scopes, and the authorized gate); auth.ts is the full Node instance with the jwt/session callbacks. It registers a base logto provider (for the phone-OTP button) plus one provider per social connector using direct_sign_in, all sharing the scope openid profile email phone offline_access.

Env var (name only) Points at
AUTH_SECRET Key that encrypts the server-side session JWT
AUTH_LOGTO_ID The Traditional Web app’s client id
AUTH_LOGTO_SECRET The Traditional Web app’s client secret
AUTH_LOGTO_ISSUER <endpoint>/oidc (Auth.js discovers the rest)
AUTH_LOGTO_RESOURCE The API-resource indicator — must equal backend IDP_AUDIENCE
REDIS_ADDR / REDIS_PASSWORD Durable rotating-refresh-token store (see the double-spend trap)

core-identity validates the JWT locally against the JWKS (app/core/auth/token.py) and provisions/enriches the user at /v1/auth/validate (app/routes/auth.py). Config lives in app/core/config.py.

Env var (name only) Purpose Legacy alias
IDP_ISSUER OIDC issuer = <endpoint>/oidc ZITADEL_OIDC_ISSUER
IDP_JWKS_URL <endpoint>/oidc/jwks ZITADEL_JWKS_URL
IDP_AUDIENCE The API-resource indicator (the token’s aud) — must equal AUTH_LOGTO_RESOURCE ZITADEL_OIDC_AUDIENCE
LOGTO_ENDPOINT Logto base URL (host of both /oidc and /api)
LOGTO_M2M_CLIENT_ID M2M app client id (Management API)
LOGTO_M2M_CLIENT_SECRET M2M app client secret
LOGTO_MANAGEMENT_RESOURCE Management-API indicator; defaults to https://default.logto.app/api for single-tenant self-host (a fixed indicator string, not a reachable URL)
LOGTO_TOKEN_URL Token endpoint; defaults to {endpoint}/oidc/token
IDP_USERINFO_URL Legacy OIDC userinfo enrichment (Zitadel rollback only; empty ⇒ skipped)

Each IDP_* setting still accepts its legacy ZITADEL_* name via AliasChoices, so an un-migrated docker-compose / ACA injection keeps working during a rollback. In staging/prod these are injected with the STAGING_/PROD_ prefix stripped — see Environment & service discovery.

Set the IDP_* and LOGTO_* names above in each service’s .env. Keep names only — never commit real client secrets. For the shared-secret and prefix-injection pattern, see Configuring .env.

core-identity provisions just-in-time: any token that passes JWKS validation resolves to an identity.users row keyed on the token sub, and the first provision of a subject grants the signup token bundle via ingest.ensure_billing (SIGNUP_GRANT_TOKENS, default 1000). The gateway is provider-agnostic — it just forwards the bearer.