Skip to content

Auth0 to OpenIddict Migration

Superseded — read this before anything below it.

This is the original December 2025 migration plan. Its last content change was 2026-05-04 (14f2dd375) and its front matter still claimed updated: 2025-12-16 until 2026-08-25. The migration it describes is live and ongoing; this document is not the plan being followed. Several statements below are now contradicted by the merged code, and it is the document a newcomer is most likely to find first — which is why it is corrected here rather than quietly edited or deleted.

The authoritative documents are:

The design record below is retained because the options it weighs (a separate Identity service, MongoDB stores, forced password reset) are the decisions that were taken, and the reasoning is still worth having.

Corrections — where this document and the code disagree (2026-08-27)

Reconciled against current SyRF main through #2905 (14755a8b2). Test totals below are measured on that final stacked candidate rather than carried forward from an earlier merge.

This document says What the code does
"Identity Linking — Not currently used — N/A, can add later if needed" External identity linking is implemented, and is one of the four native-parity workstreams (PR C, #2808). Link and unlink require a five-minute account/security-stamp-bound proof, now bound to the exact operation target by #2902 (src/services/identity/SyRF.Identity.Endpoint/Services/StepUpProofService.cs:40-52,94-120), run through Services/ExternalIdentityLifecycleService.cs, and are exposed at Pages/Account/Manage/ExternalLogins.cshtml.cs. A provider account links to at most one SyRF account (Data/IdentityUserIndexInitializer.cs:82-90), and email alone never links.
Stage 6: "Update DNS for signin.syrf.org.uk" and "Custom Domain — signin.syrf.org.uk — DNS + certificate setup" Superseded and now a locked boundary in the opposite direction: signin.syrf.org.uk remains the Auth0 custom domain until the final authorised S22 action. OpenIddict is planned to be served at identity.staging.syrf.org.uk and identity.syrf.org.uk (M005-CONTEXT, "Locked Boundaries"). Neither host is deployed: cluster-gitops@e25ca2ed declares and pins disabled staging Identity 1.24.0 / SyRF 8053842, but the selector creates no Argo Application while service.enabled: false; read-only inspection on 2026-08-27 found no Identity workload, Service, Ingress or DNS record, and no production Identity registration exists. The pin predates #2905, so it is not the eventual S08 candidate.
Stage 4 and the Angular snippet: replace @auth0/auth0-angular with angular-auth-oidc-client The browser gets no OIDC client library. The target flow is Angular → same-origin BFF → HttpOnly session cookie (src/services/api/SyRF.API.Endpoint/Auth/BffAuthController.cs, Auth/SessionAuthenticationHandler.cs), with provider, canonical issuer and an immutable generation in the session namespace (Auth/SessionKeySpace.cs:7-38). @auth0/auth0-angular is still a dependency (src/services/web/package.json:44) and is removed at S23, after the 28-day observation gate — not now.
The API snippet: point options.Authority at signin.syrf.org.uk The API keeps Auth0 JWT validation and adds OpenIddict introspection alongside it, selected per token by wire format (src/services/api/SyRF.API.Endpoint/Program.cs:342,407; Auth/DirectBearerSchemeSelector.cs:13). Introspection enforces the exact API audience and syrf_api, and fails closed.
The custom-claims snippet: add user_id from a local userId The invariant is stricter. sub is the immutable Identity account id; the namespaced user_id is a verified InvestigatorId, is never substituted, and issuance fails closed when the mapping is missing or ambiguous (SyRF.Identity.Endpoint/Controllers/AuthorizationController.cs:108,250,339-349; Services/UserClaimsService.cs:21-25; SyRF.ProjectManagement.Core/Services/ApplicationService.cs:111-140). One caveat on that citation: :339-349 is the /connect/userinfo gate (:339 guards a null user or absent SyrfUserId; :344-349 adds the account-version match and the admission evaluation), and userinfo enforces the same fail-closed rule but does not share the claim producer — it allocates its own dictionary at :352 instead of calling UserClaimsService, which is recorded as an open divergence in M005-VALIDATION.md. The transitional identities claim this document's export sample implies was decided against and exists nowhere in the code.
Required collections: IdentityUsers plus the four OpenIddict stores The Identity database also holds IdentityInvestigatorMappings — a permanent one-row-per-InvestigatorId tombstone that survives account deletion — and IdentityEmailReservations, which serialises claims on canonical email addresses.
Stage 2: "Social login providers (Google, Apple, Facebook)" Google only. A provider that does not assert a verified email cannot create an account at all today; that path refuses and directs the user to local registration (Pages/Account/ExternalLogin.cshtml.cs:209-212).
Stage 5: import users, send reset emails, "all users migrated" Migration additionally preserves per-source verification provenance, the historical app_metadata.syrf_id answers, and Auth0 blocked independently of Investigator Deactivated (SyRF.Identity.Migration/UserImporter.cs:210-213,542,589-596; InvestigatorReconciler.cs:182-184). Unconfirmed and profile-incomplete users are held in an Identity-only middle step rather than admitted (SyRF.Identity.Endpoint/Services/IdentityAdmissionMiddleware.cs:31-72). No production import has run.
"Next Steps: create identity service project structure / set up OpenIddict / implement basic authorization endpoints" All done. src/services/identity/ exists and its final stacked suite runs 1,789 tests with zero failures (1,112 endpoint + 677 migration at 14755a8b2, 2026-08-27). The live work is Phase 5 cutover and decommission, which has not reached staging: the staging Identity registration in cluster-gitops is deliberately disabled and no production registration exists.

The user-count, timeline and success-criteria figures below were not re-verified for this correction; treat them as the December 2025 record.

Overview

This document outlines the migration strategy from Auth0 to OpenIddict for authentication and authorization in SyRF. The migration is driven by cost considerations while maintaining all existing functionality.

Current State Analysis (as at 2025-12-16 — superseded)

Auth0 Features Currently Used

Feature Description Migration Complexity
JWT Bearer Auth Standard OIDC/OAuth2 with RS256 Low - OpenIddict native support
Custom Domain signin.syrf.org.uk Medium - DNS + certificate setup
Management API User CRUD, email verification, password reset High - Must implement equivalent
Social Logins Google only (custom credentials) Low - Reuse existing OAuth credentials
Identity Linking Not currently used Superseded — implemented in PR C (#2808); see the correction table above N/A - Can add later if needed
Custom Claims SYRF namespace claims in tokens Low - OpenIddict supports this
Refresh Tokens Offline access with rotation Low - OpenIddict native support
Email Verification Ticket-based verification links Medium - Email service integration
Password Reset Ticket-based reset links Medium - Email service integration

Files Requiring Changes

Backend (.NET) - High Impact: - src/services/api/SyRF.API.Endpoint/Program.cs - JWT configuration - src/services/api/SyRF.API.Endpoint/Services/Auth0Config.cs - Replace with OpenIddict config - src/services/api/SyRF.API.Endpoint/Services/Auth0Service.cs - Replace with local user service - src/services/api/SyRF.API.Endpoint/Services/AuthManagementApiClientProvider.cs - Remove (not needed) - src/services/api/SyRF.API.Endpoint/Controllers/AccountController.cs - Major refactor - src/services/api/SyRF.API.Endpoint/Handlers/Investigator*Handler.cs - Remove Auth0 sync

Frontend (Angular) - High Impact: - src/services/web/src/main.ts - Replace Auth0 module - src/services/web/src/app/core/auth/auth.effects.ts - Major refactor - src/services/web/src/app/core/http-interceptors/auth-interceptor.service.ts - Replace - src/services/web/package.json - Replace Auth0 packages

Shared Libraries - Low Impact: - src/libs/kernel/SyRF.SharedKernel/Constants.cs - Keep claim types (minor updates) - src/libs/webhostconfig/SyRF.WebHostConfig.Common/Infrastructure/CurrentUserService.cs - Minor updates


Architecture Decision: OpenIddict Deployment

Create a dedicated identity server as a new service in the monorepo.

src/services/
├── api/                        # Existing - becomes resource server only
├── project-management/         # Existing - resource server
├── identity/                   # NEW - OpenIddict authorization server
│   ├── SyRF.Identity.Endpoint/
│   │   ├── Controllers/
│   │   │   ├── AuthorizationController.cs
│   │   │   ├── AccountController.cs      # Moved from API
│   │   │   ├── UserInfoController.cs
│   │   │   └── TokenController.cs
│   │   ├── Services/
│   │   ├── Views/              # Login, consent, error pages
│   │   └── Program.cs
│   ├── SyRF.Identity.Core/
│   │   └── Entities/           # ApplicationUser, etc.
│   └── Dockerfile
├── web/
└── quartz/

Pros: - Clean separation of concerns - Independent scaling of identity service - Follows microservices best practices - Easier to maintain and update - Can be deployed to different infrastructure if needed

Cons: - New service to deploy and maintain - Additional infrastructure cost (minimal) - Cross-service database access for user data

Option B: Integrated with API Service

Embed OpenIddict server within the existing API service.

Pros: - Simpler deployment (no new service) - Direct database access for user data - Fewer moving parts

Cons: - Couples identity concerns with business logic - Harder to scale independently - Larger API service codebase - Not recommended by OpenIddict documentation

Recommendation: Option A (New Identity Service)

The identity service should be separate for: 1. Security isolation 2. Independent scaling 3. Cleaner architecture 4. Follows industry best practices


Database Strategy

Use existing MongoDB infrastructure with OpenIddict MongoDB stores.

// OpenIddict with MongoDB
services.AddOpenIddict()
    .AddCore(options =>
    {
        options.UseMongoDb()
            .UseDatabase(mongoClient.GetDatabase("syrf-identity"));
    });

Required Collections: - OpenIddictApplications - Registered clients (web app, API) - OpenIddictAuthorizations - Active authorizations - OpenIddictScopes - Defined scopes - OpenIddictTokens - Issued tokens (for revocation) - IdentityUsers - User accounts (email, password hash, claims)

Packages: - OpenIddict.MongoDb - MongoDB stores for OpenIddict - AspNetCore.Identity.MongoDbCore - ASP.NET Core Identity with MongoDB

Option 2: Entity Framework Core with PostgreSQL

Add a new PostgreSQL database for identity data.

Pros: - More common OpenIddict setup - Better tooling for identity management

Cons: - Additional database to manage - Different technology stack - Migration complexity

Recommendation: Option 1 (MongoDB)

Continue using MongoDB for consistency with existing infrastructure.


User Migration Strategy

Phase 1: Data Export from Auth0

Export all users from Auth0 Management API:

{
  "user_id": "auth0|abc123",
  "email": "user@example.com",
  "email_verified": true,
  "name": "John Doe",
  "given_name": "John",
  "family_name": "Doe",
  "picture": "https://...",
  "identities": [
    {
      "connection": "Username-Password-Authentication",
      "user_id": "abc123",
      "provider": "auth0"
    },
    {
      "connection": "google-oauth2",
      "user_id": "google123",
      "provider": "google-oauth2"
    }
  ],
  "app_metadata": {
    "syrf_groups": "admin researcher"
  }
}

Phase 2: Password Handling

Challenge: Auth0 password hashes cannot be exported (security feature).

Solutions:

  1. Password Reset Required (Recommended)
  2. Import users with email only
  3. Force password reset on first login
  4. Send bulk password reset emails

  5. Lazy Migration with Auth0 Fallback

  6. Keep Auth0 running temporarily
  7. On login failure in OpenIddict, try Auth0
  8. If Auth0 succeeds, migrate password hash
  9. Eventually disable Auth0 fallback

  10. Magic Link Only Initially

  11. Disable password login temporarily
  12. Use email magic links for authentication
  13. Allow users to set password after login

Recommendation: Option 1 (Password Reset Required)

Simplest and most secure approach. Users reset passwords once during migration.

Phase 3: Social Login Migration

Only Google social login is currently configured. Since SyRF uses custom Google OAuth credentials in Auth0 (not Auth0's built-in connection), these credentials can be directly transferred to OpenIddict.

services.AddAuthentication()
    .AddGoogle(options =>
    {
        // Reuse existing credentials from Auth0
        options.ClientId = "<existing-google-client-id>";
        options.ClientSecret = "<existing-google-client-secret>";
    });

Key Benefits of Credential Reuse: - ✅ Users will NOT see Google's consent screen again (consent is tied to Client ID) - ✅ Existing Google user IDs remain valid - ✅ Seamless experience for social login users

Migration Process: 1. Export Google OAuth Client ID and Secret from Auth0 Dashboard 2. Configure the same credentials in OpenIddict 3. Import external login mappings from Auth0 user export 4. Users can log in with Google immediately - no re-linking required


Implementation Plan

Stage 1: Identity Service Foundation

Duration: 1-2 weeks

  1. Create new src/services/identity/ service structure
  2. Set up OpenIddict with MongoDB stores
  3. Configure ASP.NET Core Identity with MongoDB
  4. Implement basic endpoints:
  5. /.well-known/openid-configuration
  6. /connect/authorize
  7. /connect/token
  8. /connect/userinfo
  9. Create login/logout views (Razor Pages or MVC)
  10. Configure development certificates
  11. Add to Docker and Helm charts

Deliverables: - Working identity server with local user registration - OpenID Connect discovery endpoint - Authorization code flow working

Stage 2: Feature Parity

Duration: 1-2 weeks

  1. Email verification flow
  2. Password reset flow
  3. Profile management endpoints
  4. Custom claims configuration
  5. Refresh token support
  6. Social login providers (Google, Apple, Facebook)

Deliverables: - Email service integration - All account management features working - Social login working

Stage 3: API Service Updates

Duration: 1 week

  1. Update JWT Bearer configuration to use OpenIddict
  2. Remove Auth0-specific services
  3. Update authorization handlers (minimal changes)
  4. Update claim extraction (if namespace changes)
  5. Remove Auth0 sync handlers
  6. Update Swagger OAuth configuration

Deliverables: - API validates tokens from OpenIddict - All endpoints working with new auth

Stage 4: Frontend Updates

Duration: 1-2 weeks

  1. Replace @auth0/auth0-angular with OIDC client library
  2. Update login/logout flows
  3. Update token handling
  4. Update HTTP interceptor
  5. Update user profile management
  6. Update ngrx auth state

Recommended Package: angular-auth-oidc-client

Deliverables: - Frontend authenticates with OpenIddict - All user flows working

Stage 5: User Migration

Duration: 1 week

  1. Export users from Auth0
  2. Import users to OpenIddict database
  3. Send password reset emails
  4. Monitor migration progress
  5. Handle edge cases

Deliverables: - All users migrated - Password reset emails sent - Login working for all users

Stage 6: Cutover and Decommission

Duration: 1 week

  1. Update DNS for signin.syrf.org.uk
  2. Switch production to OpenIddict
  3. Monitor for issues
  4. Keep Auth0 in read-only mode temporarily
  5. Decommission Auth0 after validation period

Deliverables: - Production running on OpenIddict - Auth0 decommissioned - Cost savings achieved


Technical Implementation Details

OpenIddict Server Configuration

// Program.cs for Identity Service
builder.Services.AddOpenIddict()
    .AddCore(options =>
    {
        options.UseMongoDb()
            .UseDatabase(mongoDatabase);
    })
    .AddServer(options =>
    {
        // Enable authorization code + refresh token flows
        options.AllowAuthorizationCodeFlow()
               .AllowRefreshTokenFlow();

        // Enable PKCE (required for public clients)
        options.RequireProofKeyForCodeExchange();

        // Token endpoints
        options.SetAuthorizationEndpointUris("/connect/authorize")
               .SetTokenEndpointUris("/connect/token")
               .SetUserinfoEndpointUris("/connect/userinfo")
               .SetLogoutEndpointUris("/connect/logout")
               .SetIntrospectionEndpointUris("/connect/introspect");

        // Signing credentials
        options.AddSigningKey(signingKey)
               .AddEncryptionKey(encryptionKey);

        // ASP.NET Core integration
        options.UseAspNetCore()
               .EnableAuthorizationEndpointPassthrough()
               .EnableTokenEndpointPassthrough()
               .EnableUserinfoEndpointPassthrough()
               .EnableLogoutEndpointPassthrough();

        // Token lifetimes
        options.SetAccessTokenLifetime(TimeSpan.FromMinutes(60))
               .SetRefreshTokenLifetime(TimeSpan.FromDays(14));
    })
    .AddValidation(options =>
    {
        options.UseLocalServer();
        options.UseAspNetCore();
    });

Custom Claims Configuration

// Add custom claims to tokens
public class CustomClaimsService : IOpenIddictServerHandler<ProcessSignInContext>
{
    public async ValueTask HandleAsync(ProcessSignInContext context)
    {
        var identity = (ClaimsIdentity)context.Principal!.Identity!;

        // Add SYRF-specific claims
        identity.AddClaim("https://claims.syrf.org.uk/user_id", userId);
        identity.AddClaim("https://claims.syrf.org.uk/syrf_groups", groups);
        identity.AddClaim("https://claims.syrf.org.uk/given_name", givenName);
        // ... etc
    }
}

API JWT Validation Update

// Program.cs for API Service
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://signin.syrf.org.uk"; // Now points to OpenIddict
        options.Audience = "https://syrf-api.syrf.org.uk";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true
        };

        // Keep SignalR token handling
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var accessToken = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;
                if (!string.IsNullOrEmpty(accessToken) &&
                    path.StartsWithSegments("/notifications"))
                {
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });

Angular OIDC Configuration

// Using angular-auth-oidc-client
import { AuthModule, LogLevel } from 'angular-auth-oidc-client';

@NgModule({
  imports: [
    AuthModule.forRoot({
      config: {
        authority: 'https://signin.syrf.org.uk',
        redirectUrl: window.location.origin,
        postLogoutRedirectUri: window.location.origin,
        clientId: 'syrf-web',
        scope: 'openid profile email offline_access',
        responseType: 'code',
        silentRenew: true,
        useRefreshToken: true,
        logLevel: LogLevel.Debug,
        secureRoutes: ['https://api.syrf.org.uk'],
      },
    }),
  ],
})
export class AppModule {}

Risk Assessment

Risk Impact Likelihood Mitigation
Password migration issues High Medium Bulk password reset emails, support process
Social login linking breaks Medium Medium Clear user communication, re-linking flow
Token validation issues High Low Thorough testing, gradual rollout
Performance degradation Medium Low Load testing before cutover
User confusion Medium High Clear communication, help documentation
Rollback needed High Low Keep Auth0 active during transition

Success Criteria

  1. Functional Parity: All Auth0 features replicated in OpenIddict
  2. User Migration: 100% of active users migrated successfully
  3. Zero Downtime: Seamless transition with no service interruption
  4. Performance: Token issuance < 200ms, validation < 50ms
  5. Security: Pass security review, proper key management
  6. Cost Savings: Auth0 subscription eliminated

Questions for User Decision

All Answered ✅

  • User Count: 5,711 users to migrate
  • Custom Domain: Keep signin.syrf.org.uk
  • Email Service: AWS SES for verification and password reset emails
  • Social Login: Google only, using custom OAuth credentials (reusable - no consent screen) ✅
  • Timeline: ASAP - targeting 6-8 week phased rollout
  • Weeks 1-3: Build identity service, staging deployment
  • Week 4: Frontend + API updates in staging
  • Week 5: User migration to staging, internal testing
  • Week 6: Production cutover
  • Weeks 7-8: Monitoring period
  • Rollback Plan: Keep Auth0 active for 4 weeks after production cutover
  • Monitor login success rates
  • All active users should log in at least once
  • Decommission Auth0 after validation period

Next Steps

  1. Review and approve this migration plan
  2. Answer clarifying questions
  3. Create identity service project structure
  4. Set up OpenIddict with MongoDB
  5. Implement basic authorization endpoints
  6. Configure AWS SES for email
  7. Begin frontend migration preparation

References