Skip to content

Commit b98b10f

Browse files
feat: support SSO admin mapping
Adds OIDC admin-group mapping with role reconciliation, split-horizon discovery URL support, minimal docs/config, and backend tests. Co-authored-by: BoxBoxJason <contact@boxboxjason.dev> Co-authored-by: Zimeng Xiong <me@zimengxiong.com>
1 parent ccbbd98 commit b98b10f

16 files changed

Lines changed: 918 additions & 178 deletions

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,9 @@ backend:
281281
- AUTH_MODE=oidc_enforced
282282
- OIDC_PROVIDER_NAME=Authentik
283283
- OIDC_ISSUER_URL=https://auth.example.com/application/o/excalidash/
284+
# Optional split-horizon setup when backend reaches IdP via internal DNS.
285+
# Keep OIDC_ISSUER_URL browser-routable; set OIDC_DISCOVERY_URL for backend-only access.
286+
# - OIDC_DISCOVERY_URL=http://auth-internal:9000/application/o/excalidash/
284287
- OIDC_CLIENT_ID=your-client-id
285288
# Optional for public clients; required for confidential clients
286289
# - OIDC_CLIENT_SECRET=your-client-secret
@@ -290,6 +293,10 @@ backend:
290293
# - OIDC_ID_TOKEN_SIGNED_RESPONSE_ALG=HS256
291294
- OIDC_REDIRECT_URI=https://excalidash.example.com/api/auth/oidc/callback
292295
- OIDC_SCOPES=openid profile email
296+
# Optional: path to groups/roles claim in ID token/user claims (supports dot path)
297+
- OIDC_GROUPS_CLAIM=groups
298+
# Optional: comma-separated group names that should be ADMIN in ExcaliDash
299+
- OIDC_ADMIN_GROUPS=excalidash-admins,platform-admins
293300
```
294301

295302
Quick preflight check (recommended before starting backend):
@@ -317,6 +324,9 @@ Notes:
317324
| Authentik issuer format | Use provider issuer URL: `https://<authentik-host>/application/o/<provider-slug>/`. |
318325
| Authentik `email_verified` | If Authentik does not emit `email_verified=true`, either add the scope mapping or set `OIDC_REQUIRE_EMAIL_VERIFIED=false`. |
319326
| Redirect URI | Must be exact callback: `https://<excalidash-host>/api/auth/oidc/callback`. |
327+
| Split-horizon IdP networking | Set `OIDC_ISSUER_URL` to the browser-reachable issuer and optionally `OIDC_DISCOVERY_URL` to a backend-reachable internal URL. |
328+
| OIDC admin mapping | If `OIDC_ADMIN_GROUPS` is set, admin role is reconciled on each authenticated request for OIDC users: users in those groups are promoted to `ADMIN`, users not in those groups are demoted to `USER`. |
329+
| Legacy sessions | Users with old sessions (issued before group claims were embedded) should sign out/in once so OIDC group claims are refreshed. |
320330

321331
</details>
322332

