@camstack/addon-auth-oidc 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 +259 -0
- package/dist/auth-oidc.addon.js +314 -0
- package/dist/auth-oidc.addon.js.map +1 -0
- package/dist/auth-oidc.addon.mjs +297 -0
- package/dist/auth-oidc.addon.mjs.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +6 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +70 -0
package/README.md
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# `@camstack/addon-auth-oidc`
|
|
2
|
+
|
|
3
|
+
Generic OpenID Connect (OIDC) authentication provider for CamStack. Plugs into the existing `auth-provider` collection capability so the login screen renders an SSO button next to the local username + password form.
|
|
4
|
+
|
|
5
|
+
Supports any standards-compliant OIDC IdP: **Authentik**, **Keycloak**, **Google**, **Microsoft Entra ID** (formerly Azure AD), **Okta**, **Auth0**, and any other provider that publishes `/.well-known/openid-configuration`.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Quick start (Authentik — recommended for self-hosted)
|
|
10
|
+
|
|
11
|
+
[Authentik](https://goauthentik.io) is the most operator-friendly self-hosted IdP for home labs and small deployments. It also doubles as a SAML / LDAP gateway in front of your existing identity stores.
|
|
12
|
+
|
|
13
|
+
### 1. Create the OIDC application in Authentik
|
|
14
|
+
|
|
15
|
+
In the Authentik admin UI:
|
|
16
|
+
|
|
17
|
+
1. **Applications → Providers → Create**
|
|
18
|
+
- Type: **OAuth2/OpenID Provider**
|
|
19
|
+
- Name: `CamStack`
|
|
20
|
+
- Authorization flow: `default-provider-authorization-implicit-consent`
|
|
21
|
+
- Client type: `Confidential`
|
|
22
|
+
- Client ID: *(leave the auto-generated value)*
|
|
23
|
+
- Client Secret: *(leave the auto-generated value)*
|
|
24
|
+
- Redirect URIs / Origins (RegEx):
|
|
25
|
+
```
|
|
26
|
+
https://your-camstack.example.com/addon/auth-oidc/callback
|
|
27
|
+
```
|
|
28
|
+
- Signing Key: any key (e.g. `authentik Self-signed Certificate`)
|
|
29
|
+
- Save
|
|
30
|
+
|
|
31
|
+
2. **Applications → Applications → Create**
|
|
32
|
+
- Name: `CamStack`
|
|
33
|
+
- Slug: `camstack`
|
|
34
|
+
- Provider: select the provider you just created
|
|
35
|
+
- Launch URL: `https://your-camstack.example.com/admin`
|
|
36
|
+
- Save
|
|
37
|
+
|
|
38
|
+
### 2. Install + configure the addon in CamStack
|
|
39
|
+
|
|
40
|
+
1. **Admin UI → System → Addons → Install** → search `@camstack/addon-auth-oidc` → install
|
|
41
|
+
2. **Admin UI → System → Authentication** — verify the new row appears (kind: `oidc`)
|
|
42
|
+
3. Click the row → **Configure** → fill the global settings:
|
|
43
|
+
|
|
44
|
+
| Field | Value |
|
|
45
|
+
|---|---|
|
|
46
|
+
| Display Name | `Continue with Authentik` |
|
|
47
|
+
| Icon | `shield-check` (or `key`, `user`, …) |
|
|
48
|
+
| Issuer URL | `https://authentik.your-domain.tld/application/o/camstack/` |
|
|
49
|
+
| Client ID | *paste from Authentik provider* |
|
|
50
|
+
| Client Secret | *paste from Authentik provider* |
|
|
51
|
+
| Redirect URI | `https://your-camstack.example.com/addon/auth-oidc/callback` |
|
|
52
|
+
| Scopes | `openid profile email` |
|
|
53
|
+
| Username Claim | `preferred_username` |
|
|
54
|
+
| Default Role | `viewer` |
|
|
55
|
+
|
|
56
|
+
Save. The login screen now shows an **"Continue with Authentik"** button.
|
|
57
|
+
|
|
58
|
+
> **Important**: the issuer URL **MUST** end with the trailing slash that Authentik adds to its application slug (`/application/o/<slug>/`). Without it the `.well-known/openid-configuration` discovery fetch returns 404.
|
|
59
|
+
|
|
60
|
+
### 3. Test the flow
|
|
61
|
+
|
|
62
|
+
1. Log out (or open an incognito window) → `/admin/login`
|
|
63
|
+
2. Click **Continue with Authentik**
|
|
64
|
+
3. Authentik prompts for credentials + consent → on success you land back on the CamStack admin UI as the OIDC user
|
|
65
|
+
4. **Admin UI → System → Users** — the user is auto-provisioned with the configured `defaultRole`
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Other providers — discovery URL templates
|
|
70
|
+
|
|
71
|
+
Configure exactly the same fields, only the **Issuer URL** changes:
|
|
72
|
+
|
|
73
|
+
### Keycloak
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
Issuer URL: https://your-keycloak.example.com/realms/<realm-name>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
In Keycloak: **Clients → Create** with type `OpenID Connect`, set Valid redirect URIs to `https://your-camstack.example.com/addon/auth-oidc/callback`. Username claim depends on your realm setup — `preferred_username` is the Keycloak default.
|
|
80
|
+
|
|
81
|
+
### Google
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
Issuer URL: https://accounts.google.com
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
In **Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client IDs**:
|
|
88
|
+
- Application type: `Web application`
|
|
89
|
+
- Authorized redirect URIs: `https://your-camstack.example.com/addon/auth-oidc/callback`
|
|
90
|
+
|
|
91
|
+
Username Claim: `email` (Google doesn't issue `preferred_username`).
|
|
92
|
+
|
|
93
|
+
### Microsoft Entra ID (formerly Azure AD)
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
Issuer URL: https://login.microsoftonline.com/<tenant-id>/v2.0
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
In **Entra → App registrations → New registration**:
|
|
100
|
+
- Redirect URI: `Web` → `https://your-camstack.example.com/addon/auth-oidc/callback`
|
|
101
|
+
- Generate a client secret under **Certificates & secrets**
|
|
102
|
+
- Under **API permissions** add `openid`, `profile`, `email`
|
|
103
|
+
|
|
104
|
+
Replace `<tenant-id>` with your tenant GUID. For multi-tenant apps use `common` as the tenant ID.
|
|
105
|
+
|
|
106
|
+
Username Claim: `preferred_username` (Entra issues UPN-style logins) or `email`.
|
|
107
|
+
|
|
108
|
+
### Okta
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
Issuer URL: https://<your-okta-domain>.okta.com/oauth2/default
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
In Okta: **Applications → Create App Integration → OIDC - Web Application**, sign-in redirect URI as above. Use the default Authorization Server (`/oauth2/default`) or substitute your custom one.
|
|
115
|
+
|
|
116
|
+
### Auth0
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
Issuer URL: https://<your-tenant>.auth0.com
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
In Auth0: **Applications → Create Application → Regular Web Applications**, allowed callback URLs as above.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Configuration reference
|
|
127
|
+
|
|
128
|
+
| Setting | Default | Notes |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `displayName` | `OpenID Connect` | Login button label |
|
|
131
|
+
| `icon` | `shield-check` | lucide-react icon name |
|
|
132
|
+
| `issuerUrl` | *(empty)* | IdP base URL — `<issuer>/.well-known/openid-configuration` MUST resolve |
|
|
133
|
+
| `clientId` | *(empty)* | OAuth2 client ID issued by the IdP |
|
|
134
|
+
| `clientSecret` | *(empty)* | OAuth2 client secret — server-side only, never exposed to the browser |
|
|
135
|
+
| `redirectUri` | `<CAMSTACK_PUBLIC_ORIGIN>/addon/auth-oidc/callback` | Public callback URL the IdP redirects to. Override when the server is behind a reverse proxy with a different public origin |
|
|
136
|
+
| `scopes` | `openid profile email` | Space-separated OAuth scopes |
|
|
137
|
+
| `usernameClaim` | `preferred_username` | One of `preferred_username` / `email` / `sub` |
|
|
138
|
+
| `defaultRole` | `viewer` | Role assigned to first-time SSO users — choose `admin` only when the IdP itself enforces operator vetting |
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Multi-IdP deployments
|
|
143
|
+
|
|
144
|
+
Install the addon **multiple times** under different addon IDs to support multiple IdPs simultaneously. The Authentication page lists them all and the Login screen renders one button per provider.
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
Admin UI → Addons → Install → @camstack/addon-auth-oidc
|
|
148
|
+
(becomes auth-oidc with default settings)
|
|
149
|
+
Admin UI → Addons → Install → @camstack/addon-auth-oidc-google
|
|
150
|
+
(cloned manifest with id=auth-oidc-google)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Each instance has its own settings panel and its own redirect URI: `/addon/<addonId>/callback`.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Architecture
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
161
|
+
│ Browser │
|
|
162
|
+
│ │
|
|
163
|
+
│ /admin/login ──┬─► local-auth (username + password) │
|
|
164
|
+
│ └─► [SSO buttons rendered from │
|
|
165
|
+
│ authentication.listProviders()] │
|
|
166
|
+
└────────────────────────────────────────────────────────────────┘
|
|
167
|
+
│
|
|
168
|
+
▼ click SSO button
|
|
169
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
170
|
+
│ CamStack server │
|
|
171
|
+
│ │
|
|
172
|
+
│ /addon/auth-oidc/start │
|
|
173
|
+
│ ├─ build PKCE pair │
|
|
174
|
+
│ ├─ generate state │
|
|
175
|
+
│ └─ 302 redirect → IdP authorization endpoint │
|
|
176
|
+
└────────────────────────────────────────────────────────────────┘
|
|
177
|
+
│
|
|
178
|
+
▼ user logs in at IdP
|
|
179
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
180
|
+
│ IdP (Authentik / Keycloak / Google / …) │
|
|
181
|
+
│ │
|
|
182
|
+
│ 302 redirect with `code` + `state` parameters │
|
|
183
|
+
└────────────────────────────────────────────────────────────────┘
|
|
184
|
+
│
|
|
185
|
+
▼
|
|
186
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
187
|
+
│ CamStack server │
|
|
188
|
+
│ │
|
|
189
|
+
│ /addon/auth-oidc/callback │
|
|
190
|
+
│ ├─ verify state │
|
|
191
|
+
│ ├─ POST token endpoint with code + code_verifier │
|
|
192
|
+
│ ├─ decode id_token (no signature verify yet — see TODO) │
|
|
193
|
+
│ └─ 302 → /api/auth/sso/finish?userId=…&username=…&roles=… │
|
|
194
|
+
│ │
|
|
195
|
+
│ /api/auth/sso/finish │
|
|
196
|
+
│ ├─ authService.signToken(...) → 24h CamStack JWT │
|
|
197
|
+
│ └─ 302 → /admin/login#token=<jwt>&provider=<addonId> │
|
|
198
|
+
└────────────────────────────────────────────────────────────────┘
|
|
199
|
+
│
|
|
200
|
+
▼
|
|
201
|
+
SPA picks token off URL fragment, persists to localStorage,
|
|
202
|
+
strips fragment → user is logged in.
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Security notes
|
|
208
|
+
|
|
209
|
+
This addon is **functional but not yet hardened for production**. Known gaps:
|
|
210
|
+
|
|
211
|
+
1. **id_token signature is not verified against the IdP's JWKS.** The token is base64-decoded and trusted because:
|
|
212
|
+
- It arrived on the back-channel (server-to-server token exchange, not client-controlled)
|
|
213
|
+
- The exchange was bound to a `state` parameter we generated
|
|
214
|
+
|
|
215
|
+
For full RFC 7515 / OIDC Core compliance, replace `decodeJwtPayload` with `jose.jwtVerify(idToken, await jose.createRemoteJWKSet(new URL(jwks_uri)))`.
|
|
216
|
+
|
|
217
|
+
2. **State + PKCE storage is in-memory.** A 5-minute TTL bounds the exposure but multi-replica deployments will lose state if the user's redirect lands on a different replica. Move to a settings-backed store (`ctx.api.settings.set/get`) when this becomes operationally relevant.
|
|
218
|
+
|
|
219
|
+
3. **The `nonce` parameter is not implemented.** Replay protection currently relies on the IdP-issued `aud` and `iss` claims being checked downstream; for paranoid setups generate a `nonce`, include it in the auth URL, and validate it from the id_token.
|
|
220
|
+
|
|
221
|
+
4. **The `/api/auth/sso/finish` endpoint trusts query parameters from the addon's own callback handler.** A misconfigured reverse proxy that re-emits client query strings could in theory inject claims. Defense-in-depth fix: have the callback handler sign the redirect parameters with `auth.jwtSecret` (HMAC) and verify on `/finish`.
|
|
222
|
+
|
|
223
|
+
If you're running this in production, please open an issue or PR — these gaps are tracked in `packages/addon-auth-oidc/src/auth-oidc.addon.ts` (file-level docstring) and we'd like to close them.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Troubleshooting
|
|
228
|
+
|
|
229
|
+
### "OIDC discovery failed: 404 Not Found"
|
|
230
|
+
|
|
231
|
+
The issuer URL is wrong. Hit `<issuer>/.well-known/openid-configuration` in your browser — you should see a JSON document. Common gotchas:
|
|
232
|
+
- **Authentik** appends a trailing slash to application slugs (`/application/o/<slug>/`) — keep it
|
|
233
|
+
- **Keycloak** is case-sensitive on realm names
|
|
234
|
+
- **Cloudflare** in front of your IdP needs to allow the well-known path through
|
|
235
|
+
|
|
236
|
+
### "Unknown or expired OIDC state"
|
|
237
|
+
|
|
238
|
+
Either:
|
|
239
|
+
- The user took longer than 5 minutes to complete login at the IdP
|
|
240
|
+
- The user's redirect landed on a different replica (multi-replica issue — see Security note #2)
|
|
241
|
+
- The IdP corrupted the state value (rare; check IdP logs)
|
|
242
|
+
|
|
243
|
+
Just retry — this isn't a permanent failure.
|
|
244
|
+
|
|
245
|
+
### "OIDC token exchange failed: 401 invalid_client"
|
|
246
|
+
|
|
247
|
+
The clientSecret is wrong, or the IdP rejected the redirect URI. Check:
|
|
248
|
+
- The redirect URI configured in the addon settings exactly matches the one registered at the IdP (case-sensitive, trailing slash matters)
|
|
249
|
+
- The IdP's client allows `Confidential` / `Authorization Code` grant type
|
|
250
|
+
|
|
251
|
+
### "Login succeeds but the SPA shows a blank page"
|
|
252
|
+
|
|
253
|
+
Check the browser console — the most common cause is the JWT signing failed because `auth.jwtSecret` isn't set. CamStack auto-generates one on first boot but if `config.yaml` is read-only the secret can't persist. Set `CAMSTACK_JWT_SECRET` env var as a workaround.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## License
|
|
258
|
+
|
|
259
|
+
MIT — same as the rest of CamStack.
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const types = require("@camstack/types");
|
|
5
|
+
function _interopNamespaceDefault(e) {
|
|
6
|
+
const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
|
|
7
|
+
if (e) {
|
|
8
|
+
for (const k in e) {
|
|
9
|
+
if (k !== "default") {
|
|
10
|
+
const d = Object.getOwnPropertyDescriptor(e, k);
|
|
11
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
12
|
+
enumerable: true,
|
|
13
|
+
get: () => e[k]
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
n.default = e;
|
|
19
|
+
return Object.freeze(n);
|
|
20
|
+
}
|
|
21
|
+
const crypto__namespace = /* @__PURE__ */ _interopNamespaceDefault(crypto);
|
|
22
|
+
const DEFAULT_CONFIG = {
|
|
23
|
+
displayName: "OpenID Connect",
|
|
24
|
+
icon: "shield-check",
|
|
25
|
+
issuerUrl: "",
|
|
26
|
+
clientId: "",
|
|
27
|
+
clientSecret: "",
|
|
28
|
+
redirectUri: "",
|
|
29
|
+
scopes: "openid profile email",
|
|
30
|
+
usernameClaim: "preferred_username",
|
|
31
|
+
defaultRole: "viewer"
|
|
32
|
+
};
|
|
33
|
+
const STATE_TTL_MS = 5 * 6e4;
|
|
34
|
+
class AuthOidcAddon extends types.BaseAddon {
|
|
35
|
+
discovery = null;
|
|
36
|
+
discoveryFetchedAt = 0;
|
|
37
|
+
pendingStates = /* @__PURE__ */ new Map();
|
|
38
|
+
// Provider metadata — read by the auth-orchestrator when the admin
|
|
39
|
+
// UI calls `authentication.listProviders`. Static fields on the
|
|
40
|
+
// provider object so the orchestrator's coercion picks them up.
|
|
41
|
+
displayName;
|
|
42
|
+
kind = "oidc";
|
|
43
|
+
icon;
|
|
44
|
+
hasRedirectFlow = true;
|
|
45
|
+
hasCredentialFlow = false;
|
|
46
|
+
constructor() {
|
|
47
|
+
super({ ...DEFAULT_CONFIG });
|
|
48
|
+
this.displayName = DEFAULT_CONFIG.displayName;
|
|
49
|
+
this.icon = DEFAULT_CONFIG.icon;
|
|
50
|
+
}
|
|
51
|
+
async onInitialize() {
|
|
52
|
+
Object.assign(this, {
|
|
53
|
+
displayName: this.config.values.displayName || DEFAULT_CONFIG.displayName,
|
|
54
|
+
icon: this.config.values.icon || DEFAULT_CONFIG.icon
|
|
55
|
+
});
|
|
56
|
+
const authProvider = {
|
|
57
|
+
validateCredentials: async () => null,
|
|
58
|
+
// OIDC has no credential flow
|
|
59
|
+
validateToken: async ({ token }) => this.validateLocalSessionToken(token),
|
|
60
|
+
getLoginUrl: async ({ state }) => this.buildLoginUrl(state),
|
|
61
|
+
handleCallback: async (params) => {
|
|
62
|
+
const code = params["code"];
|
|
63
|
+
const state = params["state"];
|
|
64
|
+
if (!code || !state) {
|
|
65
|
+
throw new Error("Missing code or state in OIDC callback");
|
|
66
|
+
}
|
|
67
|
+
return this.handleCallback(code, state);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const routes = [
|
|
71
|
+
{
|
|
72
|
+
method: "GET",
|
|
73
|
+
path: "/start",
|
|
74
|
+
access: "public",
|
|
75
|
+
description: "Begin OIDC redirect login flow",
|
|
76
|
+
handler: async (req, reply) => this.handleStart(req, reply)
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
method: "GET",
|
|
80
|
+
path: "/callback",
|
|
81
|
+
access: "public",
|
|
82
|
+
description: "OIDC redirect callback — exchanges code for tokens",
|
|
83
|
+
handler: async (req, reply) => this.handleCallbackRoute(req, reply)
|
|
84
|
+
}
|
|
85
|
+
];
|
|
86
|
+
const routeProvider = {
|
|
87
|
+
id: "auth-oidc",
|
|
88
|
+
getRoutes: () => routes
|
|
89
|
+
};
|
|
90
|
+
this.ctx.logger.info("OIDC auth provider initialized", {
|
|
91
|
+
meta: {
|
|
92
|
+
displayName: this.displayName,
|
|
93
|
+
issuerUrl: this.config.values.issuerUrl || "(not configured)"
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return [
|
|
97
|
+
{ capability: types.authProviderCapability, provider: authProvider },
|
|
98
|
+
{ capability: types.addonRoutesCapability, provider: routeProvider }
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
globalSettingsSchema() {
|
|
102
|
+
return this.schema({
|
|
103
|
+
sections: [{
|
|
104
|
+
id: "oidc",
|
|
105
|
+
title: "OIDC Provider Settings",
|
|
106
|
+
description: "Configure your IdP. The redirect URI must be registered at the IdP exactly as shown below.",
|
|
107
|
+
columns: 1,
|
|
108
|
+
fields: [
|
|
109
|
+
this.field({ type: "text", key: "displayName", label: "Display Name", description: 'Shown on the login button (e.g., "Continue with Google").', default: DEFAULT_CONFIG.displayName }),
|
|
110
|
+
this.field({ type: "text", key: "icon", label: "Icon", description: "lucide-react icon name. Default: shield-check.", default: DEFAULT_CONFIG.icon }),
|
|
111
|
+
this.field({ type: "text", key: "issuerUrl", label: "Issuer URL", description: "IdP base URL — discovery doc lives at <issuer>/.well-known/openid-configuration.", default: "" }),
|
|
112
|
+
this.field({ type: "text", key: "clientId", label: "Client ID", description: "OAuth2 client ID issued by the IdP.", default: "" }),
|
|
113
|
+
this.field({ type: "text", key: "clientSecret", label: "Client Secret", description: "OAuth2 client secret. Server-side only.", default: "" }),
|
|
114
|
+
this.field({ type: "text", key: "redirectUri", label: "Redirect URI", description: "Public callback URL. Default: <origin>/addon/auth-oidc/callback. Must match the IdP-registered URI.", default: "" }),
|
|
115
|
+
this.field({ type: "text", key: "scopes", label: "Scopes", description: "Space-separated OAuth scopes.", default: DEFAULT_CONFIG.scopes }),
|
|
116
|
+
this.field({
|
|
117
|
+
type: "select",
|
|
118
|
+
key: "usernameClaim",
|
|
119
|
+
label: "Username Claim",
|
|
120
|
+
description: "Which OIDC claim to use as the local username.",
|
|
121
|
+
default: DEFAULT_CONFIG.usernameClaim,
|
|
122
|
+
options: [
|
|
123
|
+
{ value: "preferred_username", label: "preferred_username" },
|
|
124
|
+
{ value: "email", label: "email" },
|
|
125
|
+
{ value: "sub", label: "sub (user id)" }
|
|
126
|
+
]
|
|
127
|
+
}),
|
|
128
|
+
this.field({
|
|
129
|
+
type: "select",
|
|
130
|
+
key: "defaultRole",
|
|
131
|
+
label: "Default Role",
|
|
132
|
+
description: "Role assigned to first-time SSO users.",
|
|
133
|
+
default: DEFAULT_CONFIG.defaultRole,
|
|
134
|
+
options: [
|
|
135
|
+
{ value: "viewer", label: "Viewer" },
|
|
136
|
+
{ value: "admin", label: "Admin" },
|
|
137
|
+
{ value: "super_admin", label: "Super Admin" }
|
|
138
|
+
]
|
|
139
|
+
})
|
|
140
|
+
]
|
|
141
|
+
}]
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// ─── OIDC discovery + URL building ─────────────────────────────────
|
|
145
|
+
/**
|
|
146
|
+
* Fetch + cache the IdP's discovery document. Refreshes every hour
|
|
147
|
+
* (issuer config rarely changes; refresh window keeps us correct
|
|
148
|
+
* without hammering on every login).
|
|
149
|
+
*/
|
|
150
|
+
async getDiscovery() {
|
|
151
|
+
if (this.discovery && Date.now() - this.discoveryFetchedAt < 60 * 6e4) {
|
|
152
|
+
return this.discovery;
|
|
153
|
+
}
|
|
154
|
+
const issuer = this.config.values.issuerUrl?.replace(/\/+$/, "");
|
|
155
|
+
if (!issuer) throw new Error("OIDC issuerUrl not configured");
|
|
156
|
+
const url = `${issuer}/.well-known/openid-configuration`;
|
|
157
|
+
const res = await fetch(url);
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
throw new Error(`OIDC discovery failed: ${res.status} ${res.statusText}`);
|
|
160
|
+
}
|
|
161
|
+
const doc = await res.json();
|
|
162
|
+
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
|
163
|
+
throw new Error("OIDC discovery response missing required endpoints");
|
|
164
|
+
}
|
|
165
|
+
this.discovery = doc;
|
|
166
|
+
this.discoveryFetchedAt = Date.now();
|
|
167
|
+
return doc;
|
|
168
|
+
}
|
|
169
|
+
buildRedirectUri() {
|
|
170
|
+
const configured = this.config.values.redirectUri?.trim();
|
|
171
|
+
if (configured) return configured;
|
|
172
|
+
const origin = process.env["CAMSTACK_PUBLIC_ORIGIN"] || "https://localhost:4443";
|
|
173
|
+
return `${origin.replace(/\/+$/, "")}/addon/auth-oidc/callback`;
|
|
174
|
+
}
|
|
175
|
+
async buildLoginUrl(state) {
|
|
176
|
+
const doc = await this.getDiscovery();
|
|
177
|
+
const codeVerifier = crypto__namespace.randomBytes(32).toString("base64url");
|
|
178
|
+
const codeChallenge = crypto__namespace.createHash("sha256").update(codeVerifier).digest("base64url");
|
|
179
|
+
this.pruneExpiredStates();
|
|
180
|
+
this.pendingStates.set(state, { codeVerifier, createdAt: Date.now() });
|
|
181
|
+
const params = new URLSearchParams({
|
|
182
|
+
response_type: "code",
|
|
183
|
+
client_id: this.config.values.clientId,
|
|
184
|
+
redirect_uri: this.buildRedirectUri(),
|
|
185
|
+
scope: this.config.values.scopes || DEFAULT_CONFIG.scopes,
|
|
186
|
+
state,
|
|
187
|
+
code_challenge: codeChallenge,
|
|
188
|
+
code_challenge_method: "S256"
|
|
189
|
+
});
|
|
190
|
+
return `${doc.authorization_endpoint}?${params.toString()}`;
|
|
191
|
+
}
|
|
192
|
+
pruneExpiredStates() {
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
for (const [k, v] of this.pendingStates.entries()) {
|
|
195
|
+
if (now - v.createdAt > STATE_TTL_MS) this.pendingStates.delete(k);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// ─── Callback / token exchange ─────────────────────────────────────
|
|
199
|
+
async handleCallback(code, state) {
|
|
200
|
+
const pending = this.pendingStates.get(state);
|
|
201
|
+
if (!pending) {
|
|
202
|
+
throw new Error("Unknown or expired OIDC state — login session timed out, please retry");
|
|
203
|
+
}
|
|
204
|
+
this.pendingStates.delete(state);
|
|
205
|
+
const doc = await this.getDiscovery();
|
|
206
|
+
const tokenRes = await fetch(doc.token_endpoint, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: {
|
|
209
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
210
|
+
Accept: "application/json"
|
|
211
|
+
},
|
|
212
|
+
body: new URLSearchParams({
|
|
213
|
+
grant_type: "authorization_code",
|
|
214
|
+
code,
|
|
215
|
+
redirect_uri: this.buildRedirectUri(),
|
|
216
|
+
client_id: this.config.values.clientId,
|
|
217
|
+
client_secret: this.config.values.clientSecret,
|
|
218
|
+
code_verifier: pending.codeVerifier
|
|
219
|
+
})
|
|
220
|
+
});
|
|
221
|
+
if (!tokenRes.ok) {
|
|
222
|
+
const text = await tokenRes.text().catch(() => "");
|
|
223
|
+
throw new Error(`OIDC token exchange failed: ${tokenRes.status} ${tokenRes.statusText} — ${text.slice(0, 200)}`);
|
|
224
|
+
}
|
|
225
|
+
const tokens = await tokenRes.json();
|
|
226
|
+
let claims = null;
|
|
227
|
+
if (tokens.id_token) {
|
|
228
|
+
claims = decodeJwtPayload(tokens.id_token);
|
|
229
|
+
}
|
|
230
|
+
if (!claims && doc.userinfo_endpoint) {
|
|
231
|
+
const ui = await fetch(doc.userinfo_endpoint, {
|
|
232
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` }
|
|
233
|
+
});
|
|
234
|
+
if (ui.ok) claims = await ui.json();
|
|
235
|
+
}
|
|
236
|
+
if (!claims?.sub) {
|
|
237
|
+
throw new Error("OIDC callback: id_token missing required `sub` claim");
|
|
238
|
+
}
|
|
239
|
+
const usernameClaim = this.config.values.usernameClaim;
|
|
240
|
+
const username = usernameClaim === "preferred_username" && claims.preferred_username || usernameClaim === "email" && claims.email || claims.sub;
|
|
241
|
+
const userId = `oidc:${claims.sub}`;
|
|
242
|
+
return {
|
|
243
|
+
userId,
|
|
244
|
+
username: String(username),
|
|
245
|
+
...claims.email ? { email: claims.email } : {},
|
|
246
|
+
...claims.name ? { displayName: claims.name } : {},
|
|
247
|
+
roles: [this.config.values.defaultRole]
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
validateLocalSessionToken(_token) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
// ─── HTTP route handlers ───────────────────────────────────────────
|
|
254
|
+
async handleStart(_req, reply) {
|
|
255
|
+
const state = crypto__namespace.randomBytes(16).toString("base64url");
|
|
256
|
+
try {
|
|
257
|
+
const url = await this.buildLoginUrl(state);
|
|
258
|
+
reply.code(302);
|
|
259
|
+
reply.header("Location", url);
|
|
260
|
+
reply.send("");
|
|
261
|
+
} catch (err) {
|
|
262
|
+
reply.code(500);
|
|
263
|
+
reply.send({ error: "OIDC start failed", message: types.errMsg(err) });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async handleCallbackRoute(req, reply) {
|
|
267
|
+
const query = req.query;
|
|
268
|
+
const code = query["code"];
|
|
269
|
+
const state = query["state"];
|
|
270
|
+
const errorParam = query["error"];
|
|
271
|
+
if (errorParam) {
|
|
272
|
+
reply.code(400);
|
|
273
|
+
reply.send({ error: "OIDC error", detail: errorParam, description: query["error_description"] });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (!code || !state) {
|
|
277
|
+
reply.code(400);
|
|
278
|
+
reply.send({ error: "Missing code or state" });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const result = await this.handleCallback(code, state);
|
|
283
|
+
const params = new URLSearchParams({
|
|
284
|
+
userId: result.userId,
|
|
285
|
+
username: result.username,
|
|
286
|
+
...result.email ? { email: result.email } : {},
|
|
287
|
+
...result.displayName ? { displayName: result.displayName } : {},
|
|
288
|
+
roles: result.roles.join(","),
|
|
289
|
+
provider: "auth-oidc"
|
|
290
|
+
});
|
|
291
|
+
reply.code(302);
|
|
292
|
+
reply.header("Location", `/api/auth/sso/finish?${params.toString()}`);
|
|
293
|
+
reply.send("");
|
|
294
|
+
} catch (err) {
|
|
295
|
+
reply.code(500);
|
|
296
|
+
reply.send({ error: "OIDC callback failed", message: types.errMsg(err) });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function decodeJwtPayload(jwt) {
|
|
301
|
+
const parts = jwt.split(".");
|
|
302
|
+
if (parts.length !== 3) return null;
|
|
303
|
+
try {
|
|
304
|
+
const payload = parts[1];
|
|
305
|
+
const padded = payload + "=".repeat((4 - payload.length % 4) % 4);
|
|
306
|
+
const buf = Buffer.from(padded, "base64url");
|
|
307
|
+
return JSON.parse(buf.toString("utf-8"));
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
exports.AuthOidcAddon = AuthOidcAddon;
|
|
313
|
+
exports.default = AuthOidcAddon;
|
|
314
|
+
//# sourceMappingURL=auth-oidc.addon.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-oidc.addon.js","sources":["../src/auth-oidc.addon.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return -- BaseAddon<TConfig>.config.values is typed-erased in the consumer's type tree under projectService context (the cap-router union resolves the generic at the call site, not in the addon's own dist). All sites flagged here are reads/writes of well-typed local fields; runtime contract is validated by Zod via globalSettingsSchema. */\n/**\n * Generic OpenID Connect (OIDC) authentication provider.\n *\n * Registers two capabilities:\n * - `auth-provider` (collection) — the standard CamStack auth surface.\n * `getLoginUrl({state})` returns the IdP's authorization URL with\n * state + PKCE; `handleCallback({code, state})` exchanges the code\n * for tokens and returns AuthResult.\n * - `addon-routes` (collection) — `/start` (302 to IdP) and\n * `/callback` (token exchange + redirect to admin UI with the\n * local session token in the URL fragment).\n *\n * Flow (login):\n * 1. Browser hits `/api/auth/sso/auth-oidc/start` (proxy to\n * `/addon/auth-oidc/start`)\n * 2. Addon redirects to `<issuer>/authorize?...&state=...&code_challenge=...`\n * 3. User authenticates at the IdP\n * 4. IdP redirects back to `/addon/auth-oidc/callback?code=...&state=...`\n * 5. Addon exchanges code for id_token + access_token, decodes claims,\n * mints local CamStack session, redirects to `/admin#token=...`\n *\n * Configuration: per-instance via global addon settings (issuer URL,\n * client ID/secret, scopes, displayName). Multiple OIDC providers can\n * coexist by installing this addon multiple times under different IDs.\n *\n * Production-readiness gaps (documented for follow-up):\n * - ID-token signature is NOT verified against JWKS. The token is\n * decoded as base64-url-JSON and trusted because the code came\n * from the IdP redirect (state param is verified). Add\n * `jose.jwtVerify(idToken, JWKS)` for full RFC 7515 compliance.\n * - State + PKCE are stored in-memory keyed by state value with a\n * 5-minute TTL. Survives addon restart only if the user retries\n * within the window. For production multi-replica deploys, move\n * to a settings-backed store.\n * - `nonce` parameter not yet implemented. Mitigate replay by\n * validating `aud` and `iss` from id_token claims.\n */\nimport * as crypto from 'node:crypto'\nimport {\n BaseAddon,\n authProviderCapability,\n addonRoutesCapability,\n errMsg,\n type ProviderRegistration,\n type IAuthProvider,\n type IAddonRouteProvider,\n type IAddonHttpRoute,\n type AddonHttpRequest,\n type AddonHttpReply,\n} from '@camstack/types'\n\ninterface OidcConfig {\n /** Display name shown on the login button + admin UI (\"Log in with Acme\"). */\n readonly displayName: string\n /** Operator-facing icon hint (lucide-react icon name). Defaults to 'shield-check'. */\n readonly icon: string\n /**\n * Issuer base URL — discovery document MUST live at\n * `<issuer>/.well-known/openid-configuration`. Examples:\n * - https://accounts.google.com\n * - https://login.microsoftonline.com/<tenant>/v2.0\n * - https://your-keycloak.example.com/realms/master\n */\n readonly issuerUrl: string\n /** OAuth2 client ID issued by the IdP. */\n readonly clientId: string\n /** OAuth2 client secret. Server-side only — never exposed to the browser. */\n readonly clientSecret: string\n /**\n * Public callback URL the IdP will redirect to. Must match what's\n * configured at the IdP. Default: `<server-origin>/addon/auth-oidc/callback`.\n * Override when the server is behind a reverse proxy with a different\n * public-facing origin (e.g. `https://hub.example.com/addon/auth-oidc/callback`).\n */\n readonly redirectUri: string\n /** Space-separated OAuth scopes. Default: 'openid profile email'. */\n readonly scopes: string\n /**\n * Scopes / claims used to derive the local username. The first\n * matching claim wins. Default order: preferred_username → email → sub.\n */\n readonly usernameClaim: 'preferred_username' | 'email' | 'sub'\n /** Default role assigned to first-time SSO users. */\n readonly defaultRole: 'viewer' | 'admin' | 'super_admin'\n}\n\nconst DEFAULT_CONFIG: OidcConfig = {\n displayName: 'OpenID Connect',\n icon: 'shield-check',\n issuerUrl: '',\n clientId: '',\n clientSecret: '',\n redirectUri: '',\n scopes: 'openid profile email',\n usernameClaim: 'preferred_username',\n defaultRole: 'viewer',\n}\n\ninterface DiscoveryDocument {\n readonly issuer: string\n readonly authorization_endpoint: string\n readonly token_endpoint: string\n readonly userinfo_endpoint?: string\n readonly jwks_uri?: string\n}\n\ninterface PendingState {\n readonly codeVerifier: string\n readonly createdAt: number\n}\n\ninterface OidcTokenResponse {\n readonly access_token: string\n readonly id_token?: string\n readonly token_type?: string\n readonly expires_in?: number\n readonly refresh_token?: string\n}\n\ninterface OidcClaims {\n readonly sub: string\n readonly iss?: string\n readonly aud?: string | readonly string[]\n readonly email?: string\n readonly preferred_username?: string\n readonly name?: string\n}\n\nconst STATE_TTL_MS = 5 * 60_000\n\nexport class AuthOidcAddon extends BaseAddon<OidcConfig> {\n private discovery: DiscoveryDocument | null = null\n private discoveryFetchedAt = 0\n private readonly pendingStates = new Map<string, PendingState>()\n\n // Provider metadata — read by the auth-orchestrator when the admin\n // UI calls `authentication.listProviders`. Static fields on the\n // provider object so the orchestrator's coercion picks them up.\n readonly displayName: string\n readonly kind = 'oidc' as const\n readonly icon: string\n readonly hasRedirectFlow = true\n readonly hasCredentialFlow = false\n\n constructor() {\n super({ ...DEFAULT_CONFIG })\n this.displayName = DEFAULT_CONFIG.displayName\n this.icon = DEFAULT_CONFIG.icon\n }\n\n protected async onInitialize(): Promise<ProviderRegistration[]> {\n // Override the static metadata with the configured displayName so\n // the orchestrator picks up the operator-chosen label.\n Object.assign(this, {\n displayName: this.config.values.displayName || DEFAULT_CONFIG.displayName,\n icon: this.config.values.icon || DEFAULT_CONFIG.icon,\n })\n\n const authProvider: IAuthProvider = {\n validateCredentials: async () => null, // OIDC has no credential flow\n validateToken: async ({ token }) => this.validateLocalSessionToken(token),\n getLoginUrl: async ({ state }) => this.buildLoginUrl(state),\n handleCallback: async (params) => {\n const code = params['code']\n const state = params['state']\n if (!code || !state) {\n throw new Error('Missing code or state in OIDC callback')\n }\n return this.handleCallback(code, state)\n },\n }\n\n const routes: IAddonHttpRoute[] = [\n {\n method: 'GET',\n path: '/start',\n access: 'public',\n description: 'Begin OIDC redirect login flow',\n handler: async (req, reply) => this.handleStart(req, reply),\n },\n {\n method: 'GET',\n path: '/callback',\n access: 'public',\n description: 'OIDC redirect callback — exchanges code for tokens',\n handler: async (req, reply) => this.handleCallbackRoute(req, reply),\n },\n ]\n const routeProvider: IAddonRouteProvider = {\n id: 'auth-oidc',\n getRoutes: () => routes,\n }\n\n this.ctx.logger.info('OIDC auth provider initialized', {\n meta: {\n displayName: this.displayName,\n issuerUrl: this.config.values.issuerUrl || '(not configured)',\n },\n })\n\n return [\n { capability: authProviderCapability, provider: authProvider as never },\n { capability: addonRoutesCapability, provider: routeProvider as never },\n ]\n }\n\n protected globalSettingsSchema() {\n return this.schema({\n sections: [{\n id: 'oidc',\n title: 'OIDC Provider Settings',\n description: 'Configure your IdP. The redirect URI must be registered at the IdP exactly as shown below.',\n columns: 1,\n fields: [\n this.field({ type: 'text', key: 'displayName', label: 'Display Name', description: 'Shown on the login button (e.g., \"Continue with Google\").', default: DEFAULT_CONFIG.displayName }),\n this.field({ type: 'text', key: 'icon', label: 'Icon', description: 'lucide-react icon name. Default: shield-check.', default: DEFAULT_CONFIG.icon }),\n this.field({ type: 'text', key: 'issuerUrl', label: 'Issuer URL', description: 'IdP base URL — discovery doc lives at <issuer>/.well-known/openid-configuration.', default: '' }),\n this.field({ type: 'text', key: 'clientId', label: 'Client ID', description: 'OAuth2 client ID issued by the IdP.', default: '' }),\n this.field({ type: 'text', key: 'clientSecret', label: 'Client Secret', description: 'OAuth2 client secret. Server-side only.', default: '' }),\n this.field({ type: 'text', key: 'redirectUri', label: 'Redirect URI', description: 'Public callback URL. Default: <origin>/addon/auth-oidc/callback. Must match the IdP-registered URI.', default: '' }),\n this.field({ type: 'text', key: 'scopes', label: 'Scopes', description: 'Space-separated OAuth scopes.', default: DEFAULT_CONFIG.scopes }),\n this.field({\n type: 'select',\n key: 'usernameClaim',\n label: 'Username Claim',\n description: 'Which OIDC claim to use as the local username.',\n default: DEFAULT_CONFIG.usernameClaim,\n options: [\n { value: 'preferred_username', label: 'preferred_username' },\n { value: 'email', label: 'email' },\n { value: 'sub', label: 'sub (user id)' },\n ],\n }),\n this.field({\n type: 'select',\n key: 'defaultRole',\n label: 'Default Role',\n description: 'Role assigned to first-time SSO users.',\n default: DEFAULT_CONFIG.defaultRole,\n options: [\n { value: 'viewer', label: 'Viewer' },\n { value: 'admin', label: 'Admin' },\n { value: 'super_admin', label: 'Super Admin' },\n ],\n }),\n ],\n }],\n })\n }\n\n // ─── OIDC discovery + URL building ─────────────────────────────────\n\n /**\n * Fetch + cache the IdP's discovery document. Refreshes every hour\n * (issuer config rarely changes; refresh window keeps us correct\n * without hammering on every login).\n */\n private async getDiscovery(): Promise<DiscoveryDocument> {\n if (this.discovery && Date.now() - this.discoveryFetchedAt < 60 * 60_000) {\n return this.discovery\n }\n const issuer = this.config.values.issuerUrl?.replace(/\\/+$/, '')\n if (!issuer) throw new Error('OIDC issuerUrl not configured')\n const url = `${issuer}/.well-known/openid-configuration`\n const res = await fetch(url)\n if (!res.ok) {\n throw new Error(`OIDC discovery failed: ${res.status} ${res.statusText}`)\n }\n const doc = (await res.json()) as DiscoveryDocument\n if (!doc.authorization_endpoint || !doc.token_endpoint) {\n throw new Error('OIDC discovery response missing required endpoints')\n }\n this.discovery = doc\n this.discoveryFetchedAt = Date.now()\n return doc\n }\n\n private buildRedirectUri(): string {\n const configured = this.config.values.redirectUri?.trim()\n if (configured) return configured\n // Fallback: server origin from env. In a multi-host deploy the\n // operator MUST set redirectUri explicitly.\n const origin = process.env['CAMSTACK_PUBLIC_ORIGIN'] || 'https://localhost:4443'\n return `${origin.replace(/\\/+$/, '')}/addon/auth-oidc/callback`\n }\n\n private async buildLoginUrl(state: string): Promise<string> {\n const doc = await this.getDiscovery()\n\n // PKCE: random code_verifier, S256 hash → code_challenge.\n const codeVerifier = crypto.randomBytes(32).toString('base64url')\n const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')\n\n this.pruneExpiredStates()\n this.pendingStates.set(state, { codeVerifier, createdAt: Date.now() })\n\n const params = new URLSearchParams({\n response_type: 'code',\n client_id: this.config.values.clientId,\n redirect_uri: this.buildRedirectUri(),\n scope: this.config.values.scopes || DEFAULT_CONFIG.scopes,\n state,\n code_challenge: codeChallenge,\n code_challenge_method: 'S256',\n })\n return `${doc.authorization_endpoint}?${params.toString()}`\n }\n\n private pruneExpiredStates(): void {\n const now = Date.now()\n for (const [k, v] of this.pendingStates.entries()) {\n if (now - v.createdAt > STATE_TTL_MS) this.pendingStates.delete(k)\n }\n }\n\n // ─── Callback / token exchange ─────────────────────────────────────\n\n private async handleCallback(code: string, state: string): Promise<{\n userId: string\n username: string\n email?: string\n displayName?: string\n roles: readonly string[]\n }> {\n const pending = this.pendingStates.get(state)\n if (!pending) {\n throw new Error('Unknown or expired OIDC state — login session timed out, please retry')\n }\n this.pendingStates.delete(state)\n\n const doc = await this.getDiscovery()\n const tokenRes = await fetch(doc.token_endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n Accept: 'application/json',\n },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code,\n redirect_uri: this.buildRedirectUri(),\n client_id: this.config.values.clientId,\n client_secret: this.config.values.clientSecret,\n code_verifier: pending.codeVerifier,\n }),\n })\n if (!tokenRes.ok) {\n const text = await tokenRes.text().catch(() => '')\n throw new Error(`OIDC token exchange failed: ${tokenRes.status} ${tokenRes.statusText} — ${text.slice(0, 200)}`)\n }\n const tokens = (await tokenRes.json()) as OidcTokenResponse\n\n // Decode id_token claims (no signature verification — see header\n // doc comment). For now we trust the token because it arrived via\n // the back-channel from a verified state-bound exchange. PRODUCTION:\n // call `jose.jwtVerify(id_token, await jose.createRemoteJWKSet(jwks_uri))`.\n let claims: OidcClaims | null = null\n if (tokens.id_token) {\n claims = decodeJwtPayload(tokens.id_token) as OidcClaims | null\n }\n // Fallback: query userinfo_endpoint with the access token\n if (!claims && doc.userinfo_endpoint) {\n const ui = await fetch(doc.userinfo_endpoint, {\n headers: { Authorization: `Bearer ${tokens.access_token}` },\n })\n if (ui.ok) claims = (await ui.json()) as OidcClaims\n }\n if (!claims?.sub) {\n throw new Error('OIDC callback: id_token missing required `sub` claim')\n }\n\n const usernameClaim = this.config.values.usernameClaim\n const username =\n (usernameClaim === 'preferred_username' && claims.preferred_username) ||\n (usernameClaim === 'email' && claims.email) ||\n claims.sub\n\n const userId = `oidc:${claims.sub}`\n return {\n userId,\n username: String(username),\n ...(claims.email ? { email: claims.email } : {}),\n ...(claims.name ? { displayName: claims.name } : {}),\n roles: [this.config.values.defaultRole],\n }\n }\n\n private validateLocalSessionToken(_token: string): null {\n // The local CamStack session token is minted by the host's auth\n // subsystem after `handleCallback` returns. Token verification\n // remains the responsibility of `local-auth` / the auth-manager;\n // this provider only mints AuthResult.\n return null\n }\n\n // ─── HTTP route handlers ───────────────────────────────────────────\n\n private async handleStart(_req: AddonHttpRequest, reply: AddonHttpReply): Promise<void> {\n const state = crypto.randomBytes(16).toString('base64url')\n try {\n const url = await this.buildLoginUrl(state)\n reply.code(302)\n reply.header('Location', url)\n reply.send('')\n } catch (err) {\n reply.code(500)\n reply.send({ error: 'OIDC start failed', message: errMsg(err) })\n }\n }\n\n private async handleCallbackRoute(req: AddonHttpRequest, reply: AddonHttpReply): Promise<void> {\n const query = req.query as Record<string, string | undefined>\n const code = query['code']\n const state = query['state']\n const errorParam = query['error']\n\n if (errorParam) {\n reply.code(400)\n reply.send({ error: 'OIDC error', detail: errorParam, description: query['error_description'] })\n return\n }\n if (!code || !state) {\n reply.code(400)\n reply.send({ error: 'Missing code or state' })\n return\n }\n\n try {\n const result = await this.handleCallback(code, state)\n // The host's auth subsystem owns local session minting. We\n // signal success by redirecting to a known admin endpoint with\n // the OIDC result encoded in the query — the server's auth\n // route handler picks it up, mints a JWT, and redirects to the\n // admin UI with `#token=...`.\n const params = new URLSearchParams({\n userId: result.userId,\n username: result.username,\n ...(result.email ? { email: result.email } : {}),\n ...(result.displayName ? { displayName: result.displayName } : {}),\n roles: result.roles.join(','),\n provider: 'auth-oidc',\n })\n reply.code(302)\n reply.header('Location', `/api/auth/sso/finish?${params.toString()}`)\n reply.send('')\n } catch (err) {\n reply.code(500)\n reply.send({ error: 'OIDC callback failed', message: errMsg(err) })\n }\n }\n}\n\nexport default AuthOidcAddon\n\n/**\n * Decode a JWT's payload as JSON without verifying its signature.\n *\n * **NOT SAFE for general token validation** — only used here because\n * the id_token arrives via the back-channel token exchange against a\n * state-bound code, which gives us implicit transport-level trust. A\n * production-grade implementation must use `jose.jwtVerify` against\n * the issuer's published JWKS.\n */\nfunction decodeJwtPayload(jwt: string): unknown {\n const parts = jwt.split('.')\n if (parts.length !== 3) return null\n try {\n const payload = parts[1]!\n const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4)\n const buf = Buffer.from(padded, 'base64url')\n return JSON.parse(buf.toString('utf-8'))\n } catch {\n return null\n }\n}\n"],"names":["BaseAddon","authProviderCapability","addonRoutesCapability","crypto","errMsg"],"mappings":"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"}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import { BaseAddon, authProviderCapability, addonRoutesCapability, errMsg } from "@camstack/types";
|
|
3
|
+
const DEFAULT_CONFIG = {
|
|
4
|
+
displayName: "OpenID Connect",
|
|
5
|
+
icon: "shield-check",
|
|
6
|
+
issuerUrl: "",
|
|
7
|
+
clientId: "",
|
|
8
|
+
clientSecret: "",
|
|
9
|
+
redirectUri: "",
|
|
10
|
+
scopes: "openid profile email",
|
|
11
|
+
usernameClaim: "preferred_username",
|
|
12
|
+
defaultRole: "viewer"
|
|
13
|
+
};
|
|
14
|
+
const STATE_TTL_MS = 5 * 6e4;
|
|
15
|
+
class AuthOidcAddon extends BaseAddon {
|
|
16
|
+
discovery = null;
|
|
17
|
+
discoveryFetchedAt = 0;
|
|
18
|
+
pendingStates = /* @__PURE__ */ new Map();
|
|
19
|
+
// Provider metadata — read by the auth-orchestrator when the admin
|
|
20
|
+
// UI calls `authentication.listProviders`. Static fields on the
|
|
21
|
+
// provider object so the orchestrator's coercion picks them up.
|
|
22
|
+
displayName;
|
|
23
|
+
kind = "oidc";
|
|
24
|
+
icon;
|
|
25
|
+
hasRedirectFlow = true;
|
|
26
|
+
hasCredentialFlow = false;
|
|
27
|
+
constructor() {
|
|
28
|
+
super({ ...DEFAULT_CONFIG });
|
|
29
|
+
this.displayName = DEFAULT_CONFIG.displayName;
|
|
30
|
+
this.icon = DEFAULT_CONFIG.icon;
|
|
31
|
+
}
|
|
32
|
+
async onInitialize() {
|
|
33
|
+
Object.assign(this, {
|
|
34
|
+
displayName: this.config.values.displayName || DEFAULT_CONFIG.displayName,
|
|
35
|
+
icon: this.config.values.icon || DEFAULT_CONFIG.icon
|
|
36
|
+
});
|
|
37
|
+
const authProvider = {
|
|
38
|
+
validateCredentials: async () => null,
|
|
39
|
+
// OIDC has no credential flow
|
|
40
|
+
validateToken: async ({ token }) => this.validateLocalSessionToken(token),
|
|
41
|
+
getLoginUrl: async ({ state }) => this.buildLoginUrl(state),
|
|
42
|
+
handleCallback: async (params) => {
|
|
43
|
+
const code = params["code"];
|
|
44
|
+
const state = params["state"];
|
|
45
|
+
if (!code || !state) {
|
|
46
|
+
throw new Error("Missing code or state in OIDC callback");
|
|
47
|
+
}
|
|
48
|
+
return this.handleCallback(code, state);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const routes = [
|
|
52
|
+
{
|
|
53
|
+
method: "GET",
|
|
54
|
+
path: "/start",
|
|
55
|
+
access: "public",
|
|
56
|
+
description: "Begin OIDC redirect login flow",
|
|
57
|
+
handler: async (req, reply) => this.handleStart(req, reply)
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
method: "GET",
|
|
61
|
+
path: "/callback",
|
|
62
|
+
access: "public",
|
|
63
|
+
description: "OIDC redirect callback — exchanges code for tokens",
|
|
64
|
+
handler: async (req, reply) => this.handleCallbackRoute(req, reply)
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
const routeProvider = {
|
|
68
|
+
id: "auth-oidc",
|
|
69
|
+
getRoutes: () => routes
|
|
70
|
+
};
|
|
71
|
+
this.ctx.logger.info("OIDC auth provider initialized", {
|
|
72
|
+
meta: {
|
|
73
|
+
displayName: this.displayName,
|
|
74
|
+
issuerUrl: this.config.values.issuerUrl || "(not configured)"
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return [
|
|
78
|
+
{ capability: authProviderCapability, provider: authProvider },
|
|
79
|
+
{ capability: addonRoutesCapability, provider: routeProvider }
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
globalSettingsSchema() {
|
|
83
|
+
return this.schema({
|
|
84
|
+
sections: [{
|
|
85
|
+
id: "oidc",
|
|
86
|
+
title: "OIDC Provider Settings",
|
|
87
|
+
description: "Configure your IdP. The redirect URI must be registered at the IdP exactly as shown below.",
|
|
88
|
+
columns: 1,
|
|
89
|
+
fields: [
|
|
90
|
+
this.field({ type: "text", key: "displayName", label: "Display Name", description: 'Shown on the login button (e.g., "Continue with Google").', default: DEFAULT_CONFIG.displayName }),
|
|
91
|
+
this.field({ type: "text", key: "icon", label: "Icon", description: "lucide-react icon name. Default: shield-check.", default: DEFAULT_CONFIG.icon }),
|
|
92
|
+
this.field({ type: "text", key: "issuerUrl", label: "Issuer URL", description: "IdP base URL — discovery doc lives at <issuer>/.well-known/openid-configuration.", default: "" }),
|
|
93
|
+
this.field({ type: "text", key: "clientId", label: "Client ID", description: "OAuth2 client ID issued by the IdP.", default: "" }),
|
|
94
|
+
this.field({ type: "text", key: "clientSecret", label: "Client Secret", description: "OAuth2 client secret. Server-side only.", default: "" }),
|
|
95
|
+
this.field({ type: "text", key: "redirectUri", label: "Redirect URI", description: "Public callback URL. Default: <origin>/addon/auth-oidc/callback. Must match the IdP-registered URI.", default: "" }),
|
|
96
|
+
this.field({ type: "text", key: "scopes", label: "Scopes", description: "Space-separated OAuth scopes.", default: DEFAULT_CONFIG.scopes }),
|
|
97
|
+
this.field({
|
|
98
|
+
type: "select",
|
|
99
|
+
key: "usernameClaim",
|
|
100
|
+
label: "Username Claim",
|
|
101
|
+
description: "Which OIDC claim to use as the local username.",
|
|
102
|
+
default: DEFAULT_CONFIG.usernameClaim,
|
|
103
|
+
options: [
|
|
104
|
+
{ value: "preferred_username", label: "preferred_username" },
|
|
105
|
+
{ value: "email", label: "email" },
|
|
106
|
+
{ value: "sub", label: "sub (user id)" }
|
|
107
|
+
]
|
|
108
|
+
}),
|
|
109
|
+
this.field({
|
|
110
|
+
type: "select",
|
|
111
|
+
key: "defaultRole",
|
|
112
|
+
label: "Default Role",
|
|
113
|
+
description: "Role assigned to first-time SSO users.",
|
|
114
|
+
default: DEFAULT_CONFIG.defaultRole,
|
|
115
|
+
options: [
|
|
116
|
+
{ value: "viewer", label: "Viewer" },
|
|
117
|
+
{ value: "admin", label: "Admin" },
|
|
118
|
+
{ value: "super_admin", label: "Super Admin" }
|
|
119
|
+
]
|
|
120
|
+
})
|
|
121
|
+
]
|
|
122
|
+
}]
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// ─── OIDC discovery + URL building ─────────────────────────────────
|
|
126
|
+
/**
|
|
127
|
+
* Fetch + cache the IdP's discovery document. Refreshes every hour
|
|
128
|
+
* (issuer config rarely changes; refresh window keeps us correct
|
|
129
|
+
* without hammering on every login).
|
|
130
|
+
*/
|
|
131
|
+
async getDiscovery() {
|
|
132
|
+
if (this.discovery && Date.now() - this.discoveryFetchedAt < 60 * 6e4) {
|
|
133
|
+
return this.discovery;
|
|
134
|
+
}
|
|
135
|
+
const issuer = this.config.values.issuerUrl?.replace(/\/+$/, "");
|
|
136
|
+
if (!issuer) throw new Error("OIDC issuerUrl not configured");
|
|
137
|
+
const url = `${issuer}/.well-known/openid-configuration`;
|
|
138
|
+
const res = await fetch(url);
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
throw new Error(`OIDC discovery failed: ${res.status} ${res.statusText}`);
|
|
141
|
+
}
|
|
142
|
+
const doc = await res.json();
|
|
143
|
+
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
|
144
|
+
throw new Error("OIDC discovery response missing required endpoints");
|
|
145
|
+
}
|
|
146
|
+
this.discovery = doc;
|
|
147
|
+
this.discoveryFetchedAt = Date.now();
|
|
148
|
+
return doc;
|
|
149
|
+
}
|
|
150
|
+
buildRedirectUri() {
|
|
151
|
+
const configured = this.config.values.redirectUri?.trim();
|
|
152
|
+
if (configured) return configured;
|
|
153
|
+
const origin = process.env["CAMSTACK_PUBLIC_ORIGIN"] || "https://localhost:4443";
|
|
154
|
+
return `${origin.replace(/\/+$/, "")}/addon/auth-oidc/callback`;
|
|
155
|
+
}
|
|
156
|
+
async buildLoginUrl(state) {
|
|
157
|
+
const doc = await this.getDiscovery();
|
|
158
|
+
const codeVerifier = crypto.randomBytes(32).toString("base64url");
|
|
159
|
+
const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
|
|
160
|
+
this.pruneExpiredStates();
|
|
161
|
+
this.pendingStates.set(state, { codeVerifier, createdAt: Date.now() });
|
|
162
|
+
const params = new URLSearchParams({
|
|
163
|
+
response_type: "code",
|
|
164
|
+
client_id: this.config.values.clientId,
|
|
165
|
+
redirect_uri: this.buildRedirectUri(),
|
|
166
|
+
scope: this.config.values.scopes || DEFAULT_CONFIG.scopes,
|
|
167
|
+
state,
|
|
168
|
+
code_challenge: codeChallenge,
|
|
169
|
+
code_challenge_method: "S256"
|
|
170
|
+
});
|
|
171
|
+
return `${doc.authorization_endpoint}?${params.toString()}`;
|
|
172
|
+
}
|
|
173
|
+
pruneExpiredStates() {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
for (const [k, v] of this.pendingStates.entries()) {
|
|
176
|
+
if (now - v.createdAt > STATE_TTL_MS) this.pendingStates.delete(k);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// ─── Callback / token exchange ─────────────────────────────────────
|
|
180
|
+
async handleCallback(code, state) {
|
|
181
|
+
const pending = this.pendingStates.get(state);
|
|
182
|
+
if (!pending) {
|
|
183
|
+
throw new Error("Unknown or expired OIDC state — login session timed out, please retry");
|
|
184
|
+
}
|
|
185
|
+
this.pendingStates.delete(state);
|
|
186
|
+
const doc = await this.getDiscovery();
|
|
187
|
+
const tokenRes = await fetch(doc.token_endpoint, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: {
|
|
190
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
191
|
+
Accept: "application/json"
|
|
192
|
+
},
|
|
193
|
+
body: new URLSearchParams({
|
|
194
|
+
grant_type: "authorization_code",
|
|
195
|
+
code,
|
|
196
|
+
redirect_uri: this.buildRedirectUri(),
|
|
197
|
+
client_id: this.config.values.clientId,
|
|
198
|
+
client_secret: this.config.values.clientSecret,
|
|
199
|
+
code_verifier: pending.codeVerifier
|
|
200
|
+
})
|
|
201
|
+
});
|
|
202
|
+
if (!tokenRes.ok) {
|
|
203
|
+
const text = await tokenRes.text().catch(() => "");
|
|
204
|
+
throw new Error(`OIDC token exchange failed: ${tokenRes.status} ${tokenRes.statusText} — ${text.slice(0, 200)}`);
|
|
205
|
+
}
|
|
206
|
+
const tokens = await tokenRes.json();
|
|
207
|
+
let claims = null;
|
|
208
|
+
if (tokens.id_token) {
|
|
209
|
+
claims = decodeJwtPayload(tokens.id_token);
|
|
210
|
+
}
|
|
211
|
+
if (!claims && doc.userinfo_endpoint) {
|
|
212
|
+
const ui = await fetch(doc.userinfo_endpoint, {
|
|
213
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` }
|
|
214
|
+
});
|
|
215
|
+
if (ui.ok) claims = await ui.json();
|
|
216
|
+
}
|
|
217
|
+
if (!claims?.sub) {
|
|
218
|
+
throw new Error("OIDC callback: id_token missing required `sub` claim");
|
|
219
|
+
}
|
|
220
|
+
const usernameClaim = this.config.values.usernameClaim;
|
|
221
|
+
const username = usernameClaim === "preferred_username" && claims.preferred_username || usernameClaim === "email" && claims.email || claims.sub;
|
|
222
|
+
const userId = `oidc:${claims.sub}`;
|
|
223
|
+
return {
|
|
224
|
+
userId,
|
|
225
|
+
username: String(username),
|
|
226
|
+
...claims.email ? { email: claims.email } : {},
|
|
227
|
+
...claims.name ? { displayName: claims.name } : {},
|
|
228
|
+
roles: [this.config.values.defaultRole]
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
validateLocalSessionToken(_token) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
// ─── HTTP route handlers ───────────────────────────────────────────
|
|
235
|
+
async handleStart(_req, reply) {
|
|
236
|
+
const state = crypto.randomBytes(16).toString("base64url");
|
|
237
|
+
try {
|
|
238
|
+
const url = await this.buildLoginUrl(state);
|
|
239
|
+
reply.code(302);
|
|
240
|
+
reply.header("Location", url);
|
|
241
|
+
reply.send("");
|
|
242
|
+
} catch (err) {
|
|
243
|
+
reply.code(500);
|
|
244
|
+
reply.send({ error: "OIDC start failed", message: errMsg(err) });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async handleCallbackRoute(req, reply) {
|
|
248
|
+
const query = req.query;
|
|
249
|
+
const code = query["code"];
|
|
250
|
+
const state = query["state"];
|
|
251
|
+
const errorParam = query["error"];
|
|
252
|
+
if (errorParam) {
|
|
253
|
+
reply.code(400);
|
|
254
|
+
reply.send({ error: "OIDC error", detail: errorParam, description: query["error_description"] });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (!code || !state) {
|
|
258
|
+
reply.code(400);
|
|
259
|
+
reply.send({ error: "Missing code or state" });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const result = await this.handleCallback(code, state);
|
|
264
|
+
const params = new URLSearchParams({
|
|
265
|
+
userId: result.userId,
|
|
266
|
+
username: result.username,
|
|
267
|
+
...result.email ? { email: result.email } : {},
|
|
268
|
+
...result.displayName ? { displayName: result.displayName } : {},
|
|
269
|
+
roles: result.roles.join(","),
|
|
270
|
+
provider: "auth-oidc"
|
|
271
|
+
});
|
|
272
|
+
reply.code(302);
|
|
273
|
+
reply.header("Location", `/api/auth/sso/finish?${params.toString()}`);
|
|
274
|
+
reply.send("");
|
|
275
|
+
} catch (err) {
|
|
276
|
+
reply.code(500);
|
|
277
|
+
reply.send({ error: "OIDC callback failed", message: errMsg(err) });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function decodeJwtPayload(jwt) {
|
|
282
|
+
const parts = jwt.split(".");
|
|
283
|
+
if (parts.length !== 3) return null;
|
|
284
|
+
try {
|
|
285
|
+
const payload = parts[1];
|
|
286
|
+
const padded = payload + "=".repeat((4 - payload.length % 4) % 4);
|
|
287
|
+
const buf = Buffer.from(padded, "base64url");
|
|
288
|
+
return JSON.parse(buf.toString("utf-8"));
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
export {
|
|
294
|
+
AuthOidcAddon,
|
|
295
|
+
AuthOidcAddon as default
|
|
296
|
+
};
|
|
297
|
+
//# sourceMappingURL=auth-oidc.addon.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-oidc.addon.mjs","sources":["../src/auth-oidc.addon.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return -- BaseAddon<TConfig>.config.values is typed-erased in the consumer's type tree under projectService context (the cap-router union resolves the generic at the call site, not in the addon's own dist). All sites flagged here are reads/writes of well-typed local fields; runtime contract is validated by Zod via globalSettingsSchema. */\n/**\n * Generic OpenID Connect (OIDC) authentication provider.\n *\n * Registers two capabilities:\n * - `auth-provider` (collection) — the standard CamStack auth surface.\n * `getLoginUrl({state})` returns the IdP's authorization URL with\n * state + PKCE; `handleCallback({code, state})` exchanges the code\n * for tokens and returns AuthResult.\n * - `addon-routes` (collection) — `/start` (302 to IdP) and\n * `/callback` (token exchange + redirect to admin UI with the\n * local session token in the URL fragment).\n *\n * Flow (login):\n * 1. Browser hits `/api/auth/sso/auth-oidc/start` (proxy to\n * `/addon/auth-oidc/start`)\n * 2. Addon redirects to `<issuer>/authorize?...&state=...&code_challenge=...`\n * 3. User authenticates at the IdP\n * 4. IdP redirects back to `/addon/auth-oidc/callback?code=...&state=...`\n * 5. Addon exchanges code for id_token + access_token, decodes claims,\n * mints local CamStack session, redirects to `/admin#token=...`\n *\n * Configuration: per-instance via global addon settings (issuer URL,\n * client ID/secret, scopes, displayName). Multiple OIDC providers can\n * coexist by installing this addon multiple times under different IDs.\n *\n * Production-readiness gaps (documented for follow-up):\n * - ID-token signature is NOT verified against JWKS. The token is\n * decoded as base64-url-JSON and trusted because the code came\n * from the IdP redirect (state param is verified). Add\n * `jose.jwtVerify(idToken, JWKS)` for full RFC 7515 compliance.\n * - State + PKCE are stored in-memory keyed by state value with a\n * 5-minute TTL. Survives addon restart only if the user retries\n * within the window. For production multi-replica deploys, move\n * to a settings-backed store.\n * - `nonce` parameter not yet implemented. Mitigate replay by\n * validating `aud` and `iss` from id_token claims.\n */\nimport * as crypto from 'node:crypto'\nimport {\n BaseAddon,\n authProviderCapability,\n addonRoutesCapability,\n errMsg,\n type ProviderRegistration,\n type IAuthProvider,\n type IAddonRouteProvider,\n type IAddonHttpRoute,\n type AddonHttpRequest,\n type AddonHttpReply,\n} from '@camstack/types'\n\ninterface OidcConfig {\n /** Display name shown on the login button + admin UI (\"Log in with Acme\"). */\n readonly displayName: string\n /** Operator-facing icon hint (lucide-react icon name). Defaults to 'shield-check'. */\n readonly icon: string\n /**\n * Issuer base URL — discovery document MUST live at\n * `<issuer>/.well-known/openid-configuration`. Examples:\n * - https://accounts.google.com\n * - https://login.microsoftonline.com/<tenant>/v2.0\n * - https://your-keycloak.example.com/realms/master\n */\n readonly issuerUrl: string\n /** OAuth2 client ID issued by the IdP. */\n readonly clientId: string\n /** OAuth2 client secret. Server-side only — never exposed to the browser. */\n readonly clientSecret: string\n /**\n * Public callback URL the IdP will redirect to. Must match what's\n * configured at the IdP. Default: `<server-origin>/addon/auth-oidc/callback`.\n * Override when the server is behind a reverse proxy with a different\n * public-facing origin (e.g. `https://hub.example.com/addon/auth-oidc/callback`).\n */\n readonly redirectUri: string\n /** Space-separated OAuth scopes. Default: 'openid profile email'. */\n readonly scopes: string\n /**\n * Scopes / claims used to derive the local username. The first\n * matching claim wins. Default order: preferred_username → email → sub.\n */\n readonly usernameClaim: 'preferred_username' | 'email' | 'sub'\n /** Default role assigned to first-time SSO users. */\n readonly defaultRole: 'viewer' | 'admin' | 'super_admin'\n}\n\nconst DEFAULT_CONFIG: OidcConfig = {\n displayName: 'OpenID Connect',\n icon: 'shield-check',\n issuerUrl: '',\n clientId: '',\n clientSecret: '',\n redirectUri: '',\n scopes: 'openid profile email',\n usernameClaim: 'preferred_username',\n defaultRole: 'viewer',\n}\n\ninterface DiscoveryDocument {\n readonly issuer: string\n readonly authorization_endpoint: string\n readonly token_endpoint: string\n readonly userinfo_endpoint?: string\n readonly jwks_uri?: string\n}\n\ninterface PendingState {\n readonly codeVerifier: string\n readonly createdAt: number\n}\n\ninterface OidcTokenResponse {\n readonly access_token: string\n readonly id_token?: string\n readonly token_type?: string\n readonly expires_in?: number\n readonly refresh_token?: string\n}\n\ninterface OidcClaims {\n readonly sub: string\n readonly iss?: string\n readonly aud?: string | readonly string[]\n readonly email?: string\n readonly preferred_username?: string\n readonly name?: string\n}\n\nconst STATE_TTL_MS = 5 * 60_000\n\nexport class AuthOidcAddon extends BaseAddon<OidcConfig> {\n private discovery: DiscoveryDocument | null = null\n private discoveryFetchedAt = 0\n private readonly pendingStates = new Map<string, PendingState>()\n\n // Provider metadata — read by the auth-orchestrator when the admin\n // UI calls `authentication.listProviders`. Static fields on the\n // provider object so the orchestrator's coercion picks them up.\n readonly displayName: string\n readonly kind = 'oidc' as const\n readonly icon: string\n readonly hasRedirectFlow = true\n readonly hasCredentialFlow = false\n\n constructor() {\n super({ ...DEFAULT_CONFIG })\n this.displayName = DEFAULT_CONFIG.displayName\n this.icon = DEFAULT_CONFIG.icon\n }\n\n protected async onInitialize(): Promise<ProviderRegistration[]> {\n // Override the static metadata with the configured displayName so\n // the orchestrator picks up the operator-chosen label.\n Object.assign(this, {\n displayName: this.config.values.displayName || DEFAULT_CONFIG.displayName,\n icon: this.config.values.icon || DEFAULT_CONFIG.icon,\n })\n\n const authProvider: IAuthProvider = {\n validateCredentials: async () => null, // OIDC has no credential flow\n validateToken: async ({ token }) => this.validateLocalSessionToken(token),\n getLoginUrl: async ({ state }) => this.buildLoginUrl(state),\n handleCallback: async (params) => {\n const code = params['code']\n const state = params['state']\n if (!code || !state) {\n throw new Error('Missing code or state in OIDC callback')\n }\n return this.handleCallback(code, state)\n },\n }\n\n const routes: IAddonHttpRoute[] = [\n {\n method: 'GET',\n path: '/start',\n access: 'public',\n description: 'Begin OIDC redirect login flow',\n handler: async (req, reply) => this.handleStart(req, reply),\n },\n {\n method: 'GET',\n path: '/callback',\n access: 'public',\n description: 'OIDC redirect callback — exchanges code for tokens',\n handler: async (req, reply) => this.handleCallbackRoute(req, reply),\n },\n ]\n const routeProvider: IAddonRouteProvider = {\n id: 'auth-oidc',\n getRoutes: () => routes,\n }\n\n this.ctx.logger.info('OIDC auth provider initialized', {\n meta: {\n displayName: this.displayName,\n issuerUrl: this.config.values.issuerUrl || '(not configured)',\n },\n })\n\n return [\n { capability: authProviderCapability, provider: authProvider as never },\n { capability: addonRoutesCapability, provider: routeProvider as never },\n ]\n }\n\n protected globalSettingsSchema() {\n return this.schema({\n sections: [{\n id: 'oidc',\n title: 'OIDC Provider Settings',\n description: 'Configure your IdP. The redirect URI must be registered at the IdP exactly as shown below.',\n columns: 1,\n fields: [\n this.field({ type: 'text', key: 'displayName', label: 'Display Name', description: 'Shown on the login button (e.g., \"Continue with Google\").', default: DEFAULT_CONFIG.displayName }),\n this.field({ type: 'text', key: 'icon', label: 'Icon', description: 'lucide-react icon name. Default: shield-check.', default: DEFAULT_CONFIG.icon }),\n this.field({ type: 'text', key: 'issuerUrl', label: 'Issuer URL', description: 'IdP base URL — discovery doc lives at <issuer>/.well-known/openid-configuration.', default: '' }),\n this.field({ type: 'text', key: 'clientId', label: 'Client ID', description: 'OAuth2 client ID issued by the IdP.', default: '' }),\n this.field({ type: 'text', key: 'clientSecret', label: 'Client Secret', description: 'OAuth2 client secret. Server-side only.', default: '' }),\n this.field({ type: 'text', key: 'redirectUri', label: 'Redirect URI', description: 'Public callback URL. Default: <origin>/addon/auth-oidc/callback. Must match the IdP-registered URI.', default: '' }),\n this.field({ type: 'text', key: 'scopes', label: 'Scopes', description: 'Space-separated OAuth scopes.', default: DEFAULT_CONFIG.scopes }),\n this.field({\n type: 'select',\n key: 'usernameClaim',\n label: 'Username Claim',\n description: 'Which OIDC claim to use as the local username.',\n default: DEFAULT_CONFIG.usernameClaim,\n options: [\n { value: 'preferred_username', label: 'preferred_username' },\n { value: 'email', label: 'email' },\n { value: 'sub', label: 'sub (user id)' },\n ],\n }),\n this.field({\n type: 'select',\n key: 'defaultRole',\n label: 'Default Role',\n description: 'Role assigned to first-time SSO users.',\n default: DEFAULT_CONFIG.defaultRole,\n options: [\n { value: 'viewer', label: 'Viewer' },\n { value: 'admin', label: 'Admin' },\n { value: 'super_admin', label: 'Super Admin' },\n ],\n }),\n ],\n }],\n })\n }\n\n // ─── OIDC discovery + URL building ─────────────────────────────────\n\n /**\n * Fetch + cache the IdP's discovery document. Refreshes every hour\n * (issuer config rarely changes; refresh window keeps us correct\n * without hammering on every login).\n */\n private async getDiscovery(): Promise<DiscoveryDocument> {\n if (this.discovery && Date.now() - this.discoveryFetchedAt < 60 * 60_000) {\n return this.discovery\n }\n const issuer = this.config.values.issuerUrl?.replace(/\\/+$/, '')\n if (!issuer) throw new Error('OIDC issuerUrl not configured')\n const url = `${issuer}/.well-known/openid-configuration`\n const res = await fetch(url)\n if (!res.ok) {\n throw new Error(`OIDC discovery failed: ${res.status} ${res.statusText}`)\n }\n const doc = (await res.json()) as DiscoveryDocument\n if (!doc.authorization_endpoint || !doc.token_endpoint) {\n throw new Error('OIDC discovery response missing required endpoints')\n }\n this.discovery = doc\n this.discoveryFetchedAt = Date.now()\n return doc\n }\n\n private buildRedirectUri(): string {\n const configured = this.config.values.redirectUri?.trim()\n if (configured) return configured\n // Fallback: server origin from env. In a multi-host deploy the\n // operator MUST set redirectUri explicitly.\n const origin = process.env['CAMSTACK_PUBLIC_ORIGIN'] || 'https://localhost:4443'\n return `${origin.replace(/\\/+$/, '')}/addon/auth-oidc/callback`\n }\n\n private async buildLoginUrl(state: string): Promise<string> {\n const doc = await this.getDiscovery()\n\n // PKCE: random code_verifier, S256 hash → code_challenge.\n const codeVerifier = crypto.randomBytes(32).toString('base64url')\n const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')\n\n this.pruneExpiredStates()\n this.pendingStates.set(state, { codeVerifier, createdAt: Date.now() })\n\n const params = new URLSearchParams({\n response_type: 'code',\n client_id: this.config.values.clientId,\n redirect_uri: this.buildRedirectUri(),\n scope: this.config.values.scopes || DEFAULT_CONFIG.scopes,\n state,\n code_challenge: codeChallenge,\n code_challenge_method: 'S256',\n })\n return `${doc.authorization_endpoint}?${params.toString()}`\n }\n\n private pruneExpiredStates(): void {\n const now = Date.now()\n for (const [k, v] of this.pendingStates.entries()) {\n if (now - v.createdAt > STATE_TTL_MS) this.pendingStates.delete(k)\n }\n }\n\n // ─── Callback / token exchange ─────────────────────────────────────\n\n private async handleCallback(code: string, state: string): Promise<{\n userId: string\n username: string\n email?: string\n displayName?: string\n roles: readonly string[]\n }> {\n const pending = this.pendingStates.get(state)\n if (!pending) {\n throw new Error('Unknown or expired OIDC state — login session timed out, please retry')\n }\n this.pendingStates.delete(state)\n\n const doc = await this.getDiscovery()\n const tokenRes = await fetch(doc.token_endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n Accept: 'application/json',\n },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code,\n redirect_uri: this.buildRedirectUri(),\n client_id: this.config.values.clientId,\n client_secret: this.config.values.clientSecret,\n code_verifier: pending.codeVerifier,\n }),\n })\n if (!tokenRes.ok) {\n const text = await tokenRes.text().catch(() => '')\n throw new Error(`OIDC token exchange failed: ${tokenRes.status} ${tokenRes.statusText} — ${text.slice(0, 200)}`)\n }\n const tokens = (await tokenRes.json()) as OidcTokenResponse\n\n // Decode id_token claims (no signature verification — see header\n // doc comment). For now we trust the token because it arrived via\n // the back-channel from a verified state-bound exchange. PRODUCTION:\n // call `jose.jwtVerify(id_token, await jose.createRemoteJWKSet(jwks_uri))`.\n let claims: OidcClaims | null = null\n if (tokens.id_token) {\n claims = decodeJwtPayload(tokens.id_token) as OidcClaims | null\n }\n // Fallback: query userinfo_endpoint with the access token\n if (!claims && doc.userinfo_endpoint) {\n const ui = await fetch(doc.userinfo_endpoint, {\n headers: { Authorization: `Bearer ${tokens.access_token}` },\n })\n if (ui.ok) claims = (await ui.json()) as OidcClaims\n }\n if (!claims?.sub) {\n throw new Error('OIDC callback: id_token missing required `sub` claim')\n }\n\n const usernameClaim = this.config.values.usernameClaim\n const username =\n (usernameClaim === 'preferred_username' && claims.preferred_username) ||\n (usernameClaim === 'email' && claims.email) ||\n claims.sub\n\n const userId = `oidc:${claims.sub}`\n return {\n userId,\n username: String(username),\n ...(claims.email ? { email: claims.email } : {}),\n ...(claims.name ? { displayName: claims.name } : {}),\n roles: [this.config.values.defaultRole],\n }\n }\n\n private validateLocalSessionToken(_token: string): null {\n // The local CamStack session token is minted by the host's auth\n // subsystem after `handleCallback` returns. Token verification\n // remains the responsibility of `local-auth` / the auth-manager;\n // this provider only mints AuthResult.\n return null\n }\n\n // ─── HTTP route handlers ───────────────────────────────────────────\n\n private async handleStart(_req: AddonHttpRequest, reply: AddonHttpReply): Promise<void> {\n const state = crypto.randomBytes(16).toString('base64url')\n try {\n const url = await this.buildLoginUrl(state)\n reply.code(302)\n reply.header('Location', url)\n reply.send('')\n } catch (err) {\n reply.code(500)\n reply.send({ error: 'OIDC start failed', message: errMsg(err) })\n }\n }\n\n private async handleCallbackRoute(req: AddonHttpRequest, reply: AddonHttpReply): Promise<void> {\n const query = req.query as Record<string, string | undefined>\n const code = query['code']\n const state = query['state']\n const errorParam = query['error']\n\n if (errorParam) {\n reply.code(400)\n reply.send({ error: 'OIDC error', detail: errorParam, description: query['error_description'] })\n return\n }\n if (!code || !state) {\n reply.code(400)\n reply.send({ error: 'Missing code or state' })\n return\n }\n\n try {\n const result = await this.handleCallback(code, state)\n // The host's auth subsystem owns local session minting. We\n // signal success by redirecting to a known admin endpoint with\n // the OIDC result encoded in the query — the server's auth\n // route handler picks it up, mints a JWT, and redirects to the\n // admin UI with `#token=...`.\n const params = new URLSearchParams({\n userId: result.userId,\n username: result.username,\n ...(result.email ? { email: result.email } : {}),\n ...(result.displayName ? { displayName: result.displayName } : {}),\n roles: result.roles.join(','),\n provider: 'auth-oidc',\n })\n reply.code(302)\n reply.header('Location', `/api/auth/sso/finish?${params.toString()}`)\n reply.send('')\n } catch (err) {\n reply.code(500)\n reply.send({ error: 'OIDC callback failed', message: errMsg(err) })\n }\n }\n}\n\nexport default AuthOidcAddon\n\n/**\n * Decode a JWT's payload as JSON without verifying its signature.\n *\n * **NOT SAFE for general token validation** — only used here because\n * the id_token arrives via the back-channel token exchange against a\n * state-bound code, which gives us implicit transport-level trust. A\n * production-grade implementation must use `jose.jwtVerify` against\n * the issuer's published JWKS.\n */\nfunction decodeJwtPayload(jwt: string): unknown {\n const parts = jwt.split('.')\n if (parts.length !== 3) return null\n try {\n const payload = parts[1]!\n const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4)\n const buf = Buffer.from(padded, 'base64url')\n return JSON.parse(buf.toString('utf-8'))\n } catch {\n return null\n }\n}\n"],"names":[],"mappings":"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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
|
|
3
|
+
const authOidc_addon = require("./auth-oidc.addon.js");
|
|
4
|
+
exports.AuthOidcAddon = authOidc_addon.AuthOidcAddon;
|
|
5
|
+
exports.default = authOidc_addon.AuthOidcAddon;
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@camstack/addon-auth-oidc",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenID Connect (OIDC) authentication provider for CamStack — Google, Microsoft, Okta, Keycloak, and any standards-compliant OIDC IdP.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"camstack",
|
|
7
|
+
"addon",
|
|
8
|
+
"camstack-addon",
|
|
9
|
+
"auth",
|
|
10
|
+
"oidc",
|
|
11
|
+
"sso"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/camstack/server"
|
|
17
|
+
},
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"module": "./dist/index.mjs",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.mjs",
|
|
25
|
+
"require": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"camstack": {
|
|
30
|
+
"displayName": "OIDC Authentication",
|
|
31
|
+
"addons": [
|
|
32
|
+
{
|
|
33
|
+
"id": "auth-oidc",
|
|
34
|
+
"name": "OpenID Connect",
|
|
35
|
+
"description": "Standards-compliant OIDC authentication. Configure issuer URL + client credentials to enable SSO via Google, Microsoft, Okta, Keycloak, etc.",
|
|
36
|
+
"entry": "./dist/auth-oidc.addon.js",
|
|
37
|
+
"execution": {
|
|
38
|
+
"placement": "hub-only"
|
|
39
|
+
},
|
|
40
|
+
"capabilities": [
|
|
41
|
+
{
|
|
42
|
+
"name": "auth-provider"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "addon-routes"
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"files": [
|
|
52
|
+
"dist"
|
|
53
|
+
],
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "vite build",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"publish": "npm publish --access public"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@camstack/types": "^0.1.0"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"zod": "^4.3.6"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@camstack/types": "*",
|
|
67
|
+
"tsup": "^8.0.0",
|
|
68
|
+
"typescript": "~5.9.0"
|
|
69
|
+
}
|
|
70
|
+
}
|