Signing Users In Through an External Identity Provider
Introduction
Your users may already have an account somewhere else: Google, Microsoft, a partner company's own identity provider. You can let them sign in with it and still issue your own tokens. The login page of your provider gets a "Sign in with Google" button; the user signs in at Google, comes back, and your provider signs them in locally and finishes the request it was handling. Your apps keep talking only to your provider, and the tokens they receive carry your issuer and your user identifiers, whichever external provider the user came through.
Abblix OIDC Server does not talk to the external provider itself. Your provider reaches it through the standard ASP.NET Core OpenID Connect handler, the same AddOpenIdConnect a web app uses to sign in against any provider. The library needs one thing back: the local user who signed in, reported through IAuthSessionService.SignInAsync, exactly as a password login reports it.
This guide extends OpenIDProviderApp.MinimalApi from the Getting Started repository, which runs at https://localhost:5006. It connects Google first, then adds Microsoft Entra ID as a second external provider and shows what changes when a provider works differently from Google.
You need the .NET 10 SDK and a trusted development certificate, so that the sample apps can call each other over HTTPS:
dotnet dev-certs https --trust
git clone https://github.com/Abblix/Oidc.Server.GettingStarted.git
cd Oidc.Server.GettingStartedThat folder is the repository root; every command below says whether it runs there or in OpenIDProviderApp.MinimalApi.
How the sign-in travels
An app sends the user to your authorization endpoint. Your provider has no session for them, stores the request and redirects to its login page with a request_uri that points to the stored request. The user picks "Sign in with Google". Your provider redirects to Google's authorization endpoint as an ordinary OpenID Connect client, the user signs in there, and Google posts an authorization code back to your provider at /signin-google. The handler exchanges the code and validates Google's ID token, which carries the user's claims. It keeps the result in a separate cookie and sends the browser on to an endpoint of yours.
There your code takes over. It finds or creates the local user linked to the Google account, signs that user in with SignInAsync, and redirects back to the authorization endpoint with the same request_uri. The library picks up the stored request, sees a session and answers the app as if the user had typed a password.
Register your provider with Google
To Google, your provider is just a web application that signs its users in with OpenID Connect. Whatever the external provider's console looks like, you register the same things for a server-side app like this one: a confidential client (one that keeps a secret), the authorization code flow, and the exact address the user is sent back to. In return you get a client ID and a credential. The ASP.NET Core handler sends the client ID and secret in the body of the token request (client_secret_post). Google and Microsoft accept that; a provider that accepts only another method needs code in the handler's OnAuthorizationCodeReceived event.
In the Google Cloud console, create a project for your provider, then open Google Auth Platform:
- In Branding, Audience and Contact information, give the app the name users will see on Google's consent screen. Choose External as the user type unless only accounts of your own Google Workspace organization should sign in; External is the only option for an account outside an organization, such as a personal Gmail address.
- An External app starts in Testing mode. Only the accounts you add under Audience as test users can sign in, at most a hundred, and each one counts against that limit from the moment it is added. That is enough to follow this guide; before real users sign in, the app has to be published from the same Audience page.
- Under Clients, create a client of type Web application and add
https://localhost:5006/signin-googleas an authorized redirect URI. Google compares it with the one in each request exactly, including the scheme, the case and a trailing slash.
Google shows the client secret once, when the client is created, so copy it then. Keep both values out of the code, in the user secrets of the project. From the repository root:
cd OpenIDProviderApp.MinimalApi
dotnet user-secrets init
dotnet user-secrets set "Authentication:Google:ClientId" "YOUR_CLIENT_ID"
dotnet user-secrets set "Authentication:Google:ClientSecret" "YOUR_CLIENT_SECRET"User secrets are read only when the app runs in the Development environment, which the sample's launch profile sets. A deployed provider takes the same keys from its own secret store.
Add the OpenID Connect handler
Add the handler package, in the version that matches the project's target framework, from OpenIDProviderApp.MinimalApi:
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect --version 10.0.*In OpenIDProviderApp.MinimalApi/Program.cs, add to the using directives at the top of the file:
using Microsoft.AspNetCore.Authentication;Then replace the line builder.Services.AddAuthentication().AddCookie(); with:
builder.Services.AddAuthentication()
.AddCookie()
// Holds the external provider's answer between its callback and the local sign-in
.AddCookie("External", options => options.ExpireTimeSpan = TimeSpan.FromMinutes(5))
.AddOpenIdConnect("google", "Google", options =>
{
options.Authority = "https://accounts.google.com";
options.ClientId = builder.Configuration["Authentication:Google:ClientId"];
options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
options.ResponseType = "code";
options.CallbackPath = "/signin-google";
options.SignInScheme = "External";
options.MapInboundClaims = false;
options.Scope.Add("email");
options.Events.OnRemoteFailure = BackToLogin;
});
// The user canceled at the external provider, or it refused: back to the login page
static Task BackToLogin(RemoteFailureContext context)
{
context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>()
.LogWarning(context.Failure, "External sign-in failed");
if (context.Properties?.GetString("request_uri") is { } requestUri)
context.Response.Redirect($"/Auth/Login?request_uri={Uri.EscapeDataString(requestUri)}&error=external");
else
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.HandleResponse();
return Task.CompletedTask;
}Finally, add app.UseAuthentication(); after app.UseCors();. The provider has needed no authentication middleware so far, because the library reads its own session cookie itself; the external handler is the first part that depends on the middleware, which is where it receives Google's answer.
What the settings do:
- The default cookie holds your provider's session, the one the library reads.
- The
Externalcookie keeps Google's answer for the moment between/signin-googleand your own sign-in, and expires after five minutes if the browser never gets that far. Setting it as the handler'sSignInSchemekeeps Google's user out of your provider's session: nobody is signed in to your provider until your code says who they are. Authorityis Google's issuer. The handler reads Google's discovery document from it to find the endpoints and the signing keys.CallbackPathis the address Google sends the user back to, the one registered in the console. The handler serves it; there is no endpoint to write.MapInboundClaimsset tofalsekeeps the claim names as Google sent them,sub,emailandname, instead of the long .NET claim type URIs.- The handler asks for
openidandprofileon its own;emailis added here. Google puts the profile and the email address straight into the ID token, so the handler does not need to call Google's userinfo endpoint. A provider that sends only identifiers in its ID token needsGetClaimsFromUserInfoEndpointset totrue. OnRemoteFailurehandles a sign-in that fails or is canceled at Google, for example when the user cancels and Google answerserror=access_denied. Without it, the handler throws, and the user sees an error page from your provider. It also catches configuration errors, such as a wrong client secret, which is why it logscontext.Failurebefore sending the user back.
The handler also does several things you do not configure: it uses PKCE, asks for the code as a form post, and from .NET 9 sends the request through the external provider's pushed authorization endpoint when the discovery document lists one. Google lists none, so the request goes through the browser as usual.
Link the external account to a local user
The endpoint that finishes the external sign-in, added in the next section, needs one method from your user store: given the external provider's issuer and subject, return the local subject, and create a local user when the external account signs in for the first time. In a real store this is a table of external logins next to the users table, with a unique index on the issuer and subject pair. For the sample, change TestUserStore in OpenIDProviderApp.MinimalApi/TestUserStore.cs to accept new users and remember the links.
Keep the file's using directives, its namespace and GetUserInfoAsync as they are, and add using System.Collections.Concurrent; to the directives. Replace the TestUser record, the class declaration line and TryAuthenticate, and add the two fields and FindOrCreateExternal, so that the file reads:
public record TestUser(string Subject, string Name, string Email, string? Password);
public class TestUserStore(params TestUser[] seed) : IUserInfoProvider
{
private readonly ConcurrentBag<TestUser> users = new(seed);
private readonly ConcurrentDictionary<(string Issuer, string Subject), string> externalLogins = new();
// GetUserInfoAsync stays as it is
public bool TryAuthenticate(
string email,
string password,
[NotNullWhen(true)] out string? subject)
{
var user = users.FirstOrDefault(u => u.Password != null && u.Email == email && u.Password == password);
subject = user?.Subject;
return subject != null;
}
public string FindOrCreateExternal(string issuer, string externalSubject, string? name, string? email)
=> externalLogins.GetOrAdd((issuer, externalSubject), _ =>
{
var user = new TestUser(Guid.NewGuid().ToString("N"), name ?? "", email ?? "", Password: null);
users.Add(user);
return user.Subject;
});
}The constructor parameter is now seed, and users is the field built from it, so GetUserInfoAsync reads the users added later as well as the seeded ones. A user created this way has no password, and TryAuthenticate skips such users, so nobody can sign in to that account with an empty password.
The link is keyed by the external provider's issuer and subject, not by the email address. The subject is the external provider's stable identifier for the account; an email address can be changed, and at some providers handed to another person later. The issuer in the key also keeps two accounts from different providers apart when they happen to share a subject value. If other code writes links too, store one fixed form of each issuer or a provider key of your own: the handler accepts only the issuer in Google's discovery document, https://accounts.google.com, while Google documents accounts.google.com as a valid iss as well.
Matching on email instead lets anyone who controls an address at a careless provider take over the local account registered with it. Attach an external account to an existing local user only while that user is signed in to your provider and has just entered their password again, or when you verified the local address yourself and the external provider is authoritative for it. Without that, two attacks open up. In pre-account hijacking, an attacker registers a local account with the victim's address before the victim does and keeps a way into it after the victim's external account is merged in. In an MFA bypass, a sign-in at an external provider with weaker checks lands in a local account that your own second factor was meant to protect.
With this code, a password user who picks "Sign in with Google" gets a second, new local account. Linking the two needs its own endpoint behind a signed-in session that has just been authenticated again, and unlinking must never remove the last way the user has to sign in.
Creating a user on the first sign-in admits everyone the external provider admits. With a published Google app, or a Microsoft registration open to any tenant, that is anyone with an account there. If only some of them should get in, check before calling FindOrCreateExternal, and send everyone else back to the login page. For members of one Google Workspace organization, Google sends the hd claim with the organization's domain and requires you to check it rather than the domain of the email address; the claim is absent for an account outside an organization. For Entra, the organizations are filtered earlier, in the handler's issuer check on the tid claim, covered below.
The sample also copies the name and email only on the first sign-in and stores an empty string when the external provider sends no email; a real store updates the profile on each sign-in if the external provider's copy should win, and leaves a missing claim out.
Send the user to Google and back
Add to the using directives at the top of Program.cs:
using System.Security.Claims;Then add the list of external providers, two endpoints and a helper after the app.MapPost("/Auth/Login", ...) block, before app.Run();:
// The schemes /Auth/External accepts; the login page links to each by hand
string[] externalProviders = ["google"];
// Sends the user to the chosen provider, keeping the pending authorization request to resume afterwards
app.MapGet("/Auth/External", (string provider, string request_uri) =>
{
if (!externalProviders.Contains(provider))
return Results.BadRequest();
var properties = new AuthenticationProperties { RedirectUri = "/Auth/External/Callback" };
properties.SetString("request_uri", request_uri);
properties.SetString("provider", provider);
return Results.Challenge(properties, [provider]);
});
app.MapGet("/Auth/External/Callback", async (
HttpContext httpContext,
IAuthSessionService authService,
ISessionIdGenerator sessionIdGenerator,
TimeProvider timeProvider,
TestUserStore userStore) =>
{
var external = await httpContext.AuthenticateAsync("External");
if (external is not { Principal: { } principal, Properties: { } properties } ||
principal.FindFirst("sub") is not { } externalSubject ||
properties.GetString("request_uri") is not { } requestUri ||
properties.GetString("provider") is not { } provider)
return Results.BadRequest();
await httpContext.SignOutAsync("External");
// The external account identifies the user; an email address can pass to another person
var email = principal.FindFirstValue("email");
var subject = userStore.FindOrCreateExternal(
externalSubject.Issuer,
externalSubject.Value,
principal.FindFirstValue("name"),
email);
await authService.SignInAsync(new AuthSession(
subject,
sessionIdGenerator.GenerateSessionId(),
timeProvider.GetUtcNow(),
IdentityProvider: provider)
{
Email = email,
EmailVerified = IsEmailVerified(provider, principal, email),
});
return Results.Redirect($"/connect/authorize?request_uri={Uri.EscapeDataString(requestUri)}");
});
// Whether the external provider vouches that the address belongs to this account
static bool? IsEmailVerified(string provider, ClaimsPrincipal principal, string? email)
{
if (email == null)
return null;
return provider switch
{
// Google is authoritative only for Gmail addresses and for Workspace accounts, which carry hd
"google" => IsTrue(principal, "email_verified") &&
(email.EndsWith("@gmail.com", StringComparison.OrdinalIgnoreCase) || principal.HasClaim(c => c.Type == "hd")),
_ => throw new InvalidOperationException($"No email verification rule for the provider {provider}"),
};
}
static bool IsTrue(ClaimsPrincipal principal, string claimType)
=> bool.TryParse(principal.FindFirstValue(claimType), out var value) && value;The first endpoint starts the handler's sign-in at the external provider the user picked, after checking it against the list: ASP.NET Core throws for a scheme that is not registered, and a registered one such as the default cookie is not an external provider. The request_uri of the pending authorization request travels inside the handler's own state together with the provider name, so both come back with the answer. OnRemoteFailure uses the request_uri to return the user to the right login page.
The second endpoint is where the handler lands after it has validated the external provider's tokens. It reads the external user from the External cookie, deletes that cookie, and signs in the local user the external account is linked to.
The external provider's issuer does not arrive as an iss claim: the handler drops that claim by default and records the issuer on every claim it takes from the ID token, sub among them, which is why the code reads it from externalSubject.Issuer. For Google that is https://accounts.google.com. If a provider is set up with GetClaimsFromUserInfoEndpoint, the claims added from the userinfo response carry the scheme name as their issuer, so take the issuer from sub only. IdentityProvider names the route the user came through, and the library writes it into the access and refresh tokens as the idp claim. The password login of the sample passes Cookies there.
The email address goes into the access and refresh tokens as it is, not into the ID token, so the code passes EmailVerified along with it: an API reading your token then cannot take an address the external provider never checked for a verified one.
IsEmailVerified holds one rule per provider. Google's email_verified alone is not enough: Google says it is authoritative for an address only when the address ends in @gmail.com or the hd claim is present, and that otherwise email_verified can be true because Google verified the address when the account was created, although the address may have changed hands since. When the external provider sends no address, both stay empty. The client app reads the address from your userinfo endpoint, which the sample answers from TestUserStore without email_verified; a real store keeps the flag with the address, returns both, and returns no email when it has none.
AuthenticationTime, the third argument of AuthSession, is the time of the local sign-in. Google may have signed the user in hours earlier, so a client that sends max_age gets a fresh auth_time even when Google let the user through from its own session without asking for anything. If your clients rely on that, forward the requirement in the challenge and take the time from the external provider's auth_time claim, deciding what to do when it is absent.
Entra accepts prompt=login, which forces the user to enter their credentials, through OpenIdConnectChallengeProperties.Prompt, and sends auth_time as an optional claim. Google documents neither prompt=login nor max_age; its prompt values are none, consent and select_account, and it sends auth_time only when the request asks for it through the claims parameter and the feature is enabled in its settings.
The external provider's sign-in method does not carry over either. The handler drops the acr claim by default, and this code sets neither AuthenticationMethodReferences nor AuthContextClassRef on the session, so your tokens do not say whether the user passed a second factor at Google or Microsoft. If your clients need that, fill those properties from what the external provider reports.
Now give the login page its button and its message. In the LoginPage function, add a link above the <form> tag inside the string the function returns; the braces are C# interpolation:
<p><a href="/Auth/External?provider=google&request_uri={Uri.EscapeDataString(requestUri)}">Sign in with Google</a></p>Then replace the existing app.MapGet("/Auth/Login", ...) endpoint with one that shows a message when OnRemoteFailure sends the user back:
app.MapGet("/Auth/Login", (string request_uri, string? error) =>
Results.Content(LoginPage(request_uri, error == null ? null : "External sign-in did not complete"), "text/html"));Try it
From the repository root, start your provider:
cd OpenIDProviderApp.MinimalApi
dotnet run --launch-profile httpsand, in a second terminal from the repository root, the web app from the same repository, TestClientApp. Its test_client registration already sends users back to it at https://localhost:5002; the argument points it at your provider instead of the MVC provider it uses by default:
cd TestClientApp
dotnet run --launch-profile https -- --OpenIdConnect:Authority=https://localhost:5006Open https://localhost:5002. The app sends you to your provider, which shows its login page with the new link. Choose "Sign in with Google", pick one of the test users you added, and accept Google's consent screen; while the app is in Testing mode, Google first warns that it has not verified the app. The browser comes back through your provider to the app, which shows the user's claims: sub is the identifier of a new local user created on the first sign-in, not Google's identifier for the account, and the name and email come from your provider's userinfo endpoint, copied from Google on that first sign-in.
The app keeps the tokens it received but shows only the user's claims. To see the access token, add <pre>@await Context.GetTokenAsync("access_token")</pre> to TestClientApp/Views/Home/Index.cshtml, with @using Microsoft.AspNetCore.Authentication at the top of that file, and decode the value: it carries idp set to google and email_verified as IsEmailVerified decided it. Choose Logout in the app and sign in through Google again: the app shows the same local sub, because the link remembers the Google account.
Add Microsoft Entra ID
A second external provider is a second AddOpenIdConnect call with its own scheme name and callback path, writing to the same External cookie. Give every provider its own callback path: the handler that owns a path takes every answer arriving there, and it cannot read the state of another provider's request, so sign-in through the second provider would always fail. Entra ID differs from Google in the email address, in sign-out, and, for an app open to several organizations, in the issuer.
Register the app in the Microsoft Entra admin center, under Entra ID, App registrations, New registration. For Supported account types, choose Single tenant only: only accounts in your own directory will sign in, including guests invited into it from other organizations. Add a redirect URI of the Web platform, https://localhost:5006/signin-microsoft; Entra also compares it exactly.
Then open Certificates & secrets and add a client secret. Entra shows its value once, right after you create it; the Secret ID next to it is not the secret. Entra also accepts a certificate or a federated credential, the better choice outside development; with this handler that means sending a client assertion from OnAuthorizationCodeReceived instead of setting ClientSecret. Store the values with the IDs from the app's Overview page, from OpenIDProviderApp.MinimalApi:
dotnet user-secrets set "Authentication:Microsoft:TenantId" "YOUR_DIRECTORY_TENANT_ID"
dotnet user-secrets set "Authentication:Microsoft:ClientId" "YOUR_APPLICATION_CLIENT_ID"
dotnet user-secrets set "Authentication:Microsoft:ClientSecret" "YOUR_CLIENT_SECRET_VALUE"In Program.cs, chain the Microsoft handler after the Google handler. Remove the semicolon after the Google handler's closing }) and add:
.AddOpenIdConnect("microsoft", "Microsoft", options =>
{
options.Authority = $"https://login.microsoftonline.com/{builder.Configuration["Authentication:Microsoft:TenantId"]}/v2.0";
options.ClientId = builder.Configuration["Authentication:Microsoft:ClientId"];
options.ClientSecret = builder.Configuration["Authentication:Microsoft:ClientSecret"];
options.ResponseType = "code";
options.CallbackPath = "/signin-microsoft";
options.SignInScheme = "External";
options.MapInboundClaims = false;
options.Scope.Add("email");
options.Events.OnRemoteFailure = BackToLogin;
});Add "microsoft" to externalProviders, and an arm for it to the switch in IsEmailVerified, above the _ arm:
// Entra sends no email_verified; the optional claim xms_edov says the address's domain is verified
"microsoft" => IsTrue(principal, "xms_edov"),Then add a second link on the login page, next to the first:
<p><a href="/Auth/External?provider=microsoft&request_uri={Uri.EscapeDataString(requestUri)}">Sign in with Microsoft</a></p>Restart your provider and choose "Sign in with Microsoft" from the app, as in the Google test.
Entra puts the profile into its ID token like Google, and the issuer recorded on sub is https://login.microsoftonline.com/ followed by your tenant ID and /v2.0. The access token from your provider then carries idp set to microsoft. The email address is where the two providers differ. For members of your directory, Entra sends email only when the email scope was requested and the account has an address; for guests it sends the address by default. An account without a mailbox signs in with no email at all.
Entra never sends email_verified, and Microsoft says its email claim is not guaranteed to be correct, can change over time, and must never be used for authorization. Its verification signal is the optional claim xms_edov, which you add under Token configuration in the app registration and which arrives only together with an email claim; without it, an address that arrives goes into your token with email_verified set to false.
Never link or merge accounts on Entra's email address. That is the weakness published as nOAuth: an administrator of any Entra tenant can give a user someone else's address, and an app that matches on it hands that user the other person's account. Key accounts on the issuer and subject, as the code above does. Entra's sub is unique to this app registration, so if you may ever replace the registration, key on tid and oid instead, which stay the same across apps. Do it for the Microsoft provider only, since Google's tokens carry neither claim, and refuse the sign-in when either is missing. In the callback, replace the FindOrCreateExternal call with:
(string? Issuer, string? Id) key = provider switch
{
"microsoft" => (principal.FindFirstValue("tid"), principal.FindFirstValue("oid")),
_ => (externalSubject.Issuer, externalSubject.Value),
};
if (key is not { Issuer: { } linkIssuer, Id: { } linkId })
return Results.BadRequest();
var subject = userStore.FindOrCreateExternal(linkIssuer, linkId, principal.FindFirstValue("name"), email);Links already stored under the issuer and sub have to be moved to the new key, or those users get a new local account.
Accounts from other organizations
To let in accounts from any Entra organization, change Supported account types to Multiple Entra ID tenants with Allow all tenants, and replace the Authority line of the Microsoft handler with:
options.Authority = "https://login.microsoftonline.com/organizations/v2.0";In an organization that does not let its users consent to apps, an administrator has to grant consent once before its users can sign in. Personal Microsoft accounts are not admitted this way; they need the option that includes them, common in place of organizations, and, with the issuer check below, the tenant ID Microsoft uses for all personal accounts, 9188040d-6c67-4c5b-b112-36a304b66dad, in the list of allowed tenants.
This endpoint's discovery document publishes its issuer as a template, https://login.microsoftonline.com/{tenantid}/v2.0, while each ID token carries the issuer of the organization the user belongs to. The handler compares the two as they are and refuses every sign-in with IDX10205: Issuer validation failed, which reaches OnRemoteFailure and sends the user back to the login page. Give the handler its own issuer check, which also decides which organizations get in. Add to the using directives at the top of Program.cs:
using Microsoft.IdentityModel.Tokens;and these lines inside the Microsoft handler's options, after options.Events.OnRemoteFailure = BackToLogin;:
// The organizations endpoint publishes its issuer as a template; accept a listed tenant's own issuer
var allowedTenants = builder.Configuration.GetSection("Authentication:Microsoft:AllowedTenants").Get<string[]>() ?? [];
options.TokenValidationParameters.IssuerValidator = (issuer, token, _) =>
token is Microsoft.IdentityModel.JsonWebTokens.JsonWebToken jwt &&
jwt.TryGetPayloadValue<string>("tid", out var tenantId) &&
allowedTenants.Contains(tenantId) &&
issuer == $"https://login.microsoftonline.com/{tenantId}/v2.0"
? issuer
: throw new SecurityTokenInvalidIssuerException($"Issuer {issuer} is not an allowed tenant");The check takes the tid claim, the ID of the user's organization, accepts it only from the list, and requires the issuer to be that organization's own. Comparing the issuer as well as tid keeps out a token whose issuer belongs to some other authority. The type is written with its namespace because Abblix OIDC Server has a JsonWebToken of its own. Microsoft's own validator, AadIssuerValidator from the Microsoft.IdentityModel.Validators package, together with EnableAadSigningKeyIssuerValidation, is the platform's alternative: it also checks that the signing key belongs to the token's issuer, but it decides nothing about which organizations get in, so the tid list stays yours.
The list comes from configuration, one tenant ID per entry, and every sign-in is refused while it is empty, so set at least your own tenant before you try. From OpenIDProviderApp.MinimalApi:
dotnet user-secrets set "Authentication:Microsoft:AllowedTenants:0" "YOUR_DIRECTORY_TENANT_ID"When the sign-in does not complete
A few ways the round trip breaks, and what the user sees:
- Every page of your provider, the discovery document included, answers 500, and the log shows
Value cannot be null. (Parameter 'ClientId'). A handler's client ID is not configured: the authentication middleware loads every external handler's options on each request, so one missing key breaks them all. Check the key names withdotnet user-secrets listfromOpenIDProviderApp.MinimalApi, and that the app runs in the Development environment. - The user cancels at the external provider. With
OnRemoteFailureas above, they are back on your login page with "External sign-in did not complete" and can try again or use a password. The stored request is still valid, so a second attempt finishes normally. - The user stays at the external provider too long. The request stored before the login page lives for
LoginSessionExpiresIn, ten minutes by default. When it has expired, the return to the authorization endpoint ends with status 400 and a JSON body:invalid_request_uriwith "Can't find a request by" and the address. The user is not sent back to the app. The local session has been created by then, so when the user starts again from the app, your provider signs them straight through. If sign-in at the external provider routinely takes longer, for example while a new user sets up a second factor, raiseLoginSessionExpiresInin theAddOidcServicesoptions, and past fifteen minutes also the handler'sRemoteAuthenticationTimeout. That one limits how long the handler waits for the answer; when it runs out, the handler fails with "Correlation failed" andOnRemoteFailuresends the user back to the login page. UnlessLoginSessionExpiresInwas raised too, the request behind that page has expired by then, so the user has to start again from the app. - Your provider runs as several instances. The round trip crosses them, and four things have to be shared or the answer lands where it cannot be used. The handler protects its state and the
Externalcookie with Data Protection, and your provider's session cookie is protected the same way, so all instances need one key ring:AddDataProtection().PersistKeysTo...()with a shared store, andSetApplicationNamewith the same name everywhere. The requests the library stores behindrequest_urilive in theIDistributedCachethe host registers, so that has to be a shared cache rather than the in-memory one of the sample (Choosing a Backend for Operational State). Behind a proxy that terminates TLS, callUseForwardedHeaderswithXForwardedProtobeforeUseAuthentication; otherwise the handler builds its redirect URI withhttp, and the external provider refuses it as not registered (Google answersredirect_uri_mismatch). The Production Hardening Checklist covers the forwarded headers in full. Without the shared key ring, an answer that reaches another instance cannot be read, the handler treats it like astateit did not issue, andOnRemoteFailurehas norequest_urito go back to and answers with status 400. - A request reaches
/Auth/External/Callbackwithout having been to the external provider, or the answer at the callback path carries astatethe handler did not issue. Both end with status 400, from the callback endpoint and fromOnRemoteFailurerespectively, and nobody is signed in.
A user disabled at the external provider keeps access through the tokens you issued: the external provider is asked again only at the next external sign-in, and a refresh token works until it expires. Keep the clients' refresh token lifetimes short (RefreshToken.AbsoluteExpiresIn in the client registration) when that matters.
Signing out
Signing out at your provider ends your session only. The session at Google or Microsoft stays, so the next "Sign in with Google" in the same browser signs the user straight back in, which on a shared computer looks as if the sign-out did not work. Google offers no way around it: its discovery document lists no end-session endpoint, so an app cannot sign the user out of Google.
Entra lists one, and documents two parameters for it: post_logout_redirect_uri, which must match one of the redirect URIs registered for the app, and logout_hint, which signs the user out without asking them to pick an account and takes the value of the optional claim login_hint. To sign the user out there too, add login_hint under Token configuration, and in the callback keep the session ID in a variable before you build the AuthSession, read principal.FindFirstValue("login_hint"), and store the value on the server, keyed by that session ID. Do not put it into the session's AdditionalClaims: the library copies those into every access and refresh token it issues. After your own sign-out, send the browser to Entra's end-session endpoint with logout_hint and a registered post_logout_redirect_uri.
A sign-out can also start at Entra, when the user signs out of another app of the same directory. Entra then sends a GET request to the front-channel logout URL set in the app registration, and expects the app to clear the session that identifies the user and answer 200. That request arrives in a frame of Entra's page, so the browser sends your provider's session cookie with it only when the cookie is set with SameSite=None and the browser allows third-party cookies; treat front-channel logout as best effort.
Other OpenID Connect providers
The code above carries over to most OpenID Connect providers. Register a web application with your own callback address, such as https://localhost:5006/signin-<name>, as the redirect URI. Then add a handler with its own scheme name, Authority set to the provider's issuer, ClientId and ClientSecret read from secret keys of their own, and CallbackPath set to the path you registered; add the scheme name to externalProviders, an arm for it to IsEmailVerified, and a link to the login page. Check four things in the provider's discovery document and documentation: that its token endpoint accepts client_secret_post, that its ID token carries the claims you need or GetClaimsFromUserInfoEndpoint has to fetch them, whether it confirms the email address with email_verified and for which addresses it is authoritative, and whether the issuer in its discovery document is the iss in its tokens. When it is not, as with Entra's multi-tenant endpoints, the handler needs an issuer check of its own.
The idp claim tells your APIs and logs which external provider the user came through. It is not part of what a client app reads about the user from the ID token.
Next steps
The login page and session handling this guide extends are built step by step in the Getting Started guide, which writes the same login flow as an MVC controller in OpenIDProviderApp rather than as the minimal endpoints used here. If your local users live in ASP.NET Core Identity, the ASP.NET Identity integration shows the same SignInAsync call for password logins; its external logins table can hold the links this guide keeps in memory. Store the external provider's issuer as LoginProvider and its subject as ProviderKey, and look the user up with UserManager.FindByLoginAsync(issuer, subject); Identity already keeps that pair unique. SignInManager.ExternalLoginSignInAsync expects the scheme name in LoginProvider instead, so do not mix the two. Before going live, go through the Production Hardening Checklist.