Skip to content

Commit 0111e24

Browse files
ryan-williamsclaude
andcommitted
Retire Cloudflare Access: demo on Google + email codes, cf-access deprecated
Demo: `/auth/sso` and the `ACCESS_*` config are gone. Staff sign in with Google like everyone else, and `POST /api/staff` promotes a view session at `STAFF_DOMAIN` to the admin gate, so one registered redirect URI serves both. Copy no longer offers Access/SSO. Library: README leads with Google/OIDC + email codes; the Access adapter moves to a "Legacy" note and its exports are `@deprecated` — kept until gcs and cw-s3 (pinned to older SHAs, still importing it) finish migrating. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DyJyzSYpRxJuL6x6NyjpG3
1 parent c9f4483 commit 0111e24

14 files changed

Lines changed: 76 additions & 86 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,7 @@ if (res.ok) return new Response(null, { headers: { 'set-cookie': res.cookie } })
104104

105105
Every knob is optional; zero-config is an unlimited-use, never-expiring, unnamed link. `maxRedeems` counts **sessions minted** (≈ distinct browsers), not requests — which is what makes "one-use link" mean what a human predicts. Note that `maxRedeems: 1` is hostile UX in practice (the recipient opens it on their phone, then their laptop, and is locked out); prefer unlimited-redeem, named, logged, and revocable.
106106

107-
**SSO.** Point one CF Access application at `/auth/sso` and leave the rest of the site public at the edge:
108-
109-
```ts
110-
import { ssoHandler } from '@open-athena/auth/cf-access'
111-
112-
export const onRequest = ssoHandler({ gate, teamDomain: 'https://acme.cloudflareaccess.com', aud: env.ACCESS_AUD })
113-
```
114-
115-
**Or skip Access entirely.** `@open-athena/auth/oidc` signs people in against an OIDC provider directly, so the hosted chooser and its generic copy are replaced by a page you own:
107+
**Sign in with Google** (or any OIDC issuer). `@open-athena/auth/oidc` signs people in against the provider directly — no Cloudflare Access, no Zero Trust seats, and a sign-in page you own. Pair it with One Tap (`GoogleOneTap`) in the page and email codes (below) for addresses Google can't vouch for:
116108

