From 0ed8ab9e3640a41b07765cbc14d4e3354f4e237a Mon Sep 17 00:00:00 2001 From: tknkaa Date: Fri, 2 Oct 2026 22:50:04 +0900 Subject: [PATCH 1/3] feat: sign in with GitHub, shown only where the server has keys for it --- README.md | 2 +- apps/web/.dev.vars.example | 4 +++ apps/web/src/routes/login.tsx | 28 ++++++++++++++++++ apps/web/src/server.tsx | 6 ++++ apps/web/src/server/auth-config.test.ts | 28 ++++++++++++++++++ apps/web/src/server/auth-config.ts | 15 ++++++++++ apps/web/src/server/auth.test.ts | 34 ++++++++++++++++++++++ apps/web/src/server/auth.ts | 10 ++++++- docs/deploy.md | 38 ++++++++++++------------- docs/web-app.md | 32 +++++++++++++++++++-- e2e/tests/auth.spec.ts | 7 +++++ 11 files changed, 181 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/server/auth-config.test.ts create mode 100644 apps/web/src/server/auth-config.ts diff --git a/README.md b/README.md index cf05305..72f9e87 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ pnpm db:migrate:local pnpm dev ``` -`pnpm dev` builds the app and starts it in Wrangler's local Workers runtime so the D1 binding is available. Set `BETTER_AUTH_SECRET` in `apps/web/.dev.vars` to a long random string (`openssl rand -hex 32`). Sign up with email and password in the app; Google OAuth is tracked in a separate issue. +`pnpm dev` builds the app and starts it in Wrangler's local Workers runtime so the D1 binding is available. Set `BETTER_AUTH_SECRET` in `apps/web/.dev.vars` to a long random string (`openssl rand -hex 32`). Sign up with email and password in the app. GitHub sign-in appears only when `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` are set (see docs/web-app.md). ## Inspecting the local database diff --git a/apps/web/.dev.vars.example b/apps/web/.dev.vars.example index c2f4ab4..e62ae74 100644 --- a/apps/web/.dev.vars.example +++ b/apps/web/.dev.vars.example @@ -1,2 +1,6 @@ BETTER_AUTH_SECRET=replace-with-a-long-random-string BETTER_AUTH_URL=http://localhost:8787 + +# Optional: set both to show "GitHub でログイン". See docs/web-app.md for how to get them. +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 0b38178..2de12fd 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -14,6 +14,24 @@ function Login() { const [mode, setMode] = useState<'signIn' | 'signUp'>('signIn') const [pending, setPending] = useState(false) const [error, setError] = useState('') + const [github, setGithub] = useState(false) + + // Which sign-in buttons exist depends on what the server has been given keys for. + useEffect(() => { + let active = true + void (async () => { + try { + const response = await fetch('/api/config') + const config = (await response.json()) as { github?: boolean } + if (active) setGithub(config.github === true) + } catch { + // No config means no extra buttons. + } + })() + return () => { + active = false + } + }, []) useEffect(() => { if (session) void navigate({ to: '/' }) @@ -77,6 +95,16 @@ function Login() { {mode === 'signIn' ? 'ログイン' : '登録する'} + {github && ( + + )} diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index f7c9770..fb87119 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -29,10 +29,15 @@ export function getAuth(env: AuthEnv) { emailAndPassword: { enabled: true }, socialProviders: socialProviders(env), account: { - // Signing in with GitHub for an address that already has a password account joins the two, - // but only because GitHub reports whether the address is verified, and better-auth links - // them only when it is. - accountLinking: { enabled: true, trustedProviders: ['github'] }, + // Signing in with GitHub joins an existing account of the same address only when that + // account's own address is verified. Password sign-ups here are not verified, so GitHub is + // refused for those addresses: otherwise someone could register another person's address + // first, and take over the account once that person signs in with GitHub. + accountLinking: { + enabled: true, + trustedProviders: ['github'], + requireLocalEmailVerified: true, + }, }, databaseHooks: { user: { diff --git a/docs/web-app.md b/docs/web-app.md index 2cc5d17..555f6d9 100644 --- a/docs/web-app.md +++ b/docs/web-app.md @@ -36,7 +36,9 @@ - **`GITHUB_CLIENT_ID` と `GITHUB_CLIENT_SECRET` の両方がある環境でだけ有効**になる。ない環境 (ローカルで書いていないとき、プレビューなど) では、ログイン画面に「GitHub でログイン」のボタンが出ない。環境の名前で分けるのではなく、設定の有無で分ける。 - ボタンを出すかどうかは、サーバーの `GET /api/config` (`{ "github": true | false }`) で決める。キーやシークレットは返さない。 -- GitHub のアカウントのメールアドレス (確認済みのもの) で、アカウントを作る。同じメールアドレスのメール/パスワードのアカウントが、すでにあれば、ひとつにまとめる (GitHub が確認済みと伝えたメールアドレスだけ)。 +- GitHub のアカウントのメールアドレス (確認済みのもの) で、アカウントを作る。 +- **同じメールアドレスのメール/パスワードのアカウントが、すでにある場合は、GitHub でのログインを断る。** better-auth は、既存のアカウントのメールアドレスが確認済みのときだけ、GitHub のアカウントを、ひとつにまとめる (`requireLocalEmailVerified`)。このアプリのメール/パスワードのアカウントは、メールの確認をしていないので、まとめない。まとめてしまうと、他人のメールアドレスで先に登録しておき、あとから本人が GitHub でログインしたときに、乗っ取れる (アカウントの事前乗っ取り)。断られた人には、ログイン画面に「このメールアドレスは、すでにメールとパスワードで登録されています」と出す (`/login?error=unable_to_link_account`)。メールの確認ができるようになったら ([#8](https://github.com/ut-code/kanban/issues/8)、[#42](https://github.com/ut-code/kanban/issues/42))、まとめられるようにできる。 +- GitHub のアカウントにメールアドレスがない、または確認されていないときも、ログイン画面に理由を出す。 - 共有の招待 (アカウントがないメールアドレスへの共有) は、GitHub でアカウントを作ったときにも、メール/パスワードのときと同じように反映される。 - 取得する権限は、`read:user` と `user:email` (メールアドレスを知るため)。 diff --git a/e2e/start-server.sh b/e2e/start-server.sh index 477bf79..14d10c9 100755 --- a/e2e/start-server.sh +++ b/e2e/start-server.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Starts the app for the end-to-end tests: a fresh local database on its own port, so a running -# `pnpm dev` and its data are left alone. +# `pnpm dev` and its data are left alone. The GitHub keys are blanked on purpose: wrangler also reads +# apps/web/.dev.vars, and the tests must not depend on whether a developer has put real keys there. set -euo pipefail cd "$(dirname "$0")/.." @@ -12,4 +13,6 @@ pnpm --filter @todo/web build pnpm --filter @todo/web exec wrangler d1 migrations apply kanban-db --local --persist-to "../../$STATE" exec pnpm --filter @todo/web exec wrangler dev --local --port "$PORT" --persist-to "../../$STATE" \ --var BETTER_AUTH_SECRET:e2e-secret-e2e-secret-e2e-secret-1234 \ - --var BETTER_AUTH_URL:"http://localhost:$PORT" + --var BETTER_AUTH_URL:"http://localhost:$PORT" \ + --var GITHUB_CLIENT_ID: \ + --var GITHUB_CLIENT_SECRET: diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts index 99e1863..e843f3a 100644 --- a/e2e/tests/auth.spec.ts +++ b/e2e/tests/auth.spec.ts @@ -41,4 +41,13 @@ test.describe('signing in', () => { expect(await (await page.request.get('/api/config')).json()).toEqual({ github: false }) await expect(page.getByRole('button', { name: /GitHub/ })).toHaveCount(0) }) + + test('tells a person whose GitHub sign-in was refused what to do instead', async ({ page }) => { + // better-auth sends a refused sign-in back to the login page with the reason in the address. + await page.goto('/login?error=unable_to_link_account') + await expect(page.getByRole('alert')).toContainText('メールとパスワードで登録') + + await page.goto('/login?error=something-unexpected') + await expect(page.getByRole('alert')).toContainText('GitHub でのログインに失敗しました') + }) })