Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
90 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
2f9f086
Docs: OIDC guide - Zitadel scope only for entitled users, compose har…
KrzysztofPajak Oct 8, 2026
937e440
Docs: OIDC guide - fixes from the end-to-end run against Keycloak 26.…
KrzysztofPajak Oct 8, 2026
fbacc24
Docs: OIDC guide - a bearer value that is not a JWT gets the OIDC cha…
KrzysztofPajak Oct 9, 2026
67f73cb
Docs: OIDC guide - automatic registration is off by default
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
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
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
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
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
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
4e58b16
OIDC guide: 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
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
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
03a26bb
Merge remote-tracking branch 'origin/develop' into feature/oidc-guide…
KrzysztofPajak Oct 11, 2026
8dbf4c9
OIDC guide: wrap long lines, Client scopes menu name; resource URI co…
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 @@ -2,7 +2,7 @@

namespace Grand.Business.Core.Interfaces.Authentication;

[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider")]
[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider (docs/oidc-resource-server.md)")]
public interface IJwtBearerAuthenticationService
{
/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace Grand.Business.Core.Interfaces.Customers;

[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider")]
[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider (docs/oidc-resource-server.md)")]
public interface IUserApiService
{
/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Grand.Domain/Customers/UserApi.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace Grand.Domain.Customers;

[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider")]
[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider (docs/oidc-resource-server.md)")]
public class UserApi : BaseEntity
{
public string Email { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class OidcProtectedResource
/// <summary>Path prefix, matched case-insensitively on a segment boundary ("/api" covers "/api/x", not "/apix")</summary>
public string PathPrefix { get; set; }

/// <summary>Canonical resource URI (RFC 8707); empty = the request's base URL + PathPrefix</summary>
/// <summary>Canonical resource URI (RFC 8707); empty = the Store URL of the request's store + PathPrefix</summary>
public string Resource { get; set; }

/// <summary>Additional accepted "aud" values, for providers that do not put the resource URI there</summary>
Expand Down
4 changes: 2 additions & 2 deletions src/Web/Grand.Web/App_Data/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@
//api for admin
//when enabled (Development environment only), API documentation (Scalar) is available at: https://your-domain/scalar/v1
//locally: https://localhost:44350/scalar/v1
//DEPRECATED: API users with tokens signed by SecretKey. Prefer OidcResourceServer; this keeps working until it is
//DEPRECATED: API users with tokens signed by SecretKey. Prefer OidcResourceServer (docs/oidc-resource-server.md); this keeps working until it is
//removed in a future major version.
"BackendAPI": {
"Enabled": true,
Expand All @@ -243,7 +243,7 @@
"ExpiryInMinutes": 1440
},
//OAuth 2.1 resource server: accept access tokens issued by an external OpenID Connect provider on protected
//resources (APIs, integrations, plugin endpoints)
//resources (APIs, integrations, plugin endpoints). See docs/oidc-resource-server.md.
"OidcResourceServer": {
"Enabled": false,
//issuer of the identity provider; discovery document at {Authority}/.well-known/openid-configuration
Expand Down
Loading