Skip to content

feat: partner delegated access (host side) - #8168

Open
renemadsen wants to merge 16 commits into
masterfrom
feat/delegated-access
Open

renemadsen wants to merge 16 commits into
masterfrom
feat/delegated-access

Conversation

@renemadsen

@renemadsen renemadsen commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Host side of cross-tenant delegated administration for partner advisors.

Design and plan live in the angular-my-microting-plugin repo:
docs/superpowers/specs/2026-10-23-partner-tenant-delegated-access-design.md and
docs/superpowers/plans/2026-10-23-delegated-access-host.md.

Why

Partner advisory firms manage many customer tenants and their staff need access to all of
them. Today an account is created and linked by hand per tenant, and for partner admins it
does not work at all — the only link candidate offered is the partner's own organisation,
which never has a running tenant.

An earlier design proposed replicating the portal's PBKDF2 PasswordHash into every
tenant. That was withdrawn: no vendor does hash copying as ongoing sync (every
hash-import feature in Auth0, Cognito, Okta and Keycloak is a one-time migration with a
documented end state), Identity silently rehashes and then downgrades the pushed copy
across a mixed-version fleet, and it would regress on the plaintext fan-out already
shipped. This branch instead follows the pattern Dynamics 365 Business Central, Azure
Lighthouse and Entra GDAP all use.

What this adds

A second, strictly additive sign-in path on every tenant:

  1. The portal signs a short-lived, single-audience RS256 assertion.
  2. The browser carries it in a URL fragment to /auth/delegated.
  3. The tenant validates it against the portal's published JWKS.
  4. On first use it provisions a credential-less pseudonymous local principal — null
    PasswordHash, a synthetic non-deliverable address, linked under its own provider
    namespace — and puts it in the eForm users group from a fixed server-side template.
  5. The tenant issues its own JWT, exactly as a password login does.

Password login is untouched. AuthService.cs, UserService.cs and
ClaimsTransformer.cs are byte-identical, the login-path fence stays green, and the frozen
guard block's hash is unchanged. Tenant-native users keep their passwords and their direct
login unconditionally.

Also included, and valuable on its own: every password-setting path now refuses a
delegated principal
, so an advisor account cannot acquire a credential through admin
change-password or the forgot/reset-password routes.

The contract the portal must match

  • Redirect to https://{customerNo}.microting.com/auth/delegated#assertion=<jwt> —
    fragment, never the query string.
  • RS256, asymmetric, verified against the portal's JWKS. alg=none and HMAC substitution
    are both rejected.
  • Claims: sub (required), jti (required, single-use), role_template
    (required, must be exactly "advisor" — unknown templates are refused),
    name (optional, audit only).
  • aud = the tenant's customerNo. Single-audience enforced: a token minted for one tenant
    is rejected by another.
  • Tenant config: DelegatedAccess:Enabled|Issuer|JwksUri|Audience|ClockSkewSeconds.

Known gaps, stated deliberately

  • IUserService.AddPasswordAsync is ungated. It is a published BasePn interface
    member calling userManager.AddPasswordAsync with no delegated-principal check. There is
    no in-repo caller, but a plugin could reach it, so the credential-less guarantee is
    airtight for host code and not for plugin code. UserService.cs is byte-frozen by the CI
    fence, so closing this needs a deliberate fence amendment in its own PR.
  • Login CSRF is only half closed. Closed by 25dd5a8ec, added to this PR. The flow
    is now tenant-initiated: /auth/delegated/start generates a state with
    crypto.getRandomValues, stores it in sessionStorage on the tenant's own origin, and
    bounces to the portal; the delegated route compares the assertion's state claim against
    the stored value before posting anything, and clears it either way. A link carrying an
    attacker's assertion arrives at a browser holding no state, and is refused — the case is
    pinned by delegated route refuses when no state is stored at all. The validator now
    requires the state claim exactly as it requires jti and role_template. The customer
    number comes from the tenant's own hostname, never from a query parameter, so the start
    route cannot be steered.
  • Replay and JWKS fetch gating are process-local. Correct while tenants stay
    replicaCount: 1, which the helm chart is. Scaling a tenant out needs a shared store.
  • The Enabled kill switch is live only when flipped through the settings mechanism —
    EfConfigurationProvider reloads on a settings write. A raw SQL edit straight into the
    table still needs a pod restart. Worth saying in operator docs.
  • No admin-facing way to see or remove a delegated link. The existing link UI filters to
    the P1 provider. Revocation today is strip-the-role or deactivate.
  • Idle expiry of stale advisor principals is not implemented. It needs somewhere to
    persist a last-sign-in time, and the host owns no migrations — BaseDbContext ships
    inside a NuGet package — so it was deliberately left out rather than decided mid-stream.
    The rows it would remove are credential-less and inert.
  • The anonymous endpoint does RSA verification per request with no rate limit. The JWKS
    fetch is gated and coalesced; the CPU is not.

