These pages describe PrivacyFence 7.0.1. Download it · What changed
OAuth relay runbook¶
How to run the OAuth redirect relay at https://oauth.privacyfence.eu: provisioning it, checking a
deploy, reviewing and revoking registrations, rotating secrets and rolling back. The Worker's
contract and development notes are in ../cloudflare/oauth-relay/README.md.
The terms are https://privacyfence.eu/relay-terms/ and the privacy statement is
https://privacyfence.eu/privacy/#relay.
The relay is a service PrivacyFence runs, like the download Worker. It is not an artifact operators install. A provider accepts only fixed redirect URLs for an app, so every organization server that uses a built-in app sends the provider's redirect to the relay, and the relay forwards it to the server that proved it controls its own domain.
How the relay is released¶
- Production deploys.
deploy-oauth-relay-worker.ymldeploys the Worker when a change undercloudflare/oauth-relay/**reachesmain, or when someone runs the workflow onmain. - Tags do not deploy it. A release tag never changes the relay, and a relay change never needs an app release.
- Order. The relay must be live before the first release whose organization servers use built-in
apps.
releasing.mdand the/cut-releasecommand checkcurl -fsSI https://oauth.privacyfence.eu/healthbefore tagging. - Compatibility rule. Every relay version must keep serving every released server that uses it.
The contract (
/register,/callback/:grant, thestateformat, the loopback rule) only grows: a route or a field may be added, and an existing one is never changed or removed while a supported release uses it. A breaking change is a new path (for example/v2/callback/...) used by the new release, with the old path kept.
Provisioning¶
Done once, in this order. The Cloudflare resources are listed in
downloads-and-release-kpi.md.
- Zone and account. The relay lives in the same Cloudflare account and zone (
privacyfence.eu) as the download Worker.oauth.privacyfence.eumust have no DNS record: the Worker's[[routes]] custom_domain = truecreates it on the first deploy. - Bot protection. Bot Fight Mode is off for the zone, or a WAF skip rule covers
/register,/callback/*and/admin/*onoauth.privacyfence.eu. Otherwise servers' register calls and the providers' redirects are challenged. - The deploy token. An account-owned API token that can edit Workers scripts and routes, made
the way
downloads-and-release-kpi.mddescribes and verified before it is stored. It needs two permission policies: Workers Scripts · Edit on the account, and Workers Routes · Edit plus Zone · Read onprivacyfence.eu. The token form offers Workers Scripts under a zone policy too, butwrangler deploycalls/accounts/<id>/workers/scripts/…, which a zone policy does not cover: the deploy then fails withNo access to the specified resource. - The GitHub environment
oauth-relay-worker, under repo Settings → Environments: - Deployment branches:
mainonly. - Required reviewers: at least one person other than the author of the change, with "Prevent
self-review" on. A Workers deploy token covers every Worker in the account, so the relay's token
could replace the download Worker and the other way round. The relay stays in the one account, and
the required reviewers are the control. Set the same on
downloads-worker. - The environment secrets, all in
oauth-relay-worker:
| Secret | Holds |
|---|---|
CF_OAUTH_RELAY_WORKER_API_TOKEN |
the deploy token |
CF_OAUTH_RELAY_WORKER_ACCOUNT_ID |
the Cloudflare account ID |
CF_OAUTH_RELAY_REVOKE_TOKEN |
the maintainer token (32 or more random characters, for example openssl rand -base64 32) |
CF_OAUTH_RELAY_GMAIL_CLIENT_ID, CF_OAUTH_RELAY_GMAIL_CLIENT_SECRET, CF_OAUTH_RELAY_GMAIL_REFRESH_TOKEN |
the credential for the registration mail (below) |
- The first deploy. Run
Deploy OAuth relay Workeronmainfrom the Actions tab. It deploys the Worker, creates the Durable Object classRegistryfrom the migration, sets the Worker secrets from the environment secrets, and checks/health. If it fails, see When a deploy fails. - The checks below, then the smoke tests.
The registration mail¶
Every first registration emails the maintainers, so a suspicious host is seen the same day. The
Worker sends through the Gmail API, with fetch only, as [email protected], to the
constant [email protected] in src/notify.ts. privacyfence.eu mail is on Google Workspace,
so Cloudflare Email Routing cannot be enabled on it.
- The sender.
[email protected]is an alias of[email protected], set up as a Gmail "Send mail as" address.NOTIFY_FROMinwrangler.tomlholds it. - The credential. An OAuth client of type Web application with audience Internal, in a Google
Cloud project of the
privacyfence.euWorkspace. An Internal app needs no verification and its refresh tokens do not expire after seven days. - Minting the refresh token. In the project add
https://developers.google.com/oauthplaygroundas an authorized redirect URI of the client. In the OAuth 2.0 Playground open the settings, tick "Use your own OAuth credentials" and enter the client ID and secret. Authorize the single scopehttps://www.googleapis.com/auth/gmail.sendsigned in as[email protected], exchange the code, and copy the refresh token. It can send but never read mail. Store the client ID, client secret and refresh token as the threeCF_OAUTH_RELAY_GMAIL_*environment secrets, then run the workflow onmain. - Containing a stolen token. Whoever holds the token can send mail as
relay-notify@or astest@itself. So[email protected]sits in its own organizational unit with a Gmail content compliance rule for outbound mail only (Admin console → Apps → Google Workspace → Gmail → Compliance): a message whose envelope recipient is not inprivacyfence.euor the maintainer's other own domain is rejected. A leaked token cannot phish anyone outside. Use content compliance, not "Restrict delivery": that also blocks incoming mail, and the QA account must keep receiving the providers' sign-up and verification emails. The QA account never sends mail through PrivacyFence; its tests only draft. - Rotating the token. Revoke the old one at https://myaccount.google.com/permissions as
[email protected], mint a new one as above, update the environment secret and run the workflow onmain. - When it fails.
GET /registrationsreturns the counters in theX-Notify-Failures,X-Notify-Last-ErrorandX-Notify-Skippedheaders; the last error is a short code (token_invalid_grant,token_http_<status>,send_http_<status>,network).token_invalid_grantmeans the refresh token was revoked, for example after a password change ontest@: mint a new one. A registration is never refused because its mail failed, and a registration whose mail was not sent is listed with"notified": false. - What mails. A new host, and a host whose verified sign-in domains gain one that no earlier
mail named (subject
PrivacyFence relay sign-in domains added: <host>). Removing a domain, putting a named one back or repeating a registration sends nothing, so nobody can use up the limit by flipping a record. - The limit. At most 3 mails a UTC day about one host, and 50 in all. A change past a host's
allowance leaves its row
"notified": falseuntil a later mail names it. The first registration over the daily limit sends one mail,More new registrations today than the notification limit; see /registrations., and the rest of the day sends none, so review thenotified: falserows.
The Worker's checks¶
Run these at setup and on every review:
- No Logpush job and no logging WAF rule cover
oauth.privacyfence.eu. The Worker writes no logs andwrangler.tomlkeeps observability off. wrangler tailaccess is limited to the maintainer token. Anyone who can tail the Worker can read authorization codes in transit.- The GitHub environment still has required reviewers and the
mainbranch restriction.
Checking a deploy¶
With RELAY=https://oauth.privacyfence.eu, REVOKE_TOKEN the maintainer token and
NONCE=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:
# /health returns 200
curl -fsS "$RELAY/health"
# /registrations is a JSON list (it needs the maintainer token)
curl -fsS -H "Authorization: Bearer $REVOKE_TOKEN" "$RELAY/registrations"
# /register for a host that cannot prove itself returns 403 proof_failed
curl -sS -X POST "$RELAY/register" -H "Content-Type: application/json" \
-d '{"host":"smoke-test.example.org","secret":"'"$NONCE"'"}'
# a loopback /callback returns a 302 to http://localhost:...
curl -sSI "$RELAY/callback/slack?state=localhost%3A8765~$NONCE&code=smoke"
# an unregistered host's /callback returns 400
curl -sSI "$RELAY/callback/slack?state=unregistered.example.org~$NONCE&code=smoke"
# a loopback /callback with no code and no error returns 400
curl -sSI "$RELAY/callback/slack?state=localhost%3A8765~$NONCE"
The deploy workflow itself only checks /health.
When a deploy fails¶
The Deploy Worker step stops before the secrets are set, so a failed deploy leaves no new
secrets behind. Fix the cause and use Re-run failed jobs on the same run.
/accounts/***/workers/scripts/privacyfence-oauth-relay/secretsfailed:No access to the specified resource. The token cannot reach the account's Workers scripts. Check thatCF_OAUTH_RELAY_WORKER_ACCOUNT_IDin theoauth-relay-workerenvironment holds this account's ID (an environment secret wins over a repository secret of the same name), and that the token has Workers Scripts · Edit in an account policy, not only in theprivacyfence.euzone policy. Editing a token's policies keeps its value, so no secret changes.Trigger configuration … was only partially updated, custom domains,[code: 10013]. The Worker was uploaded butoauth.privacyfence.eucould not be attached, usually because a DNS record for that name already exists. A failed attach can leave one behind itself: a locked record of type Worker with no Worker named next to it. Attach the domain by hand athttps://dash.cloudflare.com/<account id>/workers/services/view/privacyfence-oauth-relay/production/settings→ Domains & Routes → Add → Custom domain, then re-run;wrangler deploykeeps a domain that is already attached. Use that address because the Workers & Pages list can leave out a Worker that has never been deployed with a route.GET /accounts/<id>/workers/scriptswith the deploy token shows whether the upload reached the account.
Review new registrations¶
- Act on each notification mail the same day: the host, the time, the IP address and country of the request, the number of active registrations, and whether the host registered for the built-in Google sign-in and which domains it proved.
- Once a week read
/registrations, starting with the rows marked"notified": false. - A host that looks like a phishing page, or that a provider or a user reports, is revoked within one working day of the report.
A registered host can ask any Google account to consent to the connector app, so the trust in the
built-in app extends to every registered host. The provider docs therefore tell administrators not
to mark the built-in PrivacyFence app as trusted or pre-approved. The relay refuses hosts under a
suffix of the Public Suffix List's private section (x.github.io, a.pages.dev, a.workers.dev
and similar), which would otherwise let anyone with a free account register.
Revoking and restoring a host¶
curl -X POST https://oauth.privacyfence.eu/admin/revoke \
-H "Authorization: Bearer $REVOKE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"host":"pf.example.org"}'
The host is refused a new registration (403 revoked) and its callbacks answer 400 at once. A
revoked row is created even for a host the relay never saw. A mistaken revocation is undone with
/admin/restore, which takes the same body and clears the host's verification, so the host proves
control again with its next daily registration before callbacks are forwarded:
curl -X POST https://oauth.privacyfence.eu/admin/restore \
-H "Authorization: Bearer $REVOKE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"host":"pf.example.org"}'
Reading the registrations¶
GET /registrations with the maintainer token lists the non-revoked hosts verified within the last
35 days, sorted, each with host, registered_at, notified and the verified signin_domains.
There is no public list. A host that has not registered for 35 days is no longer forwarded to and
drops out of the list; its row stays, and it comes back when it registers again.
Rotating the maintainer token¶
Update the environment secret CF_OAUTH_RELAY_REVOKE_TOKEN, then run Deploy OAuth relay Worker on
main. The workflow stores it as the Worker secret REVOKE_TOKEN, so the old value stops working
when the run finishes.
Rolling back¶
npx wrangler rollback from cloudflare/oauth-relay/ with the deploy token, or the Cloudflare
dashboard's Deployments tab for the Worker privacyfence-oauth-relay. The registry lives in the
Durable Object and is not touched by a rollback of the code.
The built-in sign-in¶
A server that uses the built-in Google sign-in also proves the Google Workspace domains it signs
people in from: one DNS TXT record per domain, at _privacyfence-relay.<domain>, with the value
pf-relay=v1:<hex SHA-256 of "v1|<host>|<proof>">
where <host> is the server's registered host (with :port if it has one) and <proof> is what
it serves at /.well-known/privacyfence-relay. The relay checks them with DNS over HTTPS on every
successful registration and returns the missing ones with the record to publish. It forwards a
sign-in code only when Google's hd redirect parameter names a verified domain.
Two properties keep one server from taking over another organization's sign-in:
- The record names the host. A record published for
pf.acme.comdoes not match any other host, so a server that copies acme's public proof cannot claimacme.comwith acme's record. - Only the secret changes a registration. The well-known path serves the SHA-256 of a secret
the server keeps in
org/relay_token.txtand sends to the relay alone. A third party who knows the host and its proof cannot register it, refresh it or change its sign-in domains. A verified domain leaves a registration only when DNS says its record is gone or wrong, never because a request left it out.
A domain that can never be a sign-in domain (a public suffix such as co.uk, or gmail.com) is
refused on its own and listed in signin_domains_refused; the registration, and the server's
connectors, carry on. The rate limits count an IPv6 client by its /64, and /register accepts only
Content-Type: application/json, so a web page cannot make its visitors' browsers post to it.
hd is observed behaviour, not a documented contract. If Google ever stops sending it, built-in
sign-ins fail at the relay. The Worker variable SIGNIN_REQUIRE_HD in wrangler.toml (default
"true") switches the relay-side check off when set to "false"; the server checks hd in the
ID token as well. Change it in a reviewed commit and let the workflow deploy it.
Updating the Public Suffix List¶
cloudflare/oauth-relay/src/psl_icann.ts and src/psl_private.ts are generated from
public_suffix_list.dat at a pinned commit of https://github.com/publicsuffix/list, recorded in
each file's header. To update, run node scripts/update-psl.mjs <commit> in cloudflare/oauth-relay/,
read the diff (a removed private suffix lets its hosts register), run the tests and commit.
Rotating a built-in app secret¶
Add the new secret next to the old one where the provider allows two (Google and Salesforce do), ship a release that carries the new one, and retire the old one in the provider console after that release is widely adopted. Servers keep working during the overlap because they hold the credential the release they run was built with.
The privacy and abuse picture¶
The relay stores each registered server's host name, its registration dates, its verified sign-in domains and whether its mail went out. It keeps short-lived request counters for the rate limits and prunes them within the hour. It writes no logs, and it never stores an authorization code. Slack and Miro send no PKCE, so a relayed code for them passes through the Worker in the browser redirect; the Worker keeps no logs and the code is single use.