@@ -352,6 +362,8 @@ Configure ExcaliDash backend for hybrid OIDC:
352362
```bash
353363
cd backend
354364
cp .env.oidc.example .env
365+
# If backend runs in Docker and Keycloak issuer is localhost for browser, set:
366+
# OIDC_DISCOVERY_URL=http://keycloak:8080/realms/excalidash
355367
# Ensure OIDC_REDIRECT_URI matches where your frontend is running:
356368
# - http://localhost:6767/api/auth/oidc/callback (repo frontend dev default)
357369
# - https://excalidash.example.com/api/auth/oidc/callback (production)
@@ -499,6 +511,6 @@ Common flags:
499511
# Credits
500512
If you find ExcaliDash useful, please consider [sponsoring](https://github.com/sponsors/ZimengXiong)
501513
- Example designs from:
502-
- https://github.com/Prakash-sa/system-design-ultimatum/tree/main
503-
- https://github.com/kitsteam/excalidraw-examples/tree/main
514+
- <https://github.com/Prakash-sa/system-design-ultimatum/tree/main>
515+
- <https://github.com/kitsteam/excalidraw-examples/tree/main>
504516
- [The amazing work of Excalidraw & contributors](https://www.npmjs.com/package/@excalidraw/excalidraw)

backend/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,20 @@ CSRF_SECRET=change-this-secret-in-production
4545
# OIDC Configuration (required when AUTH_MODE=hybrid or AUTH_MODE=oidc_enforced)
4646
# OIDC_PROVIDER_NAME=Authentik
4747
# OIDC_ISSUER_URL=https://auth.example.com/application/o/excalidash/
48+
# Optional: internal backend-only discovery URL for split-horizon networking.
49+
# Example: browser uses OIDC_ISSUER_URL=https://auth.example.com while backend discovers via http://auth:9000
50+
# OIDC_DISCOVERY_URL=http://auth-internal:9000/application/o/excalidash/
4851
# OIDC_CLIENT_ID=your-client-id
4952
# OIDC_CLIENT_SECRET=your-client-secret
5053
# OIDC_REDIRECT_URI=https://excalidash.example.com/api/auth/oidc/callback
5154
# OIDC_TOKEN_ENDPOINT_AUTH_METHOD=client_secret_post
5255
# OIDC_SCOPES=openid profile email
5356
# OIDC_EMAIL_CLAIM=email
5457
# OIDC_EMAIL_VERIFIED_CLAIM=email_verified
58+
# OIDC_GROUPS_CLAIM=groups
59+
# Comma-separated OIDC groups/roles that should have ADMIN privileges.
60+
# When configured, OIDC users are promoted/demoted on authenticated requests.
61+
# OIDC_ADMIN_GROUPS=excalidash-admins,platform-admins
5562
# OIDC_REQUIRE_EMAIL_VERIFIED=true
5663
# OIDC_JIT_PROVISIONING=true
5764
# OIDC_FIRST_USER_ADMIN=true

backend/.env.oidc.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ AUTH_MODE=hybrid
1717

1818
OIDC_PROVIDER_NAME=Keycloak
1919
OIDC_ISSUER_URL=http://localhost:8080/realms/excalidash
20+
# Optional when backend runs in a container but Keycloak issuer is localhost for browser.
21+
# OIDC_DISCOVERY_URL=http://keycloak:8080/realms/excalidash
2022
OIDC_CLIENT_ID=excalidash
23+
OIDC_ID_TOKEN_SIGNED_RESPONSE_ALG=RS256
24+
OIDC_GROUPS_CLAIM=realm_access.roles
25+
# Example: Keycloak realm role used to grant ADMIN in ExcaliDash
26+
# OIDC_ADMIN_GROUPS=excalidash-admin
2127

2228
# Redirect URI must match the frontend you're using.
2329
OIDC_REDIRECT_URI=http://localhost:6767/api/auth/oidc/callback

backend/src/auth.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,30 @@ interface JwtPayload {
4040
email: string;
4141
type: "access" | "refresh";
4242
impersonatorId?: string;
43+
authProvider?: "local" | "oidc";
44+
oidcGroups?: string[];
4345
}
4446

47+
const isStringArray = (value: unknown): value is string[] =>
48+
Array.isArray(value) && value.every((entry) => typeof entry === "string");
49+
4550
const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
4651
if (typeof decoded !== "object" || decoded === null) {
4752
return false;
4853
}
4954
const payload = decoded as Record<string, unknown>;
55+
const authProviderOk =
56+
typeof payload.authProvider === "undefined" ||
57+
payload.authProvider === "local" ||
58+
payload.authProvider === "oidc";
59+
const oidcGroupsOk =
60+
typeof payload.oidcGroups === "undefined" || isStringArray(payload.oidcGroups);
5061
return (
5162
typeof payload.userId === "string" &&
5263
typeof payload.email === "string" &&
53-
(payload.type === "access" || payload.type === "refresh")
64+
(payload.type === "access" || payload.type === "refresh") &&
65+
authProviderOk &&
66+
oidcGroupsOk
5467
);
5568
};
5669

@@ -393,14 +406,38 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
393406
const generateTokens = (
394407
userId: string,
395408
email: string,
396-
options?: { impersonatorId?: string }
409+
options?: {
410+
impersonatorId?: string;
411+
authProvider?: "local" | "oidc";
412+
oidcGroups?: string[];
413+
}
397414
) => {
415+
const authProvider = options?.authProvider ?? "local";
416+
const sanitizedOidcGroups =
417+
authProvider === "oidc"
418+
? Array.from(
419+
new Set(
420+
(options?.oidcGroups ?? [])
421+
.map((group) => group.trim())
422+
.filter((group) => group.length > 0)
423+
)
424+
).slice(0, 100)
425+
: undefined;
426+
427+
const tokenPayload = {
428+
userId,
429+
email,
430+
impersonatorId: options?.impersonatorId,
431+
authProvider,
432+
oidcGroups: sanitizedOidcGroups,
433+
};
434+
398435
const signOptions: SignOptions = {
399436
expiresIn: config.jwtAccessExpiresIn as StringValue,
400437
jwtid: crypto.randomUUID(),
401438
};
402439
const accessToken = jwt.sign(
403-
{ userId, email, type: "access", impersonatorId: options?.impersonatorId },
440+
{ ...tokenPayload, type: "access" },
404441
config.jwtSecret,
405442
signOptions
406443
);
@@ -410,7 +447,7 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
410447
jwtid: crypto.randomUUID(),
411448
};
412449
const refreshToken = jwt.sign(
413-
{ userId, email, type: "refresh", impersonatorId: options?.impersonatorId },
450+
{ ...tokenPayload, type: "refresh" },
414451
config.jwtSecret,
415452
refreshSignOptions
416453
);

backend/src/auth/accountRoutes.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ type RegisterAccountRoutesDeps = {
3232
generateTokens: (
3333
userId: string,
3434
email: string,
35-
options?: { impersonatorId?: string }
35+
options?: {
36+
impersonatorId?: string;
37+
authProvider?: "local" | "oidc";
38+
oidcGroups?: string[];
39+
}
3640
) => { accessToken: string; refreshToken: string };
3741
getRefreshTokenExpiresAt: () => Date;
3842
setAuthCookies: (

backend/src/auth/adminRoutes.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ type RegisterAdminRoutesDeps = {
5959
generateTokens: (
6060
userId: string,
6161
email: string,
62-
options?: { impersonatorId?: string }
62+
options?: {
63+
impersonatorId?: string;
64+
authProvider?: "local" | "oidc";
65+
oidcGroups?: string[];
66+
}
6367
) => { accessToken: string; refreshToken: string };
6468
getRefreshTokenExpiresAt: () => Date;
6569
config: {

backend/src/auth/coreRoutes.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ type RegisterCoreRoutesDeps = {
5858
email: string;
5959
type: "access" | "refresh";
6060
impersonatorId?: string;
61+
authProvider?: "local" | "oidc";
62+
oidcGroups?: string[];
6163
};
6264
config: {
6365
authMode: "local" | "hybrid" | "oidc_enforced";
@@ -77,7 +79,11 @@ type RegisterCoreRoutesDeps = {
7779
generateTokens: (
7880
userId: string,
7981
email: string,
80-
options?: { impersonatorId?: string }
82+
options?: {
83+
impersonatorId?: string;
84+
authProvider?: "local" | "oidc";
85+
oidcGroups?: string[];
86+
}
8187
) => { accessToken: string; refreshToken: string };
8288
getRefreshTokenExpiresAt: () => Date;
8389
isMissingRefreshTokenTableError: (error: unknown) => boolean;
@@ -636,7 +642,11 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
636642
const { accessToken, refreshToken: newRefreshToken } = generateTokens(
637643
user.id,
638644
user.email,
639-
{ impersonatorId: decoded.impersonatorId }
645+
{
646+
impersonatorId: decoded.impersonatorId,
647+
authProvider: decoded.authProvider,
648+
oidcGroups: decoded.oidcGroups,
649+
}
640650
);
641651

642652
const expiresAt = getRefreshTokenExpiresAt();
@@ -713,6 +723,8 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
713723
email: user.email,
714724
type: "access",
715725
impersonatorId: decoded.impersonatorId,
726+
authProvider: decoded.authProvider,
727+
oidcGroups: decoded.oidcGroups,
716728
},
717729
config.jwtSecret,
718730
signOptions

0 commit comments

Comments
 (0)