Verification

Six tasks, each implemented and reviewed independently, then a whole-branch review which
returned fit to merge. Integration tests were authored locally but executed in CI — the
development machine has no MariaDB. CI caught two defects local review did not: a too-broad
password-refusal predicate that broke admin password-setting, and a first-sign-in race where
the link was written before the role, so a concurrent adopter found a role-less principal
and was refused.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6

renemadsen and others added 14 commits October 23, 2026 08:35
Adds the security core of partner-federated sign-in: DelegatedAssertionValidator
turns a raw, attacker-chosen string into a DelegatedAssertion only when signature,
algorithm, issuer, audience, lifetime and replay all pass.

- Algorithm pinned to RS256 three ways: the header is checked before anything else
  looks at the token, TokenValidationParameters.ValidAlgorithms says the same to the
  library, and the candidate key set is filtered to asymmetric keys. alg=none and
  HMAC-with-the-public-key both die at the door, before the JWKS is even fetched.
- Single audience, exactly: the library's any-match audience check would accept an
  assertion minted for {1043, 1054} at both tenants.
- Replay is checked LAST and atomically, so an unauthenticated caller cannot burn a
  jti it does not hold, and two concurrent presentations of one assertion sign in once.
- JWKS cached for 10 minutes, refetched at most once per validation on an unknown kid,
  with a 30s cooldown so a flood of unknown kids cannot amplify against the portal.
  An unreachable, slow, oversized, non-2xx or unparseable JWKS fails the validation
  closed without throwing.
- Every refusal returns one uniform message; the reason is logged, never returned, so
  the public endpoint is not an oracle for which part of a forgery to fix next.

No DI registration here: Startup.cs is Task 4's, and the login-path fence stays clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
…rd-less account

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
Review round 1 on Task 3.

Important 1 — the refetch cooldown was neither race-safe nor set on failure.
TryGetValue(cooldown) and the fetch were not serialised, so N *concurrent* unknown
kids produced N outbound requests: the 30s cap only ever bound sequential callers,
which is all the old tests exercised. And because failures were not cached and the
cooldown was written only on success, an unreachable portal cost one outbound
attempt — each holding a 10s timeout — per inbound token, an amplification any
anonymous caller could drive. Now every outbound attempt, cold start and forced
refetch alike, goes through one process-wide gate per JWKS endpoint, so concurrent
callers reuse the winner's result; and the cooldown is written BEFORE the attempt,
so a failure costs the next caller its attempt exactly as a success would. It is
cleared again only after a successful cold start, so a genuine key rotation is still
picked up immediately rather than after a cooldown.

Important 2 — role_template is now required. The claim names are this repository's
unilateral invention and the portal that mints them lives elsewhere, so a mismatch
degraded silently to "" — and Task 5 trusts this result completely, which would turn
that into some default permission set on a customer's tenant. It now fails exactly as
an absent jti does. `name` stays optional: audit-only, never an identifier.

Minor — jti, kid, alg and sub are attacker-chosen and were logged raw, so a newline
forged a whole extra entry in any line-oriented sink. Control characters are replaced
and the value truncated before it reaches a log line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
Resolve-or-provision the advisor's local principal from a validated portal
assertion, then mint this tenant's own session for it.

The principal is created with Identity's no-password overload, so PasswordHash
stays null, and it is linked under ExternalLoginProviders.MyMicrotingDelegated,
which every password-setting path refuses and P3's fan-out cannot see. The only
resolution key is (delegated provider, sub): a tenant-native account that holds
the derived address, or a person who already holds a P1 link on this tenant, is
never adopted — the delegated principal is created alongside it. The row carries
a one-way digest of sub plus a non-deliverable .invalid address, so the
advisor's real address never reaches the customer's database; the display name
is stored for audit attribution only.