117109
```ts
118110
import { GOOGLE, oidcCallback, oidcStart } from '@open-athena/auth/oidc'
@@ -139,9 +131,7 @@ Authorization-code flow, confidential clients only, nothing persisted between th
139131

140132
A verified address that policy rejects redirects with `?denied=<email>` rather than 403ing, which is what lets an app pre-fill request-access with an address the *provider* vouched for instead of one the visitor typed.
141133

142-
There's also a seat argument: every Access-authenticated user consumes a Cloudflare Zero Trust seat, while share links never touch Access at all. A growing allowlist hits that ceiling; this is the way off it.
143-
144-
`ssoSessionHandler` is the same thing for a deployment that can mint sessions but not verify them — the auth store lives in another worker, so there's no gate to hand it. It takes `{ secret, teamDomain, aud, cookieName }` and mints for any Access-verified email; the gate that later verifies the cookie re-derives scopes from `policy` on every request, so authorization isn't being skipped, just deferred to where it can be answered.
134+
**Legacy: Cloudflare Access** (`@open-athena/auth/cf-access`). The package began as a layer *on* Access — Access authenticated, the gate added share links and the log — and the adapter still exists for deployments that haven't cut over: `ssoHandler` trades an Access JWT on one Access-gated path (`/auth/sso`) for a gate session, and `ssoSessionHandler` does the same where the verifying gate lives in another worker. It's deprecated in favour of the OIDC + email-code path above, which does the same job without Access: every Access-authenticated user consumes a Zero Trust seat (the free tier caps at 50), and the hosted chooser can't be styled. It will be removed once its last consumers have migrated.
145135

146136
**Revocation is instant.** Grant-backed sessions re-join their grant row on every request, so `gate.revoke(id)` kills every session that link ever minted — no waiting out a cookie TTL. That property is what makes the social story work: assume links get forwarded, and design so forwarding is *visible and revocable* rather than prevented.
147137

@@ -257,8 +247,8 @@ The default read is the Cloud Identity API, which is what honours a group-*owner
257247

258248
```tsx
259249
<AuthGate
260-
source={{ kind: 'app' }} // or { kind: 'edge' } for Tier 1 — the only line that changes
261-
signIn={<SignInPanel signInUrl="/auth/sso" requestAccess />}
250+
source={{ kind: 'app' }}
251+
signIn={<SignInPanel googleUrl="/auth/google" emailAuth requestAccess />}
262252
>
263253
{whoami => <>
264254
<AccessNotice whoami={whoami} /> {/* "Private link for Bob Smith · access is logged" */}

‎demo/README.md‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Three pages:
66

77
| Path | What it shows |
88
|---|---|
9-
| `/` | The ways in: the library's `SignInPanel` (Google One Tap + redirect, Access SSO, an emailed code), and — more prominently — two share links you can just click |
9+
| `/` | The ways in: the library's `SignInPanel` (Google One Tap + redirect, an emailed code / magic link), and — more prominently — two share links you can just click |
1010
| `/dashboard` | The gated page. The wall in front of it, then a page whose only content is what the gate knows about you |
1111
| `/admin` | The power-user side, in a throwaway sandbox: mint links, watch their log, disable / rotate / revoke them, edit an allowlist and run a (simulated) directory sync |
1212

@@ -27,15 +27,15 @@ Step 5 is the load-bearing one: grant-backed sessions re-join their grant row on
2727
**Simulated, and labelled as such in the page:**
2828

2929
- **Mail.** `emailCodeAuth`'s `start` reply never says whether mail went out (so the form can't probe who's allowed), and delivery *is* the verification. This demo has no sending domain for most visitors, so its `send` captures the message instead of mailing it, stashes it in a short-lived `HttpOnly` cookie only your browser can read back (`GET /auth/email/outbox`), and the form shows you the code and link. That proves nothing about the address, and the page says so. Mail really goes out only when `RESEND_API_KEY` + `MAIL_FROM` are set *and* the recipient's domain is in `MAIL_DOMAINS` — a public form that mails whatever address a stranger types is a spam cannon pointed at other people.
30-
- **Google.** Without `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`, `/auth/google/*` answers 503, so the page drops One Tap and the Google button (and says why) rather than offering a sign-in that can't complete. The staff SSO button is likewise hidden under `pnpm dev`, where no Cloudflare Access sits in front of `/auth/sso`. Google exposes no API to create the client; `scripts/provision-oauth-client.mjs` walks the one manual step.
30+
- **Google.** Without `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`, `/auth/google/*` answers 503, so the page drops One Tap and the Google button (and says why) rather than offering a sign-in that can't complete. Google exposes no API to create the client; `scripts/provision-oauth-client.mjs` walks the one manual step.
3131
- **The directory sync.** `Admin → Allowlist → Sync now` `replaceSource`s `sync:staff@example.org` with a random subset of a five-person roster, returning the same `[{ group, source, count }]` shape `syncGroupsToAllowlist` returns. A real deployment puts that call at the marked spot in `functions/api/admin/[[path]].ts`, with a service-account key. The table isn't wired into any policy here (the demo admits any address) — which demonstrates a real property: mounting the editor never changes who gets in; `allowlistPolicy(store)` in the gate's `policy` is the separate line that does.
3232

3333
## Two gates, one grants table
3434

3535
`functions/_lib/gates.ts` builds two gates over the same D1 store:
3636

37-
- **`viewGate`** (`oa_demo_view`) — guards the dashboard. Its policy admits staff and *anyone who isn't a sandbox identity*: an email session (SSO, Google, or an emailed code — they all end in `gate.signIn`) re-derives its scopes from this policy on every request, so "who may sign in by email" is decided in that one line. A real deployment writes `domainPolicy`, `allowlistPolicy`, or both there.
38-
- **`adminGate`** (`oa_demo_admin`) — guards the admin page. Staff get `admin` + `requests` + `reports`; a sandbox identity gets `admin` only, so a visitor playing admin still meets the wall on the dashboard and has to mint themselves a link.
37+
- **`viewGate`** (`oa_demo_view`) — guards the dashboard. Its policy admits staff and *anyone who isn't a sandbox identity*: an email session (Google or an emailed code — both end in `gate.signIn`) re-derives its scopes from this policy on every request, so "who may sign in by email" is decided in that one line. A real deployment writes `domainPolicy`, `allowlistPolicy`, or both there.
38+
- **`adminGate`** (`oa_demo_admin`) — guards the admin page. Staff (Google sign-ins at `STAFF_DOMAIN`, promoted from their view session by `POST /api/staff` — one registered redirect URI serves both gates) get `admin` + `requests` + `reports`; a sandbox identity gets `admin` only, so a visitor playing admin still meets the wall on the dashboard and has to mint themselves a link.
3939

4040
Separate cookie names let one browser hold both roles at once. The shared store is what makes revocation in one visible to the other.
4141

@@ -63,18 +63,17 @@ Migrations come straight from the package (`migrations_dir = "../migrations"`),
6363
wrangler d1 create oa-auth-demo # paste database_id into wrangler.toml
6464
wrangler d1 migrations apply oa-auth-demo --remote
6565
wrangler pages secret put SESSION_SECRET # openssl rand -base64 32
66-
wrangler pages secret put ACCESS_AUD # the Access application's AUD tag
6766
pnpm deploy
6867
```
6968

70-
Then, for SSO, create **one** Cloudflare Access application covering `auth.oa.dev/auth/sso` and nothing else. Narrowness is the point: the landing page and its og:image stay publicly crawlable so unfurls work, and Access is reduced to an IdP on a single path. Set `ACCESS_TEAM_DOMAIN` in `wrangler.toml` to your Zero Trust team domain.
69+
No Cloudflare Access / Zero Trust anywhere: the whole site is public at the edge, and the gate is the app's.
7170

7271
Optional, each turning a simulated thing real (see `wrangler.toml` for the full list):
7372

7473
- `RESEND_API_KEY` + `MAIL_FROM` (`scripts/provision-resend.mjs` stores both) and `MAIL_DOMAINS` — email codes are mailed to those domains.
7574
- `GOOGLE_CLIENT_ID` + `GOOGLE_CLIENT_SECRET` (`scripts/provision-oauth-client.mjs`) — Google sign-in and One Tap work.
7675

77-
Without any of them, `/auth/sso` returns 401, `/auth/google/*` returns 503, and codes show on screen — everything else (share links, request-access, the log, the allowlist) still works.
76+
Without any of them, `/auth/google/*` returns 503, and codes show on screen — everything else (share links, request-access, the log, the allowlist) still works.
7877

7978
## Caveats
8079

‎demo/functions/_lib/gates.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/**
22
* Two gates over one grants table.
33
*
4-
* `viewGate` guards the dashboard — the thing a share link, an emailed code, a
5-
* Google sign-in or SSO gets you into. `adminGate` guards the admin page where
4+
* `viewGate` guards the dashboard — the thing a share link, an emailed code or a
5+
* Google sign-in gets you into. `adminGate` guards the admin page where
66
* links are minted, watched and revoked. They use different cookie names, so a
77
* visitor can hold an admin session and a recipient session at once, and the
88
* same D1 store, so revoking in the admin page kills the dashboard session on
@@ -40,8 +40,7 @@ export interface Env {
4040
/** Google OAuth "Web application" client. Absent = `/auth/google/*` answers 503 and the buttons are inert. */
4141
GOOGLE_CLIENT_ID?: string
4242
GOOGLE_CLIENT_SECRET?: string
43-
ACCESS_TEAM_DOMAIN?: string
44-
ACCESS_AUD?: string
43+
/** Google sign-ins at this domain may promote to the admin gate (`/api/staff`). */
4544
STAFF_DOMAIN?: string
4645
}
4746

