Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions apps/web/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -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=
2 changes: 1 addition & 1 deletion apps/web/src/hooks/use-require-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function useRequireSession() {
const { data: session, isPending } = authClient.useSession()

useEffect(() => {
if (!isPending && !session) void navigate({ to: '/login' })
if (!isPending && !session) void navigate({ to: '/login', search: { error: undefined } })
}, [isPending, session, navigate])

return session
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/lib/oauth-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { oauthErrorMessage } from './oauth-error'

describe('oauthErrorMessage', () => {
it('explains that an address already registered with a password cannot be joined', () => {
expect(oauthErrorMessage('unable_to_link_account')).toContain('メールとパスワードで登録')
})

it('explains a GitHub account with no usable email', () => {
expect(oauthErrorMessage('email_not_found')).toContain('メールアドレスがありません')
expect(oauthErrorMessage('email_not_verified')).toContain('確認されていません')
})

it.each(['invalid_code', 'no_code', 'anything-else', ''])(
'falls back to a general message for %j',
(code) => {
expect(oauthErrorMessage(code)).toBe(
'GitHub でのログインに失敗しました。もう一度試してください。',
)
},
)
})
13 changes: 13 additions & 0 deletions apps/web/src/lib/oauth-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** What to tell a person whose sign-in with GitHub did not work. `code` is better-auth's `?error=`. */
export function oauthErrorMessage(code: string) {
switch (code) {
case 'unable_to_link_account':
return 'このメールアドレスは、すでにメールとパスワードで登録されています。メールとパスワードでログインしてください。'
case 'email_not_found':
return 'GitHub のアカウントにメールアドレスがありません。GitHub の設定でメールアドレスを追加してから、もう一度試してください。'
case 'email_not_verified':
return 'GitHub のメールアドレスが確認されていません。GitHub で確認してから、もう一度試してください。'
default:
return 'GitHub でのログインに失敗しました。もう一度試してください。'
}
}
2 changes: 1 addition & 1 deletion apps/web/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function Home() {
email={session.user.email}
onSignOut={async () => {
await authClient.signOut()
await navigate({ to: '/login' })
await navigate({ to: '/login', search: { error: undefined } })
}}
/>
)
Expand Down
44 changes: 42 additions & 2 deletions apps/web/src/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,41 @@ import { useEffect, useState, type FormEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { authClient } from '@/lib/auth-client'
import { oauthErrorMessage } from '@/lib/oauth-error'

export const Route = createFileRoute('/login')({
// better-auth sends a failed sign-in with GitHub back here as `/login?error=<code>`.
validateSearch: (search: Record<string, unknown>) => ({
error: typeof search.error === 'string' ? search.error : undefined,
}),
component: Login,
})

function Login() {
const navigate = useNavigate()
const { error: oauthError } = Route.useSearch()
const { data: session } = authClient.useSession()
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: '/' })
Expand Down Expand Up @@ -68,15 +92,31 @@ function Login() {
minLength={8}
required
/>
{error && (
{(error || oauthError) && (
<p className="text-sm text-destructive" role="alert">
{error}
{error || (oauthError ? oauthErrorMessage(oauthError) : '')}
</p>
)}
<Button className="h-11" type="submit" disabled={pending}>
{mode === 'signIn' ? 'ログイン' : '登録する'}
</Button>
</form>
{github && (
<Button
className="mt-3 h-11 w-full"
variant="outline"
type="button"
onClick={() =>
void authClient.signIn.social({
provider: 'github',
callbackURL: '/',
errorCallbackURL: '/login',
})
}
>
GitHub でログイン
</Button>
)}
<Button
variant="ghost"
className="mt-4 w-full text-muted-foreground"
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type NotificationMessage,
} from '@todo/db'
import { getAuth } from './server/auth'
import { publicConfig } from './server/auth-config'
import {
deleteTaskAttachments,
handleAttachmentsRequest,
Expand All @@ -26,11 +27,16 @@ interface Env {
NOTIFICATION_QUEUE: Queue<NotificationMessage>
BETTER_AUTH_SECRET: string
BETTER_AUTH_URL?: string
GITHUB_CLIENT_ID?: string
GITHUB_CLIENT_SECRET?: string
}

export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url)
if (url.pathname === '/api/config') {
return Response.json(publicConfig(env))
}
if (url.pathname.startsWith('/api/auth/')) {
return getAuth(env).handler(request)
}
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/server/auth-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { publicConfig, socialProviders } from './auth-config'

describe('socialProviders', () => {
it('turns GitHub on when both values are set', () => {
expect(socialProviders({ GITHUB_CLIENT_ID: 'id', GITHUB_CLIENT_SECRET: 'secret' })).toEqual({
github: { clientId: 'id', clientSecret: 'secret' },
})
})

it.each([
{},
{ GITHUB_CLIENT_ID: 'id' },
{ GITHUB_CLIENT_SECRET: 'secret' },
{ GITHUB_CLIENT_ID: '', GITHUB_CLIENT_SECRET: '' },
])('leaves GitHub off for %j', (env) => {
expect(socialProviders(env)).toEqual({})
})
})

describe('publicConfig', () => {
it('tells whether GitHub is on without exposing the secret', () => {
const config = publicConfig({ GITHUB_CLIENT_ID: 'id', GITHUB_CLIENT_SECRET: 'secret' })
expect(config).toEqual({ github: true })
expect(JSON.stringify(config)).not.toContain('secret')
expect(publicConfig({})).toEqual({ github: false })
})
})
15 changes: 15 additions & 0 deletions apps/web/src/server/auth-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface SocialEnv {
GITHUB_CLIENT_ID?: string
GITHUB_CLIENT_SECRET?: string
}

/** Sign-in providers that are switched on. GitHub needs both its client id and its secret. */
export function socialProviders(env: SocialEnv) {
if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET) return {}
return { github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET } }
}

/** What the login page may know: which sign-in buttons to show. Never the secrets. */
export function publicConfig(env: SocialEnv) {
return { github: 'github' in socialProviders(env) }
}
34 changes: 34 additions & 0 deletions apps/web/src/server/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,37 @@ describe('sign-up', () => {
expect((await tasks.listByUser(user.id)).map((task) => task.id)).toEqual(['t1'])
})
})