The role is resolved server-side from the asserted template, which cannot name
an administrative role and is refused if this tenant does not know it. User Id 1
and the admin role are refused on the resolved principal as well, and the
template role is added once at creation so stripping it stays a working
revocation. Every sign-in is logged at information level with the local user id,
sub, jti and whether the principal was created or reused, each attacker-chosen
value sanitised through Task 3's ForLog (now internal, so there is one copy).

Two concurrent first sign-ins are settled by the UserLogins primary key rather
than a lock: the loser discards its pending insert, deletes its own orphan row
and adopts the winner. A provisioning failure costs the advisor a fresh
assertion, because the validator burns the jti before this service is called;
that is correct and is not worked around.

IAuthService gains GenerateToken, which AuthService already implements as
public, so delegated sign-in and password login share one minting
implementation. AuthService.cs, UserService.cs and ClaimsTransformer.cs are
untouched and the CI login-path fence is clean, frozen hash unchanged.

NoRuntimeBindingSourceTests' allowlist gains this file: it is the delegated
sign-in path, which creates and reads links but can never set a credential.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
CI's real MariaDB refused the loser of a concurrent first sign-in. The cause is
an ordering one, not a recovery one: the link is the discovery key, and it was
written BEFORE the role, so a request that adopted the other's principal in the
gap between those two writes read an empty role list and refused — the same
refusal a customer who stripped the template role should get. Two tabs, or two
advisors arriving together, denied one of them.

The role is now written first and the link last, so a principal becomes
discoverable only once it is complete, and any reader that sees the link sees
the role that committed before it. No lock, no retry, no second read. If the
role write fails the half-built row is deleted, because without a link nothing
could ever resolve it.

Two more, both on the same path. AddLoginAsync re-reads the key before writing,
so a lost race usually arrives as a LoginAlreadyAssociated IdentityResult rather
than a constraint violation; that shape was logged at Error, which would page
someone for a working sign-in, and now shares the information-level line with
the violation path. And a link refusal whose re-read names no holder now retries
within the existing bound instead of refusing: the holder that caused it no
longer exists.

The concurrency test registers every row matching the derived address for
cleanup, before its first assert, so a failing assert cannot leak a row into a
database that ClearDb() does not truncate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
…exact interceptor match

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
…peline test

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
… parse in interceptor

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
@renemadsen
renemadsen marked this pull request as ready for review October 23, 2026 08:09
Copilot AI balanced review requested due to automatic review settings October 23, 2026 08:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Subject identity collisions, missing advisor permissions, and a startup redirect race remain unresolved.

3 open issues
What changed in this PR

Adds host-side delegated sign-in for partner advisors, exchanging portal assertions for tenant sessions through a separate authentication path.

Changes:

  • Adds assertion validation, credential-less principal provisioning, and password-write guards.
  • Adds an Angular landing route with fragment capture, telemetry redaction, and shared login completion.
  • Adds unit and integration coverage for validation, provisioning, provider isolation, and password refusal.
