Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
111 commits
Select commit Hold shift + click to select a range
70833fb
OIDC resource server: configuration and a protected resource registry…
KrzysztofPajak Oct 8, 2026
0f66cdd
OIDC resource server: canonical resource URIs, audience, authorized p…
KrzysztofPajak Oct 8, 2026
cfd1888
OIDC resource server: RFC 6750 challenges and RFC 9728 protected reso…
KrzysztofPajak Oct 8, 2026
e4fe29d
OIDC resource server: Oidc JwtBearer scheme, startup validation and m…
KrzysztofPajak Oct 8, 2026
ef88aee
OIDC resource server: pipeline tests with an in-memory signing key
KrzysztofPajak Oct 8, 2026
7b1cc26
OIDC resource server: store URL as audience base, fail-closed account…
KrzysztofPajak Oct 8, 2026
ce114a8
OIDC resource server: map the token to a customer by subject link, ve…
KrzysztofPajak Oct 8, 2026
12e5be9
OIDC resource server: admin panel, vendor and store manager condition…
KrzysztofPajak Oct 8, 2026
6768e2b
OIDC resource server: always write the ProblemDetails body of challen…
KrzysztofPajak Oct 9, 2026
f1ed29a
OIDC resource server: the token's customer becomes the customer of th…
KrzysztofPajak Oct 11, 2026
0257877
External authentication: unique index on provider and external identi…
KrzysztofPajak Oct 8, 2026
11eb95f
External authentication: identity link index unique only on MongoDB
KrzysztofPajak Oct 8, 2026
424a487
OIDC resource server: refuse system, non-registered, other-store and …
KrzysztofPajak Oct 8, 2026
98ed651
External authentication: installer creates the identity link index as…
KrzysztofPajak Oct 8, 2026
6752457
OIDC resource server: link name of the login plugin, automatic regist…
KrzysztofPajak Oct 10, 2026
677e160
Web API: accept access tokens of the OpenID Connect provider next to …
KrzysztofPajak Oct 8, 2026
e0ecb69
Web API: never fail on a malformed bearer token while routing to the …
KrzysztofPajak Oct 8, 2026
90ad302
API users with SecretKey tokens marked obsolete in favour of the OIDC…
KrzysztofPajak Oct 8, 2026
e05657f
Web API: BackendAPI switch applies to provider tokens; route asymmetr…
KrzysztofPajak Oct 8, 2026
e89211b
Docs: OIDC resource server guide with Keycloak and Zitadel, test envi…
KrzysztofPajak Oct 8, 2026
6d8dc2e
Web API: treat a bearer value that is not a JWT as no token
KrzysztofPajak Oct 9, 2026
afe6fef
OIDC: browser sign-in mode of the customer resolver - never registers…
KrzysztofPajak Oct 10, 2026
e663216
External registration: pending provider identity in the external auth…
KrzysztofPajak Oct 10, 2026
2f9f086
Docs: OIDC guide - Zitadel scope only for entitled users, compose har…
KrzysztofPajak Oct 8, 2026
db5cbbb
Login: keep a local return URL across the two-factor step
KrzysztofPajak Oct 10, 2026
937e440
Docs: OIDC guide - fixes from the end-to-end run against Keycloak 26.…
KrzysztofPajak Oct 8, 2026
ffeadc7
Registration: complete a provider sign-in on the regular form, e-mail…
KrzysztofPajak Oct 10, 2026
fbacc24
Docs: OIDC guide - a bearer value that is not a JWT gets the OIDC cha…
KrzysztofPajak Oct 9, 2026
3103099
Browser sign-in: the first link of an account with two-factor authent…
KrzysztofPajak Oct 10, 2026
67f73cb
Docs: OIDC guide - automatic registration is off by default
KrzysztofPajak Oct 10, 2026
c65592a
My account: removing an external sign-in connection works again
KrzysztofPajak Oct 10, 2026
db6434f
OIDC resource server: resolve the audience store from the request hos…
KrzysztofPajak Oct 11, 2026
f51fb29
OIDC resource server: RFC 9728 metadata URL with the well-known segme…
KrzysztofPajak Oct 11, 2026
c611acd
OIDC resource server: 404 and a warning instead of a metadata documen…
KrzysztofPajak Oct 11, 2026
7f1a4a8
Skip store and work context setup on the protected resource metadata …
KrzysztofPajak Oct 11, 2026
30741b7
OIDC resource server: answer 503 instead of invalid_token when the au…
KrzysztofPajak Oct 11, 2026
1101972
OIDC resource server: treat a negative ClockSkewSeconds as zero inste…
KrzysztofPajak Oct 11, 2026
c0d3952
OIDC resource server: build the registry's read-only resource list once
KrzysztofPajak Oct 11, 2026
ea2c20e
OIDC resource server: leave enablement of the metadata endpoints to t…
KrzysztofPajak Oct 11, 2026
5af62c3
OIDC resource server tests: dispose request messages and RSA instances
KrzysztofPajak Oct 11, 2026
e084407
OIDC resource server: filter explicitly in the header escaping and re…
KrzysztofPajak Oct 11, 2026
fcd4cba
Merge feature/oidc-resource-server into feature/oidc-customer-mapping…
KrzysztofPajak Oct 11, 2026
0605fc6
Merge feature/oidc-module-api into feature/oidc-guides-e2e: review fi…
KrzysztofPajak Oct 11, 2026
c607833
Merge feature/oidc-customer-mapping into feature/oidc-module-api: rev…
KrzysztofPajak Oct 11, 2026
6c10d52
OIDC guide: audience store from the host, RFC 9728 metadata URLs, 503…
KrzysztofPajak Oct 11, 2026
3c6291a
Merge feature/oidc-guides-e2e into feature/oidc-login-core: review fi…
KrzysztofPajak Oct 11, 2026
be60cf3
OIDC resource server: build the work context in the store the access …
KrzysztofPajak Oct 11, 2026
964f800
Merge develop into feature/oidc-customer-mapping: #891 squash-merged
KrzysztofPajak Oct 11, 2026
ebb689f
Merge feature/oidc-customer-mapping into feature/oidc-module-api: wor…
KrzysztofPajak Oct 11, 2026
2e93760
Merge feature/oidc-module-api into feature/oidc-guides-e2e: work-cont…
KrzysztofPajak Oct 11, 2026
db68499
OIDC Web API tests: a token forwarded from the Bearer scheme pins the…
KrzysztofPajak Oct 11, 2026
1ee791b
OIDC guide: a request with an accepted token runs in the token's stor…
KrzysztofPajak Oct 11, 2026
3d30852
Merge feature/oidc-guides-e2e into feature/oidc-login-core: work-cont…
KrzysztofPajak Oct 11, 2026
3495de8
OIDC: match the request store by host and port before host alone
KrzysztofPajak Oct 11, 2026
50822d7
OIDC: no root protected resource metadata for a store under a path
KrzysztofPajak Oct 11, 2026
f204ad8
OIDC: log an unknown metadata host at debug, a store without URL as w…
KrzysztofPajak Oct 11, 2026
282522d
Context middleware: skip the protected resource metadata route exactly
KrzysztofPajak Oct 11, 2026
2eb1441
OIDC: Retry-After on the 503 for an unreachable identity provider
KrzysztofPajak Oct 11, 2026
f42b5f2
OIDC tests: recovery after an identity provider outage
KrzysztofPajak Oct 11, 2026
f9c0804
OIDC tests: dispose responses and readers
KrzysztofPajak Oct 11, 2026
9bfdb88
Merge feature/oidc-module-api into feature/oidc-guides-e2e: review fo…
KrzysztofPajak Oct 11, 2026
6333561
Merge feature/oidc-customer-mapping into feature/oidc-module-api: rev…
KrzysztofPajak Oct 11, 2026
6161803
OIDC guide: forwarded headers behind TLS termination, host:port store…
KrzysztofPajak Oct 11, 2026
7ab2f19
Merge feature/oidc-guides-e2e into feature/oidc-login-core: review fo…
KrzysztofPajak Oct 11, 2026
ef527ce
OIDC tests: dispose responses in the forwarding pipeline tests
KrzysztofPajak Oct 11, 2026
2355fd3
Merge feature/oidc-module-api into feature/oidc-guides-e2e: review fo…
KrzysztofPajak Oct 11, 2026
35630d9
Merge feature/oidc-guides-e2e into feature/oidc-login-core: review fo…
KrzysztofPajak Oct 11, 2026
537a986
External authentication: store of the linked customer
KrzysztofPajak Oct 11, 2026
9b22008
OIDC: one identity link per store when customers are registered per s…
KrzysztofPajak Oct 11, 2026
6a381c3
External authentication index: provider, identifier and store
KrzysztofPajak Oct 11, 2026
9b08fed
Merge feature/oidc-customer-mapping into feature/oidc-module-api: per…
KrzysztofPajak Oct 11, 2026
c1dae96
Merge feature/oidc-module-api into feature/oidc-guides-e2e: per-store…
KrzysztofPajak Oct 11, 2026
459e983
OIDC guide: identity links per store
KrzysztofPajak Oct 11, 2026
885a9cf
Merge feature/oidc-guides-e2e into feature/oidc-login-core: per-store…
KrzysztofPajak Oct 11, 2026
72f4ebd
OIDC sign-in: link the identity in the current store
KrzysztofPajak Oct 11, 2026
28609f5
OIDC: a link without a store to another store's customer does not blo…
KrzysztofPajak Oct 11, 2026
f76de76
Merge feature/oidc-customer-mapping into feature/oidc-module-api: per…
KrzysztofPajak Oct 11, 2026
e189dfb
Merge feature/oidc-module-api into feature/oidc-guides-e2e: per-store…
KrzysztofPajak Oct 11, 2026
449b585
Merge feature/oidc-guides-e2e into feature/oidc-login-core: per-store…
KrzysztofPajak Oct 11, 2026
4e58b16
OIDC guide: links without a store under per-store identity
KrzysztofPajak Oct 11, 2026
37d6ffd
OIDC sign-in tests: links without a store under per-store identity
KrzysztofPajak Oct 11, 2026
a511da8
External authentication: find a linked identity by its exact identifier
KrzysztofPajak Oct 11, 2026
ac3cb12
Merge feature/oidc-customer-mapping into feature/oidc-module-api: exa…
KrzysztofPajak Oct 11, 2026
492b20e
Merge feature/oidc-module-api into feature/oidc-guides-e2e: exact ide…
KrzysztofPajak Oct 11, 2026
13119ec
Merge feature/oidc-guides-e2e into feature/oidc-login-core: exact ide…
KrzysztofPajak Oct 11, 2026
41bb378
Enforce one OIDC identity per customer with a customer-derived link id
KrzysztofPajak Oct 11, 2026
10f54e2
Merge feature/oidc-customer-mapping into feature/oidc-module-api: one…
KrzysztofPajak Oct 11, 2026
065a26e
Merge feature/oidc-module-api into feature/oidc-guides-e2e: one OIDC …
KrzysztofPajak Oct 11, 2026
575712e
Document that the link id enforces one OIDC identity per customer
KrzysztofPajak Oct 11, 2026
cdffe55
Merge feature/oidc-guides-e2e into feature/oidc-login-core: one OIDC …
KrzysztofPajak Oct 11, 2026
e7b7f8b
Test that browser sign-in linking keeps one OIDC identity per custome…
KrzysztofPajak Oct 11, 2026
dc0bcb2
Merge develop into feature/oidc-module-api
KrzysztofPajak Oct 11, 2026
d84f81b
API users: trust only tickets the Bearer scheme issued itself, not re…
KrzysztofPajak Oct 11, 2026
3d544c3
OIDC: forward only the Web API's Bearer scheme to Oidc, not every Jwt…
KrzysztofPajak Oct 11, 2026
6d1b565
OIDC scheme selector: drop the Enabled check it is never registered w…
KrzysztofPajak Oct 11, 2026
f768c12
OIDC scheme selector: route on the signing algorithm alone, without t…
KrzysztofPajak Oct 11, 2026
6ea8c1e
OIDC scheme selector: route by the Oidc scheme's own algorithm list i…
KrzysztofPajak Oct 11, 2026
09824fe
OIDC scheme selector: parse the bearer token once and keep the routin…
KrzysztofPajak Oct 11, 2026
789718c
OIDC request customer resolver: follow the scheme selector's decision…
KrzysztofPajak Oct 11, 2026
4c70e34
OIDC: withdraw the Web API's protected resource while BackendAPI is d…
KrzysztofPajak Oct 11, 2026
abfa8ae
Web API: apply the BackendAPI kill switch before the ignore flag of A…
KrzysztofPajak Oct 11, 2026
13fb897
appsettings: deny vendors and store managers on the Web API's protect…
KrzysztofPajak Oct 11, 2026
535e85c
Web API tests: join the temp settings path instead of combining it
KrzysztofPajak Oct 11, 2026
ac081ce
Merge feature/oidc-module-api into feature/oidc-guides-e2e: review fi…
KrzysztofPajak Oct 11, 2026
0eefaf5
OIDC guide: routing by algorithm only, Web API kill switch before cus…
KrzysztofPajak Oct 11, 2026
b386fd5
Merge feature/oidc-guides-e2e into feature/oidc-login-core: review fi…
KrzysztofPajak Oct 11, 2026
03a26bb
Merge remote-tracking branch 'origin/develop' into feature/oidc-guide…
KrzysztofPajak Oct 11, 2026
8ca7b93
Merge feature/oidc-guides-e2e into feature/oidc-login-core: develop a…
KrzysztofPajak Oct 11, 2026
8dbf4c9
OIDC guide: wrap long lines, Client scopes menu name; resource URI co…
KrzysztofPajak Oct 11, 2026
99ce77e
Merge branch 'feature/oidc-guides-e2e' into feature/oidc-login-core
KrzysztofPajak Oct 11, 2026
bbd1ca7
External registration: connect only a verified e-mail, warn when conn…
KrzysztofPajak Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
502 changes: 502 additions & 0 deletions docs/oidc-resource-server.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/oidc/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env
92 changes: 92 additions & 0 deletions docs/oidc/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# TEST ENVIRONMENT ONLY. Placeholder credentials, plain http, no persistence guarantees.
# Never use this file for a real installation. Guide: docs/oidc-resource-server.md
#
# docker compose -f docs/oidc/docker-compose.yml up -d
#
# Required variables (a .env file next to this file, example in docs/oidc-resource-server.md section 10):
# KC_ADMIN_USER, KC_ADMIN_PASSWORD, ZITADEL_DB_PASSWORD, ZITADEL_MASTERKEY (32 chars), ZITADEL_ADMIN_PASSWORD
# Optional: GRANDNODE_PROJECT_ID (Zitadel project id, for the claim target)
# Ports are bound to 127.0.0.1 only.
#
# Keycloak 26.8.0 http://localhost:8080 admin user: KC_ADMIN_USER / KC_ADMIN_PASSWORD
# Zitadel v4.19.4 http://localhost:8081 admin: zitadel-admin@zitadel.localhost / ZITADEL_ADMIN_PASSWORD
#
# Use exactly these public URLs as OidcResourceServer:Authority and in the browser - the token issuer is built from them
# (Keycloak: http://localhost:8080/realms/<realm>, Zitadel: http://localhost:8081).

