Articles · Compliance & Migration

Importing identity

Try out the running example

Nobody adopts an identity system on an empty database. Somewhere there’s an AspNetUsers table, a Keycloak realm, an Auth0 tenant or a Duende config — full of users who must keep logging in with passwords you cannot read. This article is about the machinery that moves them: four importers, one target, one invariant — no password-reset email blast, ever.

One target, four sources

Every importer writes through IImportTarget — upsert-shaped methods over users, credentials, roles, groups, clients. AddSentinelEfImportTarget() implements it over the EF stores; the sample runs all four importers against one SQLite-backed host:

var options = new ImportOptions { TargetRealmId = RealmId, DryRun = dryRun };

var aspnet = await new AspNetIdentityImporter(target)
    .ImportAsync(LegacyExports.AspNetIdentity(), options, ct);
var keycloak = await new KeycloakRealmImporter(target, hasher)
    .ImportAsync(LegacyExports.KeycloakRealmExport(), options, ct);
var auth0 = await new Auth0Importer(target)
    .ImportAsync(LegacyExports.Auth0Export(), options, ct);
var duende = await new DuendeConfigImporter(target, hasher)
    .ImportAsync(LegacyExports.DuendeClientsExport(), options, ct);

Each run returns an ImportReport: created/updated/skipped counts per entity, an Issues list with severities, and (for client importers) GeneratedClientSecrets — more on that below.

Two properties hold across all four:

  • DryRun = true writes nothing. Full report, zero rows — the triage list you run in CI against last night’s export before anyone schedules a cutover window (Dry_run_reports_everything_and_writes_nothing).
  • Re-import is idempotent. Entities match by natural key — email, imported: role key, group key, clientId — so re-running updates in place and never duplicates. A delta export the night before cutover is routine, not scary (Reimport_is_idempotent_updating_instead_of_duplicating).

Imported roles arrive prefixed (Support Agentimported:support-agent) so they can never collide with the catalog your declarative config declares.

ASP.NET Core Identity: the hash rides along

The Identity importer reads the classic four tables — AspNetUsers, AspNetRoles, AspNetUserRoles, AspNetUserClaims (claims become user attributes) — through an IAspNetIdentitySource you implement over your legacy connection. The interesting part is the password column: Identity V3’s PBKDF2 blob is carried verbatim, tagged aspnet-identity-v3:

services.AddSingleton(new PasswordHasher(
    new Argon2idPasswordHashAlgorithm(memoryKib: 8, iterations: 1, parallelism: 1), // cheap: teaching code
    [new Pbkdf2PasswordHashAlgorithm(), .. ImporterHashAlgorithms.All()]));

The first argument is what new hashes are minted with (argon2id); the accepted set adds the foreign verifiers. At login the credential’s algorithm tag picks the verifier, and on success the hash is transparently rehashed to argon2id — the upgrade happens the only moment the plaintext legitimately exists:

GET /migration/credentials/alice@legacy.sample   → { "algorithms": ["aspnet-identity-v3"] }
POST /auth/login  (her password since 2019)      → 200
GET /migration/credentials/alice@legacy.sample   → { "algorithms": ["argon2id"] }

The sample generates its fixture hashes with the actual Microsoft.AspNetCore.Identity hasher, so the coexistence path is exercised against the real producer (Identity_v3_hash_verifies_at_login_and_rehashes_to_argon2id).

Keycloak: the whole realm export

Point KeycloakRealmImporter at the JSON kc.sh export writes and it walks realm roles, groups (by path), users — with pbkdf2-sha256 credentials re-encoded into PHC format for Sentinel’s PBKDF2 verifier, bcrypt ones carried as-is — plus clients: publicClient maps to public/PKCE, plaintext client secrets are re-hashed through your hasher on the way in. Both Keycloak’s older hashedSaltedValue credential field and the newer secretData/credentialData shape are understood. Kara logs in with her Keycloak password on day one (Keycloak_pbkdf2_and_auth0_bcrypt_credentials_both_log_in).

Auth0: ndjson, bcrypt, and the blocked flag

Auth0’s bulk export is newline-delimited JSON; bcrypt hashes appear either as the export’s top-level passwordHash or in the import format’s custom_password_hash.hash.value. Both land as bcrypt credentials that verify natively. email_verified carries over, blocked: true becomes a Sentinel suspension — Dan can’t log in on the new system either, which is the point. Users without a bcrypt hash (social-only accounts) import credential-less — unsupported algorithms get a warning issue — so plan a federated login for them instead.

Duende: rotation-on-migration

IdentityServer stores client secrets as base64(sha256(secret)) — a fine storage format and an unmigratable one: there’s no plaintext to re-hash into argon2id. The importer refuses to pretend otherwise. For every confidential client it generates a fresh secret, stores only its hash, and surfaces the plaintext exactly once, in the report:

var generated = report.GeneratedClientSecrets;   // { "legacy-mvc": "<fresh 256-bit secret>" }

That dictionary is your rotation checklist: hand each value to the owning team, update their config, done. The old secret is dead on arrival —

hasher.VerifyAndUpgrade(newSecret, mvc.SecretAlgorithm!, mvc.SecretHash!, out _)  // true
hasher.VerifyAndUpgrade("old-mvc-secret", mvc.SecretAlgorithm!, mvc.SecretHash!, out _)  // false

— and public clients, having no secret, don’t appear (Duende_sha256_secrets_trigger_rotation_on_migration). Grant types, PKCE flags, redirect URIs and scopes map onto the OIDC server’s client model; the exotic ones (device flow, CIBA) come back as issues rather than silently dropping.

The import is the easy half

Users in, credentials verifying, clients rotated — that’s the data. The migration risk lives in the other half: does the new engine make the same authorization decisions the old code did? That’s a different article — shadow-mode migration runs Sentinel’s evaluator silently beside your legacy checks and gates cutover on a divergence counter reading zero. The pipeline is deliberately staged: dry-run → import → login natively → shadow → cutover, and each stage is a test you can re-run, not a leap you take once.