File Description
eFormAPI/​eFormAPI.Web/​Startup.cs Registers delegated options and services.
eFormAPI/​eFormAPI.Web/​Services/​DelegatedSignInService.cs Provisions advisor principals and issues sessions.
eFormAPI/​eFormAPI.Web/​Services/​DelegatedAssertionValidator.cs Validates assertions, signing keys, and replay protection.
eFormAPI/​eFormAPI.Web/​Services/​AccountService.cs Rejects delegated password changes and resets.
eFormAPI/​eFormAPI.Web/​Infrastructure/​Models/​ExternalLinks/​ExternalLoginProviders.cs Defines the separate delegated provider.
eFormAPI/​eFormAPI.Web/​Infrastructure/​Models/​DelegatedAccess/​DelegatedSignInModel.cs Defines the assertion request body.
eFormAPI/​eFormAPI.Web/​Infrastructure/​Models/​DelegatedAccess/​DelegatedAssertion.cs Defines validated assertions and claim names.
eFormAPI/​eFormAPI.Web/​Infrastructure/​Models/​DelegatedAccess/​DelegatedAccessOptions.cs Defines tenant opt-in and validation settings.
eFormAPI/​eFormAPI.Web/​Hosting/​Security/​UserManagerLockoutExtensions.cs Guards shared password replacement.
eFormAPI/​eFormAPI.Web/​Controllers/​DelegatedSignInController.cs Exposes the delegated exchange endpoint.
eFormAPI/​eFormAPI.Web/​Abstractions/​IDelegatedSignInService.cs Defines the delegated session contract.
eFormAPI/​eFormAPI.Web/​Abstractions/​IDelegatedAssertionValidator.cs Defines the assertion-validation contract.
eFormAPI/​eFormAPI.Web/​Abstractions/​IAuthService.cs Exposes existing tenant token generation.
eFormAPI/​eFormAPI.Web.Tests/​DelegatedSignInControllerTests.cs Tests endpoint outcomes and binding metadata.
eFormAPI/​eFormAPI.Web.Tests/​DelegatedAssertionValidatorTests.cs Tests validation, replay, and key-fetch behavior.
eFormAPI/​eFormAPI.Web.Integration.Tests/​Services/​DelegatedSignInServiceTests.cs Tests Identity provisioning and tenant tokens.
eFormAPI/​eFormAPI.Web.Integration.Tests/​Security/​NoRuntimeBindingTests.cs Updates the external-login API allowlist.
eFormAPI/​eFormAPI.Web.Integration.Tests/​Security/​ExternalLinkContractTests.cs Tests delegated provider isolation.
eFormAPI/​eFormAPI.Web.Integration.Tests/​Security/​DelegatedSignInPipelineTests.cs Tests anonymous routing and body binding.
eFormAPI/​eFormAPI.Web.Integration.Tests/​Security/​DelegatedPrincipalPasswordRefusalTests.cs Tests stored-hash protection and native-user compatibility.
eFormAPI/​eFormAPI.Web.Integration.Tests/​Identity/​IdentityTestHost.cs Tracks externally created users for cleanup.
eform-client/​src/​main.ts Captures assertions before telemetry initialization.
eform-client/​src/​assets/​i18n/​enUS.ts Adds English sign-in messages.
eform-client/​src/​assets/​i18n/​da.ts Adds Danish sign-in messages.
eform-client/​src/​app/​modules/​auth/​delegated-assertion.ts Captures, consumes, and redacts assertions.
eform-client/​src/​app/​modules/​auth/​delegated-assertion.spec.ts Tests fragment handling and redaction.
eform-client/​src/​app/​modules/​auth/​components/​index.ts Exports the delegated component.
eform-client/​src/​app/​modules/​auth/​components/​auth/​delegated/​delegated.component.ts Exchanges assertions and displays outcomes.
eform-client/​src/​app/​modules/​auth/​components/​auth/​delegated/​delegated.component.spec.ts Tests exchange outcomes and existing-session refusal.
eform-client/​src/​app/​modules/​auth/​components/​auth/​delegated/​delegated.component.html Displays progress and refusal messages.
eform-client/​src/​app/​modules/​auth/​auth.routing.ts Registers the delegated landing route.
eform-client/​src/​app/​common/​store/​service/​auth-state.service.ts Shares login completion between authentication paths.
eform-client/​src/​app/​common/​services/​auth/​auth.service.ts Adds the delegated exchange request.
eform-client/​src/​app/​common/​interceptors/​http-error.interceptor.ts Passes delegated failures to the landing page.
eform-client/​src/​app/​common/​interceptors/​http-error.interceptor.spec.ts Tests delegated error handling and path matching.

Review effort: Balanced

