Skip to content
Merged
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
15 changes: 14 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,21 @@ SESSION_SECRET=
# length is fine, but treat it as a real secret.
SERVER_SECRET=

# Spotify Web API uses PKCE; no client secret is needed.
# PostgreSQL connection URL. Required: stores short-lived consumed OAuth
# states, replay nonces, and encrypted one-time credentials.
DATABASE_URL=postgres://postgres@localhost:5432/integration_proxy

# Base64url-encoded, random 32-byte key for versioned XChaCha20-Poly1305
# credential envelopes. Required. Generate one with, e.g.:
# openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\n'
ENCRYPTION_KEY=

# Spotify Web API uses PKCE; no client secret is needed. Its authorization
# server advertises only the public "none" client authentication method, so
# that must be set explicitly (the proxy otherwise defaults to
# client_secret_post, which requires a secret).
OAUTH_SPOTIFY_CLIENT_ID=
OAUTH_SPOTIFY_CLIENT_AUTH_METHOD=none

# Discord OAuth application; callback ${BASE_URL}/oauth/discord/callback
OAUTH_DISCORD_CLIENT_ID=
Expand Down
58 changes: 37 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# integration-proxy

A stateless Rust web server that lets a user log in with a configured OIDC
provider or a catalog-trusted authenticated API identity. Sign-in sets an encrypted session cookie; there is a logout button
to clear it. No database, no server-side session store — the cookie *is*
the session, so any number of instances can run behind a load balancer with
no shared session state. PostgreSQL stores only short-lived consumed OAuth
states, replay nonces, and encrypted one-time credentials.
A Rust web server that lets a user log in with a configured OIDC provider or
a catalog-trusted authenticated API identity. Sign-in sets an encrypted
session cookie; there is a logout button to clear it. There is no
server-side session store — the cookie *is* the session, so any number of
instances can run behind a load balancer with no shared session state.
PostgreSQL is required, though: it stores short-lived consumed OAuth states,
replay nonces, and encrypted one-time credentials.

