From 5f51e9d16dddd776450aa435d992e595247a21a4 Mon Sep 17 00:00:00 2001 From: Kawa Date: Wed, 10 Jun 2026 15:47:04 +0200 Subject: [PATCH] Pin OIDC redirect to App__Domain when set Override the OIDC redirect_uri (and post-logout redirect) to /signin-oidc instead of deriving it from the request host. Set in both the authorize request and the code->token redemption so Entra sees a matching redirect_uri. Falls back to request-host derivation when App__Domain is unset. Domain binding hoisted so OIDC and ClientConnect share one AppDomainOptions. Co-Authored-By: Claude Opus 4.8 (1M context) --- Program.cs | 36 ++++++++++++++++++++++++++++++++++-- README.md | 4 ++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/Program.cs b/Program.cs index 3aabd9d..b2dcfcf 100644 --- a/Program.cs +++ b/Program.cs @@ -69,6 +69,12 @@ builder.Services.AddDataProtection() // Localization string source — Scoped: one per circuit, with its own explicit culture. builder.Services.AddScoped(); +// ── Public domain ───────────────────────────────────────────────────────────── +// App__Domain (e.g. sptb.example.com) drives both OIDC sign-in (below) and the +// SharePoint-connect redirect URI. Bound once here so both consumers share it. +var appDomain = new AppDomainOptions(); +builder.Configuration.GetSection("App").Bind(appDomain); + // ── Authentication ──────────────────────────────────────────────────────────── if (builder.Environment.IsDevelopment()) { @@ -133,6 +139,34 @@ else options.MapInboundClaims = false; options.TokenValidationParameters.NameClaimType = "preferred_username"; + // When App__Domain is set, pin the OIDC redirect_uri (and post-logout redirect) to that + // public host instead of deriving it from the request scheme/host. Keeps sign-in working + // when the app can't see its real external host (no/incorrect forwarded Host header, or + // several hostnames reach the same instance). The value must match the /signin-oidc URI + // registered on the Oidc app. The authorize request and the code→token redemption MUST + // send the identical redirect_uri, so override it in both events. + var oidcRedirectUri = appDomain.BuildUrl(options.CallbackPath.Value ?? "/signin-oidc"); + var postLogoutUri = appDomain.BuildUrl(options.SignedOutCallbackPath.Value ?? "/signout-callback-oidc"); + if (oidcRedirectUri is not null) + { + options.Events.OnRedirectToIdentityProvider = ctx => + { + ctx.ProtocolMessage.RedirectUri = oidcRedirectUri; + return Task.CompletedTask; + }; + options.Events.OnAuthorizationCodeReceived = ctx => + { + if (ctx.TokenEndpointRequest is not null) + ctx.TokenEndpointRequest.RedirectUri = oidcRedirectUri; + return Task.CompletedTask; + }; + options.Events.OnRedirectToIdentityProviderForSignOut = ctx => + { + ctx.ProtocolMessage.PostLogoutRedirectUri = postLogoutUri; + return Task.CompletedTask; + }; + } + options.Events.OnTokenValidated = async ctx => { var userService = ctx.HttpContext.RequestServices.GetRequiredService(); @@ -176,8 +210,6 @@ builder.Services.Configure(builder.Configuration.GetSectio // when ClientConnect__RedirectUri isn't set explicitly. Lets a deployment configure a // single domain (e.g. sptb.example.com) instead of spelling out the full callback URL. // An explicit RedirectUri still wins, so existing configs are unaffected. -var appDomain = new AppDomainOptions(); -builder.Configuration.GetSection("App").Bind(appDomain); builder.Services.PostConfigure(opts => { if (string.IsNullOrWhiteSpace(opts.RedirectUri) && diff --git a/README.md b/README.md index 5ad7d60..91000e9 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Set these as environment variables (or in `appsettings.json` under the `Oidc` se | `Oidc__TenantId` | Entra tenant GUID | | `Oidc__ClientId` | App registration client ID | | `Oidc__ClientSecret` | App registration client secret | -| `App__Domain` | Public domain the app is reached at, e.g. `sptb.example.com` or `https://sptb.example.com` (scheme defaults to `https`). The SharePoint-connect redirect URI is derived from it. | +| `App__Domain` | Public domain the app is reached at, e.g. `sptb.example.com` or `https://sptb.example.com` (scheme defaults to `https`). Pins the OIDC sign-in redirect (`/signin-oidc`) and derives the SharePoint-connect redirect URI. | | `DataFolder` | Persistent data path (default `/data`) | | `ASPNETCORE_ENVIRONMENT` | Must be `Production` to enable OIDC | @@ -38,7 +38,7 @@ Set these as environment variables (or in `appsettings.json` under the `Oidc` se These are separate and registered on **different** Entra apps. Don't conflate them. -1. **App sign-in (OIDC).** Logging into the toolbox itself via "Sign in with Microsoft". Uses the `Oidc__*` app above. Callback path is the framework default `/signin-oidc` (not configurable here). +1. **App sign-in (OIDC).** Logging into the toolbox itself via "Sign in with Microsoft". Uses the `Oidc__*` app above. Callback path is the framework default `/signin-oidc` (not configurable here). When `App__Domain` is set, the redirect is pinned to `/signin-oidc`; otherwise it's derived from the request host (`X-Forwarded-Host`/`Host`). → On **this** app registration, add redirect URI `https://your-host/signin-oidc` under the **Web** platform. This app also needs the Graph permissions the audit/reporting features require: `GroupMember.Read.All`, `Group.Read.All`, `User.Read.All`. 2. **SharePoint connect (per-profile).** Getting a delegated SharePoint/Graph token for a client tenant. A PKCE public-client flow that uses **each connection profile's own `ClientId`/`TenantId`** — not the `Oidc__*` app. The callback for this flow is derived from `App__Domain` as `/connect/callback`; set `ClientConnect__RedirectUri` to override the full URL directly.