services:
keycloak:
image: quay.io/keycloak/keycloak:26.8.0
# resource-indicators and cimd are experimental features in Keycloak 26.8
command: start-dev --features=resource-indicators,cimd
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USER:?set KC_ADMIN_USER}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:?set KC_ADMIN_PASSWORD}
ports:
- "127.0.0.1:8080:8080"

zitadel-db:
image: postgres:17
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${ZITADEL_DB_PASSWORD:?set ZITADEL_DB_PASSWORD}
POSTGRES_DB: zitadel
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d zitadel"]
interval: 5s
timeout: 5s
retries: 20
volumes:
- zitadel-db-data:/var/lib/postgresql/data

zitadel:
# latest stable release at the time of writing (2026-10-08)
image: ghcr.io/zitadel/zitadel:v4.19.4
command: start-from-init --masterkeyFromEnv --tlsMode disabled
depends_on:
zitadel-db:
condition: service_healthy
environment:
# exactly 32 characters, cannot be changed after the first start
ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY:?set ZITADEL_MASTERKEY (32 characters)}
ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DB_PASSWORD:?set ZITADEL_DB_PASSWORD}
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${ZITADEL_DB_PASSWORD:?set ZITADEL_DB_PASSWORD}
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
# the public address: issuer = http://localhost:8081
ZITADEL_EXTERNALDOMAIN: localhost
ZITADEL_EXTERNALPORT: 8081
ZITADEL_EXTERNALSECURE: "false"
# use the login UI built into the zitadel container (the default in v4 is a separate "zitadel-login" container
# behind a reverse proxy, see https://github.com/zitadel/zitadel/tree/main/deploy/compose)
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false"
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: zitadel-admin
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD:?set ZITADEL_ADMIN_PASSWORD}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false"
# TEST ONLY - restores SSRF exposure of Actions targets; do not copy to production.
# Targets on private addresses are refused by default; the e-mail target below lives in this network
ZITADEL_HTTPCLIENT_DENYLIST: "169.254.0.0/16"
ports:
- "127.0.0.1:8081:8080"

