Conversation
Allow self-hosted Admin operators to configure an upstream OIDC provider via environment-variable-backed settings. When configured, a "Sign in with SSO" option appears on the login page alongside the existing email magic-link flow. Authorization remains gated by the AdminSettings__Admins allowlist: OIDC authenticates who a user is, the allowlist decides whether they get in.
|
@claude please review this. |
|
Claude finished @trmartin4's task in 5m 26s —— View job Code review
Nice, self-contained feature — the design decision to keep 🔴 Logout will not send
|
| File | State |
|---|---|
src/Admin/AdminSettings.cs:1 |
BOM removed |
src/Admin/Auth/Controllers/LoginController.cs:1 |
BOM removed |
src/Admin/IdentityServer/AdminAuthenticationSchemes.cs |
no BOM |
test/Admin.Test/AdminSettingsTests.cs |
no BOM |
test/Admin.Test/Auth/Controllers/LoginControllerTests.cs |
no BOM |
(src/Admin/IdentityServer/ServiceCollectionExtensions.cs still has its BOM, so this looks like an editor artifact rather than intent.) Running dotnet format locally should fix all five. Fix this →
🟠 The email claim is trusted without email_verified
src/Admin/Auth/Controllers/LoginController.cs:122-135
Matching on the email claim alone means anyone who can get an account at the configured IdP with an email string matching an entry in AdminSettings__Admins gets full Admin Portal access. With a single-tenant, operator-controlled IdP that's fine; with any IdP that permits self-registration or unverified email addresses (or a multi-tenant Auth0/Okta org), it's an account-takeover path into the highest-privilege surface Bitwarden operates.
Given this is being positioned as the FedRAMP access gate, I'd require email_verified == true when the claim is present (and make rejecting-on-absent configurable). Worth stating the trust assumption explicitly in the config table either way. Fix this →
🟠 No logging on the SSO auth path
src/Admin/Auth/Controllers/LoginController.cs:104-150
Every failure mode — remote error, null ExternalLoginInfo, missing email claim, email not on the allowlist — collapses into the same error=5/error=4 redirect with nothing recorded. For the Admin Portal, failed and successful privileged sign-ins are exactly the events that should be auditable (and FedRAMP AU-2/AU-3 will want them). Inject ILogger<LoginController> and log each branch distinctly. Per CLAUDE.md, keep the email out of the message — logging the reason ("SSO sign-in rejected: email claim not present in admin allowlist") is enough to diagnose without PII. This also directly affects the next item: today an operator hitting it sees only "SSO sign-in failed" with no way to tell why. Fix this →
🟠 Verify GetExternalLoginInfoAsync() works with MapInboundClaims = false
src/Admin/IdentityServer/ServiceCollectionExtensions.cs (options.MapInboundClaims = false) + LoginController.cs:116
SignInManager.GetExternalLoginInfoAsync() derives ProviderKey from ClaimTypes.NameIdentifier and returns null if it can't find one. With MapInboundClaims = false the ID token's sub stays typed as "sub" and is never mapped to the WS-Fed nameidentifier URI, and the OIDC handler's default claim actions map sub → sub as well. If that's the case here, info is always null and every SSO sign-in fails with error=5.
The demo video suggests it works against your IdP, so this may already be settled — but please confirm which IdP/claim shape was tested, since it'd be IdP-dependent. A robust alternative is to skip GetExternalLoginInfoAsync entirely and read the principal directly:
var result = await HttpContext.AuthenticateAsync(IdentityConstants.ExternalScheme);
if (!result.Succeeded) { /* error */ }
var email = result.Principal.FindFirst(_adminSettings.Oidc.EmailClaimType)?.Value;Either way, adding options.ClaimActions.MapUniqueJsonKey(ClaimTypes.NameIdentifier, "sub") makes the intent explicit.
🟡 Scope parsing can silently produce an invalid request
src/Admin/IdentityServer/ServiceCollectionExtensions.cs
options.Scope.Clear() followed by splitting on ' ' means an operator who omits openid (or who sets AdminSettings__Oidc__Scopes=openid,profile,email, which is a very natural thing to type into an env var) gets a broken flow with no diagnostic. Suggest splitting on ' ' and ',', and unconditionally ensuring openid is in the set.
🟡 Tests don't cover the security-relevant branches
test/Admin.Test/Auth/Controllers/LoginControllerTests.cs
The current tests cover the disabled-feature guards, the remote-error redirect, and view-model wiring. The branches that actually decide who gets admin access are untested:
- email claim present but not in
AdminSettings__Admins→error=4, no sign-in - email claim missing entirely →
error=5, external cookie cleared - successful sign-in →
SignInWithClaimsAsynccalled with theadmin_auth_method=ssomarker returnUrlis non-local → redirects to Home rather than the external URL
Also worth aligning with the convention already used in this project: test/Admin.Test/Controllers/UsersControllerTests.cs uses [ControllerCustomize]/[SutProviderCustomize]/[BitAutoData] with SutProvider<T>, which would remove the ~30 lines of hand-rolled UserManager/SignInManager construction in BuildController. Fix this →
🟡 Smaller items
launchSettings.json:19—https://localhost:5001is the generic ASP.NET Core default and is the one port in this repo likely to already be in use by something else on a dev's machine; every other project here uses a unique port. A project-specific HTTPS port (e.g.62913) would be less collision-prone.Startup.cs:41-43—AdminSettingsis bound twice.var adminSettings = Configuration.GetSection("AdminSettings").Get<AdminSettings>() ?? new AdminSettings();collapses it to one line.src/Admin/IdentityServer/AdminAuthenticationSchemes.cs— auth-owned code in this app lives undersrc/Admin/Auth/(Auth/IdentityServer/PasswordlessSignInManager.cs). This file is auth-domain and used only fromAuth/Controllers/LoginController.cs;src/Admin/Auth/IdentityServer/seems like the better home for CODEOWNERS purposes.LoginController.cs:129—email.ToLowerInvariant()is redundant:UserManager.FindByEmailAsyncappliesLowerInvariantLookupNormalizer(registered inServiceCollectionExtensions.cs:16) before it reachesReadOnlyEnvIdentityUserStore.LoginController.cs:141— the SSO path callsSignInWithClaimsAsyncdirectly, bypassingPreSignInCheck/CanSignInAsyncthat the passwordless path goes through. Impact is nil againstReadOnlyEnvIdentityUserStore(no lockout, always confirmed), but it's a divergence worth a comment if deliberate.AdminSettings.cs:10—public virtual OidcSettings Oidc { get; set; } = new OidcSettings()isvirtualfor no apparent reason, and the non-null default sits slightly oddly next to theOidc != null/Oidc?.null checks everywhere else. Not wrong, just mixed signals about whether it can be null.- Docs — for self-hosted,
app.UsePathBase("/admin")(Startup.cs:148) means the callback and post-logout URLs registered at the IdP need the/adminprefix. Worth adding to the config table so self-hosters don't lose an hour to it. - The
github-code-qualityternary suggestion onBuildControlleris cosmetic; I wouldn't act on it (and it goes away if you move toSutProvider).
Not changed, by design, and I agree with it: AllowAnonymous on both SSO actions (the controller has no [Authorize], so it's redundant but explicitly self-documenting), no ValidateAntiForgeryToken on the GET challenge (the handler's state/nonce/correlation cookie covers it), and keeping the magic-link flow intact as a fallback.
• branch admin/upstream-oidc
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #8458 +/- ##
==========================================
+ Coverage 64.81% 64.83% +0.01%
==========================================
Files 2490 2490
Lines 107143 107448 +305
Branches 9699 9738 +39
==========================================
+ Hits 69442 69660 +218
- Misses 35361 35436 +75
- Partials 2340 2352 +12 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
@claude re-review please. |
|
Claude finished @trmartin4's task in 5m 33s —— View job Re-review
Big improvement — the rewrite of Three things I'd want resolved before merge, then some smaller ones. 🔴 The
|
|
@claude please review again. |
|
Claude finished @trmartin4's task in 5m 25s —— View job Re-review
All three blockers from the last round are properly fixed, and the fixes are the right ones. Replacing the Two things I'd want resolved before merge, then some smaller ones. Both are new — neither was visible in the prior diff. 🟠
|
Source of email |
Resulting claim type | FindFirst("email") |
|---|---|---|
| ID token | email (MapInboundClaims=false) |
✅ |
| UserInfo only | ClaimTypes.Email (xmlsoap URI) |
❌ null |
For an IdP that omits email from the ID token — the Okta case the comment cites — LoginController.cs:139 returns null, the missing-email branch fires, and every SSO sign-in fails with error=5. email_verified is unaffected, so this would present as "the email claim is missing" even though UserInfo returned it.
Since EmailClaimType is configurable, the general fix is one line inside the options callback:
// ClaimActions apply to the UserInfo response and are unaffected by MapInboundClaims;
// without this the default MapUniqueJsonKey(ClaimTypes.Email, "email") would file a
// UserInfo-sourced email under the xmlsoap URI instead of EmailClaimType.
options.ClaimActions.MapUniqueJsonKey(oidc.EmailClaimType, oidc.EmailClaimType);MapUniqueJsonKey dedupes on type+value, so this is a no-op when the ID token already carried the claim. Worth confirming which IdP the demo video ran against — if the ID token carried email there, this path was never exercised. Fix this →
🟠 CookieSecurePolicy.Always is an unconditional breaking change for HTTP-only self-hosts, and it hits the magic-link flow too
src/Admin/IdentityServer/ServiceCollectionExtensions.cs:42-44
This line is in AddPasswordlessIdentityServices, not in AddAdminUpstreamOidc — so it applies to every Admin deployment regardless of whether OIDC is configured, including ones that never opt into this feature. Self-hosters who declined SSL at install time reach nginx over plain HTTP; the browser will silently drop the application cookie, and the admin bounces back to /login with no error message and nothing in the logs. Complete lockout from an opt-out-impossible change, in a PR whose stated scope is an opt-in feature.
The stated justification doesn't hold for the standard topology either:
Default (SameAsRequest) can downgrade behind a TLS-terminating proxy that doesn't forward X-Forwarded-Proto
Startup.cs:146-150 already calls UseForwardedHeaders(globalSettings) for self-hosted, and SharedWeb/Utilities/ServiceCollectionExtensions.cs:541 includes ForwardedHeaders.XForwardedProto. So SameAsRequest resolves correctly behind the Bitwarden proxy today. (That the Admin launchSettings.json had to grow an HTTPS binding in this same PR is the same symptom showing up in local dev.)
The security posture is right where SSO is concerned — the OIDC correlation cookie defaults to SameSite=None, which browsers reject without Secure, so SSO over HTTP can't work regardless. Scoping it accordingly keeps the hardening without the regression:
options.Cookie.SecurePolicy = adminSettings.OidcEnabled
? CookieSecurePolicy.Always
: CookieSecurePolicy.SameAsRequest;If you'd rather keep it unconditional — defensible, and I wouldn't argue hard against it — it needs to be called out as a breaking change in the PR description and release notes, since nothing else in the description signals that non-SSO deployments are affected at all. Fix this →
🟡 The id_token round-trips through the IdP inside the state parameter
src/Admin/Auth/Controllers/LoginController.cs:243-245
OpenIdConnectHandler.HandleSignOutAsync ends with message.State = Options.StateDataFormat.Protect(properties) — that's how RedirectUri survives the trip to SignedOutCallbackPath. Because the id_token is stored on those same properties, it gets Data-Protection-encrypted into state in addition to being sent as id_token_hint. Net effect is the ID token traverses the end-session URL twice: roughly 1–2 KB as the hint plus another ~2 KB of ciphertext in state.
It's encrypted with the app's own keys so the IdP learns nothing, but a 4–6 KB end-session GET is close to common request-line limits (nginx large_client_header_buffers defaults to 8k; several IdPs cap lower), and tokens with large group claims — Entra ID in particular — push it further. A logout that 414s is a poor failure mode for the feature this plumbing exists to enable.
Passing it out-of-band avoids the duplication entirely, since the event runs in the same request:
// Logout
HttpContext.Items["admin_id_token"] = idToken;
return SignOut(new AuthenticationProperties { RedirectUri = loggedOutRedirect },
AdminAuthenticationSchemes.UpstreamOidc);
// OnRedirectToIdentityProviderForSignOut
if (ctx.HttpContext.Items.TryGetValue("admin_id_token", out var t) && t is string s && s.Length > 0)
{
ctx.ProtocolMessage.IdTokenHint = s;
}Worth measuring against your IdP before changing anything — if the end-session URL comes in comfortably under 4 KB, leaving it as-is is reasonable.
🟡 Correlation and nonce cookie policies left implicit
src/Admin/IdentityServer/ServiceCollectionExtensions.cs:69-170
The app cookie gets SecurePolicy/SameSite pinned explicitly, and the token-validation block at :128-136 is pinned with the rationale "implicit defaults can shift across major .NET versions." CorrelationCookie and NonceCookie get neither — and src/Identity/Startup.cs:114-115 pins exactly those two for the existing SSO handler, so there's in-repo precedent. Same one-line treatment for consistency.
🟡 max_age=3600 bounds sign-in freshness but not the resulting admin session
src/Admin/IdentityServer/ServiceCollectionExtensions.cs:49-51
ExpireTimeSpan = 2 days with SlidingExpiration = true (both pre-existing, unchanged here). So the prompt=login + max_age=3600 + server-side auth_time enforcement guarantee only that the operator re-authenticated at the IdP at the moment of sign-in; the admin session it mints then renews indefinitely on activity. Not introduced by this PR, and not something I'd change here — flagging because the description positions session freshness as a security property and cites FedRAMP, where AC-11/AC-12 will ask about the session side too. Probably a follow-up ticket.
🟡 Smaller items
- Scopes (
AdminSettings.cs:68-70,ServiceCollectionExtensions.cs:118-122) — the doc comment now says space-delimited and required-openid, which addresses the discoverability half. Still worth two lines:openidisn't force-added, soScopes=profile emailyields a request with noopenid, and if the IdP tolerates it and returns no ID token you land on!external.Succeeded→ genericerror=5with no hint that the config is the cause. Fine to decline now that it's documented. launchSettings.json:19—https://localhost:5001is still the generic ASP.NET Core default; every other project here uses a unique port (46273,62911,50024,33656,44518,61840,54103).62913would fit. Third mention, lowest stakes on the list.- Test gaps — the suite is in good shape now. The one branch I'd still pin:
RequireEmailVerifiedClaim = falsewith the claim absent → accepted (LoginController.cs:254-257). It's the single config flag that weakens the security posture, and nothing currently asserts it behaves as documented.IndexPOST /Confirmreturning 404 whenEnablePasswordlessLoginis false are also uncovered and cheap. LoginController.cs:211— the SSO path still bypassesPreSignInCheck/CanSignInAsync. Impact is nil againstReadOnlyEnvIdentityUserStore, and the comment block above it is excellent on everything else; one line noting this is deliberate would save the next reader the derivation.
Previously raised, now resolved
admin_auth_method claim dropped by SecurityStampValidator → replaced with id_token on AuthenticationProperties, with Logout_LocalOnly_WhenNoIdTokenStored / Logout_TriggersRpInitiatedLogout_WhenIdTokenPresent covering it • auth_time clock-skew tolerance via the shared ClockSkewSeconds constant, with SsoSignIn_AcceptsAuthTimeSlightlyInFuture • OidcEnabled guard on Logout(), with Logout_LocalOnly_WhenIdTokenPresentButOidcDisabled • id_token read before SignOutAsync() so id_token_hint no longer depends on handler-level ticket caching • magic-link submit button no longer disabled-in-markup, inline script removed • SsoSignIn_StoresIdTokenOnAppCookie_OnSuccess, SsoSignIn_RedirectsToLocalReturnUrl_WhenLocal, Sso_ReturnsChallengeResultForUpstreamOidc_WhenEnabled all added.
dotnet build / dotnet test weren't permitted in this environment, so the above is static analysis only. Build, Testing, and Code Quality were still in progress when I finished — worth confirming they're green. Build (Fork PR) startup_failure is a workflow-trigger artifact, not a code failure.
• branch admin/upstream-oidc
|
@claude please review again. |
|
Claude finished @trmartin4's task in 3m 24s —— View job Re-review
Both blockers from the last round are resolved, and I like how. Dropping The new session-timeout work ( 🔴 Enabling the absolute timeout silently disables session revocation
options.Events.OnValidatePrincipal = async ctx => { ... };This is an assignment, not a chain. o.Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync
};Named That's not cosmetic here, because of how So the net effect of turning on an AC-12 control is losing a working AC-2(3)-style revocation control, with nothing in logs or config to indicate it. Chain rather than replace: var inner = options.Events.OnValidatePrincipal;
options.Events.OnValidatePrincipal = async ctx =>
{
if (/* absolute cap exceeded */)
{
ctx.RejectPrincipal();
await ctx.HttpContext.SignOutAsync(IdentityConstants.ApplicationScheme);
return;
}
await inner(ctx);
};🔴
|
|
@claude please review. |
|
Claude finished @trmartin4's task in 4m 37s —— View job Re-review
The session refactor ( One CI failure and one substantive finding about what 🔴 Lint is failing —
|
🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-44143
📔 Objective
Adds the ability to configure an SSO provider to gate access to the Admin Portal, based on environment variables.
Allows an administrator to configure an Admin Portal OIDC IdP, by setting the following environment variables.
This has come up in the following contexts:
Logout
When a user signs in via SSO and later clicks Logout, the Admin Portal triggers OIDC RP-initiated logout — the browser is redirected to the IdP's end_session_endpoint (with the ID token as
id_token_hint), the IdP clears its own session, and the user is returned to the Admin login page. Magic-link users log out locally as before.For this to work, the value of
AdminSettings__Oidc__SignedOutCallbackPathmust be registered on the IdP:Configuration
Set as environment variables (or user-secrets for local dev):
Additional authentication settings exposed
AdminSettings__EnablePasswordlessLogintrueAdminSettings__SessionTimeoutMinutes2880(2 days)AdminSettings__AbsoluteSessionTimeoutMinutes0(disabled)AuthenticationProperties.Items— sessions terminate at this age regardless of activity. Must be >SessionTimeoutMinuteswhen both are set.OIDC-specific configuration
AdminSettings__Oidc__AuthorityAdminSettings__Oidc__ClientIdAdminSettings__Oidc__ClientSecretAdminSettings__Oidc__Scopesopenid profile emailAdminSettings__Oidc__EmailClaimTypeemailAdminSettings__AdminsAdminSettings__Oidc__DisplayNameSSOAdminSettings__Oidc__CallbackPath/login/sso-callbackAdminSettings__Oidc__SignedOutCallbackPath/login/sso-signoutAdminSettings__Oidc__RequireEmailVerifiedClaimtrueemail_verified=true. Setfalseonly for operator-controlled IdPs that don't emit the claim.OIDC activates only when Authority, ClientId, and ClientSecret are all set. If any is missing, the login page reverts to email-magic-link only.
Additional IdP requirements
emailandemail_verifiedclaims in the ID token (not only in the UserInfo response). Auth0, Azure AD/Entra, and Keycloak do this by default when theemailscope is requested. Okta requires enabling "Include user info in ID token" (or equivalent) on the app's OpenID Connect settings.email_verifiedmust be a JSON booleantrue; string values other than"true"are rejected (fail-secure). SetRequireEmailVerifiedClaim=falseonly for operator-controlled IdPs that don't emit the claim at all.📸 Screenshots
Full flow, including showing upstream logout (Auth0 requesting IdP login again)
login_logout.mov
UI without passwordless also enabled
Session expiration logout
🔐 OIDC compliance report
Mapping of the configuration to relevant specs and best-current-practice documents. All items ✅ unless noted.
OAuth 2.0 Security BCP (RFC 9700) / OAuth 2.1
options.ResponseType = Codeoptions.UsePkce = trueCallbackPath+SignedOutCallbackPathdocumented as must-matchoptions.RequireHttpsMetadata = true(explicit)stateclient_secret_postvia ClientId + ClientSecretid_tokenstored in the app cookie (access/refresh dropped)OpenID Connect Core 1.0
openidscope requestedScopessetting; failure to include causes IdP to reject the requestnoncein authorize + ID-token bindingValidateIssuerSigningKey = true,RequireSignedTokens = trueissvalidationValidateIssuer = true(bound to Authority via discovery)audvalidationValidateAudience = true(bound to ClientId)expvalidationValidateLifetime = true,RequireExpirationTime = true,ClockSkew = 2 minemailmatched against allowlistAdminSettings__Adminsemail_verifiedrequiredRequireEmailVerifiedClaim = trueby default; fail-secure when claim absent or non-booleanoptions.MapInboundClaims = falseOpenID Connect RP-Initiated Logout 1.0
post_logout_redirect_uriregistered at IdPSignedOutCallbackPathdocumented per-IdPid_token_hintsent on end-sessionOnRedirectToIdentityProviderForSignOutevent pullsid_tokenfrom the app cookie and attaches it_signInManager.SignOutAsync()before the RP-initiated logout redirectSession freshness / re-authentication
prompt=loginon the authorize request. Per OIDC Core 3.1.2.1 this is a request the IdP SHOULD honor — not something we verify server-side (nothing in the response proves it happened). Auth0, Okta, Entra, and Keycloak all honor it in practice.SessionTimeoutMinutes. Sliding — extends on activity; short values (15-60 min) satisfy AC-11.AbsoluteSessionTimeoutMinutes. Anchor stored inAuthenticationProperties.Items(survives cookie renewal); enforced viaOnValidatePrincipalregardless of activity orIssuedUtcrewrites bySecurityStampValidator.Cookie / session security
HttpOnlyon app cookieoptions.Cookie.HttpOnly = trueSecureon app cookie (explicit, not proxy-dependent)options.Cookie.SecurePolicy = AlwaysSameSiteexplicitoptions.Cookie.SameSite = Lax(required for OIDC redirect-back)SecurityStampValidatorOptions.ValidationInterval = 5 minFail-closed / secure-by-default posture
emailclaimerror=5), external cookie clearedemail_verifiedabsent when requirederror=5), external cookie clearedemail_verifiedpresent but not JSON-booleantrueerror=5), external cookie clearedAdminSettings__Adminsallowlisterror=5, same as other rejections to prevent admin enumeration), external cookie clearedThreat model coverage
returnUrlUrl.IsLocalUrlcheck before redirect[HttpPost]+[ValidateAntiForgeryToken]ClockSkew = 2 minRequireHttpsMetadata = trueSecurePolicy = Always(explicit, notSameAsRequest)prompt=loginrequests credential + MFA re-entry on every re-sign-in (IdP-honored; not server-side verified)id_tokenstoredemail_verifiedenforced, fail-secure on absence