describe('GitHub sign-in', () => {
const social = (env: { GITHUB_CLIENT_ID?: string; GITHUB_CLIENT_SECRET?: string }) =>
getAuth({
DB: createTestDatabase(),
BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret',
BETTER_AUTH_URL: 'http://localhost',
...env,
}).handler(
new Request('http://localhost/api/auth/sign-in/social', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: 'http://localhost' },
body: JSON.stringify({ provider: 'github', callbackURL: '/' }),
}),
)

it('sends the browser to GitHub with the app’s callback address when it is configured', async () => {
const response = await social({ GITHUB_CLIENT_ID: 'client-id', GITHUB_CLIENT_SECRET: 'secret' })
expect(response.status).toBe(200)

const { url } = (await response.json()) as { url: string }
const target = new URL(url)
expect(target.origin + target.pathname).toBe('https://github.com/login/oauth/authorize')
expect(target.searchParams.get('client_id')).toBe('client-id')
expect(target.searchParams.get('redirect_uri')).toBe(
'http://localhost/api/auth/callback/github',
)
expect(target.searchParams.get('scope')).toContain('user:email')
})

it('refuses to sign in with GitHub when it is not configured', async () => {
expect((await social({})).ok).toBe(false)
})
})
15 changes: 14 additions & 1 deletion apps/web/src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { betterAuth } from 'better-auth'
import { drizzle } from 'drizzle-orm/d1'
import { D1TaskMemberRepository } from '@todo/db'
import * as schema from '@todo/db/schema'
import { socialProviders, type SocialEnv } from './auth-config'

interface AuthEnv {
interface AuthEnv extends SocialEnv {
DB: D1Database
BETTER_AUTH_SECRET: string
BETTER_AUTH_URL?: string
Expand All @@ -26,6 +27,18 @@ export function getAuth(env: AuthEnv) {
},
}),
emailAndPassword: { enabled: true },
socialProviders: socialProviders(env),
account: {
// 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: {
create: {
Expand Down
Loading
Loading