porchlight/README.md
Johan Lundberg 6b6ff29b9a
All checks were successful
publish-latest / docker (push) Successful in 22s
Harden forward-auth redirect and header handling
Tighten forward-auth return URL validation to reject unsafe host syntax,
including backslash-based browser/parser mismatches, and normalize
scheme/port matching so bare allowlist entries are HTTPS default-port
only. Support explicit scheme-qualified targets, including IPv6
host:port entries.

Always emit deterministic identity headers with empty values when
attributes are absent, zero unsafe header values, and clear stale
forward-auth return targets when OIDC or registration flows take
precedence.

Add regression coverage for wildcard redirect bypasses, scheme/port
handling, IPv6 allowlist entries, empty identity headers, unsafe header
values, and stale return-target cleanup. Update docs with proxy
header-stripping guidance and the new allowlist semantics.
2026-07-05 00:00:58 +02:00

235 lines
7 KiB
Markdown

# Porchlight
OpenID Connect Provider with user management, built with FastAPI.
Porchlight handles user registration (via magic links), credential management
(passwords and WebAuthn security keys), and issues OIDC tokens for relying
parties. It uses SQLite for storage, Jinja2 + HTMX for the UI, and idpyoidc
for the OIDC protocol layer.
## Production Setup
### Docker (recommended)
```bash
docker compose --profile prod up --build
```
This starts Porchlight on port 8000 with 4 uvicorn workers. Data is persisted
in a named Docker volume.
Set required environment variables in `docker-compose.yml` or via `.env`:
```bash
OIDC_OP_ISSUER=https://auth.example.com
OIDC_OP_SESSION_SECRET=<random-secret>
```
### Manual
Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/).
```bash
uv sync --no-dev
OIDC_OP_ISSUER=https://auth.example.com \
OIDC_OP_SESSION_SECRET=$(python -c "import secrets; print(secrets.token_hex(32))") \
uv run uvicorn porchlight.app:create_app \
--factory --host 0.0.0.0 --port 8000 --workers 4
```
### Bootstrap the first admin user
After starting the app for the first time, create an admin account:
```bash
OIDC_OP_ISSUER=https://auth.example.com \
uv run porchlight initial-admin admin
```
This prints a one-time registration URL. Open it in a browser to set up
credentials (password or security key) for the admin user.
### CLI commands
Porchlight includes a CLI for administrative tasks. All commands read the same
`OIDC_OP_*` environment variables as the server.
**`porchlight create-invite <username>`** -- Generate a magic link registration
URL for a new user.
```bash
uv run porchlight create-invite alice
uv run porchlight create-invite alice --ttl 3600 --note "Onboarding"
```
| Option | Description |
|---|---|
| `--ttl SECONDS` | Link expiration (default: `OIDC_OP_INVITE_TTL`, 86400s) |
| `--note TEXT` | Optional note stored with the link |
**`porchlight initial-admin <username>`** -- Bootstrap the first admin user with
a registration link.
```bash
uv run porchlight initial-admin admin
uv run porchlight initial-admin admin --group admin --group superusers
```
| Option | Description |
|---|---|
| `--group TEXT` | Groups to assign (repeatable, default: `admin`, `users`) |
### Configuration
All settings are read from environment variables with the `OIDC_OP_` prefix.
Settings can also be provided via a TOML config file (see below). Environment
variables always take priority over file values.
| Variable | Default | Description |
|---|---|---|
| `OIDC_OP_ISSUER` | **required** | OIDC issuer URL (must match public URL) |
| `OIDC_OP_SESSION_SECRET` | random per process | Session cookie signing secret |
| `OIDC_OP_DEBUG` | `false` | Enable `/docs` Swagger UI |
| `OIDC_OP_SQLITE_PATH` | `data/oidc_op.db` | SQLite database path |
| `OIDC_OP_SIGNING_KEY_PATH` | `data/keys` | OIDC signing key storage |
| `OIDC_OP_INVITE_TTL` | `86400` | Magic link expiry in seconds |
| `OIDC_OP_MANAGE_CLIENT_ID` | `manage-app` | Client ID for the management UI |
| `OIDC_OP_SESSION_HTTPS_ONLY` | `true` | Restrict session cookie to HTTPS (set `false` for local dev) |
| `OIDC_OP_FORWARD_AUTH_ALLOWED_REDIRECT_HOSTS` | `[]` | JSON list of hosts allowed as post-login return targets for `/forward-auth` |
| `OIDC_OP_CONFIG_FILE` | `porchlight.toml` | Path to TOML config file |
Database migrations run automatically on startup.
### Configuration file
Copy `porchlight.example.toml` to `porchlight.toml` and edit to suit your
deployment. The file supports all the same settings as environment variables
(without the `OIDC_OP_` prefix), plus OIDC client registrations.
```toml
issuer = "https://auth.example.com"
session_secret = "your-random-secret"
[clients.my-webapp]
client_secret = "change-me-to-a-long-random-string"
redirect_uris = ["https://app.example.com/callback"]
response_types = ["code"]
scope = ["openid", "profile", "email"]
token_endpoint_auth_method = "client_secret_basic"
```
Each `[clients.<client-id>]` section registers an OIDC Relying Party on
startup. Only `client_secret` and `redirect_uris` are required; the other
fields have sensible defaults (`response_types = ["code"]`,
`scope = ["openid"]`, `token_endpoint_auth_method = "client_secret_basic"`).
### Forward-auth for reverse proxies
Porchlight exposes `/forward-auth` for proxies such as Traefik, Caddy, and
nginx `auth_request`. Authenticated active users receive `204 No Content` with
identity headers (`Remote-User`, `Remote-Email`, `Remote-Groups`, and
`X-Forwarded-*` equivalents). Anonymous users receive a `303` redirect to the
Porchlight login page.
To redirect users back to a protected app after login, allow that app's host:
```toml
forward_auth_allowed_redirect_hosts = ["app.example.com", "*.apps.example.com"]
```
The endpoint uses `X-Forwarded-Proto`, `X-Forwarded-Host`, and
`X-Forwarded-Uri` (or `X-Original-Url`) to reconstruct the original request URL.
Bare host patterns are HTTPS-only and match the default HTTPS port; `host:port`
patterns are also HTTPS-only unless you include an explicit scheme such as
`http://localhost:9000` for local cleartext deployments. Exact hosts,
`host:port` entries, scheme-qualified hosts, and explicit `*.example.com`
wildcard suffixes listed in `forward_auth_allowed_redirect_hosts` are accepted
as return targets.
Configure the reverse proxy to strip any inbound `Remote-*` and `X-Forwarded-*`
identity headers from client requests before applying the headers returned by
`/forward-auth`.
To use a config file at a different path:
```bash
export OIDC_OP_CONFIG_FILE=/etc/porchlight/config.toml
```
If the config file does not exist, it is silently ignored and all settings
fall back to environment variables and defaults.
## Development Setup
### Prerequisites
- Python 3.13+
- [uv](https://docs.astral.sh/uv/)
- Node.js (for e2e tests)
### Getting started
```bash
# Install dependencies (including dev tools)
uv sync
# Start the dev server with hot reload
OIDC_OP_ISSUER=http://localhost:8000 OIDC_OP_DEBUG=true \
uv run uvicorn porchlight.app:create_app \
--factory --host 127.0.0.1 --port 8000 --reload --reload-dir src
```
Or with Docker:
```bash
docker compose --profile dev up --build
```
### Running tests
```bash
# Unit/integration tests
uv run pytest
# Lint and format
uv run ruff check src/ tests/ --fix
uv run ruff format src/ tests/
# Type checking
uv run ty check src/
```
### End-to-end browser tests
The e2e suite uses Playwright (Node.js) to test all user-facing flows against a
running instance of the app.
```bash
# One-time setup: install Playwright and Chromium
cd tests/e2e
npm install && npm run setup
cd ../..
# Run all e2e tests
./tests/e2e/run.sh
# Run a specific test
./tests/e2e/run.sh tests/e2e/test_login.js
# Run with visible browser (not headless)
E2E_HEADLESS=0 ./tests/e2e/run.sh
```
The runner starts the app on port 8099, seeds test fixtures into a temporary
SQLite database, runs all `test_*.js` files, and tears everything down.
### Full quality check
```bash
uv run ruff format src/ tests/
uv run ruff check src/ tests/ --fix
uv run ty check src/
uv run pytest
./tests/e2e/run.sh
```