A GitHub-OAuth preview-access gate for ePHPm, as native middleware. It puts a private preview site (a per-PR WordPress preview, a staging Laravel app) behind GitHub login: does the person at this browser have access, on GitHub, to the repo this preview is for? — answered once, at login, then carried in a stateless signed session that every later request is judged by locally.
This repo is source that the preview build compiles in — see Distribution. It is not a released binary and has no release pipeline.
The gate is a pair, split so a network call can never land on the hot path:
| Crate | Role | Runs on | Talks to GitHub |
|---|---|---|---|
ephpm-middleware-github-auth |
Issuer (cold path) — runs the OAuth dance, checks access, mints the session cookie | login, callback, and any request with no session cookie | yes, ~3 calls per login |
ephpm-middleware-session-cookie |
Verifier (hot path) — verifies the session cookie, redirects to login when it is absent or invalid | every request | never |
Mount the issuer first (it owns the login/callback paths), the verifier second.
Three things line up between them: the HMAC key (session_secret ↔ secret),
the cookie name (cookie_name ↔ cookie, whose defaults already match), and
the login path (login_path ↔ login_url). Mounted alone the issuer is not
an authenticator — it stops a request with no cookie but does not judge one
that has a cookie; it logs that at startup. Always mount the verifier.
# Issuer — owns /_ephpm/auth/github/{login,callback}, issues the session.
[[middleware]]
library = "/opt/ephpm/modules/libgithub_auth.so"
order = 10
config = { client_id = "Iv1.…", client_secret = "env:GH_CLIENT_SECRET",
session_secret = "env:EPHPM_SESSION_SECRET", repo = "acme/web" }
# Verifier — checks the cookie on every request, redirects to login otherwise.
[[middleware]]
library = "/opt/ephpm/modules/libsession_cookie.so"
order = 20
config = { secret = "env:EPHPM_SESSION_SECRET", cookie = "ephpm_session",
login_url = "/_ephpm/auth/github/login", return_to_param = "next" }| Request | Verdict | Cost |
|---|---|---|
| no session cookie | 302 → GitHub authorize, Set-Cookie signed state |
no network |
GET {callback_path}?code=…&state=… |
verify state, exchange the code, check access, 302 → return-to with Set-Cookie session |
3 GitHub calls |
GET {login_path} |
302 → GitHub authorize |
no network |
| valid bypass token, no cookie | 302 → same path with Set-Cookie session |
no network |
| session cookie present | verifier checks it: CONTINUE, or 302 → login |
no network |
The issued session is a compact HS256 JWT carrying the GitHub login, the
vhost it was issued for, how it was obtained, and an expiry — no server-side
session record, so restarts and second nodes log nobody out. The issuer's three
GitHub calls per login are: POST /login/oauth/access_token (code → token),
GET /user (the identity), and the access check (GET /repos/{owner}/{name},
/user/memberships/orgs/{org}, or a team-membership call).
Access is unrevocable until it expires — that is the cost of a
self-contained token. session_ttl_secs (default 8h) is therefore the blast
radius of a stolen cookie; rotating session_secret is the only early
revocation, and it logs everyone out at once.
Issuer (ephpm-middleware-github-auth), key knobs — full validation and
defaults are in config.rs:
| key | default | meaning |
|---|---|---|
client_id |
required | OAuth app / GitHub App client id |
client_secret |
required | client secret — use env:NAME, never a literal |
session_secret |
required, ≥32 bytes | HMAC key for the session token; must match the verifier's secret |
repo / org / team |
one is required | the access target: read access to owner/name, active org membership, or active team membership |
sites |
unset | per-vhost table mapping each preview hostname to its own target (authoritative when present) |
session_ttl_secs |
28800 |
session lifetime (60 … 604800) |
bypass_token |
unset | pre-shared token (≥32 bytes) letting CI reach a preview headlessly; presenting it mints a normal session |
github_base / github_api_base |
https://github.com / https://api.github.com |
override for GitHub Enterprise Server |
scopes |
empty | OAuth scopes; empty is correct for a GitHub App |
Allowed users are expressed as GitHub relationships, not a user list: anyone
who can read repo (or is an active member of org / team) is allowed. A
GitHub App is the recommended shape — a classic OAuth App needs the broad repo
scope to see a private repository at all, which is why scopes defaults to
empty.
Verifier (ephpm-middleware-session-cookie): secret (required, matches
the issuer's session_secret), login_url (required, the issuer's
login_path), cookie (ephpm_session), return_to_param, site_param,
issuer, audience, claims_header.
Since ePHPm #408 the middleware
request phase runs on the static-file path as well as the PHP path, and it
fails closed — the chain is evaluated before the file on disk is opened. Both
modules gate on path/query/cookie alone and are blind to whether the target is a
PHP script or a static byte, so an unauthenticated request for /assets/app.js
(or a private PDF, or any file under the document root) gets the same login
redirect and the bytes are never served. That is the
#395 fix: before it, an auth gate
protected only PHP-dispatched requests and leaked static assets. Each module has
a test pinning it (the_gate_denies_a_static_asset_before_it_is_read /
a_static_asset_with_no_session_is_denied_before_it_is_read).
There is none — this repo has no release workflow, no prebuilt .so, no
checksums or manifest. It is source the preview build compiles in (the
switchboard preview control plane and the wordpress-sample PR-preview app)
via a git dependency / vendored source and a [[middleware]] mount. The
official in-tree modules (jwt, cors, ratelimit, …) ship inside the ePHPm
binary; this gate is preview-only infrastructure and lives here instead.
The trade-off of the dlopen'd cdylib form: a fully static (musl) ePHPm cannot
dlopen, so it cannot load these; the stock glibc-dynamic Linux release can. A
musl build would need them compiled in as builtins.
Both modules build against the ephpm-middleware ABI as a git dependency
pinned by rev to ePHPm main (see the workspace
Cargo.toml) — the same way ePHPm pins litewire. A drift in
EphpmHostV1 / ABI_V1 would be silent UB at the FFI boundary, so the pin is
exact. The pinned rev is past #408 (the static-path request phase) and does not
depend on the request-scheme accessor proposed in #409 (not merged).
cargo build --workspace --release # → target/release/lib{github_auth,session_cookie}.so
cargo test --workspace # unit tests + the OAuth round trip vs a stub GitHub
cargo clippy --workspace --all-targets -- -D warnings
cargo +nightly fmt --all -- --checkCI (.github/workflows/ci.yml) runs fmt / clippy / test / release-build on
every PR and push to main, on GitHub-hosted runners.
What CI cannot cover, and needs a human: a round trip against the real
github.com with a registered GitHub App. Everything this side of that is
exercised over real TCP against a stub in
oauth_round_trip.rs.