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.
Endpoints
Section titled “Endpoints”| 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.
Token validation
Section titled “Token validation”Local, provider-agnostic JWKS validation lives in
app/core/auth/token.py:
- Asymmetric algorithms only — the RSA/ECDSA/PSS set (
RS256…PS512), neverHS*/none. Allowing an HMAC alg against a JWKS public key is a classic alg-confusion takeover; this refuses it outright. Logto signs withES384by default;RS256stays allowed for a Zitadel rollback. - Issuer pinned, audience checked when set (
IDP_ISSUER,IDP_AUDIENCE), signature verified against the kid-matched JWKS key.PyJWKClientfetches and caches the keys; the synchronous verify is offloaded to a thread so a cache-miss network fetch never blocks the event loop.
Profile enrichment
Section titled “Profile enrichment”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.
JIT provisioning
Section titled “JIT provisioning”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).
-
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. -
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_verifiedis promoted monotonically. -
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.
-
Create + link, race-safe. Otherwise
CreateUserthenLinkExternalIdentity. If a concurrent first-login won the unique(provider, subject), the loser soft-deletes its freshly-created orphan and returns the authoritative winner. -
Migrate a legacy account, then grant. See the next two sections.
The signup starter grant
Section titled “The signup starter grant”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.
JIT legacy (1.0 → 2.0) migration
Section titled “JIT legacy (1.0 → 2.0) migration”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).
Key files
Section titled “Key files”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
- auth.py
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
Observability
Section titled “Observability”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). |
Configuration
Section titled “Configuration”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. |
Run & test locally
Section titled “Run & test locally”uv sync # installs deps incl. private soundverse-protocp .env.example .env # fill values; set CORE_DATABASE_USE_TLS=false locallyuv run hypercorn app.main:app -b 0.0.0.0:8000uv run pytest -q # fully mocked — no network neededDeploy 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.
Related
Section titled “Related”- Identity & auth: Logto + JIT provisioning — the full auth sequence
- Configure Logto OIDC — the API-resource / JWT gotcha and frontend wiring
- core-database — the IdentityDataService this service calls
- core-migration — where the JIT wake lands
- The token ledger & money path — where the starter grant settles
- Environment variable catalog — every name in one place