Migration from Duende IdentityServer
The Authagonal.Migration package performs a one-time migration from Duende IdentityServer + SQL
Server into Authagonal’s stores. The same engine is available two ways:
- Hosted runner (recommended) — a background service inside your Authagonal host that runs the migration once on deploy, gated on cluster leadership, without blocking startup.
- CLI —
tools/Authagonal.Migration.Cli, for local/offline runs against a Table Storage target.
SqlClient lives only in this package, so hosts that don’t migrate never inherit it.
Hosted runner
Add it after AddAuthagonal (it depends on the stores, the secret provider, and cluster leadership):
builder.Services.AddAuthagonal(builder.Configuration, c => c.UseAzureStorage(blob, table));
builder.Services.AddAuthagonalDuendeMigration(builder.Configuration);
var app = builder.Build();
app.MapAuthagonalEndpoints();
app.MapAuthagonalDuendeMigration(); // GET /admin/migration/status
The second Map call is required and separate: this package references Authagonal.Server, so
MapAuthagonalEndpoints cannot reach it. Without it GET /admin/migration/status answers 404 —
indistinguishable from the IdentityAdmin policy refusing you — and the run logs a warning at startup
saying so.
Configure via the Migration section:
{
"Migration": {
"Enabled": true,
"DryRun": false,
"Version": "1",
"UsersMode": "CreateOnly",
"MigrateClients": true,
"MigrateRefreshTokens": false,
"LeaseWaitMinutes": 10,
"StartupDelaySeconds": 30,
"Source": { "ConnectionString": "Server=...;Database=Identity;..." }
}
}
The runner:
- Waits
StartupDelaySeconds(seed services finish first; startup is never blocked). - Skips if a
Completed, non-DryRunmarker already exists forVersion. - Waits up to
LeaseWaitMinutesto become cluster leader (only one pod runs the migration). - Writes a
Startedmarker, runs the engine, then aCompleted/Failedmarker with the report.
Losing leadership mid-run cancels the engine; the new leader re-runs — safe because every pass is
idempotent. Check progress at GET /admin/migration/status (gated by the IdentityAdmin policy).
CLI
docker run authagonal-migration \
--Source:ConnectionString "Server=sql.example.com;Database=Identity;User Id=...;Password=...;" \
--Target:ConnectionString "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;TableEndpoint=https://..." \
--DryRun true --UsersMode CreateOnly
(No -- separator after the image name.) Or from source:
dotnet run --project tools/Authagonal.Migration.Cli -- \
--Source:ConnectionString "Server=...;Database=...;" \
--Target:ConnectionString "DefaultEndpointsProtocol=https;..." \
--DryRun true
What gets migrated
| Source (SQL Server) | Target | Notes |
|---|---|---|
AspNetUsers + AspNetUserClaims |
Users + email/name indexes | Ids preserved verbatim. Claim folding: given_name→FirstName, family_name→LastName, company→CompanyName, org_id→OrganizationId (xmlsoap variants too); email claims dropped; everything else → custom attributes. Null password hashes (external-SSO-only users) are fine. BCrypt / ASP.NET Identity V3 hashes verify unchanged and upgrade to native PBKDF2 on next login. |
AspNetUserLogins |
UserLogins | 409 Conflict = skip (idempotent) |
AspNetRoles + AspNetUserRoles |
Roles + user role links | Role id→name map resolves user assignments |
ApiScopes + IdentityResources |
Scopes | Existing (seed) names skipped; scope claims copied |
Duende Clients + child tables |
Clients | Secrets tagged SHA256$/SHA512$ by digest length (others dropped with a warning); expired secrets skipped; config-seeded clients win (skipped) |
Duende ApiResources |
(flattened) | Audiences → migration-created clients; resource claims → migration-created scopes |
SamlProviderConfigurations |
SamlProviders + SsoDomains | AllowedDomains CSV split into SSO domain records |
OidcProviderConfigurations |
OidcProviders + SsoDomains | Same domain splitting |
AspNetUserTokens (AuthenticatorKey, RecoveryCodes) |
MfaCredentials | TOTP secret base32→protected (duende-totp); recovery codes hashed (duende-rc-{n}); user skipped if MFA already present |
Duende PersistedGrants (refresh tokens) |
Grants | Not possible against stock Duende — see below. Requires MigrateRefreshTokens and SourceGrantKeysAreUnhashed; otherwise skipped with a warning and users re-login. |
Options
| Option | Default | Description |
|---|---|---|
Enabled |
false |
Master switch for the hosted runner |
DryRun |
false |
Walk the source and produce the full validation report (id charset/length, duplicate emails, table/column inventory, per-pass counts) without writing |
Version |
"1" |
Run marker. Bump to re-run a delta sweep. Only a Completed, non-DryRun marker blocks a re-run |
UsersMode |
CreateOnly |
CreateOnly skips existing users; Upsert overwrites. Never Upsert post-cutover — it clobbers rehashed passwords and new MFA |
MigrateClients |
true |
Migrate OAuth clients |
MigrateRefreshTokens |
false |
Include active refresh tokens. Requires SourceGrantKeysAreUnhashed |
SourceGrantKeysAreUnhashed |
false |
Asserts the source PersistedGrants.Key holds handles verbatim. Only true for a fork with a custom grant store |
Idempotency & delta sweeps
Every pass is idempotent (skip-if-exists, deterministic MFA ids), so the migration is safe to re-run.
Run it days ahead of cutover, then bump Version for a final delta sweep close to cutover to pick up
users registered since. Existing records are skipped (or updated under Upsert), never duplicated.
What is NOT migrated
- Live refresh tokens, against stock Duende. Duende’s
DefaultGrantStorenever persists a refresh-token handle:PersistedGrants.Keyholdsbase64(SHA-256(handle + ":" + grantType)), and the presented handle is hashed again on lookup. The handle is therefore not recoverable from the source database, and migrated rows would be permanently unredeemable — which is worse than not migrating, because the report counts them as created and the breakage only surfaces at the first token refresh after cutover. Plan the cutover around one re-login, or run a dual-read shim during the transition window.SourceGrantKeysAreUnhashedexists only for a fork whose grant store persists handles verbatim, and such a fork also owns translatingPersistedGrants.Datafrom Duende’sRefreshTokenshape intoRefreshTokenData. - SCIM tokens and groups, user provisions — no Duende equivalent; start empty.
- Signing keys — not automated. To keep existing tokens valid across cutover, export the RSA
signing key from Duende and import it into the
SigningKeystable close to cutover.
Cutover strategy
- Deploy dark (
Enabled=false). Enabled=true, DryRun=true→ restart → review the report at/admin/migration/status.DryRun=false→ restart → verify the marker isCompleted+ spot-check logins.- Bump
Versionfor the final delta sweep, then repoint clients/BFFs to Authagonal. Expect one forced re-login — see below. - Monitor; rollback = repoint to the untouched Duende deployment.