Skip to content

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.

Build the gatehouse Read the section

DRAWBRIDGE mark: a cream gatehouse tower on a green tile, its bridge lowered on a gold chain
Section through the DRAWBRIDGE gatehouse An architect's section: a visitor stands on the approach bank, a moat lies between the bank and the gatehouse, and the drawbridge hangs from a gold chain. Inside the gatehouse are the header guard, the OpenID Connect client and the assertion signer; Entra ID, the identity provider, sits off the sheet; the passage leads to the app. 1 2 3 signed in drawbridge+jwt span: one sign-in 13 rules high moat: 60 s deep guard OIDC sign the approach the Citadel → Entra ID (OIDC) off this sheet, by others A section A-A · scale: one request
The bridge is down for a signed-in visitor. A forged header would be stripped at the gate, before sign-in runs.

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.

  1. Browser

    Arrives with whatever headers it likes. It may have brought a forgery.

    the approach

  2. Guard

    Strips every inbound identity header, in every hyphen and underscore spelling, before anything else runs.

    mod_lua

  3. 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

  4. Signer

    Mints a fresh ES256 assertion for this request. If it cannot sign, the answer is 503.

    mod_rewrite prg: map + PHP

  5. App

    Gets the request with one signed header, and verifies it with SESAME's gateway driver.

    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 schedule for app1.apps.wayne.edu, from config/drawbridge.example.yaml
DoorPathRuleNeeds
D1/upexactpublic
D2/assets/prefixpublic
D3/admin/prefix
D4/api/prefixapi
D0anything elsedefault

$ php bin/drawbridge explain app1.apps.wayne.edu /admin/users
app1 /admin/users => login (matched as /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-User
  • X-Remote-User
  • X-Forwarded-*
  • Forwarded
  • X-Real-IP
  • X-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.

  1. Strip first. Every inbound identity header, every spelling, every request, before authentication.
  2. Fail closed. No session, no signature, a refused path, a missing key or a bad config means no access.
  3. Public is a decision. A path with no rule gets login. Public paths carry no identity.
  4. Match the normalised path. Encoded slashes, backslashes, semicolons and bad UTF-8 are treated as protected.
  5. Exact or prefix, nothing clever. The Apache pattern is built from the literal, never typed by hand.
  6. Sign narrowly and briefly. ES256, a pinned typ, one audience per app, a fresh jti each time.
  7. Let the module do OpenID Connect. DRAWBRIDGE writes no OAuth client. Forged callbacks and missing contexts are refused.
  8. One session per app host. A session cookie carried to another host opens nothing there.
  9. Overwrite forwarded headers. X-Forwarded-* come from the gateway, never the client.
  10. 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.
  11. Do not trust the network. The app verifies the signature even if only the gateway can reach it.
  12. Run small and boring. Non-root, port above 1024, state under /tmp, logs to stdout.
  13. Bounded dependencies. No *, no dev-master; composer audit runs 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 .

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.yaml the per-app config
  • signing.key the ES256 key; keys:generate makes one
  • DRAWBRIDGE_OIDC_CLIENT_SECRET the Entra client secret, from the app registration
  • DRAWBRIDGE_OIDC_CRYPTO_PASSPHRASE encrypts 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.conf and policy.lua for 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.

  1. DRAWBRIDGEsigns the visitor in, once
  2. SESAMEits gateway driver verifies the token
  3. HELMETsets the security headers
  4. 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.

Planned revisions
RevItemUntil then
nextShared session store, more than one instanceOne instance, sessions in its shared memory; a restart signs everyone out.
nextBearer tokens on API pathsAn api path without a session gets a plain 401.
nextTwo signing keys at once, for rotationOne key, one kid; rotation is a short coordinated window.
nextIdentity on public paths, if wantedPublic means no identity, ever.
nextPrometheus metrics, off by defaultLogs to stdout and stderr.