@@ -113,7 +112,7 @@ export function gates(env: Env, req: Request) {
113112
profiles,
114113
secret,
115114
cookieName: VIEW_COOKIE,
116-
// An email session (SSO, Google, or an emailed code — they all end in
115+
// An email session (Google or an emailed code — both end in
117116
// `gate.signIn`) re-derives its scopes from this policy on every request,
118117
// so "who may sign in by email" is decided here and nowhere else.
119118
policy: firstMatch(domainPolicy([staffDomain], [VIEW_SCOPE]), anyoneButSandbox([VIEW_SCOPE])),

‎demo/functions/api/staff.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Promote a staff Google sign-in to the admin gate.
3+
*
4+
* Google sign-in lands in `viewGate` (one registered redirect URI serves both
5+
* gates). The admin page then POSTs here: if the view session is an email
6+
* session the *admin* gate's policy admits — the staff domain — this mints the
7+
* admin cookie for the same address. Anyone else gets a 403, and a sandbox
8+
* never reaches this (it has no view session).
9+
*/
10+
import { type Env, gates, json } from '../_lib/gates.js'
11+
12+
interface Ctx {
13+
request: Request
14+
env: Env
15+
}
16+
17+
export const onRequestPost = async ({ request, env }: Ctx): Promise<Response> => {
18+
const { viewGate, adminGate } = gates(env, request)
19+
const auth = await viewGate.authenticate(request, undefined, { logView: false })
20+
if (!auth || auth.kind !== 'sso') return json({ error: 'sign in first' }, 401)
21+
const signedIn = await adminGate.signIn(auth.email, request)
22+
if (!signedIn) return json({ error: 'not staff' }, 403)
23+
return json({ ok: true, email: auth.email }, 200, { 'set-cookie': signedIn.cookie })
24+
}

‎demo/functions/auth/email/[[path]].ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* GET /auth/email/outbox -> demo only, see below
1010
*
1111
* They sign into the same gate the dashboard checks, so an emailed code, a
12-
* Google sign-in and Cloudflare Access all converge on one `gate.signIn`.
12+
* and Google sign-in converge on one `gate.signIn`.
1313
*
1414
* **The dishonest step.** The library's `start` never says whether mail went
1515
* out — the reply is identical for an admitted and a not-admitted address, so

‎demo/functions/auth/google/[[path]].ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
* POST /auth/google/onetap -> `googleOneTapVerify`: same verification, session in this response
99
* GET /auth/google/client -> demo only: the public client id, so the page knows whether to render One Tap
1010
*
11-
* Both land in `viewGate.signIn`, like an emailed code or Access SSO. Dormant
11+
* Both land in `viewGate.signIn`, like an emailed code; staff then promote to
12+
* the admin gate via `/api/staff`. Dormant
1213
* (503) until a client exists: Google exposes no API to create one, so
1314
* `scripts/provision-oauth-client.mjs` walks the one manual step and stores
1415
* `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` as Pages secrets.

‎demo/functions/auth/sso.ts‎

Lines changed: 0 additions & 37 deletions
This file was deleted.

‎demo/index.html‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6-
<title>@open-athena/auth — share links, SSO, and an access log</title>
6+
<title>@open-athena/auth — share links, Google sign-in, and an access log</title>
77
<meta
88
name="description"
99
content="Live demo: sign in with Google or an emailed code, or open a share link; mint your own, watch what the recipient did, revoke it and see the session die."
1010
/>
1111
<meta property="og:title" content="@open-athena/auth" />
1212
<meta
1313
property="og:description"
14-
content="Named share links, SSO, email codes, and an access log for gated dashboards. Try it: open a link, mint your own, watch the log, revoke it."
14+
content="Named share links, Google sign-in, email codes, and an access log for gated dashboards. Try it: open a link, mint your own, watch the log, revoke it."
1515
/>
1616
<meta property="og:image" content="https://auth.oa.dev/og.jpg" />
1717
<meta name="twitter:card" content="summary_large_image" />

‎demo/src/SignIn.tsx‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const FORM = { form: 'stack', field: 'field', input: 'input', button: 'btn', mes
88
/**
99
* The composed sign-in every gated page here shows — the library's own
1010
* `SignInPanel`, wired the way an adopter would wire it: Google (in-page One
11-
* Tap, then the redirect button), Cloudflare Access for staff, and an emailed
11+
* Tap, then the redirect button) and an emailed
1212
* code for everyone else. All three end in the same `gate.signIn`, so the
1313
* dashboard can't tell them apart, which is the point.
1414
*/
@@ -43,9 +43,6 @@ export function SignIn({ onSignedIn, title }: { onSignedIn: () => void; title?:
4343
<SignInPanel
4444
title={title}
4545
googleUrl={clientId ? '/auth/google/start?next=%2Fdashboard' : undefined}
46-
// Cloudflare Access fronts `/auth/sso` only on the deployed host; in local dev it can only 401.
47-
signInUrl={import.meta.env.DEV ? undefined : '/auth/sso?next=%2Fdashboard'}
48-
signInLabel="Sign in with SSO (staff)"
4946
withNext={false}
5047
emailAuth={{
5148
startEndpoint: '/auth/email/start',

‎demo/src/pages/Admin.tsx‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
import { type Grant, subjectName } from '@open-athena/auth'
22
import { AllowlistPanel, type AppWhoami, Avatar, useWhoami } from '@open-athena/auth/react'
33
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
4-
import { type FormEvent, useState } from 'react'
4+
import { type FormEvent, useEffect, useState } from 'react'
55
import { ago, api, startSandbox } from '../api.js'
66

77
const SOURCE = { kind: 'app', endpoint: '/api/admin/whoami' } as const
88
const SANDBOX_KEY = 'oa-auth-demo:sandbox'
99

1010
export function Admin() {
1111
const { whoami, refresh } = useWhoami<AppWhoami>(SOURCE)
12-
if (whoami === undefined) return <p className="muted">Checking…</p>
12+
// A staff member who signed in with Google holds a *view* session; promote it
13+
// to the admin gate once (`/api/staff` 403s anyone outside the staff domain).
14+
const [promoted, setPromoted] = useState<boolean | null>(null)
15+
useEffect(() => {
16+
if (whoami !== null || promoted !== null) return
17+
void fetch('/api/staff', { method: 'POST', credentials: 'include' }).then(res => {
18+
setPromoted(res.ok)
19+
if (res.ok) refresh()
20+
})
21+
}, [whoami, promoted, refresh])
22+
if (whoami === undefined || (whoami === null && promoted === null)) return <p className="muted">Checking…</p>
1323
if (whoami === null) return <StartSandbox onStarted={refresh} />
1424
return <Console whoami={whoami} />
1525
}
@@ -43,8 +53,8 @@ function StartSandbox({ onStarted }: { onStarted: () => void }) {
4353
{busy ? 'Starting…' : 'Start a sandbox'}
4454
</button>
4555
<p className="muted small">
46-
Staff can instead <a href="/auth/sso?next=%2Fadmin">sign in with SSO</a>, which also unlocks the access-request
47-
queue.
56+
Staff (<code>@openathena.ai</code>) can instead <a href="/auth/google/start?next=%2Fadmin">sign in with Google</a>,
57+
which also unlocks the access-request queue.
4858
</p>
4959
</div>
5060
)

0 commit comments

Comments
 (0)