Skip to content

core-identity

The bridge between our self-hosted Logto IdP and our own user records. It does exactly two things: it validates a Logto-issued OIDC access token (locally, against the JWKS), and on first sight it provisions that subject into identity.users — enriching the profile, granting starter tokens, and migrating a legacy (1.0) account if there is one.

It is the thinnest of the Python services: a FastAPI app served by Hypercorn with a single HTTP ingress port. It never runs a gRPC server — it is a gRPC client of core-database (outbound only), so one ACA ingress (transport http) covers everything. For the end-to-end auth story see Identity & auth; to stand Logto up see Configure Logto OIDC.

Method Path Auth Purpose
GET /healthz none ACA readiness probe (returns {"status":"ok"})
POST /v1/auth/validate Bearer ${INTERNAL_RPC_SECRET} Validate an OIDC access token → {valid, user_id, email, subject, claims}; provisions JIT

/v1/auth/validate is internal-only. Callers (the consumer gateway) present the same shared INTERNAL_RPC_SECRET bearer that authorizes core-database — a mismatch is a 401, not a login failure. See app/routes/auth.py.

Local, provider-agnostic JWKS validation lives in app/core/auth/token.py:

  • Asymmetric algorithms only — the RSA/ECDSA/PSS set (RS256…PS512), never HS*/none. Allowing an HMAC alg against a JWKS public key is a classic alg-confusion takeover; this refuses it outright. Logto signs with ES384 by default; RS256 stays allowed for a Zitadel rollback.
  • Issuer pinned, audience checked when set (IDP_ISSUER, IDP_AUDIENCE), signature verified against the kid-matched JWKS key. PyJWKClient fetches and caches the keys; the synchronous verify is offloaded to a thread so a cache-miss network fetch never blocks the event loop.

The resource-scoped JWT carries little more than sub/iss/aud, and Logto’s /oidc/me rejects that token — so email / name / avatar / locale come from Logto’s Management API instead. A cached M2M client-credentials token authorizes GET /api/users/{sub} (the Logto user id equals the JWT sub), implemented in app/core/clients/management.py. When the M2M creds are unset it falls back to the legacy OIDC userinfo client (oidc.py, Zitadel rollback); when neither is configured, enrichment is disabled. Enrichment is best-effort — any failure returns {} and the caller falls back to whatever claims the JWT carried; a hiccup must never break login.

Because a Logto primaryEmail is by definition verified, the client marks email_verified = bool(primaryEmail). That authoritative, server-to-server signal is what makes the verified-email convergence (below) safe to drive.

All the provisioning logic is orchestrated in app/services/ingest.py, composed from single-statement core-database RPCs (GetUserByExternalIdentity, GetUserByEmail, CreateUser, LinkExternalIdentity, UnlinkExternalIdentity, UpdateUserCore, SoftDeleteUser) rather than one clever query. A user is keyed on (provider, provider_subject), where provider is AUTH_PROVIDER_NAME (default logto).

  1. Resolve the subject. Look up (provider, subject). If it already maps to a user and that user already has an email, we return it and skip the Management-API call entirely.

  2. Enrich when needed. On a brand-new subject — or an existing user still missing an email — fetch the profile and upsert_from_external. Profile fields are backfilled empty-only: a column is filled only when currently blank, never overwriting a name/avatar the user customized. email_verified is promoted monotonically.

  3. Converge by verified email. If a different active user already owns this verified email, the new subject is linked to that user instead of forking a duplicate — one human who signs in through several social connectors (each a distinct Logto subject) stays one local user. A pre-existing fork is re-pointed (unlink + relink) onto the canonical owner; historical rows on the fork are swept by the data-recovery runbook, not moved here.

  4. Create + link, race-safe. Otherwise CreateUser then LinkExternalIdentity. If a concurrent first-login won the unique (provider, subject), the loser soft-deletes its freshly-created orphan and returns the authoritative winner.

  5. Migrate a legacy account, then grant. See the next two sections.

SIGNUP_GRANT_TOKENS (default 1000, 0 disables) non-expiring extra tokens are granted on first provision via ensure_billing(), which calls GrantExtraTokens with reason="signup_starter_grant" and idempotency_key="signup-grant:{user_id}". The idempotency key dedups per user, so a retry or race returns ALREADY_APPLIED and can never double-grant. EnsureBalance is isolated in its own try/except (its ON CONFLICT DO NOTHING RETURNING returns zero rows once a balance exists, which the codegen handler can surface as an error) so it can’t block the grant, and the grant retries a transient UNAVAILABLE/DEADLINE_EXCEEDED.

