A-101 Gatehouse · section A-A
DRAWBRIDGE
Comes down for signed-in people.
- draw·bridge noun, not an acronym
- The one way into the Citadel. It comes down only for people who are signed in, and goes back up for everyone else.
One OpenID Connect client at auth.apps.wayne.edu, signed in through Microsoft Entra ID, in front of every app on the platform. It makes sure the visitor has a session, throws away any identity header they tried to bring along, and hands the app a 60-second ES256-signed assertion that SESAME verifies.
A-201 Longitudinal section
One request through the gatehouse
Apache httpd with four modules and a small PHP component. Every request takes the same passage, in this order.
-
Browser
Arrives with whatever headers it likes. It may have brought a forgery.
the approach
-
Guard
Strips every inbound identity header, in every hyphen and underscore spelling, before anything else runs.
mod_lua
-
OpenID Connect client
One client for every app, signed in through Entra ID, one session per app host. No session: off to Entra to sign in.
mod_auth_openidc
-
Signer
Mints a fresh ES256 assertion for this request. If it cannot sign, the answer is 503.
mod_rewrite prg: map + PHP
-
App
Gets the request with one signed header, and verifies it with SESAME's
gatewaydriver.mod_proxy
login
No session? The browser goes to sign in, then comes back.
api
No session? A plain 401. A script is never handed an HTML login page.
public
Passes through with no identity at all. Public is a decision, not a default.
signer down
503. A request never reaches the app unsigned.
Why a moat and not a lock on every door: each app doing its own OpenID Connect is another chance to get a nonce, a state check or a token signature wrong. DRAWBRIDGE signs in once, in a maintained module, and every app checks one small signed token.
A-301 Door schedule
Which doors need a key
Each app declares its paths in one config file. Nothing else decides. Rules are exact (/up) or a prefix (/assets/); the longest wins, and exact beats a prefix of the same length. No wildcards, no regular expressions, and the query string never chooses.
| Door | Path | Rule | Needs |
|---|---|---|---|
| D1 | /up | exact | public |
| D2 | /assets/ | prefix | public |
| D3 | /admin/ | prefix | login |
| D4 | /api/ | prefix | api |
| D0 | anything else | default | login |
$ php bin/drawbridge explain app1.apps.wayne.edu /admin/users
This box runs the same matching rules in your browser, written to agree with explain for the example config. The output lines are the real command's wording.
A-401 Detail · the assertion
What crosses the bridge
A compact JWT, minted for every request, good for 60 seconds by default. The key never leaves the signer.
header
{
"alg": "ES256",
"typ": "drawbridge+jwt",
"kid": "drawbridge-1"
}
claims (sample)
{
"iss": "https://auth.apps.wayne.edu",
"aud": "app1.apps.wayne.edu",
"sub": "ab1234",
"iat": 1798000000, "nbf": 1798000000,
"exp": 1798000060,
"jti": "4f1c9a2e7b3d",
"auth_time": 1797999400,
"amr": ["pwd", "mfa"],
"email": "…", "name": "…",
"affiliations": ["staff"]
}
Sent as X-Sesame-Assertion. Apps that cannot verify a token yet can take plain X-Authenticated-* headers instead (mode: headers), or both.
Left at the gate
Removed from every request, in every hyphen and underscore spelling, before authentication runs. Then the gateway sets its own.
X-Authenticated-*X-Sesame-*X-Drawbridge-*Remote-UserX-Remote-UserX-Forwarded-*ForwardedX-Real-IPX-Auth-Request-*OIDC-Claim-*Proxy
Connection is trimmed to close, keep-alive and upgrade, so a client cannot ask the proxy to drop a header the gateway set.
A-501 General notes
Thirteen rules, each with a test
From BEST_PRACTICES.md. The contractor shall not improvise.
- Strip first. Every inbound identity header, every spelling, every request, before authentication.
- Fail closed. No session, no signature, a refused path, a missing key or a bad config means no access.
- Public is a decision. A path with no rule gets
login. Public paths carry no identity. - Match the normalised path. Encoded slashes, backslashes, semicolons and bad UTF-8 are treated as protected.
- Exact or prefix, nothing clever. The Apache pattern is built from the literal, never typed by hand.
- Sign narrowly and briefly. ES256, a pinned
typ, one audience per app, a freshjtieach time. - Let the module do OpenID Connect. DRAWBRIDGE writes no OAuth client. Forged callbacks and missing contexts are refused.
- One session per app host. A session cookie carried to another host opens nothing there.
- Overwrite forwarded headers.
X-Forwarded-*come from the gateway, never the client. - Secrets stay out. Keys are mounted files; the client secret and passphrase live in the environment. Logs carry a reason code, never a subject or a token.
- Do not trust the network. The app verifies the signature even if only the gateway can reach it.
- Run small and boring. Non-root, port above 1024, state under
/tmp, logs to stdout. - Bounded dependencies. No
*, nodev-master;composer auditruns in CI.
A-601 Construction sequence
Build the gatehouse
You need Docker. The PHP component wants PHP 8.3+ and Composer, but the container carries its own, so these steps need nothing else.
PS> git clone https://github.com/waynestate/drawbridge.git drawbridge; cd drawbridge PS> docker run --rm -v "${PWD}:/app" -w /app composer:2.10.3 composer install --no-interaction PS> Copy-Item .env.example .env PS> Copy-Item config\drawbridge.example.yaml config\drawbridge.yaml PS> docker run --rm -v "${PWD}:/app" -w /app composer:2.10.3 php bin/drawbridge config:validate --config=config/drawbridge.yaml The config is valid: 1 app(s). OpenID Connect provider: https://login.microsoftonline.com/<tenant-id>/v2.0 … client id, secret (set or not, never its value), claims, amr map - app1 (app1.apps.wayne.edu): default login, 5 path rule(s), mode assertion, auth context: /admin/payroll/ c1 PS> docker build -f docker/gateway/Dockerfile -t drawbridge-gateway .
$ git clone https://github.com/waynestate/drawbridge.git drawbridge && cd drawbridge $ docker run --rm -v "$PWD:/app" -w /app composer:2.10.3 composer install --no-interaction $ cp .env.example .env $ cp config/drawbridge.example.yaml config/drawbridge.yaml $ docker run --rm -v "$PWD:/app" -w /app composer:2.10.3 php bin/drawbridge config:validate --config=config/drawbridge.yaml The config is valid: 1 app(s). OpenID Connect provider: https://login.microsoftonline.com/<tenant-id>/v2.0 … client id, secret (set or not, never its value), claims, amr map - app1 (app1.apps.wayne.edu): default login, 5 path rule(s), mode assertion, auth context: /admin/payroll/ c1 $ docker build -f docker/gateway/Dockerfile -t drawbridge-gateway .
Then raise it
The entrypoint refuses to start without these: two files mounted read-only and two secrets in the environment. None of them is ever in git.
drawbridge.yamlthe per-app configsigning.keythe ES256 key;keys:generatemakes oneDRAWBRIDGE_OIDC_CLIENT_SECRETthe Entra client secret, from the app registrationDRAWBRIDGE_OIDC_CRYPTO_PASSPHRASEencrypts sessions; 32+ characters
$ docker run --rm -p 8080:8080 --env-file .env \
-v "$PWD/config/drawbridge.yaml:/etc/drawbridge/drawbridge.yaml:ro" \
-v "$PWD/secrets:/run/drawbridge:ro" drawbridge-gateway
The drafting kit php bin/drawbridge <command>
config:validate- checks the config and prints every problem at once
explain <host> <path>- says which rule applies, and why
render- writes
apps.confandpolicy.luafor Apache keys:generate- an ES256 key pair; refuses to overwrite
$ composer ci # manifest, unit tests, Pint, PHPStan level 8, composer audit PS> rig\run.ps1 # the real image, a mock OpenID Connect provider and an echo app (Windows) $ sh rig/run.sh # the same rig on Linux and macOS
The rig covers sign-in, public and protected paths, header spoofing (forged OIDC_* headers too), path tricks, forged callbacks, step-up to an authentication context, signature checks against SESAME's rules, and logout. Nothing in it reaches Entra.
A-701 Site plan
Where the gate stands in WELD
WELD is the Wayne State app stack: one contract, several paths that agree. DRAWBRIDGE is the wall's one door; the rest of the stack lives inside.
-
DRAWBRIDGEsigns the visitor in, once
-
SESAMEits gateway driver verifies the token
-
HELMETsets the security headers
-
WayneUIrenders the page
Key
WayneUI the interface layer
CREED the shared rules and policy
PROCTOR checks an app against the policy
PREFLIGHT the shared CI components
SESAME sign-in inside the app
HELMET security headers and CSP reports
Pinstripe the coding standard, pressed
Sourdough the starter that keeps the stack fed
WELD every part, already joined
BOOSTER keeps an app's PHP current
ROLLCALL every app answers "present"
ADVISOR what applies, and what to do next
SQUIRE carries the release to Citadel
DRAWBRIDGE the one OpenID Connect front door you are here
A-801 Revision schedule
Drawn, not yet built
Release 0.2 moves the chain to OpenID Connect with Entra ID; the gate, the guard, the signer and the rig stay. What waits is listed in OPEN_QUESTIONS.md, with the safer default the code uses until then.
| Rev | Item | Until then |
|---|---|---|
| next | Shared session store, more than one instance | One instance, sessions in its shared memory; a restart signs everyone out. |
| next | Bearer tokens on API paths | An api path without a session gets a plain 401. |
| next | Two signing keys at once, for rotation | One key, one kid; rotation is a short coordinated window. |
| next | Identity on public paths, if wanted | Public means no identity, ever. |
| next | Prometheus metrics, off by default | Logs to stdout and stderr. |