# Zitadel Actions v2 target that adds email, email_verified and scope to access tokens
# (endpoint for the target: http://zitadel-email-target:8090/)
zitadel-email-target:
image: node:22-alpine
command: node /app/zitadel-email-target.js
environment:
PORT: 8090
GRANDNODE_SCOPE: grandnode.api
# project role key and the id of the GrandNode project (set in .env after creating the project in Zitadel)
GRANDNODE_ROLE: grandnode.api
GRANDNODE_PROJECT_ID: ${GRANDNODE_PROJECT_ID:-}
volumes:
- ./zitadel-email-target.js:/app/zitadel-email-target.js:ro

volumes:
zitadel-db-data:
63 changes: 63 additions & 0 deletions docs/oidc/zitadel-email-target.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Zitadel Actions v2 target for the "preaccesstoken" function (test environment helper, Node 18+, no dependencies).
//
// Zitadel puts neither `email`/`email_verified` nor a `scope` claim into JWT access tokens. The GrandNode resource
// server links a user on first use by the verified e-mail address and checks RequiredScopes, so this target appends:
// email, email_verified - from the payload Zitadel sends (userinfo, falling back to the human user)
// scope - GRANDNODE_SCOPE (default "grandnode.api") ONLY when the user is entitled: the user has a
// project role GRANDNODE_ROLE (default "grandnode.api") in the project GRANDNODE_PROJECT_ID
// (user_grants of the payload). Not entitled: no scope claim, so GrandNode answers 403.
// Zitadel sends user_grants only when the project has "Assert Roles on Authentication"
// on (or the client requests urn:zitadel:iam:org:projects:roles).
// ID tokens never pass through this function, so they never carry the scope.
// GRANDNODE_PROJECT_ID is required; without it no scope is ever added.
// Zitadel calls it as a "Call" target (the response is used): see docs/oidc-resource-server.md.
// Listens on PORT (default 8090). Signature verification (ZITADEL-Signature header) is NOT done: test use only.
const http = require('node:http');

