feat(auth): add Discord OAuth sign-in and user timezones
Add persistent user and session storage, protected profile requests, and React sign-in controls. Capture and preserve each user's timezone, and load development and production configuration before migrations and server startup.
This commit is contained in:
135
README.md
135
README.md
@@ -14,7 +14,7 @@ Open http://127.0.0.1:3000. Set `PORT` to use a different port.
|
||||
- Hono endpoint: `GET /api/health`.
|
||||
- Unknown API routes return JSON with a 404 status.
|
||||
|
||||
Pages contain only a heading and native navigation buttons. Tailwind utilities
|
||||
Pages contain a heading, native navigation buttons, and account controls. Tailwind utilities
|
||||
and shadcn configuration are available; Tailwind Preflight is omitted to preserve
|
||||
native browser styling.
|
||||
|
||||
@@ -34,7 +34,8 @@ created at `data/minabot.sqlite`, with WAL mode and foreign keys enabled. Databa
|
||||
files are ignored by Git. Development and production use the same file.
|
||||
|
||||
Optionally copy `.env.example` to `.env` and set `DATABASE_PATH` to another local
|
||||
file. Relative paths are resolved from the project root by the package scripts.
|
||||
file. Environment-specific database paths can be set in `.env.development` and
|
||||
`.env.production`. Relative paths are resolved from the project root by the package scripts.
|
||||
|
||||
Define tables in `src/db/schema.ts`, then generate and apply migrations:
|
||||
|
||||
@@ -45,7 +46,135 @@ bun run db:migrate
|
||||
|
||||
Commit the generated `drizzle/` files alongside schema changes. `bun dev` and
|
||||
`bun start` apply pending migrations before starting the server. The initial
|
||||
schema contains no application tables.
|
||||
migration creates the users and sessions tables.
|
||||
|
||||
Import `db` from `src/db/index.ts` in backend code to query the database.
|
||||
`GET /api/health` checks the database connection before returning success.
|
||||
|
||||
## Discord sign-in
|
||||
|
||||
1. Copy `.env.example` to `.env` for shared defaults, and copy
|
||||
`.env.development.example` / `.env.production.example` to `.env.development` /
|
||||
`.env.production` if those files do not exist. These local files are Git-ignored.
|
||||
2. Create an application in the [Discord Developer Portal](https://discord.com/developers/applications).
|
||||
Under OAuth2, copy the client ID and client secret into `DISCORD_CLIENT_ID`
|
||||
and `DISCORD_CLIENT_SECRET` in `.env`.
|
||||
3. Set `AUTH_COOKIE_SECRET` to a random secret of at least 32 characters, generated
|
||||
with `openssl rand -hex 32`. Keep it server-side along with the client secret.
|
||||
4. Set `APP_ORIGIN` in `.env.development` to the development browser origin,
|
||||
initially `http://127.0.0.1:3000`, and set the same variable in `.env.production`
|
||||
to the production origin, such as `https://habits.example.com`. Register **both**
|
||||
origins with `/api/auth/discord/callback` appended as redirect URIs in Discord.
|
||||
Use the matching hostname throughout each sign-in; `localhost` and `127.0.0.1`
|
||||
have different cookie storage.
|
||||
5. Run `bun dev`, click **Sign in with Discord**, and authorize the `identify` scope.
|
||||
No bot token or email scope is needed. After returning, **View my profile** opens
|
||||
`/api/me`. **Sign out** revokes the current browser's session.
|
||||
|
||||
Without the OAuth settings, public pages and `/api/health` remain usable, `/api/me`
|
||||
returns 401, and sign-in displays a configuration message. Restart after editing
|
||||
any env file. Use HTTPS for non-local deployments; HTTP is only allowed on loopback
|
||||
origins. When using a reverse proxy, preserve the public request host and serve
|
||||
both React and `/api` from the selected origin.
|
||||
|
||||
The package scripts explicitly select the environment before migrations or server
|
||||
startup:
|
||||
|
||||
| Command | Environment file | Mode |
|
||||
| --- | --- | --- |
|
||||
| `bun dev` | `.env.development` | development |
|
||||
| `bun start` | `.env.production` | production |
|
||||
| `bun run db:generate` / `bun run db:migrate` | `.env.development` | development |
|
||||
| `bun run db:migrate:production` | `.env.production` | production |
|
||||
|
||||
Each command loads optional `.env` shared defaults, then the selected file.
|
||||
Variables exported by the shell or deployment platform take precedence over files.
|
||||
The selected `APP_ORIGIN` controls OAuth callbacks, cookie security, and logout
|
||||
Origin validation. Production requires an explicit `APP_ORIGIN`; development can
|
||||
fall back to `http://127.0.0.1:$PORT` (port 3000 if unset). To test `bun start`
|
||||
locally, set `APP_ORIGIN=http://127.0.0.1:3000` in `.env.production`.
|
||||
|
||||
Automatic env-file loading is disabled in `bunfig.toml` so the outer `bun start`
|
||||
process cannot preload development settings into production. Package scripts load
|
||||
the files explicitly using Bun's `--env-file` option; `.env.local` files are not
|
||||
loaded by these commands. Build, test, and typecheck do not load application env
|
||||
files. For custom Bun commands, pass the desired `--env-file` flags explicitly.
|
||||
See [Bun environment variables](https://bun.sh/docs/runtime/environment-variables).
|
||||
|
||||
Use `APP_ORIGIN` in each file; `APP_ORIGIN_DEV` and `APP_ORIGIN_PROD` are no longer
|
||||
used. Credentials can stay in `.env` or be overridden separately in each file.
|
||||
For deployments sharing a machine, set distinct `DATABASE_PATH` values to keep
|
||||
production habit data separate from development data.
|
||||
|
||||
### Schema and session behavior
|
||||
|
||||
- `users`: app UUID, unique Discord ID stored as text, username, nullable display
|
||||
name and avatar hash, IANA timezone, and creation/update timestamps.
|
||||
- `sessions`: SHA-256 token hash, user foreign key with cascading deletion,
|
||||
creation timestamp, and expiry timestamp. User and expiry indexes support
|
||||
session management and cleanup.
|
||||
- Timestamps are Unix milliseconds. Each session expires 30 days after sign-in;
|
||||
activity does not extend it. A new sign-in replaces the current browser's session.
|
||||
Other browsers retain independent sessions. Expired sessions are removed during
|
||||
sign-in or when presented to an authenticated route.
|
||||
- Cookies are HttpOnly, SameSite=Lax, and host-only. HTTPS enables Secure and
|
||||
`__Host-` cookie names. OAuth state uses a signed cookie with a ten-minute
|
||||
lifetime and is cleared on callback. Token exchange and profile retrieval happen
|
||||
server-side; Discord access/refresh tokens are never persisted or sent to React.
|
||||
- React detects the browser timezone during initial registration. The server
|
||||
validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins
|
||||
update the Discord profile without changing the saved timezone. A timezone
|
||||
settings editor and habit reset logic are future work. Habit timestamps should
|
||||
remain UTC; daily boundaries should be calculated using the user's timezone.
|
||||
|
||||
### API
|
||||
|
||||
| Method | Route | Behavior |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/health` | Public database health check |
|
||||
| GET | `/api/auth/discord?timezone=Europe%2FBelgrade` | Start sign-in |
|
||||
| GET | `/api/auth/discord/callback` | Validate OAuth response and create a session |
|
||||
| POST | `/api/auth/logout` | Revoke current session; requires matching Origin |
|
||||
| GET | `/api/me` | Public user fields for the current session, or 401 |
|
||||
|
||||
`/api/me` returns the object directly:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "app-generated-uuid",
|
||||
"discordId": "123456789012345678",
|
||||
"username": "mina",
|
||||
"displayName": "Mina",
|
||||
"avatarUrl": null,
|
||||
"timezone": "Europe/Belgrade"
|
||||
}
|
||||
```
|
||||
|
||||
Only the fields declared in `src/shared/user.ts` are returned. Missing/invalid or
|
||||
expired sessions receive `401 {"error":"Unauthorized"}`. Authentication endpoints
|
||||
and `/api/me` use `Cache-Control: no-store`.
|
||||
|
||||
In `src/api.ts`, protect another route with the same middleware:
|
||||
|
||||
```ts
|
||||
app.get("/api/example", auth.requireAuth, c => c.json({ userId: c.get("user").id }));
|
||||
app.post("/api/example", auth.requireSameOrigin, auth.requireAuth, c => {
|
||||
// Validate input and enforce ownership using c.get("user").id.
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
Use `requireSameOrigin` for all cookie-authenticated mutations. Same-origin browser
|
||||
fetches send the cookie automatically; tokens should not be placed in localStorage.
|
||||
|
||||
```sh
|
||||
bun test
|
||||
bun run typecheck
|
||||
bun run build
|
||||
```
|
||||
|
||||
Tests use an isolated in-memory SQLite database with the real migrations and a
|
||||
stubbed Discord HTTP client. They cover registration, profile refresh, timezone
|
||||
preservation, cookie security, invalid/expired state, provider failures, session
|
||||
expiry/rotation, logout CSRF checks, and the exact `/api/me` response. A real Discord
|
||||
consent flow additionally requires your application credentials and browser login.
|
||||
|
||||
Reference in New Issue
Block a user