Built with [axum](https://github.com/tokio-rs/axum) and the
[`oauth2`](https://docs.rs/oauth2) crate, following the OAuth 2.0
Expand Down Expand Up @@ -62,8 +63,17 @@ Configure an OIDC authorization-code client and register `<BASE_URL>/auth/callba

### 2. Configure environment variables

Copy `.env.example` to `.env` and fill it in (or export the variables
directly):
Copy `.env.example` to `.env` and fill it in, then load it into your shell
before running the server — nothing in the process reads `.env` files on its
own, only actual process environment variables:

```sh
set -a
source .env
set +a
```

Or export the variables directly without a `.env` file.

| Variable | Required | Description |
| ----------------------| -------- | ---------------------------------------------------------------------------- |
Expand Down Expand Up @@ -97,21 +107,26 @@ OAuth credentials are provider-specific. For a catalog platform named
letters, digits, and hyphens, and are converted to uppercase with hyphens
replaced by underscores for environment-variable names.

The server owns the OAuth endpoints and scopes. The built-in providers are
`google-calendar` (read-only Calendar scope) and `github-issues` (`repo`
scope); a request cannot supply a provider URL, token URL, or scope.
The server owns the OAuth endpoints and scopes for every platform in the
catalog, reading them from that platform's composed OpenAPI document; a
request cannot supply a provider URL, token URL, or scope.

The PostgreSQL client validates the database TLS certificate. Heroku assigns
`DATABASE_URL` automatically when its Postgres add-on is attached.

Use the `connection_code` returned by the OAuth redirect as the Bearer token
for `/proxy/{platform}/{path}`. Each successful proxy response includes a new
single-use value in `X-Connection-Code`; use that value for the next request.
The proxy refreshes an expired provider access token when a refresh token is
available, and rotates the handoff code after every request. GitHub sometimes
returns repository pagination links using its canonical numeric repository
path; the proxy rewrites that metadata to the current allowlisted owner/repo
path only when the collection suffix matches.
These two flows hand back a proxy credential differently. In the legacy
signed `/connect` and `/oauth/{platform}/start` flow, use the
`connection_code` returned directly by the OAuth redirect as the Bearer token
for `/proxy/{platform}/{path}`. In the browser bootstrap flow (`POST
/connect/authorize`), the OAuth callback instead returns a short-lived,
PKCE-bound handoff code that is **not** a proxy credential; redeem it first
at `POST /connect/redeem` (see above) to obtain the actual `connection_code`.
Either way, each successful proxy response includes a new single-use value
in `X-Connection-Code`; use that value as the Bearer token for the next
request. The proxy refreshes an expired provider access token when a refresh
token is available, and rotates the connection code after every request.
Pagination `Link` headers from the upstream are forwarded to the caller
unchanged.

## Trusted API identities

Expand Down Expand Up @@ -143,8 +158,9 @@ Discord access tokens expire and use the existing refresh-token flow.
Spotify uses `OAUTH_SPOTIFY_CLIENT_ID` and the callback
`https://localthought.io/oauth/spotify/callback` in production. Register a
Spotify Web API app with that exact redirect URI. The integration uses
Authorization Code with PKCE, so no client secret is required or transmitted.
It imports playlists with `playlist-read-private` and
Authorization Code with PKCE, so no client secret is required or transmitted;
set `OAUTH_SPOTIFY_CLIENT_AUTH_METHOD=none` so the proxy does not require or
send one. It imports playlists with `playlist-read-private` and
`playlist-read-collaborative`; no write scopes are requested. No account ID
parameter is needed. Development-mode access is subject to Spotify's Premium
and app-user allowlist requirements. Access tokens refresh automatically;
Expand Down
28 changes: 24 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Provider OAuth state binds the platform, tenant, user, callback, PKCE verifier a

A new callback returns only a random five-minute handoff code, never a token envelope or tenant secret. `/connect/redeem` requires the original PKCE verifier and atomically deletes the matching, unexpired handoff before issuing one rotating connection credential. Wrong verifiers do not burn a valid handoff; replays and concurrent second redemptions fail. The encrypted database payload carries platform/tenant/user identity and the exact requested grant. Revocation is checked again at redemption. Handoff codes cannot be used directly as proxy credentials. Redemption and consent responses are non-cacheable and use a no-referrer policy.

The browser clears callback parameters before further use, retains pending state outside graph resources, and removes its verifier when redeeming. A lost redemption response requires reconnecting rather than blindly retrying. The existing five-minute idle expiry and one-use rotation rules still apply to proxy credentials.
The browser clears callback parameters before further use, retains pending state outside graph resources, and removes its verifier when redeeming. A lost redemption response requires reconnecting rather than blindly retrying. The existing ten-minute idle expiry and one-use rotation rules still apply to proxy credentials (the handoff code above keeps its own, separate five-minute lifetime).

The legacy tenant-proof endpoints remain for compatibility. They are not used by the new hub flow. The legacy tenant-secret redirect is deprecated; new callers must request the optional grant through PKCE redemption. No existing tenant or provider secrets are rotated by this deployment.

Expand All @@ -27,6 +27,9 @@ subsequent consumption.

## OAuth (#9)

The following are the requirements this deployment targets; see "Release
gate" below for what is still outstanding rather than already implemented.

Register a distinct redirect URI per provider and validate it exactly. Keep
the OAuth state and PKCE verifier in authenticated, short-lived, `Secure`,
`HttpOnly`, `SameSite=Lax` cookies. Bind the state to the tenant and user id;
Expand Down Expand Up @@ -56,9 +59,26 @@ credential. Do not forward the upstream's cookies or authorization headers.
Rate-limit per tenant, audit token use without logging secrets, and return
generic authentication errors.

Implemented today: the requested path is rejected if it contains a `.` or
`..` segment before catalog validation, so it cannot normalize to a
different path than the one authorized (see `proxy::contains_traversal_segment`).
Upstream requests use a bounded connect/read timeout and disable automatic
redirects, so a redirect cannot send a request to a destination the catalog
never validated. The upstream response body is read incrementally and capped
at 10 MiB instead of being buffered in full before the limit is checked.
Request validation against the OAD (`Catalog::validate_request`) is a bounded
subset: it checks that declared *required* query parameters are present,
that an enum-constrained query parameter's value is one of the declared
values, and that a request body's presence and content type match the
operation's declared `requestBody`. It does not validate full JSON Schema
for bodies or non-enum query parameter values.

## Release gate

Provider callback URLs and credential variable names are now deterministic
from catalog platform names. The remaining gate for #9 and #10 is provider
registration, OAD request validation, SSRF tests, and an external review of
the token envelope format before live credentials are handled.
from catalog platform names. Path-traversal rejection, upstream redirect
disabling, and the bounded OAD request validation above are implemented.
The remaining gate for #9 and #10 is provider registration, full JSON Schema
body validation, further SSRF hardening (e.g. blocking requests to internal
network ranges), rate limiting, and an external review of the token envelope
format before live credentials are handled.
Loading
Loading