feat: add customizable OIDC login button text and icon (#7609)

* feat: add customizable OIDC login button text and update related configurations * feat(oidc): add support for custom remote icon URLs for OIDC login button * refactor: move isPrivateAddress from webserver.js to common.js using ipcheck * fix: declare oidcButton variables to prevent ReferenceError in strict mode * fix: add missing semicolons in OIDC button customization code * fix: simplify validateUrl to basic http/https check for admin-configured URLs * revert: move isPrivateAddress from common.js back to webserver.js * fix: pass proxy agent to validateRemoteImage for proxy-required environments * fix: validate min and max length for buttonText option (1-128 chars) * fix: correct casing for authStrategies in OIDC custom icon URL documentation * fix: extend validateRemoteImage to support ICO file format * fix: add support for high-DPI icons with buttonIconUrl2x in OIDC configuration * fix: update allowed image formats to include SVG in remote image validation and documentation * fix: update button text to use hyphenation for consistency in OIDC sign-in messages * fix: update OIDC button tooltip description to clarify character limits * fix: add missing newline before checkPasswordRequirements function for code readability * fix: update OIDC login button icon customization instructions for clarity and consistency * fix: remove require('url') in function as its already available at the module level Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: update button text to use hyphenation for consistency in OpenID Connect login messages * fix: update OIDC login button text examples to be consistent * fix: lowercase property names to correctly grab them at this point * fix: allow custom OIDC button icon URLs in Content Security Policy * fix: add background color to OIDC button images for better visibility with transparent icons * fix: update OIDC button titles to retain translation ability for default titles --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

Nick Szittai committed Feb 18, 2026 at 12:23 UTC fce9d32a15bae0a9663775e2d021552d0e10368d
9 files changed +398 -25
common.js
+196
@@ -17,6 +17,7 @@
17 const fs = require('fs');
18 const crypto = require('crypto');
19 const path = require('path');
20 +const { URL } = require('url');
21
22 // Binary encoding and decoding functions
23 module.exports.ReadShort = function (v, p) { return (v.charCodeAt(p) << 8) + v.charCodeAt(p + 1); };
@@ -216,6 +217,201 @@ module.exports.validateEmailDomain = function(email, allowedDomains) {
217
218 return true;
219 }
220 +
221 +// Validate that a string is a parseable http or https URL with a hostname
222 +module.exports.validateUrl = function (url) {
223 + if (!module.exports.validateString(url, 1, 4096)) return false;
224 + try {
225 + const u = new URL(url);
226 + const scheme = (u.protocol || '').replace(/:$/, '').toLowerCase();
227 + if (scheme !== 'http' && scheme !== 'https') return false;
228 + if (!u.hostname || u.hostname.length === 0) return false;
229 + return true;
230 + } catch (ex) {
231 + return false;
232 + }
233 +}
234 +
235 +// Validate a remote image URL by checking headers and magic bytes (PNG/JPEG/GIF/WEBP/ICO)
236 +// Returns a Promise<boolean> that always resolves to true (valid image) or false (invalid/error) and never rejects.
237 +module.exports.validateRemoteImage = function (url, options) {
238 + options = options || {};
239 + const timeoutMs = (typeof options.timeoutMs === 'number') ? options.timeoutMs : 5000;
240 + const maxHeadBytes = (typeof options.maxHeadBytes === 'number') ? options.maxHeadBytes : 16384; // 16 KB
241 + const maxContentLength = (typeof options.maxContentLength === 'number') ? options.maxContentLength : (5 * 1024 * 1024); // 5 MB
242 + const allowedMimes = options.allowedMimes || ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/x-icon', 'image/vnd.microsoft.icon', 'image/svg+xml'];
243 + const agent = options.agent || undefined;
244 + // This function MUST always resolve to boolean true/false and never reject.
245 + return new Promise((resolve) => {
246 + try {
247 + if (!module.exports.validateUrl(url)) return resolve(false);
248 +
249 + const http = require('http');
250 + const https = require('https');
251 +
252 + function doRequest(method, reqUrl, headers, redirectsLeft, cb) {
253 + try {
254 + const u = new URL(reqUrl);
255 + const lib = (u.protocol === 'https:') ? https : http;
256 + const reqOpts = { method: method, headers: headers || {}, timeout: timeoutMs };
257 + if (agent) { reqOpts.agent = agent; }
258 + const req = lib.request(u, reqOpts, (res) => {
259 + // Follow redirects (3xx)
260 + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
261 + res.resume();
262 + const redirectUrl = new URL(res.headers.location, u).toString();
263 + // Re-validate each redirect target
264 + if (!module.exports.validateUrl(redirectUrl)) {
265 + return cb(new Error('Invalid redirect URL'));
266 + }
267 + return doRequest(method, redirectUrl, headers, redirectsLeft - 1, cb);
268 + }
269 + cb(null, res, u.toString());
270 + });
271 + req.on('error', (e) => cb(e));
272 + req.on('timeout', () => { req.destroy(new Error('timeout')); });
273 + req.end();
274 + } catch (ex) { cb(ex); }
275 + }
276 +
277 + // Centralized magic-byte detector
278 + function detectBuffer(b) {
279 + if (!b || b.length < 4) return null;
280 + // PNG
281 + if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47 && b[4] === 0x0D && b[5] === 0x0A && b[6] === 0x1A && b[7] === 0x0A) return { mime: 'image/png', ext: 'png' };
282 + // JPEG
283 + if (b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF) return { mime: 'image/jpeg', ext: 'jpg' };
284 + // GIF
285 + if (b.length >= 6 && b.slice(0, 3).toString('ascii') === 'GIF') return { mime: 'image/gif', ext: 'gif' };
286 + // WEBP (RIFF....WEBP)
287 + if (b.length >= 12 && b.slice(0, 4).toString('ascii') === 'RIFF' && b.slice(8, 12).toString('ascii') === 'WEBP') return { mime: 'image/webp', ext: 'webp' };
288 + // ICO (00 00 01 00)
289 + if (b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && b[3] === 0x00) return { mime: 'image/x-icon', ext: 'ico' };
290 + // SVG (text-based XML; look for '<svg' or '<?xml' after optional whitespace/BOM)
291 + try {
292 + var svgHead = b.slice(0, Math.min(b.length, 256)).toString('utf8').trimStart();
293 + if (svgHead.charCodeAt(0) === 0xFEFF) { svgHead = svgHead.slice(1); } // strip BOM
294 + if (svgHead.startsWith('<svg') || svgHead.startsWith('<?xml')) return { mime: 'image/svg+xml', ext: 'svg' };
295 + } catch (e) {}
296 + return null;
297 + }
298 +
299 + // Inspect an incoming GET response stream for image magic bytes.
300 + function inspectStreamForImage(getRes) {
301 + return new Promise((resolveInspect) => {
302 + try {
303 + const chunks = [];
304 + let length = 0;
305 + let timedOut = false;
306 + let finished = false;
307 + const to = setTimeout(() => { timedOut = true; try { getRes.destroy(new Error('timeout')); } catch (e) {} }, timeoutMs + 1000);
308 +
309 + function finalize(result) {
310 + if (finished) return;
311 + finished = true;
312 + clearTimeout(to);
313 + try { getRes.removeAllListeners('data'); getRes.removeAllListeners('end'); getRes.removeAllListeners('close'); getRes.removeAllListeners('error'); } catch (e) {}
314 + try { return resolveInspect(result); } catch (e) { return; }
315 + }
316 +
317 + getRes.on('data', (chunk) => {
318 + if (timedOut || finished) return;
319 + if (length < maxHeadBytes) {
320 + const remaining = maxHeadBytes - length;
321 + if (chunk.length <= remaining) {
322 + chunks.push(chunk);
323 + length += chunk.length;
324 + } else {
325 + chunks.push(chunk.slice(0, remaining));
326 + length += remaining;
327 + }
328 + }
329 + if (length >= maxHeadBytes) {
330 + try {
331 + const buf = Buffer.concat(chunks, Math.min(length, maxHeadBytes));
332 + const det = detectBuffer(buf);
333 + if (!det) finalize(false);
334 + else if (allowedMimes.indexOf(det.mime) === -1) finalize(false);
335 + else finalize(true);
336 + } catch (ex) { finalize(false); }
337 + try { getRes.destroy(); } catch (e) {}
338 + }
339 + });
340 +
341 + getRes.on('end', () => {
342 + if (finished) return;
343 + try {
344 + const buf = Buffer.concat(chunks, Math.min(length, maxHeadBytes));
345 + const det = detectBuffer(buf);
346 + if (!det) finalize(false);
347 + else if (allowedMimes.indexOf(det.mime) === -1) finalize(false);
348 + else finalize(true);
349 + } catch (ex) { finalize(false); }
350 + });
351 +
352 + getRes.on('close', () => { if (!finished) finalize(false); });
353 + getRes.on('error', (e) => { if (!finished) finalize(false); });
354 + } catch (ex) { try { return resolveInspect(false); } catch (e) {} }
355 + });
356 + }
357 +
358 + // First do a HEAD to validate content-type/length. Some hosts/CDNs don't
359 + // support HEAD (returning 405/403/501) even though GET would work. In
360 + // that case, fall back to a small ranged GET to validate magic bytes.
361 + doRequest('HEAD', url, { 'User-Agent': 'MeshCentral/validateRemoteImage' }, 5, (err, headRes, finalUrl) => {
362 + // If doRequest failed to produce a finalUrl (network error),
363 + // fall back to the original requested URL to allow GET to be
364 + // attempted when HEAD fails with network/DNS issues.
365 + const effectiveUrl = finalUrl || url;
366 + try {
367 + // If HEAD errored or returned a client/server error
368 + if (err || (headRes && headRes.statusCode >= 400)) {
369 + // If the status suggests HEAD is not allowed/implemented or forbidden,
370 + // attempt a ranged GET fallback. For other 4xx/5xx responses, fail fast.
371 + const status = headRes && headRes.statusCode ? headRes.statusCode : 0;
372 + if (err || status === 405 || status === 501 || status === 403) {
373 + try { if (headRes && headRes.resume) headRes.resume(); } catch (e) {}
374 + const headers = { 'Range': 'bytes=0-' + (maxHeadBytes - 1), 'User-Agent': 'MeshCentral/validateRemoteImage' };
375 + // Ranged GET fallback: reuse the same GET inspection logic.
376 + // Use the effectiveUrl (finalUrl or original url) in case
377 + // the HEAD redirect wasn't available.
378 + doRequest('GET', effectiveUrl, headers, 5, (err2, getRes) => {
379 + try {
380 + if (err2) return resolve(false);
381 + if (getRes.statusCode !== 200 && getRes.statusCode !== 206) { getRes.resume(); return resolve(false); }
382 + inspectStreamForImage(getRes).then((r) => resolve(r)).catch(() => resolve(false));
383 + } catch (ex) { return resolve(false); }
384 + });
385 + return;
386 + }
387 + // Other HTTP errors from HEAD should fail fast
388 + try { if (headRes && headRes.resume) headRes.resume(); } catch (e) {}
389 + return resolve(false);
390 + }
391 + const ct = (headRes.headers['content-type'] || '').toLowerCase();
392 + const clen = parseInt(headRes.headers['content-length'] || '0', 10) || 0;
393 + if (clen > 0 && clen > maxContentLength) { headRes.resume(); return resolve(false); }
394 + if (ct && (allowedMimes.indexOf(ct.split(';')[0].trim()) === -1)) { headRes.resume(); return resolve(false); }
395 + // Now fetch a partial range to inspect magic bytes
396 + const headers = { 'Range': 'bytes=0-' + (maxHeadBytes - 1), 'User-Agent': 'MeshCentral/validateRemoteImage' };
397 + // Drain any potential body from the HEAD response so the
398 + // socket can be reused and not held open when issuing the GET.
399 + try { if (headRes && headRes.resume) headRes.resume(); } catch (e) {}
400 + doRequest('GET', effectiveUrl, headers, 5, (err2, getRes) => {
401 + try {
402 + if (err2) return resolve(false);
403 + if (getRes.statusCode !== 200 && getRes.statusCode !== 206) { getRes.resume(); return resolve(false); }
404 + inspectStreamForImage(getRes).then((r) => resolve(r)).catch(() => resolve(false));
405 + } catch (ex) { return resolve(false); }
406 + });
407 + } catch (ex) { return resolve(false); }
408 + });
409 + } catch (ex) {
410 + return resolve(false);
411 + }
412 + });
413 +}
414 +
415 // Check password requirements
416 module.exports.checkPasswordRequirements = function(password, requirements) {
417 if ((requirements == null) || (requirements == '') || (typeof requirements != 'object')) return true;
docs/docs/meshcentral/openidConnectStrategy.md
+65 -5
@@ -341,8 +341,9 @@ These are all the options that dont fit with the issuer or client, including the
341
342 | Name | Description | Default | Example | Required |
343 | -------- | ------------------------------------------------ | --------------------------------------------------------- | ----------------------------------- | -------- |
344 -| `scope` | A list of scopes to request from the issuer. | `"openid profile email"` | `["openid", "profile"]` | `false` |
345 -| `claims` | A group of claims to use instead of the defaults | Defauts to name of property except that `uuid` used `sub` | `"claims": {"uuid": "unique_name"}` | `false` |
344 +| `scope` | A list of scopes to request from the issuer. | `"openid profile email"` | `["openid", "profile"]` | `false` |
345 +| `claims` | A group of claims to use instead of the defaults | Defaults to name of property except that `uuid` used `sub` | `"claims": {"uuid": "unique_name"}` | `false` |
346 +| `buttonText` | Custom tooltip for the OIDC login button. Min 1 character. Max 128 characters. | `Sign-in using OpenID Connect` | `"buttonText": "Login to Custom SSO Brand"` | `false` |
347
348 #### *Advanced Config Example*
349
@@ -353,7 +354,8 @@ These are all the options that dont fit with the issuer or client, including the
354 "claims": {
355 "name": "nameOfUser",
356 "email": "publicEmail"
356 - }
357 + },
358 + "buttonText": "Login to Custom SSO Brand"
359 },
360 ```
361
@@ -384,12 +386,69 @@ As should be apparent by the name alone, the custom property does not need to be
386 },
387 "preset": { "type": "string", "enum": ["azure", "google"]},
388 "tenant_id": { "type": "string", "description": "REQUIRED FOR AZURE PRESET: Tenantid for Azure"},
387 - "customer_id": { "type": "string", "description": "REQUIRED FOR GOOGLE PRESET IF USING GROUPS: Customer ID from Google, should start with 'C'."}
389 + "customer_id": { "type": "string", "description": "REQUIRED FOR GOOGLE PRESET IF USING GROUPS: Customer ID from Google, should start with 'C'."},
390 + "buttonText": { "type": "string", "description": "Custom text for the OIDC login button. Default: 'Sign-in using OpenID Connect'."}
391 },
392 "additionalProperties": false
393 },
394 ```
395
396 +#### *Custom Login Button*
397 +
398 +MeshCentral allows customizing the tooltip/label text shown on OpenID Connect login buttons via the `authStrategies.oidc.custom` object. These settings are optional and can be defined per-domain and are part of the `custom` object described above.
399 +
400 +| Preset | Default button text |
401 +| --- | --- |
402 +| Generic (no preset) | `Sign-in using OpenID Connect` |
403 +| Google preset | `Sign-in with Google using OpenID Connect` |
404 +| Azure preset | `Sign-in with Azure using OpenID Connect` |
405 +
406 +Example:
407 +
408 +```json
409 +"authStrategies": {
410 + "oidc": {
411 + "custom": {
412 + "buttonText": "Login to Custom SSO Brand"
413 + }
414 + }
415 +}
416 +```
417 +
418 +These properties only affect the login page UI (button tooltip) and are safe to set for branding or clarity. If using a custom button text, it will not be translated when changing languages.
419 +
420 +#### *Custom Icon*
421 +
422 +The OIDC login button icon can be customized by setting a remote image URL in the domain config under `authStrategies.oidc.custom`. MeshCentral will validate the image at startup and use it for the login button. Example:
423 +
424 +```json
425 +"authStrategies": {
426 + "oidc": {
427 + "custom": {
428 + "buttonIconUrl": "https://cdn.example.com/icons/oidc32.png",
429 + "buttonIconUrl2x": "https://cdn.example.com/icons/oidc64.png",
430 + "buttonText": "Login to Custom SSO Brand"
431 + }
432 + }
433 +}
434 +```
435 +
436 +- **Server-side validation:** remote URLs are validated before use (timeouts, size limits, content-type checks and magic-byte inspection). If validation fails the UI falls back to the built-in local icons.
437 +- **Allowed formats:** PNG, JPEG, WEBP, GIF, ICO, and SVG.
438 +- **2x icon:** if `buttonIconUrl2x` is not set, `buttonIconUrl` is used for both standard and high-DPI displays.
439 +- **Fallback behavior:** if a remote image is not provided or validation fails, MeshCentral uses the default local icons (or any local overrides, see note below).
440 +
441 +!!! note "Alternative: local file replacement"
442 + You can also replace the login icons on disk instead of using a remote URL. Create `meshcentral-web/public/images/login` in the MeshCentral installation root and copy the original icons from `node_modules/meshcentral/public/images/login` into it. Replace the files you want to change while keeping the same filenames and sizes:
443 +
444 + | Icon | Filenames | Size / Notes |
445 + | --- | --- | --- |
446 + | Generic OIDC | `oidc32.png`, `oidc64.png` | 32x32, 64x64 (@2x) |
447 + | Google preset | `google32.png`, `google64.png` | 32x32, 64x64 (@2x) |
448 + | Azure preset | `azure32.png`, `azure64.png` | 32x32, 64x64 (@2x) |
449 +
450 + Restart MeshCentral (or hard-refresh your browser) after replacing files. For full whitelabeling guidance see the Web Branding section: [customization.md](customization.md#customizing-web-icons).
451 +
452 ### "Groups" Options
453
454 #### *Introduction*
@@ -512,7 +571,8 @@ If you notice above I forgot to add any preset related configs, however because
571 },
572 "custom": {
573 "preset": "google",
515 - "customer_id": "C46kyhmps"
574 + "customer_id": "C46kyhmps",
575 + "buttonText": "Custom Button Text"
576 },
577 "groups": {
578 "siteadmin": ["GroupA", "GroupB"],
meshcentral-config-schema.json
+18
@@ -3929,6 +3929,24 @@
3929 "google"
3930 ]
3931 },
3932 + "buttonText": {
3933 + "type": "string",
3934 + "minLength": 1,
3935 + "maxLength": 128,
3936 + "description": "Custom text for the OIDC login buttons shown on the login page (tooltip only). Minimum 1 character, maximum 128 characters."
3937 + },
3938 + "buttonIconUrl": {
3939 + "type": "string",
3940 + "description": "URL to a hosted icon for standard displays (supports PNG, JPEG, WEBP, GIF, ICO, SVG). The server validates remote images before use; if validation fails the UI falls back to local login icons. Used for both 1x and 2x if buttonIconUrl2x is not set.",
3941 + "format": "uri",
3942 + "pattern": "^[hH][tT][tT][pP][sS]?://"
3943 + },
3944 + "buttonIconUrl2x": {
3945 + "type": "string",
3946 + "description": "Optional URL to a higher resolution icon for 2x/high-DPI displays. If not set, buttonIconUrl is used for both. The server validates remote images before use.",
3947 + "format": "uri",
3948 + "pattern": "^[hH][tT][tT][pP][sS]?://"
3949 + },
3950 "tenant_id": {
3951 "type": "string",
3952 "description": "REQUIRED FOR AZURE PRESET: Tenantid for Azure"
sample-config-advanced.json
+5
@@ -568,6 +568,11 @@
568 "client_id": "00000000-0000-0000-0000-000000000000",
569 "client_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
570 },
571 + "_custom": {
572 + "_buttonText": "Sign-in using Custom Button Text",
573 + "_buttonIconUrl": "https://example.com/custom-icon.png",
574 + "_buttonIconUrl2x": "https://example.com/custom-icon-2x.png"
575 + },
576 "groups": {
577 "required": [ "groupA", "groupB", "groupC" ],
578 "siteadmin": [ "groupA" ],
translate/translate.json
+9 -9
@@ -69959,9 +69959,9 @@
69959 "zh-chs": "使用 OpenID Connect 登录",
69960 "zh-cht": "使用 OpenID Connect 登錄",
69961 "xloc": [
69962 - "login-mobile.handlebars->container->page_content->column_l->1->1->0->1->loginpanel->1->authStrategies->auth-oidc",
69963 - "login.handlebars->container->column_l->centralTable->1->0->logincell->loginpanel->1->authStrategies->auth-oidc",
69964 - "login2.handlebars->centralTable->1->0->logincell->loginpanel->loginpanelform->authStrategies->auth-oidc"
69962 + "login-mobile.handlebars->container->page_content->column_l->1->1->0->1->loginpanel->1->authStrategies",
69963 + "login.handlebars->container->column_l->centralTable->1->0->logincell->loginpanel->1->authStrategies",
69964 + "login2.handlebars->centralTable->1->0->logincell->loginpanel->loginpanelform->authStrategies"
69965 ]
69966 },
69967 {
@@ -70001,9 +70001,9 @@
70001 "nl": "Meld u aan met Azure OpenID Connect ",
70002 "uk": "Увійдіть за допомогою Azure завдяки OpenID Connect",
70003 "xloc": [
70004 - "login-mobile.handlebars->container->page_content->column_l->1->1->0->1->loginpanel->1->authStrategies->auth-oidc-azure",
70005 - "login.handlebars->container->column_l->centralTable->1->0->logincell->loginpanel->1->authStrategies->auth-oidc-azure",
70006 - "login2.handlebars->centralTable->1->0->logincell->loginpanel->loginpanelform->authStrategies->auth-oidc-azure"
70004 + "login-mobile.handlebars->container->page_content->column_l->1->1->0->1->loginpanel->1->authStrategies",
70005 + "login.handlebars->container->column_l->centralTable->1->0->logincell->loginpanel->1->authStrategies",
70006 + "login2.handlebars->centralTable->1->0->logincell->loginpanel->loginpanelform->authStrategies"
70007 ]
70008 },
70009 {
@@ -70012,9 +70012,9 @@
70012 "nl": "Meld u aan met Google OpenID Connect ",
70013 "uk": "Увійдіть за допомогою Google завдяки OpenID Connect",
70014 "xloc": [
70015 - "login-mobile.handlebars->container->page_content->column_l->1->1->0->1->loginpanel->1->authStrategies->auth-oidc-google",
70016 - "login.handlebars->container->column_l->centralTable->1->0->logincell->loginpanel->1->authStrategies->auth-oidc-google",
70017 - "login2.handlebars->centralTable->1->0->logincell->loginpanel->loginpanelform->authStrategies->auth-oidc-google"
70015 + "login-mobile.handlebars->container->page_content->column_l->1->1->0->1->loginpanel->1->authStrategies",
70016 + "login.handlebars->container->column_l->centralTable->1->0->logincell->loginpanel->1->authStrategies",
70017 + "login2.handlebars->centralTable->1->0->logincell->loginpanel->loginpanelform->authStrategies"
70018 ]
70019 },
70020 {
views/login-mobile.handlebars
+10 -3
@@ -91,9 +91,9 @@
91 <a id="auth-google" href="auth-google" style="display:none"><img src="images/login/google32.png" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using Google" /></a>
92 <a id="auth-github" href="auth-github" style="display:none"><img src="images/login/github32.png" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using GitHub" /></a>
93 <a id="auth-azure" href="auth-azure" style="display:none"><img src="images/login/azure32.png" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using Azure" /></a>
94 - <a id="auth-oidc" href="auth-oidc" style="display:none"><img src="images/login/oidc32.png" srcset="images/login/oidc64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using OpenID Connect" /></a>
95 - <a id="auth-oidc-azure" href="auth-oidc" style="display:none"><img src="images/login/azure32.png" srcset="images/login/azure64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in with Azure using OpenID Connect" /></a>
96 - <a id="auth-oidc-google" href="auth-oidc" style="display:none"><img src="images/login/google32.png" srcset="images/login/google64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in with Google using OpenID Connect" /></a>
94 + <a id="auth-oidc" href="auth-oidc" title="Sign-in using OpenID Connect" style="display:none"><img src="{{oidcButtonIcon}}" srcset="{{oidcButtonIcon2x}}" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer;background-color:white" /></a>
95 + <a id="auth-oidc-azure" href="auth-oidc" title="Sign-in with Azure using OpenID Connect" style="display:none"><img src="{{oidcButtonIcon}}" srcset="{{oidcButtonIcon2x}}" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer;background-color:white" /></a>
96 + <a id="auth-oidc-google" href="auth-oidc" title="Sign-in with Google using OpenID Connect" style="display:none"><img src="{{oidcButtonIcon}}" srcset="{{oidcButtonIcon2x}}" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer;background-color:white" /></a>
97 <a id="auth-jumpcloud" href="auth-jumpcloud" style="display:none"><img src="images/login/jumpcloud32.png" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using JumpCloud" /></a>
98 <a id="auth-intel" href="auth-intel" style="display:none"><img src="images/login/intel32.png" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using Intel" /></a>
99 <a id="auth-saml" href="auth-saml" style="display:none"><img src="images/login/generic32.png" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Single Sign-in" /></a>
@@ -420,6 +420,13 @@
420 if (authStrategies.indexOf('jumpcloud') >= 0) { QV('auth-jumpcloud', true); }
421 if (authStrategies.indexOf('intel') >= 0) { QV('auth-intel', true); }
422 if (authStrategies.indexOf('saml') >= 0) { QV('auth-saml', true); }
423 + // Override OIDC button title if custom text is configured by admin
424 + var oidcButtonText = '{{{oidcButtonText}}}';
425 + if (oidcButtonText != '') {
426 + Q('auth-oidc').title = oidcButtonText;
427 + Q('auth-oidc-azure').title = oidcButtonText;
428 + Q('auth-oidc-google').title = oidcButtonText;
429 + }
430 }
431
432 window.onresize = center;
views/login.handlebars
+10 -3
@@ -84,9 +84,9 @@
84 <a id="auth-google" href="auth-google" style="display:none"><img src="images/login/google32.png" srcset="images/login/google64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using Google" /></a>
85 <a id="auth-github" href="auth-github" style="display:none"><img src="images/login/github32.png" srcset="images/login/github64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using GitHub" /></a>
86 <a id="auth-azure" href="auth-azure" style="display:none"><img src="images/login/azure32.png" srcset="images/login/azure64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using Azure" /></a>
87 - <a id="auth-oidc" href="auth-oidc" style="display:none"><img src="images/login/oidc32.png" srcset="images/login/oidc64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using OpenID Connect" /></a>
88 - <a id="auth-oidc-azure" href="auth-oidc" style="display:none"><img src="images/login/azure32.png" srcset="images/login/azure64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in with Azure using OpenID Connect" /></a>
89 - <a id="auth-oidc-google" href="auth-oidc" style="display:none"><img src="images/login/google32.png" srcset="images/login/google64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in with Google using OpenID Connect" /></a>
87 + <a id="auth-oidc" href="auth-oidc" title="Sign-in using OpenID Connect" style="display:none"><img src="{{oidcButtonIcon}}" srcset="{{oidcButtonIcon2x}}" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer;background-color:white" /></a>
88 + <a id="auth-oidc-azure" href="auth-oidc" title="Sign-in with Azure using OpenID Connect" style="display:none"><img src="{{oidcButtonIcon}}" srcset="{{oidcButtonIcon2x}}" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer;background-color:white" /></a>
89 + <a id="auth-oidc-google" href="auth-oidc" title="Sign-in with Google using OpenID Connect" style="display:none"><img src="{{oidcButtonIcon}}" srcset="{{oidcButtonIcon2x}}" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer;background-color:white" /></a>
90 <a id="auth-jumpcloud" href="auth-jumpcloud" style="display:none"><img src="images/login/jumpcloud32.png" srcset="images/login/jumpcloud64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using JumpCloud" /></a>
91 <a id="auth-intel" href="auth-intel" style="display:none"><img src="images/login/intel32.png" srcset="images/login/intel64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using Intel" /></a>
92 <a id="auth-saml" href="auth-saml" style="display:none"><img src="images/login/generic32.png" srcset="images/login/generic64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Single Sign-in" /></a>
@@ -448,6 +448,13 @@
448 if (authStrategies.indexOf('jumpcloud') >= 0) { QV('auth-jumpcloud', true); }
449 if (authStrategies.indexOf('intel') >= 0) { QV('auth-intel', true); }
450 if (authStrategies.indexOf('saml') >= 0) { QV('auth-saml', true); }
451 + // Override OIDC button title if custom text is configured by admin
452 + var oidcButtonText = '{{{oidcButtonText}}}';
453 + if (oidcButtonText != '') {
454 + Q('auth-oidc').title = oidcButtonText;
455 + Q('auth-oidc-azure').title = oidcButtonText;
456 + Q('auth-oidc-google').title = oidcButtonText;
457 + }
458 }
459
460 // Display the welcome text
views/login2.handlebars
+10 -3
@@ -107,9 +107,9 @@
107 <a id="auth-google" href="auth-google" style="display:none"><img src="images/login/google32.png" srcset="images/login/google64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using Google" /></a>
108 <a id="auth-github" href="auth-github" style="display:none"><img src="images/login/github32.png" srcset="images/login/github64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using GitHub" /></a>
109 <a id="auth-azure" href="auth-azure" style="display:none"><img src="images/login/azure32.png" srcset="images/login/azure64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using Azure" /></a>
110 - <a id="auth-oidc" href="auth-oidc" style="display:none"><img src="images/login/oidc32.png" srcset="images/login/oidc64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using OpenID Connect" /></a>
111 - <a id="auth-oidc-azure" href="auth-oidc" style="display:none"><img src="images/login/azure32.png" srcset="images/login/azure64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in with Azure using OpenID Connect" /></a>
112 - <a id="auth-oidc-google" href="auth-oidc" style="display:none"><img src="images/login/google32.png" srcset="images/login/google64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in with Google using OpenID Connect" /></a>
110 + <a id="auth-oidc" href="auth-oidc" title="Sign-in using OpenID Connect" style="display:none"><img src="{{oidcButtonIcon}}" srcset="{{oidcButtonIcon2x}}" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer;background-color:white" /></a>
111 + <a id="auth-oidc-azure" href="auth-oidc" title="Sign-in with Azure using OpenID Connect" style="display:none"><img src="{{oidcButtonIcon}}" srcset="{{oidcButtonIcon2x}}" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer;background-color:white" /></a>
112 + <a id="auth-oidc-google" href="auth-oidc" title="Sign-in with Google using OpenID Connect" style="display:none"><img src="{{oidcButtonIcon}}" srcset="{{oidcButtonIcon2x}}" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer;background-color:white" /></a>
113 <a id="auth-jumpcloud" href="auth-jumpcloud" style="display:none"><img src="images/login/jumpcloud32.png" srcset="images/login/jumpcloud64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using JumpCloud" /></a>
114 <a id="auth-intel" href="auth-intel" style="display:none"><img src="images/login/intel32.png" srcset="images/login/intel64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Sign-in using Intel" /></a>
115 <a id="auth-saml" href="auth-saml" style="display:none"><img src="images/login/generic32.png" srcset="images/login/generic64.png 2x" width="32" height="32" style="margin-left:3px;margin-right:3px;border-radius:3px;box-shadow:2px 2px 5px black;cursor:pointer" title="Single Sign-in" /></a>
@@ -532,6 +532,13 @@
532 if (authStrategies.indexOf('jumpcloud') >= 0) { QV('auth-jumpcloud', true); }
533 if (authStrategies.indexOf('intel') >= 0) { QV('auth-intel', true); }
534 if (authStrategies.indexOf('saml') >= 0) { QV('auth-saml', true); }
535 + // Override OIDC button title if custom text is configured by admin
536 + var oidcButtonText = '{{{oidcButtonText}}}';
537 + if (oidcButtonText != '') {
538 + Q('auth-oidc').title = oidcButtonText;
539 + Q('auth-oidc-azure').title = oidcButtonText;
540 + Q('auth-oidc-google').title = oidcButtonText;
541 + }
542 }
543
544 validateCreate();
webserver.js
+75 -2
@@ -3500,6 +3500,36 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
3500 }
3501
3502 // Render the login page
3503 + // Allow configurable OIDC login button text via domain.authstrategies.oidc.custom
3504 + var oidcButtonIcon, oidcButtonIcon2x, oidcButtonText;
3505 + if (obj.common.validateObject(domain.authstrategies) && obj.common.validateObject(domain.authstrategies.oidc) && obj.common.validateObject(domain.authstrategies.oidc.custom)) {
3506 + if (obj.common.validateUrl(domain.authstrategies.oidc.custom.buttoniconurl)) {
3507 + oidcButtonIcon = domain.authstrategies.oidc.custom.buttoniconurl;
3508 + if (obj.common.validateUrl(domain.authstrategies.oidc.custom.buttoniconurl2x)) {
3509 + oidcButtonIcon2x = domain.authstrategies.oidc.custom.buttoniconurl2x + ' 2x';
3510 + } else {
3511 + oidcButtonIcon2x = domain.authstrategies.oidc.custom.buttoniconurl + ' 2x';
3512 + }
3513 + } else {
3514 + switch (domain.authstrategies.oidc.custom.preset) {
3515 + case 'azure':
3516 + oidcButtonIcon = "images/login/azure32.png";
3517 + oidcButtonIcon2x = "images/login/azure64.png 2x";
3518 + break;
3519 + case 'google':
3520 + oidcButtonIcon = "images/login/google32.png";
3521 + oidcButtonIcon2x = "images/login/google64.png 2x";
3522 + break;
3523 + default:
3524 + oidcButtonIcon = "images/login/oidc32.png";
3525 + oidcButtonIcon2x = "images/login/oidc64.png 2x";
3526 + }
3527 + }
3528 +
3529 + if (obj.common.validateString(domain.authstrategies.oidc.custom.buttontext, 1, 128)) {
3530 + oidcButtonText = domain.authstrategies.oidc.custom.buttontext;
3531 + }
3532 + }
3533 render(req, res,
3534 getRenderPage((domain.sitestyle >= 2) ? 'login2' : 'login', req, domain),
3535 getRenderArgs({
@@ -3551,6 +3581,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
3581 autofido: autofido,
3582 twoFactorCookieDays: twoFactorCookieDays,
3583 authStrategies: authStrategies.join(','),
3584 + oidcButtonText: oidcButtonText || '',
3585 + oidcButtonIcon: oidcButtonIcon || 'images/login/oidc32.png',
3586 + oidcButtonIcon2x: oidcButtonIcon2x || 'images/login/oidc64.png 2x',
3587 loginpicture: (typeof domain.loginpicture == 'string'),
3588 tokenTimeout: twoFactorTimeout, // Two-factor authentication screen timeout in milliseconds,
3589 renderLanguages: obj.renderLanguages,
@@ -6809,13 +6842,25 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
6842 if ((typeof domain.duo2factor == 'object') && (typeof domain.duo2factor.apihostname == 'string')) {
6843 duoSrc = domain.duo2factor.apihostname;
6844 }
6812 -
6845 +
6846 + // If a custom OIDC button icon URL is configured, allow its origin in img-src CSP
6847 + var extraImgSrc = '';
6848 + if (obj.common.validateObject(domain.authstrategies) && obj.common.validateObject(domain.authstrategies.oidc) && obj.common.validateObject(domain.authstrategies.oidc.custom)) {
6849 + const seen = {};
6850 + const urls = [domain.authstrategies.oidc.custom.buttoniconurl, domain.authstrategies.oidc.custom.buttoniconurl2x];
6851 + for (var k = 0; k < urls.length; k++) {
6852 + if (obj.common.validateUrl(urls[k])) {
6853 + try { const u = new URL(urls[k]); if (!seen[u.origin]) { extraImgSrc += ' ' + u.origin; seen[u.origin] = true; } } catch (e) {}
6854 + }
6855 + }
6856 + }
6857 +
6858 // Finish setup security headers
6859 const headers = {
6860 'Referrer-Policy': 'no-referrer',
6861 'X-XSS-Protection': '1; mode=block',
6862 'X-Content-Type-Options': 'nosniff',
6818 - 'Content-Security-Policy': "default-src 'none'; font-src 'self' fonts.gstatic.com data:; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' " + extraScriptSrc + "; connect-src 'self'" + geourl + selfurl + "; img-src 'self' blob: data:" + geourl + " data:; style-src 'self' 'unsafe-inline' fonts.googleapis.com; frame-src 'self' blob: mcrouter:" + extraFrameSrc + "; media-src 'self'; form-action 'self' " + duoSrc + "; manifest-src 'self'"
6863 + 'Content-Security-Policy': "default-src 'none'; font-src 'self' fonts.gstatic.com data:; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' " + extraScriptSrc + "; connect-src 'self'" + geourl + selfurl + "; img-src 'self' blob: data:" + geourl + extraImgSrc + " data:; style-src 'self' 'unsafe-inline' fonts.googleapis.com; frame-src 'self' blob: mcrouter:" + extraFrameSrc + "; media-src 'self'; form-action 'self' " + duoSrc + "; manifest-src 'self'"
6864 };
6865 if (req.headers['user-agent'] && (req.headers['user-agent'].indexOf('Chrome') >= 0)) { headers['Permissions-Policy'] = 'interest-cohort=()'; } // Remove Google's FLoC Network, only send this if Chrome browser
6866 if ((parent.config.settings.allowframing !== true) && (typeof parent.config.settings.allowframing !== 'string')) { headers['X-Frame-Options'] = 'sameorigin'; }
@@ -8046,6 +8091,34 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
8091 strategy.client = client.metadata
8092 strategy.obj.client = client
8093
8094 + // Validate OIDC Icon Url once and null it if it fails validation
8095 + if (obj.common.validateObject(strategy.custom) && obj.common.validateString(strategy.custom.buttoniconurl)) {
8096 + if (obj.common.validateUrl(strategy.custom.buttoniconurl)){
8097 + if (await obj.common.validateRemoteImage(strategy.custom.buttoniconurl, { agent: obj.httpsProxyAgent })) {
8098 + parent.debug('verbose', 'OIDC: Validated Icon URL and Image: ' + strategy.custom.buttoniconurl);
8099 + } else {
8100 + parent.debug('warning', 'OIDC: Icon URL and Image validation failed: ' + strategy.custom.buttoniconurl);
8101 + strategy.custom.buttoniconurl = null;
8102 + }
8103 + } else {
8104 + parent.debug('warning', 'OIDC: Invalid Icon URL: ' + strategy.custom.buttoniconurl);
8105 + strategy.custom.buttoniconurl = null;
8106 + }
8107 + }
8108 + // Validate OIDC 2x Icon Url once and null it if it fails validation
8109 + if (obj.common.validateObject(strategy.custom) && obj.common.validateString(strategy.custom.buttoniconurl2x)) {
8110 + if (obj.common.validateUrl(strategy.custom.buttoniconurl2x)){
8111 + if (await obj.common.validateRemoteImage(strategy.custom.buttoniconurl2x, { agent: obj.httpsProxyAgent })) {
8112 + parent.debug('verbose', 'OIDC: Validated 2x Icon URL and Image: ' + strategy.custom.buttoniconurl2x);
8113 + } else {
8114 + parent.debug('warning', 'OIDC: 2x Icon URL and Image validation failed: ' + strategy.custom.buttoniconurl2x);
8115 + strategy.custom.buttoniconurl2x = null;
8116 + }
8117 + } else {
8118 + parent.debug('warning', 'OIDC: Invalid 2x Icon URL: ' + strategy.custom.buttoniconurl2x);
8119 + strategy.custom.buttoniconurl2x = null;
8120 + }
8121 + }
8122 // Setup strategy and save configs for later
8123 passport.use('oidc-' + domain.id, new strategy.obj.openidClient.Strategy(strategy.options, oidcCallback));
8124 parent.config.domains[domain.id].authstrategies.oidc = strategy;