Before granting, migrate_legacy() calls core-database’s MigrateLegacyUser. For a verified, non-held legacy account it reads the old public.users row, grants the carried-over token balance, backfills identity satellites, records migration.legacy_user_map, and flags the legacy row so the old backend stops accepting spends — all idempotent per old user id. It returns whether the standard signup grant should still run, so a migrated user gets their carried balance instead of a fresh 1000, while non-legacy / zero-balance / enterprise users still get the starter grant. On a successful migration it LPUSHes the user id onto the shared {env}:common:migration:wake Redis list (migration_wake.py) to nudge core-migration to re-ingest their library now instead of on its next sweep — purely a latency optimization; the whole path is fail-safe (any error still lands the user a starter balance).

  • Directorycore-identity/
    • Directoryapp/
      • main.py lifespan: builds TokenValidator, the enrichment client, the core-database client
      • Directoryroutes/
        • auth.py /v1/auth/validate — the validate hot path
        • health.py /healthz
      • Directoryservices/
        • ingest.py JIT provisioning, convergence, grant, legacy migration
        • migration_wake.py best-effort Redis wake to core-migration
      • Directorycore/
        • auth/token.py JWKS / asymmetric-alg / issuer / audience validator
        • Directoryclients/
          • management.py Logto Management API (M2M) profile enrichment
          • oidc.py legacy OIDC userinfo (Zitadel rollback)
          • core_database.py async gRPC client (Identity / Token / Migration stubs)
        • config.py all settings + env aliases

All emitted as OTel counters into SigNoz (see Observability):

Counter Meaning
identity.provision.no_email validate resolved/provisioned a user with no email — the blank-user seed. Alert if > 0.
identity.provision.billing_failed A new user’s starter grant never landed. Alert if > 0.
identity.backfill.email_collision A verified-email backfill collided with another active user (a split slipping through).
identity.migration.jit_migrated A legacy user migrated into 2.0 at first login.
identity.migration.jit_failed The JIT legacy-migration RPC errored (user fell back to the standard grant).

Names only — real values arrive via the ACA template’s STAGING_/PROD_-prefixed environment secrets. Full catalog: Environment variable catalog.

Var Purpose
CORE_DATABASE_GRPC / CORE_DATABASE_USE_TLS Upstream core-database (Identity / Token / Migration services). Set USE_TLS false locally.
INTERNAL_RPC_SECRET (alias INTERNAL_AUTH_SECRET) Bearer on every core-database call and the credential callers present to /v1/auth/validate.
IDP_ISSUER (alias ZITADEL_OIDC_ISSUER) Pinned token issuer — the …/oidc URL, not the bare host.
IDP_JWKS_URL (alias ZITADEL_JWKS_URL) Public keys the signature is verified against.
IDP_AUDIENCE (alias ZITADEL_OIDC_AUDIENCE) The API-resource indicator; audience is checked only when set.
IDP_USERINFO_URL Legacy OIDC userinfo enrichment (Zitadel rollback only); empty for Logto.
LOGTO_ENDPOINT / LOGTO_M2M_CLIENT_ID / LOGTO_M2M_CLIENT_SECRET Management-API M2M creds for profile enrichment (GET /api/users/{sub}).
LOGTO_MANAGEMENT_RESOURCE / LOGTO_TOKEN_URL Management API indicator + token endpoint (both default sanely).
AUTH_PROVIDER_NAME Provider label stored on the identity link (default logto).
SIGNUP_GRANT_TOKENS Starter grant size on first provision (default 1000; 0 disables).
REDIS_ADDR (alias REDIS_URL) / REDIS_PASSWORD Optional — the JIT-migration wake push to core-migration.
APP_PORT / ENVIRONMENT Ingress port (default 80) and the env label that namespaces the Redis wake key.
core-identity
uv sync # installs deps incl. private soundverse-proto
cp .env.example .env # fill values; set CORE_DATABASE_USE_TLS=false locally
uv run hypercorn app.main:app -b 0.0.0.0:8000
uv run pytest -q # fully mocked — no network needed

Deploy by pushing to staging / prod: the ACA template builds the image and deploys core-identity-{staging,prod} with ingress_transport: http, target_port: 80, health_check_path: /healthz. Logto itself is deployed separately (self-hosted ghcr.io/logto-io/logto). See Deploy a service.