pilotswarm-web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -0
- package/auth/authz/engine.js +139 -0
- package/auth/config.js +110 -0
- package/auth/index.js +153 -0
- package/auth/normalize/entra.js +22 -0
- package/auth/providers/entra.js +76 -0
- package/auth/providers/none.js +24 -0
- package/auth.js +10 -0
- package/bin/serve.js +53 -0
- package/config.js +20 -0
- package/dist/app.js +469 -0
- package/dist/assets/index-BSVg-lGb.css +1 -0
- package/dist/assets/index-BXD5YP7A.js +24 -0
- package/dist/assets/msal-CytV9RFv.js +7 -0
- package/dist/assets/pilotswarm-WX3NED6m.js +40 -0
- package/dist/assets/react-jg0oazEi.js +1 -0
- package/dist/index.html +16 -0
- package/node_modules/pilotswarm-ui-core/README.md +6 -0
- package/node_modules/pilotswarm-ui-core/package.json +32 -0
- package/node_modules/pilotswarm-ui-core/src/commands.js +72 -0
- package/node_modules/pilotswarm-ui-core/src/context-usage.js +212 -0
- package/node_modules/pilotswarm-ui-core/src/controller.js +3613 -0
- package/node_modules/pilotswarm-ui-core/src/formatting.js +872 -0
- package/node_modules/pilotswarm-ui-core/src/history.js +571 -0
- package/node_modules/pilotswarm-ui-core/src/index.js +13 -0
- package/node_modules/pilotswarm-ui-core/src/layout.js +196 -0
- package/node_modules/pilotswarm-ui-core/src/reducer.js +1027 -0
- package/node_modules/pilotswarm-ui-core/src/selectors.js +2786 -0
- package/node_modules/pilotswarm-ui-core/src/session-tree.js +109 -0
- package/node_modules/pilotswarm-ui-core/src/state.js +80 -0
- package/node_modules/pilotswarm-ui-core/src/store.js +23 -0
- package/node_modules/pilotswarm-ui-core/src/system-titles.js +24 -0
- package/node_modules/pilotswarm-ui-core/src/themes/catppuccin-mocha.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/cobalt2.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/dark-high-contrast.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/dracula.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/github-dark.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/gruvbox-dark.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/hacker-x-matrix.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/hacker-x-orion-prime.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/helpers.js +77 -0
- package/node_modules/pilotswarm-ui-core/src/themes/index.js +42 -0
- package/node_modules/pilotswarm-ui-core/src/themes/noctis-viola.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/noctis.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/nord.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/solarized-dark.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/tokyo-night.js +56 -0
- package/node_modules/pilotswarm-ui-react/README.md +5 -0
- package/node_modules/pilotswarm-ui-react/package.json +36 -0
- package/node_modules/pilotswarm-ui-react/src/components.js +1316 -0
- package/node_modules/pilotswarm-ui-react/src/index.js +4 -0
- package/node_modules/pilotswarm-ui-react/src/platform.js +15 -0
- package/node_modules/pilotswarm-ui-react/src/use-controller-state.js +38 -0
- package/node_modules/pilotswarm-ui-react/src/web-app.js +2661 -0
- package/package.json +64 -0
- package/runtime.js +146 -0
- package/server.js +311 -0
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# pilotswarm-web
|
|
2
|
+
|
|
3
|
+
Web portal for PilotSwarm — browser-based durable agent orchestration UI.
|
|
4
|
+
|
|
5
|
+
Full feature parity with the TUI: session management, real-time chat, agent
|
|
6
|
+
splash screens (ASCII art), sequence diagrams, node maps, worker logs,
|
|
7
|
+
artifact downloads, and keyboard shortcuts.
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Install
|
|
13
|
+
npm install pilotswarm-web
|
|
14
|
+
|
|
15
|
+
# Run (starts server + serves React app)
|
|
16
|
+
npx pilotswarm-web --env .env.remote
|
|
17
|
+
npx pilotswarm-web --env .env.remote --plugin ./plugin
|
|
18
|
+
|
|
19
|
+
# Development (Vite HMR)
|
|
20
|
+
cd packages/portal
|
|
21
|
+
npm run dev # React app at http://localhost:5173
|
|
22
|
+
node server.js # API server at http://localhost:3001
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Portal Customization
|
|
26
|
+
|
|
27
|
+
The web portal reads app-facing customization from `plugin.json` in your app
|
|
28
|
+
plugin directory. Pass the plugin path with `--plugin` or set `PLUGIN_DIRS`
|
|
29
|
+
so the portal process can see the same metadata the TUI and worker use.
|
|
30
|
+
|
|
31
|
+
Supported keys:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"tui": {
|
|
36
|
+
"title": "DevOps Command Center",
|
|
37
|
+
"splashFile": "./tui-splash.txt"
|
|
38
|
+
},
|
|
39
|
+
"portal": {
|
|
40
|
+
"branding": {
|
|
41
|
+
"title": "DevOps Command Center",
|
|
42
|
+
"pageTitle": "DevOps Command Center Portal",
|
|
43
|
+
"splashFile": "./tui-splash.txt",
|
|
44
|
+
"logoFile": "./assets/logo.svg",
|
|
45
|
+
"faviconFile": "./assets/favicon.png"
|
|
46
|
+
},
|
|
47
|
+
"ui": {
|
|
48
|
+
"loadingMessage": "Preparing the DevOps workspace",
|
|
49
|
+
"loadingCopy": "Connecting dashboards, session feeds, and orchestration state..."
|
|
50
|
+
},
|
|
51
|
+
"auth": {
|
|
52
|
+
"provider": "entra",
|
|
53
|
+
"signInTitle": "Sign in to DevOps Command Center",
|
|
54
|
+
"signInMessage": "Use your organization's identity provider to open the shared operations workspace.",
|
|
55
|
+
"signInLabel": "Sign In"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Notes:
|
|
62
|
+
|
|
63
|
+
- Preferred schema is nested: `portal.branding`, `portal.ui`, and `portal.auth`.
|
|
64
|
+
- Flat legacy keys such as `portal.title` and `portal.loadingMessage` are still accepted for backwards compatibility.
|
|
65
|
+
- `portal.auth.provider` selects the active auth provider when the deployment does not override it with `PORTAL_AUTH_PROVIDER`.
|
|
66
|
+
- `branding.logoFile` is used on the loading splash, sign-in card, and signed-in header.
|
|
67
|
+
- If `branding.faviconFile` is omitted, the browser tab icon reuses `branding.logoFile`.
|
|
68
|
+
- Keep logo assets inside the plugin directory so the portal image can package and serve them alongside `plugin.json`.
|
|
69
|
+
|
|
70
|
+
Fallback order:
|
|
71
|
+
|
|
72
|
+
- `portal.branding.*` / `portal.ui.*` / `portal.auth.*`
|
|
73
|
+
- flat `portal.*`
|
|
74
|
+
- `tui.title` / `tui.splash` / `tui.splashFile`
|
|
75
|
+
- built-in `PilotSwarm` defaults
|
|
76
|
+
|
|
77
|
+
Named-agent creation in the portal comes from the same plugin metadata surface.
|
|
78
|
+
If the portal process cannot see your plugin directory, the web UI falls back
|
|
79
|
+
to generic sessions even when the worker supports named agents.
|
|
80
|
+
|
|
81
|
+
## Auth Add-Ons
|
|
82
|
+
|
|
83
|
+
Portal authentication is provider-based.
|
|
84
|
+
|
|
85
|
+
- Default: `none`
|
|
86
|
+
- Built-in optional provider: `entra`
|
|
87
|
+
- AuthZ is common across providers and currently supports Phase 1 group-based admission control
|
|
88
|
+
|
|
89
|
+
Enable Entra ID with env vars:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
PORTAL_AUTH_PROVIDER=entra
|
|
93
|
+
PORTAL_AUTH_ENTRA_TENANT_ID=<tenant-id>
|
|
94
|
+
PORTAL_AUTH_ENTRA_CLIENT_ID=<client-id>
|
|
95
|
+
PORTAL_AUTHZ_ADMIN_GROUPS=admin1@contoso.com,admin2@contoso.com
|
|
96
|
+
PORTAL_AUTHZ_USER_GROUPS=user1@contoso.com,user2@contoso.com
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Notes:
|
|
100
|
+
|
|
101
|
+
- `PORTAL_AUTHZ_ADMIN_GROUPS` and `PORTAL_AUTHZ_USER_GROUPS` are currently comma-delimited email allowlists despite the historical variable names.
|
|
102
|
+
- If no admin/user groups are configured, any successfully authenticated user is allowed in as the default `user` role.
|
|
103
|
+
- Phase 1 keeps `admin` and `user` permissions the same inside the portal; the email allowlists act as an admission gate and role assignment surface.
|
|
104
|
+
|
|
105
|
+
The portal core no longer assumes Entra specifically. New providers can plug
|
|
106
|
+
into the same browser/server provider interfaces, while sharing the same common
|
|
107
|
+
authz layer.
|
|
108
|
+
|
|
109
|
+
## Architecture
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
Browser (React + Vite)
|
|
113
|
+
│
|
|
114
|
+
├── WebSocket ──► Portal Server (Express + ws)
|
|
115
|
+
│ │
|
|
116
|
+
│ ├── PilotSwarmClient
|
|
117
|
+
│ ├── PilotSwarmManagementClient
|
|
118
|
+
│ └── PilotSwarmWorker (embedded or remote)
|
|
119
|
+
│
|
|
120
|
+
└── REST (session list, models, artifacts)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Same public API boundary as the TUI — only `PilotSwarmClient`,
|
|
124
|
+
`PilotSwarmManagementClient`, and `PilotSwarmWorker` APIs. No internal
|
|
125
|
+
module imports.
|
|
126
|
+
|
|
127
|
+
## Package Relationship
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
pilotswarm-web (this package)
|
|
131
|
+
├── pilotswarm-cli (shared node/runtime host glue)
|
|
132
|
+
│ ├── pilotswarm-sdk
|
|
133
|
+
│ ├── pilotswarm-ui-core
|
|
134
|
+
│ └── pilotswarm-ui-react
|
|
135
|
+
├── express
|
|
136
|
+
├── ws
|
|
137
|
+
├── react, react-dom
|
|
138
|
+
└── vite (devDependency)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`pilotswarm-web` now consumes a small supported portal-facing surface from
|
|
142
|
+
`pilotswarm-cli` rather than importing monorepo-relative source files. That
|
|
143
|
+
keeps the publishable package graph explicit and lets the portal reuse the same
|
|
144
|
+
Node transport and plugin-config behavior as the TUI.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
function normalizeRole(role) {
|
|
2
|
+
const normalized = String(role || "").trim().toLowerCase();
|
|
3
|
+
if (normalized === "admin") return "admin";
|
|
4
|
+
if (normalized === "user") return "user";
|
|
5
|
+
if (normalized === "anonymous") return "anonymous";
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function firstRoleMatch(roles = []) {
|
|
10
|
+
for (const role of roles) {
|
|
11
|
+
const normalized = normalizeRole(role);
|
|
12
|
+
if (normalized === "admin") return "admin";
|
|
13
|
+
}
|
|
14
|
+
for (const role of roles) {
|
|
15
|
+
const normalized = normalizeRole(role);
|
|
16
|
+
if (normalized === "user") return "user";
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeIdentifier(value) {
|
|
22
|
+
return String(value || "").trim().toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function intersectIdentifier(value, allowed = []) {
|
|
26
|
+
const normalizedValue = normalizeIdentifier(value);
|
|
27
|
+
if (!normalizedValue) return [];
|
|
28
|
+
|
|
29
|
+
const allowedSet = new Set((allowed || []).map(normalizeIdentifier).filter(Boolean));
|
|
30
|
+
return allowedSet.has(normalizedValue) ? [normalizedValue] : [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function authorizePrincipal(principal, policy = {}) {
|
|
34
|
+
const defaultRole = normalizeRole(policy.defaultRole) || "user";
|
|
35
|
+
const adminGroups = Array.isArray(policy.adminGroups) ? policy.adminGroups : [];
|
|
36
|
+
const userGroups = Array.isArray(policy.userGroups) ? policy.userGroups : [];
|
|
37
|
+
const allowUnauthenticated = policy.allowUnauthenticated === true;
|
|
38
|
+
|
|
39
|
+
if (!principal) {
|
|
40
|
+
if (allowUnauthenticated) {
|
|
41
|
+
return {
|
|
42
|
+
allowed: true,
|
|
43
|
+
role: "anonymous",
|
|
44
|
+
reason: "Authentication disabled",
|
|
45
|
+
matchedGroups: [],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
allowed: false,
|
|
50
|
+
role: null,
|
|
51
|
+
reason: "Authentication required",
|
|
52
|
+
matchedGroups: [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const principalEmail = String(principal.email || "").trim();
|
|
57
|
+
const principalRoles = Array.isArray(principal.roles) ? principal.roles : [];
|
|
58
|
+
const matchedAdminGroups = intersectIdentifier(principalEmail, adminGroups);
|
|
59
|
+
const matchedUserGroups = intersectIdentifier(principalEmail, userGroups);
|
|
60
|
+
|
|
61
|
+
if (adminGroups.length === 0 && userGroups.length === 0) {
|
|
62
|
+
return {
|
|
63
|
+
allowed: true,
|
|
64
|
+
role: firstRoleMatch(principalRoles) || defaultRole,
|
|
65
|
+
reason: "No email allowlists configured",
|
|
66
|
+
matchedGroups: [],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (matchedAdminGroups.length > 0) {
|
|
71
|
+
return {
|
|
72
|
+
allowed: true,
|
|
73
|
+
role: "admin",
|
|
74
|
+
reason: "Matched admin email allowlist",
|
|
75
|
+
matchedGroups: matchedAdminGroups,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (matchedUserGroups.length > 0) {
|
|
80
|
+
return {
|
|
81
|
+
allowed: true,
|
|
82
|
+
role: "user",
|
|
83
|
+
reason: "Matched user email allowlist",
|
|
84
|
+
matchedGroups: matchedUserGroups,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!principalEmail) {
|
|
89
|
+
return {
|
|
90
|
+
allowed: false,
|
|
91
|
+
role: null,
|
|
92
|
+
reason: "Authenticated token did not include a usable email claim",
|
|
93
|
+
matchedGroups: [],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
allowed: false,
|
|
99
|
+
role: null,
|
|
100
|
+
reason: "Authenticated principal email is not in an allowed admin/user list",
|
|
101
|
+
matchedGroups: [],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getPublicAuthContext(authContext) {
|
|
106
|
+
if (!authContext) {
|
|
107
|
+
return {
|
|
108
|
+
principal: null,
|
|
109
|
+
authorization: {
|
|
110
|
+
allowed: false,
|
|
111
|
+
role: null,
|
|
112
|
+
reason: "Unauthenticated",
|
|
113
|
+
matchedGroups: [],
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const principal = authContext.principal
|
|
119
|
+
? {
|
|
120
|
+
provider: authContext.principal.provider,
|
|
121
|
+
subject: authContext.principal.subject,
|
|
122
|
+
email: authContext.principal.email ?? null,
|
|
123
|
+
displayName: authContext.principal.displayName ?? null,
|
|
124
|
+
tenantId: authContext.principal.tenantId ?? null,
|
|
125
|
+
groups: [...(authContext.principal.groups || [])],
|
|
126
|
+
roles: [...(authContext.principal.roles || [])],
|
|
127
|
+
}
|
|
128
|
+
: null;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
principal,
|
|
132
|
+
authorization: {
|
|
133
|
+
allowed: authContext.authorization?.allowed === true,
|
|
134
|
+
role: authContext.authorization?.role ?? null,
|
|
135
|
+
reason: authContext.authorization?.reason ?? null,
|
|
136
|
+
matchedGroups: [...(authContext.authorization?.matchedGroups || [])],
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
package/auth/config.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { getPluginDirsFromEnv, readPluginMetadata } from "pilotswarm-cli/portal";
|
|
3
|
+
|
|
4
|
+
function getObject(value) {
|
|
5
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
6
|
+
? value
|
|
7
|
+
: {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function firstNonEmptyString(...values) {
|
|
11
|
+
for (const value of values) {
|
|
12
|
+
if (typeof value === "string" && value.trim()) {
|
|
13
|
+
return value.trim();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseCsv(value) {
|
|
20
|
+
return String(value || "")
|
|
21
|
+
.split(",")
|
|
22
|
+
.map((entry) => entry.trim())
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseBoolean(value, defaultValue = false) {
|
|
27
|
+
if (value == null || value === "") return defaultValue;
|
|
28
|
+
const normalized = String(value).trim().toLowerCase();
|
|
29
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
30
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
31
|
+
return defaultValue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeRole(value, defaultRole = "user") {
|
|
35
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
36
|
+
return normalized === "admin" ? "admin" : defaultRole;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolvePluginAuthConfigFromPluginDirs(pluginDirs = getPluginDirsFromEnv()) {
|
|
40
|
+
for (const pluginDir of pluginDirs) {
|
|
41
|
+
const pluginMeta = readPluginMetadata(path.resolve(pluginDir));
|
|
42
|
+
if (!pluginMeta) continue;
|
|
43
|
+
const portal = getObject(pluginMeta.portal);
|
|
44
|
+
const portalAuth = getObject(portal.auth);
|
|
45
|
+
if (Object.keys(portalAuth).length === 0 && typeof portal.provider !== "string") {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
return portalAuth;
|
|
49
|
+
}
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function inferAuthProviderId(env = process.env) {
|
|
54
|
+
if (
|
|
55
|
+
env.PORTAL_AUTH_ENTRA_TENANT_ID
|
|
56
|
+
|| env.PORTAL_AUTH_ENTRA_CLIENT_ID
|
|
57
|
+
) {
|
|
58
|
+
return "entra";
|
|
59
|
+
}
|
|
60
|
+
return "none";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function resolveAuthProviderId({
|
|
64
|
+
env = process.env,
|
|
65
|
+
pluginAuthConfig = resolvePluginAuthConfigFromPluginDirs(),
|
|
66
|
+
} = {}) {
|
|
67
|
+
const explicitProvider = firstNonEmptyString(env.PORTAL_AUTH_PROVIDER);
|
|
68
|
+
if (explicitProvider) return explicitProvider.toLowerCase();
|
|
69
|
+
|
|
70
|
+
const pluginProvider = firstNonEmptyString(pluginAuthConfig?.provider);
|
|
71
|
+
if (pluginProvider) return pluginProvider.toLowerCase();
|
|
72
|
+
|
|
73
|
+
return inferAuthProviderId(env);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getProviderScopedGroupEnv({ env, providerId, groupKind }) {
|
|
77
|
+
const normalizedProvider = String(providerId || "").trim().toUpperCase();
|
|
78
|
+
const normalizedKind = String(groupKind || "").trim().toUpperCase();
|
|
79
|
+
return firstNonEmptyString(env[`PORTAL_AUTH_${normalizedProvider}_${normalizedKind}_GROUPS`]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function loadAuthorizationPolicy({
|
|
83
|
+
env = process.env,
|
|
84
|
+
providerId = resolveAuthProviderId({ env }),
|
|
85
|
+
} = {}) {
|
|
86
|
+
const defaultRole = normalizeRole(env.PORTAL_AUTHZ_DEFAULT_ROLE, "user");
|
|
87
|
+
const adminGroups = parseCsv(
|
|
88
|
+
firstNonEmptyString(
|
|
89
|
+
env.PORTAL_AUTHZ_ADMIN_GROUPS,
|
|
90
|
+
getProviderScopedGroupEnv({ env, providerId, groupKind: "ADMIN" }),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
const userGroups = parseCsv(
|
|
94
|
+
firstNonEmptyString(
|
|
95
|
+
env.PORTAL_AUTHZ_USER_GROUPS,
|
|
96
|
+
getProviderScopedGroupEnv({ env, providerId, groupKind: "USER" }),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
const allowUnauthenticated = parseBoolean(
|
|
100
|
+
env.PORTAL_AUTH_ALLOW_UNAUTHENTICATED,
|
|
101
|
+
providerId === "none",
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
defaultRole,
|
|
106
|
+
adminGroups,
|
|
107
|
+
userGroups,
|
|
108
|
+
allowUnauthenticated,
|
|
109
|
+
};
|
|
110
|
+
}
|
package/auth/index.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createNoAuthProvider } from "./providers/none.js";
|
|
2
|
+
import { createEntraAuthProvider } from "./providers/entra.js";
|
|
3
|
+
import { authorizePrincipal } from "./authz/engine.js";
|
|
4
|
+
import { loadAuthorizationPolicy, resolveAuthProviderId, resolvePluginAuthConfigFromPluginDirs } from "./config.js";
|
|
5
|
+
|
|
6
|
+
const PROVIDERS = {
|
|
7
|
+
none: createNoAuthProvider,
|
|
8
|
+
entra: createEntraAuthProvider,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
let cachedBundle = null;
|
|
12
|
+
|
|
13
|
+
function getProviderBundle() {
|
|
14
|
+
if (cachedBundle) return cachedBundle;
|
|
15
|
+
|
|
16
|
+
const pluginAuthConfig = resolvePluginAuthConfigFromPluginDirs();
|
|
17
|
+
const providerId = resolveAuthProviderId({ pluginAuthConfig });
|
|
18
|
+
const factory = PROVIDERS[providerId];
|
|
19
|
+
if (!factory) {
|
|
20
|
+
throw new Error(`Unsupported portal auth provider: ${providerId}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
cachedBundle = {
|
|
24
|
+
providerId,
|
|
25
|
+
pluginAuthConfig,
|
|
26
|
+
provider: factory({ pluginAuthConfig }),
|
|
27
|
+
policy: loadAuthorizationPolicy({ providerId }),
|
|
28
|
+
};
|
|
29
|
+
return cachedBundle;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildDeniedResult({ status, error, principal = null, authorization = null }) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
status,
|
|
36
|
+
error,
|
|
37
|
+
principal,
|
|
38
|
+
authorization,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getAuthProvider() {
|
|
43
|
+
return getProviderBundle().provider;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getAuthorizationPolicy() {
|
|
47
|
+
return getProviderBundle().policy;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getResolvedAuthProviderId() {
|
|
51
|
+
return getProviderBundle().providerId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function getAuthConfig(req) {
|
|
55
|
+
return getAuthProvider().getPublicConfig(req);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function validateToken(token, req) {
|
|
59
|
+
return getAuthProvider().authenticateRequest(token, req);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function authenticateToken(token, req) {
|
|
63
|
+
const provider = getAuthProvider();
|
|
64
|
+
const policy = getAuthorizationPolicy();
|
|
65
|
+
|
|
66
|
+
if (provider.enabled) {
|
|
67
|
+
if (!token) {
|
|
68
|
+
return buildDeniedResult({
|
|
69
|
+
status: 401,
|
|
70
|
+
error: "Unauthorized",
|
|
71
|
+
authorization: {
|
|
72
|
+
allowed: false,
|
|
73
|
+
role: null,
|
|
74
|
+
reason: "Authentication required",
|
|
75
|
+
matchedGroups: [],
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const principal = await validateToken(token, req);
|
|
81
|
+
if (!principal) {
|
|
82
|
+
return buildDeniedResult({
|
|
83
|
+
status: 401,
|
|
84
|
+
error: "Unauthorized",
|
|
85
|
+
authorization: {
|
|
86
|
+
allowed: false,
|
|
87
|
+
role: null,
|
|
88
|
+
reason: "Token validation failed",
|
|
89
|
+
matchedGroups: [],
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const authorization = authorizePrincipal(principal, policy);
|
|
95
|
+
if (!authorization.allowed) {
|
|
96
|
+
return buildDeniedResult({
|
|
97
|
+
status: 403,
|
|
98
|
+
error: authorization.reason || "Forbidden",
|
|
99
|
+
principal,
|
|
100
|
+
authorization,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
status: 200,
|
|
107
|
+
principal,
|
|
108
|
+
authorization,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const authorization = authorizePrincipal(null, policy);
|
|
113
|
+
if (!authorization.allowed) {
|
|
114
|
+
return buildDeniedResult({
|
|
115
|
+
status: 401,
|
|
116
|
+
error: authorization.reason || "Unauthorized",
|
|
117
|
+
authorization,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
status: 200,
|
|
124
|
+
principal: null,
|
|
125
|
+
authorization,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function authenticateRequest(req) {
|
|
130
|
+
return authenticateToken(extractToken(req), req);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Extract Bearer token from various sources.
|
|
135
|
+
* Checks: Authorization header, then sec-websocket-protocol header.
|
|
136
|
+
*/
|
|
137
|
+
export function extractToken(req) {
|
|
138
|
+
const authHeader = req.headers["authorization"];
|
|
139
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
140
|
+
return authHeader.slice(7);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const protocols = req.headers["sec-websocket-protocol"];
|
|
144
|
+
if (protocols) {
|
|
145
|
+
const parts = protocols.split(",").map((segment) => segment.trim());
|
|
146
|
+
const tokenIndex = parts.indexOf("access_token");
|
|
147
|
+
if (tokenIndex >= 0 && parts[tokenIndex + 1]) {
|
|
148
|
+
return parts[tokenIndex + 1];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
function toStringArray(value) {
|
|
2
|
+
return Array.isArray(value)
|
|
3
|
+
? value.map((entry) => String(entry || "").trim()).filter(Boolean)
|
|
4
|
+
: [];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function normalizeEntraPrincipal(payload = {}) {
|
|
8
|
+
const subject = String(payload.oid || payload.sub || "").trim();
|
|
9
|
+
if (!subject) return null;
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
provider: "entra",
|
|
13
|
+
subject,
|
|
14
|
+
email: String(payload.preferred_username || payload.email || payload.upn || "").trim() || null,
|
|
15
|
+
displayName: String(payload.name || "").trim() || null,
|
|
16
|
+
groups: toStringArray(payload.groups),
|
|
17
|
+
roles: toStringArray(payload.roles),
|
|
18
|
+
tenantId: String(payload.tid || "").trim() || null,
|
|
19
|
+
rawClaims: payload,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as jose from "jose";
|
|
2
|
+
import { normalizeEntraPrincipal } from "../normalize/entra.js";
|
|
3
|
+
|
|
4
|
+
const JWKS_CACHE = new Map();
|
|
5
|
+
|
|
6
|
+
function getEntraConfig(pluginAuthConfig = {}) {
|
|
7
|
+
const tenantId = process.env.PORTAL_AUTH_ENTRA_TENANT_ID;
|
|
8
|
+
const clientId = process.env.PORTAL_AUTH_ENTRA_CLIENT_ID;
|
|
9
|
+
const displayName = String(
|
|
10
|
+
pluginAuthConfig?.providers?.entra?.displayName
|
|
11
|
+
|| pluginAuthConfig?.displayName
|
|
12
|
+
|| "Entra ID",
|
|
13
|
+
).trim() || "Entra ID";
|
|
14
|
+
if (!tenantId || !clientId) return null;
|
|
15
|
+
return { tenantId, clientId, displayName };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function ensureJwks(tenantId) {
|
|
19
|
+
const cached = JWKS_CACHE.get(tenantId);
|
|
20
|
+
if (cached) return cached;
|
|
21
|
+
|
|
22
|
+
const issuer = `https://login.microsoftonline.com/${tenantId}/v2.0`;
|
|
23
|
+
const jwks = jose.createRemoteJWKSet(new URL(`https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`));
|
|
24
|
+
const bundle = { issuer, jwks };
|
|
25
|
+
JWKS_CACHE.set(tenantId, bundle);
|
|
26
|
+
return bundle;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function validateToken(token, config) {
|
|
30
|
+
const { issuer, jwks } = await ensureJwks(config.tenantId);
|
|
31
|
+
const { payload } = await jose.jwtVerify(token, jwks, {
|
|
32
|
+
issuer,
|
|
33
|
+
audience: config.clientId,
|
|
34
|
+
});
|
|
35
|
+
return normalizeEntraPrincipal(payload);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createEntraAuthProvider({ pluginAuthConfig } = {}) {
|
|
39
|
+
const config = getEntraConfig(pluginAuthConfig);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
id: "entra",
|
|
43
|
+
enabled: Boolean(config),
|
|
44
|
+
displayName: config?.displayName || "Entra ID",
|
|
45
|
+
async authenticateRequest(token) {
|
|
46
|
+
if (!config || !token) return null;
|
|
47
|
+
try {
|
|
48
|
+
return await validateToken(token, config);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error("[portal-auth:entra] Token validation failed:", error?.message || String(error));
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
async getPublicConfig(req) {
|
|
55
|
+
if (!config) {
|
|
56
|
+
return {
|
|
57
|
+
enabled: false,
|
|
58
|
+
provider: "entra",
|
|
59
|
+
displayName: config?.displayName || "Entra ID",
|
|
60
|
+
client: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const host = req?.get?.("x-forwarded-host") || req?.get?.("host") || "";
|
|
64
|
+
return {
|
|
65
|
+
enabled: true,
|
|
66
|
+
provider: "entra",
|
|
67
|
+
displayName: config.displayName,
|
|
68
|
+
client: {
|
|
69
|
+
clientId: config.clientId,
|
|
70
|
+
authority: `https://login.microsoftonline.com/${config.tenantId}`,
|
|
71
|
+
redirectUri: `${req?.protocol || "https"}://${host}`,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function createNoAuthProvider({ pluginAuthConfig } = {}) {
|
|
2
|
+
const displayName = String(
|
|
3
|
+
pluginAuthConfig?.providers?.none?.displayName
|
|
4
|
+
|| pluginAuthConfig?.displayName
|
|
5
|
+
|| "No auth",
|
|
6
|
+
).trim() || "No auth";
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
id: "none",
|
|
10
|
+
enabled: false,
|
|
11
|
+
displayName,
|
|
12
|
+
async authenticateRequest() {
|
|
13
|
+
return null;
|
|
14
|
+
},
|
|
15
|
+
async getPublicConfig() {
|
|
16
|
+
return {
|
|
17
|
+
enabled: false,
|
|
18
|
+
provider: "none",
|
|
19
|
+
displayName,
|
|
20
|
+
client: null,
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|