const scope = process.env.GRANDNODE_SCOPE || 'grandnode.api';
const role = process.env.GRANDNODE_ROLE || 'grandnode.api';
const projectId = process.env.GRANDNODE_PROJECT_ID || '';
const port = Number(process.env.PORT || 8090);

function isEntitled(payload) {
if (!projectId) return false;
const grants = Array.isArray(payload.user_grants) ? payload.user_grants : [];
return grants.some((g) => g && (g.projectId === projectId || g.project_id === projectId) &&
Array.isArray(g.roles) && g.roles.includes(role));
}

function claimsFor(payload) {
const info = payload.userinfo || {};
const human = (payload.user && payload.user.human) || {};
const email = info.email || (human.email && (human.email.email || human.email)) || '';
// Zitadel v4 sends user.human.email as a string and the flag as user.human.is_email_verified
const verifiedRaw = info.email_verified !== undefined
? info.email_verified
: human.is_email_verified ?? (human.email && (human.email.is_verified ?? human.email.isVerified));
const claims = [];
if (isEntitled(payload)) claims.push({ key: 'scope', value: scope });
if (typeof email === 'string' && email) {
claims.push({ key: 'email', value: email });
claims.push({ key: 'email_verified', value: verifiedRaw === true });
}
return { append_claims: claims };
}