{
var result = await userManager.AddLoginAsync(user, new UserLoginInfo(
ExternalLoginProviders.MyMicrotingDelegated,
assertion.Subject,
this.errorKey = 'Delegated sign-in missing assertion';
return;
}
const sub = this.authService.loginDelegated(assertion).subscribe({
// The role is added ONCE, at creation, and never re-asserted on a later
// sign-in: re-adding it would quietly undo a customer who stripped it, which
// is the revocation lever the spec gives them (§8.7).
if (!await AddRoleAsync(user, roleName, assertion))
Closes the login-CSRF item left open by #8168. Until now the flow was
IdP-initiated: an assertion was genuine, but nothing tied it to the browser
that received it, so anyone who could have one minted for their own advisor
account on their own customer could mail the link to a victim and have the
victim's browser sign itself in as somebody else.

The tenant now asks first. A new /auth/delegated/start generates a state with
crypto.getRandomValues, keeps it in sessionStorage on the tenant's own origin,
and sends only its copy to the portal's bridge page. The portal echoes it into
the assertion, and /auth/delegated compares the two before it posts anything.
A browser holding no state refuses, which is the case the whole change exists
for.

Nothing about the start route's destination is steerable: the portal host is a
constant and the customerNo is the first label of the tenant's own hostname, so
a ?customerNo=, ?issuer= or ?redirect_uri= is ignored entirely. A hostname with
no numeric first label refuses rather than guesses. The state is taken, and so
cleared, before every branch of the landing route, so no attempt can leave a
usable one behind; every refusal shows the one existing message and says
nothing about which condition failed.

Server-side, DelegatedAssertion carries State and the validator requires the
claim exactly as it requires jti and role_template. The comparison can only
happen in the browser that holds the value, but the endpoint is public, so an
assertion carrying no state at all dies at the validator rather than relying on
a client to notice.

main.ts is untouched: captureDelegatedAssertion still runs before Sentry.init.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
Copilot AI balanced review requested due to automatic review settings October 23, 2026 11:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The documented portal integration contract omits the mandatory state handshake, so an implementation following it cannot complete sign-in.

4 open issues

Review effort: Balanced

Comment on lines +302 to +303
var state = ClaimOrEmpty(jwt, DelegatedAssertionClaims.State);
if (string.IsNullOrWhiteSpace(state))
… from telemetry

Review round 1 on the tenant-initiated flow. Two gaps, both in what the tests
and the telemetry hooks cover rather than in the shipped behaviour.

Nothing pinned the CSPRNG, which is the one load-bearing property of the whole
defence. Replacing crypto.getRandomValues with a Math.random byte fill left all
25 specs green: the charset and 200-draw uniqueness tests are satisfied by
Math.random, and even by a counter, so a well-meaning "add a fallback so old
browsers work" refactor would have shipped silently. Three specs now pin it:
the CSPRNG must be called, with a 32-byte buffer, and the output must be
exactly what those bytes map to, so no second source can be mixed in; b and
b+64 must land on the same character, which only holds while the alphabet is
exactly 64 symbols and the mapping unbiased; and a browser with no CSPRNG at
all must get null rather than a guessable state. That last branch had no test
and is now covered twice, in the helper and end to end through the start route,
by removing the function rather than stubbing it — a stub is still a function
and would take the happy path. All four fail against the Math.random mutation.

The Sentry URL scrub matched assertion= only. The same bundle also serves the
portal origin, whose authorize bridge page carries ?state= in the address bar,
and replaysOnErrorSampleRate: 1.0 ships the buffered replay, URL bar included,
on any error. Unlike OAuth the portal echoes whatever state the caller supplies
and mints for the caller's own advisor identity, so someone reading a state in
flight could mint an assertion echoing it and race the victim's round trip. One
token in the regex closes it, in the same file an earlier review already
hardened for this leak class. customerNo stays legible, so a scrubbed replay is
still readable.

Also notes why decodeBase64Url does not reuse JwtHelper: it must answer null
for arbitrary attacker-supplied input rather than throw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
Copilot AI balanced review requested due to automatic review settings October 23, 2026 11:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved identity isolation, permission provisioning, and Replay nonce exposure issues block approval.

6 open issues
1 previously missed issue

In code that hasn't changed since last review

Medium severity Persist advisor security-group membership during provisioning

eFormAPI/​eFormAPI.Web/​Services/​DelegatedSignInService.cs:379

Provisioning adds an Identity role but no security-group membership. ClaimsService.GetUserClaims (lines 129–145) derives non-admin host and plugin permissions from SecurityGroupUsers and GroupPermissions, so a new advisor gets a valid session without the permissions needed to administer the customer. AdminService.Create adds this membership separately from the role. Map the advisor template to a server-selected security group and persist membership before publishing the login link, with cleanup if provisioning fails. Verify the resulting permissions using the real claims service rather than the empty substitute in the integration tests.

Review effort: Balanced

Comment on lines +172 to +173
var user = await userManager.FindByLoginAsync(
ExternalLoginProviders.MyMicrotingDelegated, assertion.Subject);
Comment thread eform-client/src/main.ts
Comment on lines +139 to +141
beforeBreadcrumb: (breadcrumb) => scrubAssertionDeep(breadcrumb),
beforeSend: (event) => scrubAssertionDeep(event),
beforeSendTransaction: (event) => scrubAssertionDeep(event),
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants