npm package: @pixlcore/xyplug-sso-aws-alb
Description: A xyOps SSO Plugin for authenticating users via AWS ALB OIDC.
Author: Joseph Huckaby
Latest version: 1.0.0

# xyOps AWS ALB SSO Plugin

`xyplug-sso-aws-alb` is a custom SSO command plugin for `xyOps`.

It is designed for AWS Application Load Balancers that use native OIDC authentication at the listener layer. The plugin reads the ALB headers that AWS forwards to your backend, validates the ALB-signed JWT, extracts user claims, and emits standard trusted headers back to xyOps.

Please read the [xyOps SSO](https://docs.xyops.io/sso) documentation before proceeding.

## What it does

The plugin reads these incoming AWS ALB headers from the xyOps SSO command request:

- `x-amzn-oidc-identity`
- `x-amzn-oidc-data`

It then:

1. Verifies the JWT from `x-amzn-oidc-data` using `aws-jwt-verify`.
2. Validates the ALB signer ARN, issuer, and client ID.
3. Extracts identity and claims from the verified payload.
4. Emits headers that match your xyOps `header_map`, such as:
   - `x-forwarded-user`
   - `x-forwarded-name`
   - `x-forwarded-email`
   - `x-forwarded-groups`

xyOps then continues through its normal SSO flow as if those trusted headers had been forwarded by a traditional auth proxy.

## Requirements

- Node.js 18 or higher
- `npx` available on the xyOps conductor
- xyOps with SSO custom command support (v1.0.40+)
- An AWS ALB listener already configured for OIDC authentication
- Network egress from the xyOps conductor to the AWS ALB public key endpoint on the first verification of each new token or key ID

## Install and run

The plugin is designed to be invoked by xyOps itself through `sso.json`.

Recommended command:

```json
"command": "npx -y @pixlcore/xyplug-sso-aws-alb@1.0.0"
```

If you prefer, you can also preinstall the package and execute it directly:

```json
"command": "xyplug-sso-aws-alb"
```

That works if the executable is already on your server `PATH`.

## How xyOps calls the plugin

xyOps launches the command before its normal SSO header processing. It sends a one-line JSON document to STDIN containing:

- the full `SSO` config object
- the request headers
- parsed cookies
- request IP metadata

The plugin must return a one-line JSON document on STDOUT using the xyOps wire protocol:

```json
{
	"xy": 1,
	"code": 0,
	"headers": {
		"x-forwarded-user": "user@example.com",
		"x-forwarded-name": "Example User",
		"x-forwarded-email": "user@example.com",
		"x-forwarded-groups": "admins,operators"
	}
}
```

Important:

- Logical auth failures are returned with `code: 1` in JSON.
- The plugin is intentionally written to emit JSON and exit normally, so xyOps can display a meaningful SSO error.

## Recommended xyOps configuration

Edit `/opt/xyops/conf/sso.json` and configure SSO like this:

```json
{
	"enabled": true,
	"whitelist": ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"],
	"header_map": {
		"username": "x-forwarded-user",
		"full_name": "x-forwarded-name",
		"email": "x-forwarded-email",
		"groups": "x-forwarded-groups"
	},
	"cleanup_username": false,
	"cleanup_full_name": false,
	"group_role_map": {},
	"group_privilege_map": {},
	"command": "npx -y @pixlcore/xyplug-sso-aws-alb@1.0.0",
	"jwt": {
		"provider": "aws_alb",
		"header": "x-amzn-oidc-data",
		"claim_map": {
			"username": "email",
			"email": "email",
			"full_name": "name",
			"groups": "groups"
		},
		"aws_alb": {
			"alb_arn": "arn:aws:elasticloadbalancing:us-west-2:123456789012:loadbalancer/app/example/abc123",
			"issuer": "https://accounts.google.com",
			"client_id": "your-oidc-client-id",
			"grace_seconds": 0
		}
	}
}
```

This works because:

- xyOps only cares about the standard trusted headers in `header_map`
- xyOps ignores the custom `jwt` block
- the plugin reads `jwt` from the command input, verifies the AWS token, and prints matching headers back out

## Header map recommendations

Recommended header map:

```json
"header_map": {
	"username": "x-forwarded-user",
	"full_name": "x-forwarded-name",
	"email": "x-forwarded-email",
	"groups": "x-forwarded-groups"
}
```

Using distinct headers is the cleanest configuration.

You can map multiple xyOps fields to the same header name if you want, for example:

```json
"header_map": {
	"username": "x-forwarded-user",
	"full_name": "x-forwarded-user",
	"email": "x-forwarded-email",
	"groups": "x-forwarded-groups"
}
```

In that case, the plugin emits only one value for the shared header name. This is useful when you want xyOps to derive a prettier full name using `cleanup_full_name`.

Recommendation:

- Use distinct headers if you want the plugin to pass a real full name.
- Reuse the username header only if you intentionally want xyOps to derive the display name itself.

## AWS JWT configuration

The plugin expects a `jwt` object in `sso.json`.

### `jwt.provider`

Set this to:

```json
"provider": "aws_alb"
```

### `jwt.header`

This is the incoming request header that contains the ALB JWT.

For AWS ALB, use:

```json
"header": "x-amzn-oidc-data"
```

### `jwt.claim_map`

This tells the plugin which verified claims to use for xyOps fields.

Example:

```json
"claim_map": {
	"email": "email",
	"full_name": "name",
	"groups": "groups"
}
```

Each value may be:

- a string claim name such as `"email"`
- a dotted path such as `"custom.profile.name"`
- an array of fallbacks such as `["name", "preferred_username", "email"]`

Common mappings:

- `username`: often `email` or `preferred_username`
- `email`: usually `email`
- `full_name`: usually `name`
- `groups`: depends on your IdP, often `groups`, `roles`, or a custom claim

### `jwt.aws_alb.alb_arn`

This must match the AWS ALB signer ARN.

Example:

```json
"alb_arn": "arn:aws:elasticloadbalancing:us-west-2:123456789012:loadbalancer/app/example/abc123"
```

You may also provide an array if you trust multiple ALBs for the same issuer:

```json
"alb_arn": [
	"arn:aws:elasticloadbalancing:us-west-2:123456789012:loadbalancer/app/alb-a/abc123",
	"arn:aws:elasticloadbalancing:us-west-2:123456789012:loadbalancer/app/alb-b/def456"
]
```

### `jwt.aws_alb.issuer`

This must match the JWT `iss` claim from your OIDC provider.

Examples:

- `https://accounts.google.com`
- `https://YOUR_OKTA_DOMAIN/oauth2/default`
- `https://cognito-idp.us-west-2.amazonaws.com/us-west-2_ABC123`

### `jwt.aws_alb.client_id`

This must match the ALB JWT client identifier.

Example:

```json
"client_id": "your-oidc-client-id"
```

You may also set it to `null` if you intentionally want to skip client validation, but that is not recommended.

### `jwt.aws_alb.grace_seconds`

Optional clock-skew allowance in seconds.

Example:

```json
"grace_seconds": 30
```

### Optional advanced settings

The plugin also understands these optional properties:

```json
"jwt": {
	"cache_dir": "/tmp/xyplug-sso-aws-alb-cache",
	"aws_alb": {
		"jwks_uri": "https://public-keys.auth.elb.us-west-2.amazonaws.com"
	}
}
```

Notes:

- `cache_dir` overrides the default on-disk token cache path.
- `jwks_uri` is mainly useful for advanced testing. In normal AWS setups you should omit it and let `aws-jwt-verify` derive the correct ALB public key endpoint from the ALB ARN region.

## Username, email, and full name behavior

The plugin resolves values in this general order:

### Username

Preferred sources:

1. `claim_map.username`
2. common fallback claims such as `preferred_username`, `username`, `email`
3. `x-amzn-oidc-identity`
4. JWT `sub`

Note:

- AWS ALB sets `x-amzn-oidc-identity` to the OIDC `sub` claim.
- In many environments that is a stable opaque identifier rather than a friendly username.
- For that reason, the plugin prefers mapped claims first and uses the ALB identity header as a fallback.

### Email

Preferred sources:

1. `claim_map.email`
2. common `email` claim
3. username fallback

This fallback exists because current xyOps SSO requires both username and email to be present.

Recommendation:

- Make sure your IdP returns a real email claim if you want a proper user email in xyOps.

### Full name

Preferred sources:

1. `claim_map.full_name`
2. common `name` claim
3. `given_name + family_name`
4. email
5. username

If your IdP does not provide a full name, that is fine. You can:

- map `full_name` to its own header and let the plugin fall back automatically
- or map `full_name` to the same header as `username` or `email` and let xyOps derive a prettier display name via `cleanup_full_name`

## Groups, roles, and privileges

The plugin returns groups as a single trusted header, usually `x-forwarded-groups`.

xyOps then consumes that header through its normal SSO settings:

- `group_role_map`
- `group_privilege_map`
- `group_role_separator`
- `replace_roles`
- `replace_privileges`

Example:

```json
"header_map": {
	"groups": "x-forwarded-groups"
},
"group_role_map": {
	"devops": ["r12345"],
	"oncall": ["r67890"]
},
"group_privilege_map": {
	"platform-admins": ["admin"],
	"ticket-managers": ["edit_tickets", "delete_tickets"]
}
```

If the verified JWT contains:

```json
{
	"groups": ["devops", "platform-admins"]
}
```

The plugin will emit:

```json
"x-forwarded-groups": "devops,platform-admins"
```

Then xyOps will:

- add role `r12345`
- grant the `admin` privilege

### Group separator

If your group claim is an array, the plugin joins it using your xyOps `group_role_separator`.

Example:

```json
"group_role_separator": "|"
```

In that case the plugin will emit:

```json
"x-forwarded-groups": "devops|platform-admins"
```

If your JWT claim is already a string, the plugin passes it through as-is. So if your IdP returns a string, make sure its delimiter matches the separator you configured in xyOps.

## Performance and caching

To keep things fast, the plugin maintains a small on-disk cache keyed by:

- the raw ALB JWT
- the relevant SSO config

Default cache path:

```text
/tmp/xyplug-sso-aws-alb-cache
```

The cache stores only:

- the verified token expiration time
- the generated trusted headers

The cache expires automatically when the JWT expires.

## Security notes

- The plugin validates the ALB JWT signature with `aws-jwt-verify`.
- The plugin validates the ALB signer ARN.
- The plugin validates the issuer.
- The plugin validates the client ID unless you explicitly disable that by setting `client_id` to `null`.
- If both `x-amzn-oidc-identity` and JWT `sub` are present, the plugin checks that they match.
- The plugin does not perform IP/CIDR validation. xyOps still does that via `SSO.whitelist`.

Infrastructure recommendations:

- Restrict your xyOps target so only the ALB can reach it.
- Keep `SSO.whitelist` enabled.
- Use HTTPS end-to-end where possible.

## What this plugin does not do

- It does not replace xyOps SSO user creation, session creation, or group mapping.
- It does not manage logout behavior for AWS ALB auth cookies.
- It does not validate source IPs. xyOps still does that.
- It does not mutate any xyOps config on disk.

## Troubleshooting

### Login fails with a JWT verification error

Check:

- `jwt.aws_alb.alb_arn`
- `jwt.aws_alb.issuer`
- `jwt.aws_alb.client_id`
- network access to the AWS ALB public key endpoint

### Groups are missing

Check:

- that your IdP is actually returning the group claim
- the `jwt.claim_map.groups` value
- your xyOps `group_role_separator`

### Usernames look strange

Remember that xyOps has its own username normalization rules.

If your OIDC `sub` contains characters outside the xyOps username character set, xyOps may normalize them. If you prefer friendlier usernames, map username to another claim that is stable in your environment.

### Need more debugging output

On the xyOps side:

- set `debug_level` to `9`
- inspect `logs/SSO.log`

On the plugin side:

- set `XYP_SSO_DEBUG=1` in the environment for the xyOps service to have the plugin write extra diagnostic information to STDERR

xyOps logs raw plugin STDOUT and STDERR at SSO debug level 9.

## Local development

To syntax-check the CLI:

```sh
npm test
```

To run it manually with a saved JSON input:

```sh
cat sample-input.json | node index.js
```

The plugin expects the same XYWP payload that xyOps sends to custom SSO commands.