if (require.main === module) {
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
try {
const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(claimsFor(payload)));
} catch (e) {
res.writeHead(400);
res.end();
}
});
}).listen(port, () => console.log(`zitadel-email-target listening on ${port}`));
}

module.exports = { claimsFor };
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Grand.Business.Core.Utilities.Authentication;
using Grand.Business.Core.Utilities.Customers;
using Grand.Data;
using Grand.Domain.Common;
using Grand.Domain.Customers;
using Grand.Domain.Permissions;
using Grand.Infrastructure.Configuration;
Expand All @@ -20,7 +21,7 @@
namespace Grand.Business.Authentication.Services;

/// <summary>
/// Maps a validated access token to a customer: stored subject link; customer with the verified e-mail (link
/// Maps a validated access token or a browser sign-in to a customer: stored subject link; customer with the verified e-mail (link
/// stored); registration with the Registered group only. Only active, registered, non-system accounts are used,
/// and one customer holds at most one identity (enforced by the link's identifier, see <see cref="OidcLinkId" />).
/// With per-store identity a subject has one link per store (plus the link of a customer of no store, valid in
Expand Down Expand Up @@ -107,14 +108,12 @@ public virtual async Task<OidcCustomerResult> Resolve(ClaimsPrincipal principal,

//one identity per customer - a second subject with the same e-mail must not take over the account; the
//link's identifier enforces it against concurrent requests, this check gives the answer in the normal case
var otherLink = await _externalAuthenticationRepository.GetOneAsync(x =>
x.ProviderSystemName == ProviderSystemName && x.CustomerId == customer.Id && x.ExternalIdentifier != sub);
if (otherLink != null)
return LinkedToOtherIdentity(customer, sub, otherLink);
if (await HasOtherLink(customer, sub))
return LinkedToOtherIdentity(customer, sub);

var (linked, other) = await LinkOrGetWinner(customer, sub, email, storeId);
if (other != null)
return LinkedToOtherIdentity(customer, sub, other);
return LinkedToOtherIdentity(customer, sub);
if (linked?.Id != customer.Id)
return await Usable(linked, sub, storeId);

Expand All @@ -123,6 +122,80 @@ public virtual async Task<OidcCustomerResult> Resolve(ClaimsPrincipal principal,
return result;
}

public virtual async Task<OidcLoginResult> ResolveForLogin(string sub, string email, bool emailVerified, string storeId)
{
if (string.IsNullOrEmpty(sub))
return OidcLoginResult.Refused(OidcLoginRefusal.InvalidIdentity);

var link = await GetLink(sub, storeId);
if (link != null)
{
var linked = await _customerService.GetCustomerById(link.CustomerId);
var reason = await Check(linked, sub, storeId);
return reason == Unusable.None ? OidcLoginResult.SignIn(linked) : OidcLoginResult.Refused(ToRefusal(reason));
}

if (string.IsNullOrWhiteSpace(email))
return OidcLoginResult.Refused(OidcLoginRefusal.InvalidIdentity);

var customer = await _customerService.GetCustomerByEmail(email,
_customerConfig.RegisterCustomersPerStore ? storeId : "");
if (customer == null)
{
var customerSettings = await _settingService.LoadSetting<CustomerSettings>(storeId);
if (customerSettings.UserRegistrationType == UserRegistrationType.Disabled)
{
_logger.LogWarning("Sign-in subject {Subject}: registration is disabled in store {StoreId}", sub, storeId);
return OidcLoginResult.Refused(OidcLoginRefusal.RegistrationDisabled);
}

return OidcLoginResult.RegistrationRequired();
}

var usable = await Check(customer, sub, storeId);
if (usable != Unusable.None)
return OidcLoginResult.Refused(ToRefusal(usable));

if (await HasOtherLink(customer, sub))
{
_logger.LogWarning("Sign-in subject {Subject}: customer {CustomerId} is linked to another identity", sub, customer.Id);
return OidcLoginResult.Refused(OidcLoginRefusal.LinkedToOtherIdentity);
}

//the first link of a back-office account, of an account protected by two-factor authentication, and of any account
//whose e-mail nobody verified, needs the account's password (and its second factor)
if (!emailVerified || await IsBackOfficeAccount(customer) || await HasTwoFactor(customer, storeId))
return OidcLoginResult.LinkRequired(customer);

return await LinkIdentity(customer, sub, email, storeId) switch {
OidcLinkResult.Linked => OidcLoginResult.SignIn(customer),
_ => OidcLoginResult.Refused(OidcLoginRefusal.LinkedToOtherIdentity)
};
}

public virtual async Task<OidcLinkResult> LinkIdentity(Customer customer, string sub, string email, string storeId)
{
ArgumentNullException.ThrowIfNull(customer);
ArgumentException.ThrowIfNullOrEmpty(sub);

//the link this store would use - with per-store identity a link of another store does not block this one
var existing = await GetLink(sub, storeId);
if (existing != null)
return existing.CustomerId == customer.Id ? OidcLinkResult.Linked : OidcLinkResult.SubjectLinkedToOtherCustomer;

if (await HasOtherLink(customer, sub))
return OidcLinkResult.CustomerLinkedToOtherSubject;

var (winner, other) = await LinkOrGetWinner(customer, sub, email ?? customer.Email ?? "", storeId);
if (other != null)
return OidcLinkResult.CustomerLinkedToOtherSubject;
if (winner?.Id != customer.Id)
return OidcLinkResult.SubjectLinkedToOtherCustomer;

_logger.LogInformation("Subject {Subject} linked to customer {CustomerId}", sub, customer.Id);
return OidcLinkResult.Linked;
}

private async Task<OidcCustomerResult> Register(string sub, string email, string storeId)
{
if (!_config.AutoRegister)
Expand Down Expand Up @@ -169,7 +242,7 @@ await _customerManagerService.RegisterCustomer(new RegistrationRequest(customer,
{
//lost the race: drop the account just created and use the one the winner linked
await _customerService.DeleteCustomer(customer, true);
return other != null ? LinkedToOtherIdentity(customer, sub, other) : await Usable(linked, sub, storeId);
return other != null ? LinkedToOtherIdentity(customer, sub) : await Usable(linked, sub, storeId);
}

//only after the link is stored, so the loser of a race sends no welcome message
Expand Down Expand Up @@ -220,10 +293,10 @@ private async Task<ExternalAuthentication> GetLink(string sub, string storeId)
private string LinkStoreId(Customer customer) =>
_customerConfig.RegisterCustomersPerStore ? customer.StoreId ?? "" : "";

private OidcCustomerResult LinkedToOtherIdentity(Customer customer, string sub, ExternalAuthentication otherLink)
private OidcCustomerResult LinkedToOtherIdentity(Customer customer, string sub)
{
_logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is already linked to subject {LinkedSubject}",
sub, customer.Id, otherLink.ExternalIdentifier);
_logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is already linked to another subject",
sub, customer.Id);
return OidcCustomerResult.Forbidden("The customer account is linked to another identity");
}

Expand Down Expand Up @@ -281,38 +354,63 @@ public static string OidcLinkId(string customerId)
/// The checks every other login path applies: an active, registered, non-system account, and with per-store
/// identity one of the request's store (or of no store)
/// </summary>
private async Task<OidcCustomerResult> Usable(Customer customer, string sub, string storeId)
private enum Unusable { None, Missing, Inactive, Unusable, OtherStore }

//the checks every other login path applies: an active, registered, non-system account, and with per-store
//identity one of the request's store (or of no store)
private async Task<Unusable> Check(Customer customer, string sub, string storeId)
{
if (customer == null)
{
_logger.LogWarning("Access token subject {Subject} is linked to a customer that no longer exists", sub);
return OidcCustomerResult.Forbidden("The customer account no longer exists");
_logger.LogWarning("Subject {Subject} is linked to a customer that no longer exists", sub);
return Unusable.Missing;
}

if (!customer.Active || customer.Deleted)
{
_logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is inactive or deleted", sub, customer.Id);
return OidcCustomerResult.Forbidden("The customer account is inactive");
_logger.LogWarning("Subject {Subject}: customer {CustomerId} is inactive or deleted", sub, customer.Id);
return customer.Deleted ? Unusable.Unusable : Unusable.Inactive;
}

if (customer.IsSystemAccount() || !await _groupService.IsRegistered(customer))
{
_logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is a system or non-registered account",
sub, customer.Id);
return OidcCustomerResult.Forbidden("The customer account cannot be used");
_logger.LogWarning("Subject {Subject}: customer {CustomerId} is a system or non-registered account", sub, customer.Id);
return Unusable.Unusable;
}

if (_customerConfig.RegisterCustomersPerStore && !string.IsNullOrEmpty(customer.StoreId) &&
customer.StoreId != storeId)
if (_customerConfig.RegisterCustomersPerStore && !string.IsNullOrEmpty(customer.StoreId) && customer.StoreId != storeId)
{
_logger.LogWarning("Access token subject {Subject}: customer {CustomerId} belongs to store {CustomerStoreId}, not {StoreId}",
_logger.LogWarning("Subject {Subject}: customer {CustomerId} belongs to store {CustomerStoreId}, not {StoreId}",
sub, customer.Id, customer.StoreId, storeId);
return OidcCustomerResult.Forbidden("The account belongs to another store");
return Unusable.OtherStore;
}

return OidcCustomerResult.Success(customer);
return Unusable.None;
}

private async Task<OidcCustomerResult> Usable(Customer customer, string sub, string storeId) =>
await Check(customer, sub, storeId) switch {
Unusable.None => OidcCustomerResult.Success(customer),
Unusable.Missing => OidcCustomerResult.Forbidden("The customer account no longer exists"),
Unusable.Inactive => OidcCustomerResult.Forbidden("The customer account is inactive"),
Unusable.OtherStore => OidcCustomerResult.Forbidden("The account belongs to another store"),
_ => customer.Deleted
? OidcCustomerResult.Forbidden("The customer account is inactive")
: OidcCustomerResult.Forbidden("The customer account cannot be used")
};

private static OidcLoginRefusal ToRefusal(Unusable reason) =>
reason == Unusable.Inactive ? OidcLoginRefusal.AccountInactive : OidcLoginRefusal.AccountUnusable;

private async Task<bool> HasOtherLink(Customer customer, string sub) =>
await _externalAuthenticationRepository.GetOneAsync(x =>
x.ProviderSystemName == ProviderSystemName && x.CustomerId == customer.Id && x.ExternalIdentifier != sub) != null;

//the same condition the password sign-in uses to ask for the second factor
private async Task<bool> HasTwoFactor(Customer customer, string storeId) =>
customer.GetUserFieldFromEntity<bool>(SystemCustomerFieldNames.TwoFactorEnabled) &&
(await _settingService.LoadSetting<CustomerSettings>(storeId)).TwoFactorAuthenticationEnabled;

private async Task<bool> IsBackOfficeAccount(Customer customer) =>
!string.IsNullOrEmpty(customer.VendorId) || !string.IsNullOrEmpty(customer.StaffStoreId) ||
await _permissionService.Authorize(StandardPermission.ManageAccessAdminPanel, customer);
Expand Down
Loading
Loading