Table of Contents

External logins (add-on)

CodeLogic.WebLogic.ExternalLogins adds ready-made IWebExternalLoginProviders: Steam (OpenID 2.0) and a generic OAuth 2.0 / OpenID Connect provider with presets for Google, Microsoft, GitHub and Discord. It has no third-party dependencies: tokens are checked with System.Security.Cryptography.

builder.AddCodeLogicWebLogic(web => web
    .AddSteamLogin()
    .AddGoogleLogin(env("GOOGLE_CLIENT_ID"), env("GOOGLE_CLIENT_SECRET"))
    .AddMicrosoftLogin(env("MS_CLIENT_ID"), env("MS_CLIENT_SECRET"), tenant: "common")
    .AddGitHubLogin(env("GITHUB_CLIENT_ID"), env("GITHUB_CLIENT_SECRET"))
    .AddDiscordLogin(env("DISCORD_CLIENT_ID"), env("DISCORD_CLIENT_SECRET"))
    .AddOAuthLogin(new WebOAuthOptions
    {
        Name = "corp",
        DisplayName = "Corp SSO",
        ClientId = "weblogic",
        ClientSecret = env("CORP_SECRET"),
        Authority = "https://login.corp.example",          // discovery + ID token validation
        Scopes = ["openid", "email", "profile"]
    }));

Register {PublicBaseUrl}/account/external/{name}/callback (or whatever your callback route is) at the provider. Set PublicBaseUrl in production so the callback never depends on the request's Host.

Routes

The provider only speaks the protocol; the app maps two routes with the core WebExternalLoginFlow and decides what an unknown identity means (register, link, refuse):

b.MapPage("/account/external/{provider}", (r, ct) => new ValueTask<WebResult>(flow.ChallengeAsync(
    r, r.GetRouteValue("provider")!, $"/account/external/{r.GetRouteValue("provider")}/callback", r.GetQuery("returnUrl"), ct))).AllowAnonymous();

b.MapPage("/account/external/{provider}/callback", async (r, ct) =>
{
    var outcome = await flow.HandleCallbackAsync(r, r.GetRouteValue("provider")!, ct);
    if (outcome.IsFailure) return WebResult.Page("account/external-failed", new { outcome.Error!.Message });
    var user = outcome.Value!.User ?? await RegisterOrLinkAsync(outcome.Value.Info, ct);
    var result = await r.Auth.SignInExternalAsync(user, outcome.Value.Info.Provider, cancellationToken: ct);
    if (result.Suspension is { } suspension) return WebResult.AccountSuspended(suspension);
    return result.Status switch
    {
        WebSignInStatus.Succeeded => WebResult.Redirect(outcome.Value.ReturnUrl),       // already a safe local URL
        WebSignInStatus.RequiresTwoFactor => WebResult.Redirect("/account/two-factor"),
        _ => WebResult.Page("account/external-failed", new { Message = "This account cannot sign in right now." })
    };
}).AllowAnonymous();

SignInExternalAsync applies the account rules (disabled, locked, suspended, other tenant) and asks two-factor users for their code. The starter sample has the complete flow.

Linking and unlinking

flow.LinkAsync(user, info) links an identity to a signed-in user and flow.UnlinkAsync(user, provider) removes it (weblogic.auth.external_login_not_found when there is none). Both raise ExternalLoginChangedEvent and are audited. Before unlinking, check that the user keeps another way to sign in (a password, a passkey or another provider). Custom user stores implement IWebUserExternalLoginStore.RemoveExternalLoginAsync; stores without it throw NotSupportedException.

What is checked

Step Check
Challenge Random state, nonce and PKCE verifier in the flow's protected, 10-minute __Host- cookie (works on any node). The verifier never reaches the browser; the redirect carries the S256 challenge.
Callback state compared in constant time; error= handled; RFC 9207 iss checked when sent.
Token request code, redirect_uri and code_verifier; client secret in the body or HTTP Basic. Responses are size-capped.
ID token (OIDC) Signature with a key from the issuer's JWKS (RS256/384/512, PS256/384/512, ES256/384/512; none and HMAC refused; RSA keys under 2048 bits ignored; a key's alg restriction honoured), iss (Microsoft's {tenantid} template uses the token's tid), aud and azp, exp/nbf/iat with ClockSkew (5 minutes), nonce, and no unknown crit headers.
Discovery The document's issuer must be the configured authority; every endpoint must be https. Cached for a day.
Key rotation A token with an unknown kid refreshes the key set, at most every 30 seconds per issuer.
Profile The subject claim (strings or numbers) becomes ProviderKey. An e-mail is reported as verified only when the provider says so — never for Microsoft (EmailVerifiedClaim = null); GitHub's primary verified address comes from /user/emails.

Failure reasons are logged as warnings (External sign-in with 'google' failed: id_token: wrong nonce); users only see a generic message.

Presets

Preset Kind Subject Notes
Google OIDC (accounts.google.com) sub email_verified from Google
Microsoft OIDC (login.microsoftonline.com/{tenant}/v2.0) sub common, organizations, consumers or a tenant id
GitHub OAuth 2.0 id name = login; primary verified e-mail
Discord OAuth 2.0 id name = username; verified
Steam OpenID 2.0 64-bit SteamID no keys, no e-mail; assertion confirmed with check_authentication

WebOAuthOptions is a record, so a preset can be adjusted with with:

web.AddOAuthLogin(WebOAuthPresets.Google(id, secret) with
{
    AuthorizationParameters = new Dictionary<string, string> { ["prompt"] = "select_account" }
});

HTTP

All providers use the named HttpClient WebExternalLoginHttp.ClientName (10-second timeout). Configure a proxy or handler with services.AddHttpClient(WebExternalLoginHttp.ClientName).