Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions .github/workflows/proxy-auth-lib-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: proxy-auth-lib release

# Publishes the proxy-auth-lib packages to every registry when a tag of the
# form proxy-auth-lib-v<semver> is pushed (e.g. proxy-auth-lib-v1.2.3).
#
# Each registry is an independent job so one failing publish never blocks the
# others. Every job derives the same version from the tag and runs the matching
# script in proxy-auth-lib/scripts/, which are the exact commands a maintainer
# can run locally.

on:
push:
tags:
- 'proxy-auth-lib-v*'

permissions:
contents: read

jobs:
npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: ./proxy-auth-lib/scripts/publish-npm.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}"

jsr:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Publish to JSR
run: ./proxy-auth-lib/scripts/publish-jsr.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}"

pypi:
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build distribution
run: ./proxy-auth-lib/scripts/build-pypi.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}"
- name: Publish to PyPI (Trusted Publishing)
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: proxy-auth-lib/python/dist

crates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- name: Publish to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: ./proxy-auth-lib/scripts/publish-crates.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}"

meteor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install Meteor
run: |
curl -fsSL https://install.meteor.com/ | sh
echo "$HOME/.meteor" >> "$GITHUB_PATH"
- name: Publish to Atmosphere
env:
METEOR_SESSION_FILE: ${{ runner.temp }}/meteor-session
run: |
printf '%s' "${{ secrets.METEOR_SESSION }}" > "$METEOR_SESSION_FILE"
./proxy-auth-lib/scripts/publish-meteor.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}"

go:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Tag and index the Go module
run: ./proxy-auth-lib/scripts/publish-go.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}"

smoke-runtimes:
runs-on: ubuntu-latest
needs: npm
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: oven-sh/setup-bun@v2
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Smoke test Bun and Deno
run: ./proxy-auth-lib/scripts/smoke-runtimes.sh "${GITHUB_REF_NAME#proxy-auth-lib-v}"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ node_modules
.env
.tmp-verify/
.playwright-mcp/
__pycache__/
*.pyc
target/

