Skip to content

OIDC resource server: never link a back-office account by e-mail - #901

Merged
KrzysztofPajak merged 3 commits into
developfrom
fix/oidc-review-develop
Oct 11, 2026
Merged

KrzysztofPajak merged 3 commits into
developfrom
fix/oidc-review-develop

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Resolves: no issue (found while reviewing the OpenID Connect work merged in #891-#897)
Type: bugfix

Issue

An access token on a protected resource (by default /api) linked an existing back-office account (admin panel access, vendor, store staff) to the provider identity by a verified e-mail, without the account's password.

With a provider where someone can mark an address as verified without owning it (for example an administrator of another organization in Zitadel, or a realm administrator in Keycloak creating a user), that person got the Web API with the administrator's rights. The stored link is shared with the browser sign-in plugin, so the same account could then also sign in to the store without its password. That skipped the password and two-factor step the plugin requires for the first link of a staff account (#896).

Two related gaps:

  • An account with GrandNode two-factor authentication was linked by e-mail on the token path, although the browser sign-in asks for its password.
  • A link made by e-mail while the account was an ordinary customer kept serving it after an administrator granted back-office access or the customer enabled two-factor authentication.

To reproduce on develop: enable the resource server, have an administrator account admin@shop.test without an OIDC link, issue a token for a provider user with email = admin@shop.test, email_verified = true and the grandnode.api scope, then call /api/.... The account is linked and the call succeeds.

Solution

  • Never link by e-mail: back-office accounts and accounts with GrandNode two-factor authentication are not linked by e-mail, verified or not, on the token path either. Without a link the answer is 403 "The account must be connected to the identity with its password first". Both paths follow the same rule.
  • Links remember how they were made: ExternalAuthentication.LinkedByEmail marks a link made by the e-mail alone, whether by an access token or by the browser sign-in without a password. Such a link is re-checked on every use:
    • On the token path it stops serving an account that has since become back-office or enabled two-factor authentication (403).
    • On the browser sign-in it leads to "Connect your account" in that case, and also whenever the provider does not report the e-mail as verified.
  • Connecting with the password (LinkIdentity: plugin "Connect your account", registration) clears the mark. A link made by a token registration (AutoRegister) is not marked, because the identity created that account.
  • Order of checks: the token path checks a link to another subject before the password rule, so the answer names the actual conflict.
  • How staff get API access:
    • connect the account once through the browser sign-in plugin (password and two-factor code), after which their tokens use that link; or
    • without the plugin, let the token register a dedicated account (AutoRegister = true) and grant that account the access.
  • Guide (docs/oidc-resource-server.md, sections 3.1, 3.3, 11.2, 11.6) updated. The guide also no longer claims that after switching Authority the old links are only refused: a sub is unique per provider, so those links must be removed before switching.

Breaking changes

Behaviour change against unreleased develop only; the resource server is not in any release.

  • An existing back-office account or account with two-factor authentication is no longer linked by an access token.
  • Granting back-office access to a customer linked by e-mail stops its tokens until the account is connected with its password.
  • ExternalAuthentication gets the bool field LinkedByEmail. No migration is needed: links stored before this change read as false (made with the password).

Testing

  1. Run dotnet test on src/Tests/Grand.Business.Authentication.Tests (168/168), Grand.Web.Common.Tests (385/385), Grand.Modules.Tests (246/246), Grand.Web.Tests (77/77), Grand.Module.Api.Tests (47/47) and Grand.Mapping.Tests (236/236).
  2. Manual, with the test environment from docs/oidc/docker-compose.yml (remove existing ExternalAuth.OpenIdConnect links first):
    1. Call /api/... with a token of a user whose verified e-mail belongs to an unlinked administrator. Expect 403 and no ExternalAuthentication record.
    2. Sign in with the login plugin as that user. Expect the "Connect your account" page; sign in with the store password (and 2FA).
    3. Repeat the API call. Expect 200.
    4. Call /api/... with a token of a plain registered customer (verified e-mail). Expect 403 (no admin panel access) and a link with LinkedByEmail = true. Grant the customer an admin group and repeat. Expect 403 "must be connected ... with its password first". Connect through the plugin and repeat. Expect 200 and LinkedByEmail = false.
    5. With AutoRegister = true, call /api/... with a token of a new user. The account is registered with an unmarked link. Grant it an admin group and repeat. Expect 200.

A verified e-mail in an access token linked an existing administrator,
vendor or store staff account without its password. A provider where
someone can mark an address as verified without owning it (an
administrator of another organization or realm) would hand over the
Web API with admin rights, and the stored link then also signed the
account in through the browser plugin, skipping the password step that
plugin requires for staff.

Staff now connect once through the browser sign-in (password and
two-factor code); tokens use that link. Ordinary customers are linked
by e-mail as before, and an account linked while ordinary keeps its
link when an administrator grants back-office access.

The guide also stops claiming that switching Authority only refuses old
links: a sub is unique per provider, so the links must be removed.
Copilot AI balanced review requested due to automatic review settings October 11, 2026 17:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

A link is now marked when it was made by the e-mail address alone (by an
access token, or by the browser sign-in without a password). Such a link
is held to the rules of a first link on every use: it does not serve an
account that has back-office access or two-factor authentication, and the
browser sign-in asks for the password when the provider does not report
the e-mail as verified. Linking with the password (LinkIdentity) clears
the mark.

The access-token path applies the two-factor rule of the browser sign-in
when linking, and checks an existing link to another subject before the
back-office rule, so the answer names the actual conflict.

Documentation of RequireEmailVerified, IOidcCustomerResolver and the
guide follow; the back-office tests are merged into data rows that also
check the refusal reason.
The constant holds the fixed refusal text logged when an account has to
be connected with its password first. Its name made code scanning read
it as a stored password (cs/cleartext-storage-of-sensitive-information);
no password is logged. No behaviour change.
@KrzysztofPajak
KrzysztofPajak merged commit 33bdf20 into develop Oct 11, 2026
6 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the fix/oidc-review-develop branch October 11, 2026 18:27
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