Skip to content
bits
Build the front end with agents · Lesson 6 of 11 · 30 min

Sign in with GitHub

Desk: best at a computer, with a terminal

Verified remix 3.1.0wrangler 4.157.0

Start from d-05 on GitHub. Starting here, or behind? Get the code at that tag:

Shell
git clone https://github.com/ryanyogan/klippy
cd klippy
git checkout d-05
Tutor:

Each person gets their own Klippy, named after them. That name must come from the server, from who's signed in, never from the browser: a name the browser chose is a name anyone could change. This lesson adds GitHub sign-in with Remix's own auth, and an allowlist so only you get in.

Tutor:

1. The prompt

Fresh session, plan mode:

Plain text
Goal: add GitHub sign-in so each person gets their own Klippy, chosen on the server from their GitHub id.
Use the remix skill. Read node_modules/remix/INDEX.md, guide 10, and the auth, auth-middleware, session and cookie
READMEs first.
Constraints:
- remix/auth's GitHub provider, remix/session with cookie storage, remix/middleware/auth, cop(). No other auth package.
- Only my GitHub account id may sign in (ALLOWED_GITHUB_IDS, not logins: those can be renamed); anyone else
  gets a 403 page and no session.
- The agent token stays on the server: add it to the service-binding call, never send it to the browser.
- Secrets in .dev.vars and `wrangler secret put` only; placeholders in .dev.vars.example. Touch only apps/web/**.
Plan first, then wait for my go-ahead.
Done when typecheck and remix doctor pass, and curl shows: / redirects to /login, /auth/github redirects to GitHub,
and a cross-site POST /logout gets 403. Report the files and the outputs.
  • "No other auth package" stops a hand-written OAuth exchange. Auth is where home-made code goes wrong quietly.

  • The allowlist line. Sign-in says who you are. The allowlist says whether you're allowed. Klippy's notes have no owner column: without the list, anyone with a GitHub account could sign in, spend your model budget and read your notes.

  • Ids, not logins. A GitHub login can be renamed, and someone else can then register the old name. The numeric id never changes.

Tutor:

2. Find your GitHub id

Shell
gh api user --jq .id

No GitHub CLI? Open https://api.github.com/users/<your-login> and read the "id" field. Put it in ALLOWED_GITHUB_IDS in your .dev.vars.

Tutor:

3. What to expect: the parts worth reading

New: app/auth.ts, app/agent.ts, app/actions/auth/, an allowlist with a small Node test, and .dev.vars.example. Changed: routes, router, the home page, the layout and wrangler.jsonc.

The agent's name, chosen on the server:

// The agent instance is chosen here, on the server, from the signed-in user. The browser never names it.
export function agentName(user: User) {
return `gh-${user.id}`;
}
TypeScriptUTF-8at d-06
Tutor:

The token, added on the server side of the binding:

import { env } from 'cloudflare:workers';
// Call the agent Worker through the service binding, with the agent's token. The token stays on the server:
// the browser only ever talks to this web app.
export function agentFetch(path: string, init: RequestInit = {}) {
const headers = new Headers(init.headers);
headers.set('Authorization', `Bearer ${env.KLIPPY_TOKEN}`);
return env.AGENT.fetch(new URL(path, 'https://agent'), { ...init, headers });
}
TypeScriptUTF-8at d-06● lines 5–8: apps/web/app/agent.ts
Tutor:

The agent's token check is exactly as it was. The browser never sees the token, and never names the agent.

The allowlist is checked twice: in the callback (no session for a stranger) and again on every request, so removing an id works at once:

export const loadUser = auth({
schemes: [
createSessionAuthScheme<User, User>({
read: (session) => (session.get('user') as User | undefined) ?? null,
verify: (user) => (isAllowed(user.id) ? user : null),
invalidate: (session) => session.unset('user'),
}),
],
});
TypeScriptUTF-8at d-06● lines 5–5: apps/web/app/auth.ts
Tutor:

4. Check it, up to GitHub's door

With placeholder GitHub credentials in .dev.vars, our checks (real):

recorded output
curl -si localhost:8802/auth/github | head -3
HTTP/1.1 302 Found
Location: https://github.com/login/oauth/authorize?client_id=placeholder-client-id&redirect_uri=http%3A%2F%2Flocalhost%3A8802%2Fauth%2Fgithub%2Fcallback&scope=read%3Auser&state=…&code_challenge_method=S256
Set-Cookie: __klippy=<signed>; HttpOnly; Max-Age=604800; Path=/; SameSite=Lax; Secure
Tutor:
  • scope=read:user: only who you are, not your email or repos.

  • state and code_challenge (PKCE) come from remix/auth for free.

  • The session cookie is signed, HttpOnly and Secure.

recorded output
pnpm --filter web test
✔ lets in an id on the list
✔ refuses the same login with a different id (a renamed or re-registered account)
✔ refuses everyone when the list is empty or a placeholder
Tutor:

Also checked: / redirects to /login; a callback with a wrong state gets a 400 and "GitHub sign-in didn't finish"; a POST /logout from another site gets 403 (cop()); one from this site signs you out.

What we didn't record. We haven't created a GitHub OAuth app for the recording, so there's no recorded round trip through GitHub. To try it:

  1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App. Homepage http://localhost:8802, callback http://localhost:8802/auth/github/callback. Copy the client id and make a secret.

  2. Put GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET and a SESSION_SECRET (openssl rand -hex 32) in apps/web/.dev.vars.

  3. pnpm dev:web, sign in. Expect a greeting with your name, and your Klippy named gh- plus your id. A second GitHub account should get the 403 page.

Tutor:

5. Review it

  • The cookie is httpOnly, secure, sameSite: 'lax' and signed with SESSION_SECRET.

  • completeAuth() runs before the user is written (a new session id), and logout is a POST that destroys the session.

  • The agent name comes from the session, never a URL or form field.

  • KLIPPY_TOKEN appears only in app/agent.ts (grep -r KLIPPY_TOKEN apps/web/app).

  • The allowlist compares numeric ids. No real secret in any committed file.

  • Reject: a hand-written token exchange with fetch, a session in localStorage, an allowlist checked only at sign-in, ?token= in a browser URL.

Steer prompts:

Plain text
You wrote the GitHub token exchange by hand with fetch. Delete it and use createGitHubAuthProvider with startExternalAuth / finishExternalAuth from remix/auth, as the README shows.
Plain text
context.auth.identity doesn't typecheck in home. Move requireUser from the action to the controller's middleware, so the controller's context knows the user is signed in.
Plain text
You compared logins. A login can be renamed and re-registered by someone else: compare the numeric id instead.

The second one is a real snag we hit: as action middleware, requireAuth didn't narrow the type; as controller middleware, it did.

quick check+20 neurons
Tutor:

Where should the web app get the agent's name, gh-<id>?

    Pick one option

    Pick one, then Check.