Skip to content

Commit be4b79f

Browse files
committed
Feature: Custom SSO Plugin command, to validate and transform request for prep for SSO login.
1 parent 9b9bb47 commit be4b79f

2 files changed

Lines changed: 242 additions & 1 deletion

File tree

docs/sso.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ Here are descriptions of all the SSO properties:
151151
| `replace_privileges` | Boolean | Set this to `true` to replace **all** the user's privileges with those mapped via `group_role_map` only. See [User Groups](#user-groups) below for details. |
152152
| `admin_bootstrap` | String | Temporarily assign full administrator privileges to a given user. This is used for bootstrapping the system on initial setup. See [Admin Bootstrap](#admin-bootstrap) for more. |
153153
| `logout_url` | String | Set this to the URL to redirect the user to after xyOps performs its own logout. See [Logging Out](#logging-out) below for details. |
154+
| `command` | String | Optional custom shell command to filter all incoming SSO requests and inject headers. See [Custom Command](#custom-command) below for details. |
154155

155156
### Header Map
156157

@@ -602,6 +603,129 @@ Finally, make sure you set your [IP Whitelist](#ip-whitelist) to only accept hea
602603
"whitelist": ["127.0.0.1", "::1/128"]
603604
```
604605

606+
## Custom Command
607+
608+
The SSO subsystem can optionally launch a custom shell command to filter and transform incoming requests. The idea is that command can read the request and construct proper headers to initiate the SSO flow. The new headers emitted by the command are injected back into the request as it is sent through SSO login. This is for complex integrations where a simple [header map](#header-map) will not suffice, and additional logic needs to take place. The command itself should be placed into the [SSO configuration](#configuration) object as a property named `command`. Example:
609+
610+
```json
611+
"command": "npx xyplug-sso-aws-alb@1.0.0"
612+
```
613+
614+
As with other xyOps Plugin types, communication with the command follows the [xyOps Wire Protocol](xywp.md). Request metadata is sent to the command process via JSON over STDIN, and the process is expected to emit JSON over STDOUT. See below for details.
615+
616+
### Command Input
617+
618+
When the SSO custom command is invoked, it is passed a JSON document on STDIN (compressed to a single line). This should contain everything needed to validate the request and construct the proper headers for SSO login. The following top-level properties will be present in the object:
619+
620+
| Property Name | Type | Description |
621+
|---------------|------|-------------|
622+
| `xy` | Number | Indicates the [xyOps Wire Protocol](xywp.md) version. Will be set to `1`. |
623+
| `type` | String | Indicates the type of action, which will be set to `sso`. |
624+
| `config` | Object | The complete [SSO Configuration](#configuration) object is included for the command to use. |
625+
| `method` | String | The request method, which should always be `GET`. |
626+
| `url` | String | The request URI path, which should always be `/`. |
627+
| `headers` | Object | The request headers object (all header names are lower-cased). |
628+
| `cookies` | Object | Any cookies found in the `Cookie` header are parsed and placed into this object. |
629+
| `id` | String | An internal ID for the request, used for logging. |
630+
| `ip` | String | The "public" IP address of the request (best effort guess). See [args.ip](https://github.com/jhuckaby/pixl-server-web#argsip). |
631+
| `ips` | Array | All the IPs of the request, including forwarded proxy IPs. See [args.ips](https://github.com/jhuckaby/pixl-server-web#argsips). |
632+
633+
Here is an example JSON document sent to the SSO command's STDIN (pretty-printed for display purposes):
634+
635+
```json
636+
{
637+
"xy": 1,
638+
"type": "sso",
639+
"config": {
640+
/* Entire sso.json config contents here */
641+
},
642+
"method": "GET",
643+
"url": "/",
644+
"headers": {
645+
"host": "local.xyops.io:5523",
646+
"connection": "keep-alive",
647+
"sec-ch-ua": "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"",
648+
"sec-ch-ua-mobile": "?0",
649+
"sec-ch-ua-platform": "\"macOS\"",
650+
"upgrade-insecure-requests": "1",
651+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
652+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
653+
"sec-fetch-site": "none",
654+
"sec-fetch-mode": "navigate",
655+
"sec-fetch-user": "?1",
656+
"sec-fetch-dest": "document",
657+
"accept-encoding": "gzip, deflate, br, zstd",
658+
"accept-language": "en-US,en;q=0.9",
659+
"x-amzn-oidc-identity": "jhuckaby",
660+
"x-amzn-oidc-data": "BfAmkJ8BxEIeI4NVu4qpZn6usqkx8J5WKZvSikTS87ImJDhacUsV3wCPmS5RC58n8JDTwrx-90ns_FefwQRCQM",
661+
"ssl": 1,
662+
"https": 1
663+
},
664+
"cookies": {},
665+
"id": "r2",
666+
"ip": "127.0.0.1",
667+
"ips": [
668+
"127.0.0.1"
669+
]
670+
}
671+
```
672+
673+
### Command Output
674+
675+
After your custom command validates and processes the request, it needs to send output back to xyOps. This is done by way of a JSON document printed to STDOUT. It should be compressed onto one line, and contain the following top-level properties:
676+
677+
| Property Name | Type | Description |
678+
|---------------|------|-------------|
679+
| `xy` | Number | Indicates the [xyOps Wire Protocol](xywp.md) version. This must be set to `1`. |
680+
| `code` | Number | Zero (`0`) indicates success, any other value is an error. |
681+
| `description` | String | Optional error message, will be displayed to the user if `code` is non-zero. |
682+
| `headers` | Object | New headers to inject into the request for SSO login. |
683+
684+
Here is an example output (pretty-printed for display purposes):
685+
686+
```json
687+
{
688+
"xy": 1,
689+
"code": 0,
690+
"headers": {
691+
"x-forwarded-user": "jhuckaby",
692+
"x-forwarded-name": "Joseph Huckaby",
693+
"x-forwarded-email": "jhuckaby@example.com",
694+
"x-forwarded-groups": "pixlcore:owners"
695+
}
696+
}
697+
```
698+
699+
The idea here is that the command validates and parses the request, using whatever bits of information are required. In the above example it's these two headers which contain the encoded user information:
700+
701+
```
702+
"x-amzn-oidc-identity": "jhuckaby",
703+
"x-amzn-oidc-data": "BfAmkJ8BxEIeI4NVu4qpZn6usqkx8J5WKZvSikTS87ImJDhacUsV3wCPmS5RC58n8JDTwrx-90ns_FefwQRCQM",
704+
```
705+
706+
After decoding the data, the command then produces properly-formatted trusted headers (which match the [Header Map](#header-map)) to initiate the SSO login process:
707+
708+
```
709+
"x-forwarded-user": "jhuckaby",
710+
"x-forwarded-name": "Joseph Huckaby",
711+
"x-forwarded-email": "jhuckaby@example.com",
712+
"x-forwarded-groups": "pixlcore:owners"
713+
```
714+
715+
If something goes wrong and the request cannot be validated, your command should send back a non-zero `code` along with a `description` which will be logged and displayed to the user. Example:
716+
717+
```json
718+
{
719+
"xy": 1,
720+
"code": 1,
721+
"description": "Failed to validate request: Missing x-amzn-oidc-identity header"
722+
}
723+
```
724+
725+
### Command Debugging
726+
727+
To debug custom commands, set your [debug_level](config.md#debug_level) to `9`, and watch the `logs/SSO.log` log. It will contain the full command request and response, including raw STDOUT and STDERR.
728+
605729
## Live Production
606730

607731
In a production environment, it is crucial to ensure the security and reliability of the SSO implementation. Here is a checklist:

lib/sso.js

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
const fs = require('fs');
77
const Path = require('path');
88
const os = require('os');
9+
const cp = require('child_process');
910
const async = require("async");
1011
const Tools = require("pixl-tools");
1112
const ACL = require('pixl-acl');
13+
const noop = function() {};
1214

1315
class SSO {
1416

@@ -78,7 +80,6 @@ class SSO {
7880
var usermgr = this.usermgr;
7981
var sso = this.config.get('SSO');
8082

81-
this.logSSO(7, "Starting SSO auth flow", { uri: args.request.url, headers: this.debug ? args.request.headers : undefined });
8283
this.forceNoCacheResponse(args);
8384

8485
if (this.ssoWhitelist && !this.ssoWhitelist.check(args.request.socket.remoteAddress)) {
@@ -90,6 +91,13 @@ class SSO {
9091
return self.doSSOError('sso', "Server is not a primary conductor.", callback);
9192
}
9293

94+
// custom sso plugin command
95+
if (sso.command && !args._sso_command_response) {
96+
return this.execSSOCommand(args, callback);
97+
}
98+
99+
this.logSSO(5, "Starting SSO auth flow", { uri: args.request.url, headers: this.debug ? args.request.headers : undefined });
100+
93101
// see if user already has a valid session
94102
this.loadSession( args, function(err, session, user) {
95103
if (session && user) {
@@ -369,6 +377,115 @@ class SSO {
369377
} ); // request.get
370378
}
371379

380+
execSSOCommand(args, callback) {
381+
// run custom sso command to intercept request and inject headers
382+
var self = this;
383+
var sso = this.config.get('SSO');
384+
var request = args.request;
385+
386+
var hook_args = {};
387+
hook_args.xy = 1; // wire protocol version
388+
hook_args.type = 'sso'; // wire action
389+
hook_args.config = sso;
390+
hook_args.method = request.method;
391+
hook_args.url = request.url;
392+
hook_args.headers = request.headers;
393+
hook_args.cookies = args.cookies;
394+
hook_args.id = args.id;
395+
hook_args.ip = args.ip;
396+
hook_args.ips = args.ips;
397+
398+
var child_cmd = sso.command;
399+
var child_opts = {
400+
cwd: os.tmpdir(),
401+
env: Object.assign( {}, this.cleanEnv() ),
402+
timeout: 60 * 1000
403+
};
404+
405+
child_opts.env['XYOPS'] = this.server.__version;
406+
407+
var puid = this.config.getPath('default_plugin_credentials.sso.uid') || '';
408+
var pgid = this.config.getPath('default_plugin_credentials.sso.gid') || '';
409+
410+
if (puid && (puid != 0)) {
411+
var user_info = Tools.getpwnam( puid, true );
412+
if (user_info) {
413+
child_opts.uid = parseInt( user_info.uid );
414+
child_opts.gid = parseInt( user_info.gid );
415+
child_opts.env.USER = child_opts.env.USERNAME = user_info.username;
416+
child_opts.env.HOME = user_info.dir;
417+
child_opts.env.SHELL = user_info.shell;
418+
}
419+
else {
420+
return this.doSSOError('sso', "Could not determine user information for: " + puid, callback);
421+
}
422+
}
423+
if (pgid && (pgid != 0)) {
424+
var grp_info = Tools.getgrnam( pgid, true );
425+
if (grp_info) {
426+
child_opts.gid = grp_info.gid;
427+
}
428+
else {
429+
return this.doSSOError('sso', "Could not determine group information for: " + pgid, callback);
430+
}
431+
}
432+
433+
this.logSSO(5, "Calling SSO Plugin for request ID: " + args.id + ": " + child_cmd);
434+
435+
// sensitive data: only log at level 9
436+
this.logSSO(9, "Plugin input data for req: " + args.id, hook_args);
437+
438+
var child = cp.exec( child_cmd, child_opts, function(err, stdout, stderr) {
439+
// parse json from output
440+
var json = null;
441+
if (!err && stdout.match(/\S/)) {
442+
// parse last line only, to omit any noise from plugin
443+
try { json = JSON.parse( stdout.replace(/\r\n/g, "\n").trim().split(/\n/).pop() ); }
444+
catch (e) {
445+
err = new Error("JSON Parse Error: " + (e.message || e));
446+
err.code = 'json';
447+
}
448+
}
449+
450+
// sensitive data: only log at level 9
451+
self.logSSO(9, "Plugin raw response for req: " + args.id, { stdout, stderr });
452+
453+
var err_prefix = "SSO Plugin Error: ";
454+
if (err) {
455+
return self.doSSOError('sso', err_prefix + err, callback);
456+
}
457+
if (!json) {
458+
return self.doSSOError('sso', err_prefix + "No JSON found in response STDOUT", callback);
459+
}
460+
if (!json.xy) {
461+
return self.doSSOError('sso', err_prefix + "No XYWP format flag found in response JSON", callback);
462+
}
463+
if (json.code) {
464+
return self.doSSOError('sso', err_prefix + (json.description || json.code), callback);
465+
}
466+
if (!json.headers || !Tools.isaHash(json.headers)) {
467+
return self.doSSOError('sso', err_prefix + "No headers found in response JSON", callback);
468+
}
469+
470+
// sensitive data: only log at level 9
471+
self.logSSO(9, "Plugin JSON response for req: " + args.id, json);
472+
473+
// merge headers back into original request
474+
Tools.mergeHashInto(request.headers, json.headers);
475+
476+
// add re-entry flag
477+
args._sso_command_response = true;
478+
479+
// recurse for SSO
480+
self.handleSSO(args, callback);
481+
} ); // cp.exec
482+
483+
// Write hook data to child's stdin
484+
child.stdin.on('error', noop);
485+
child.stdin.write( JSON.stringify(hook_args) + "\n" );
486+
child.stdin.end();
487+
}
488+
372489
}; // class SSO
373490

374491
module.exports = SSO;

0 commit comments

Comments
 (0)