# packaging build artifacts
/dist/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Full documentation lives in [`mie-opensource-landing/docs/`](mie-opensource-land
| [`pull-config/`](pull-config/) | Cron-driven config distribution for nginx and dnsmasq on agents — see [pull-config docs](mie-opensource-landing/docs/developers/pull-config.md) |
| [`images/`](images/) | Docker Bake definitions for the `base`, `nodejs`, `agent`, and `manager` images — see [Docker Images](mie-opensource-landing/docs/developers/docker-images.md) |
| [`manager-control-program/`](manager-control-program/) | MCP server for AI-assisted container management — see [MCP Server](mie-opensource-landing/docs/users/mcp-server.md) |
| [`proxy-auth-lib/`](proxy-auth-lib/) | Vendor-neutral trusted proxy identity middleware examples for Node.js, Python, Rust, and Go |
| [`mie-opensource-landing/`](mie-opensource-landing/) | Documentation site source |
| [`error-pages/`](error-pages/) | Static error pages served by NGINX |
| [`compose.yml`](compose.yml) | Local development stack (used by the Development Workflow guide) |
Expand Down
4 changes: 4 additions & 0 deletions create-a-container/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ data/
/*.deb
/*.rpm
/*.apk

# Playwright test artifacts
/playwright-report/
/test-results/
2 changes: 2 additions & 0 deletions mie-opensource-landing/docs/developers/system-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,5 +168,7 @@ sequenceDiagram

NGINX captures identity headers from the auth server subrequest (`X-User-ID`, `X-Username`, `X-User-First-Name`, `X-User-Last-Name`, `X-Email`, `X-Groups`) and forwards them to the backend container via `proxy_set_header`.

Backends should treat those raw headers as untrusted unless the auth layer also provides a signed assertion that the application verifies against JWKS, issuer, audience, and expiration checks. See [Trusted Proxy Auth Libraries](trusted-proxy-auth.md).

If `authRequired` is enabled but no `authServer` is configured on the domain, NGINX serves a 503 error page.

88 changes: 88 additions & 0 deletions mie-opensource-landing/docs/developers/trusted-proxy-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Trusted proxy auth libraries

`proxy-auth-lib/` contains vendor-neutral middleware examples for applications that sit behind an identity-aware proxy or access gateway.

## Problem boundary

The middleware validates a signed JWT/JWS assertion from a trusted upstream component. It does not implement login flows, browser sessions, or authorization policy.

## Trust model

```mermaid
flowchart TD
User[User] --> Proxy[Identity-aware proxy or gateway]
Proxy -->|signed assertion header| App[Application middleware]
App -->|verified identity| Handler[Application handler]

classDef trusted fill:#e8f5e9,stroke:#1b5e20
classDef edge fill:#fff3e0,stroke:#e65100

class Proxy,App,Handler trusted
class User edge
```

Never trust raw identity headers such as `X-Forwarded-User`, `X-User`, `X-Email`, or `Remote-User` without cryptographic verification. The examples in this repository require a signed assertion, a JWKS, an expected issuer, an expected audience, and a valid expiration time.

## Shared configuration

Every setting is optional. By default the auth domain is derived from the host's FQDN (`web1.os.example.org` → `auth.os.example.org`); the issuer and JWKS URL come from that domain. Override any single value with its own variable.

| Variable | Default | Purpose |
|---|---|---|
| `TRUSTED_PROXY_AUTH_DOMAIN` | `auth.<parent-domain-of-hostname>` | Base domain used to derive the issuer and JWKS URL |
| `TRUSTED_PROXY_ASSERTION_HEADER` | `X-Trusted-Proxy-Assertion` | Header containing the signed assertion |
| `TRUSTED_PROXY_JWKS_URL` | `https://<domain>/.well-known/jwks.json` | JWKS URL for key discovery |
| `TRUSTED_PROXY_ISSUER` | `https://<domain>` | Expected issuer |
| `TRUSTED_PROXY_AUDIENCE` | `https://<domain>` | Expected audience |
| `TRUSTED_PROXY_PUBLIC_KEY` | _(unset)_ | Inline PEM public key; skips JWKS and verifies offline |
| `TRUSTED_PROXY_PUBLIC_KEY_FILE` | _(unset)_ | Path to a PEM public key (alternative to the inline form) |

### Key source: JWKS vs static public key

JWKS is preferred and is the default: it supports key rotation and is what OIDC providers (Authentik, Cloudflare Access, Pomerium) publish. A static public key is an opt-in alternative for the self-signed case where your own proxy mints assertions with a key you control. When `TRUSTED_PROXY_PUBLIC_KEY` (or `_FILE`) is set, verification uses it directly and never calls the network; otherwise the JWKS URL is used. Either way the algorithm is pinned to `RS256`.

## Implemented MVP targets

| Language | Framework target | Package path |
|---|---|---|
| Node.js | Express-style middleware | `proxy-auth-lib/nodejs/` |
| Python | ASGI middleware for FastAPI / Starlette-style apps | `proxy-auth-lib/python/` |
| Rust | Axum middleware | `proxy-auth-lib/rust/` |
| Go | `net/http` middleware | `proxy-auth-lib/go/` |

## Vendor-neutral examples

Cloudflare Access, Pomerium, OAuth2 Proxy, Envoy `ext_authz`, Traefik ForwardAuth, NGINX `auth_request`, and custom gateways all fit the same pattern when they can forward a signed assertion. The application should trust the signature and claims, not the proxy brand.

## Verified identity examples

### Node.js

```js
app.use(createTrustedProxyAuth(loadConfigFromEnv()));
app.get('/me', (req, res) => res.json(req.trustedProxyIdentity));
```

### Python

```python
app = TrustedProxyAuthMiddleware(app, load_config_from_env())
# request.scope["trusted_proxy_identity"] inside the downstream app
```

### Rust

```rust
let app = Router::new()
.route("/me", get(handler))
.layer(middleware::from_fn_with_state(auth.clone(), axum_middleware));
```

### Go

```go
mux.Handle("/me", auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, _ := trustedproxyauth.IdentityFromContext(r.Context())
json.NewEncoder(w).Encode(identity)
})))
```
1 change: 1 addition & 0 deletions mie-opensource-landing/zensical.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ nav = [
{ "Docker Images" = "developers/docker-images.md" },
{ "Release Pipeline" = "developers/release-pipeline.md" },
{ "Database Schema" = "developers/database-schema.md" },
{ "Trusted Proxy Auth" = "developers/trusted-proxy-auth.md" },
{ "Pull Config" = "developers/pull-config.md" },
{ "Contributing" = "developers/contributing.md" },
] },
Expand Down
80 changes: 80 additions & 0 deletions proxy-auth-lib/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Trusted proxy auth libraries

Vendor-neutral JWT/JWKS middleware for applications that sit behind an identity-aware proxy, ingress, or access gateway.

## Layout

| Path | Target |
|---|---|
| `nodejs/` | Express, Fastify, and Hono adapters |
| `meteor/accounts-proxy-auth/` | `mieweb:accounts-proxy-auth` Atmosphere package (accounts-base login) |
| `python/` | ASGI (FastAPI/Starlette), WSGI (Flask), and Django middleware |
| `rust/` | Axum middleware |
| `go/` | `net/http` middleware (drops into Chi as-is) |
| `testdata/` | Shared JWKS and JWT fixtures used by all language tests |

### Node.js entry points

| Import | Adapter |
|---|---|
| `@mieweb/trusted-proxy-auth/express` | `createTrustedProxyAuth(config)` → `(req, res, next)` |
| `@mieweb/trusted-proxy-auth/fastify` | `fastifyTrustedProxyAuth(config)` → `preHandler` hook |
| `@mieweb/trusted-proxy-auth/hono` | `honoTrustedProxyAuth(config)` → `(c, next)` |

### Meteor

`meteor add mieweb:accounts-proxy-auth` wires the verification into
`accounts-base`: the server validates the assertion on page load and logs the
user in via a one-time login token. See
[meteor/accounts-proxy-auth](meteor/accounts-proxy-auth/README.md).

### Python entry points

| Import | Adapter |
|---|---|
| `trusted_proxy_auth.TrustedProxyAuthMiddleware` | ASGI (FastAPI/Starlette) |
| `trusted_proxy_auth.flask.TrustedProxyAuthMiddleware` | WSGI (`app.wsgi_app = ...`) |
| `trusted_proxy_auth.django.TrustedProxyAuthMiddleware` | Django `MIDDLEWARE` entry |

The verified identity exposes `subject`, `email`, `name`, and the raw `claims`.

## Shared configuration

Every setting is optional. By default the auth domain is derived from the host's
FQDN (`web1.os.example.org` → `auth.os.example.org`), and the issuer and JWKS URL
are derived from that domain. Override any single value with its own variable.

| Variable | Default | Purpose |
|---|---|---|
| `TRUSTED_PROXY_AUTH_DOMAIN` | `auth.<parent-domain-of-hostname>` | Base domain used to derive the issuer and JWKS URL |
| `TRUSTED_PROXY_ASSERTION_HEADER` | `X-Trusted-Proxy-Assertion` | Header containing the signed identity assertion |
| `TRUSTED_PROXY_JWKS_URL` | `https://<domain>/.well-known/jwks.json` | JWKS URL used to resolve signing keys |
| `TRUSTED_PROXY_ISSUER` | `https://<domain>` | Expected JWT issuer |
| `TRUSTED_PROXY_AUDIENCE` | `https://<domain>` | Expected JWT audience |
| `TRUSTED_PROXY_PUBLIC_KEY` | _(unset)_ | Inline PEM public key; skips JWKS and verifies offline |
| `TRUSTED_PROXY_PUBLIC_KEY_FILE` | _(unset)_ | Path to a PEM public key (alternative to the inline form) |

Set `TRUSTED_PROXY_AUTH_DOMAIN` explicitly in any environment where the host
FQDN does not match the auth domain, and set `TRUSTED_PROXY_AUDIENCE` to a
per-application value if you want tokens scoped to one service.

### Key source: JWKS vs static public key

JWKS is preferred and is the default: it supports key rotation and is what
OIDC providers (Authentik, Cloudflare Access, Pomerium) publish. A static
public key is an opt-in alternative for the self-signed case where your own
proxy mints assertions with a key you control. When `TRUSTED_PROXY_PUBLIC_KEY`
(or `_FILE`) is set, verification uses it directly and never calls the network;
otherwise the JWKS URL is used. Either way the algorithm is pinned to `RS256`.

## Security boundary

Do not trust raw identity headers such as `X-Forwarded-User`, `X-User`, `X-Email`, or `Remote-User` on their own. A backend should trust only a signed assertion that it verifies against a trusted JWKS, issuer, audience, and expiration policy.

## Proxy pattern

The same middleware works when the upstream component is Cloudflare Access, Pomerium, OAuth2 Proxy, Envoy `ext_authz`, Traefik ForwardAuth, NGINX `auth_request`, or a custom gateway. The application trusts the signature, not the proxy brand.

## Releasing

Pushing a `proxy-auth-lib-v<semver>` tag (e.g. `proxy-auth-lib-v1.2.3`) runs [`.github/workflows/proxy-auth-lib-release.yml`](../.github/workflows/proxy-auth-lib-release.yml), which stamps that one version into every manifest and publishes each package to its registry — npm, JSR, PyPI, crates.io, Atmosphere (Meteor), and the Go module proxy — as independent jobs. The same commands run locally from [`scripts/`](scripts/README.md).
Loading
Loading