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:
4
.env.development.example
Normal file
4
.env.development.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Loaded by bun dev and the default database commands.
|
||||||
|
APP_ORIGIN=http://127.0.0.1:3000
|
||||||
|
# Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID,
|
||||||
|
# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET.
|
||||||
10
.env.example
10
.env.example
@@ -1,2 +1,12 @@
|
|||||||
|
# Optional shared defaults. Values in the selected environment file override these.
|
||||||
DATABASE_PATH=./data/minabot.sqlite
|
DATABASE_PATH=./data/minabot.sqlite
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# Discord Developer Portal > OAuth2. Register both redirect URIs:
|
||||||
|
# http://127.0.0.1:3000/api/auth/discord/callback
|
||||||
|
# https://YOUR_PRODUCTION_HOST/api/auth/discord/callback
|
||||||
|
# Credentials may be shared here or set separately in each environment file.
|
||||||
|
DISCORD_CLIENT_ID=
|
||||||
|
DISCORD_CLIENT_SECRET=
|
||||||
|
# Generate once: openssl rand -hex 32 (at least 32 characters required)
|
||||||
|
AUTH_COOKIE_SECRET=
|
||||||
|
|||||||
5
.env.production.example
Normal file
5
.env.production.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Loaded by bun start and bun run db:migrate:production.
|
||||||
|
# Set your public origin, e.g. https://habits.example.com
|
||||||
|
APP_ORIGIN=
|
||||||
|
# Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID,
|
||||||
|
# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET.
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -26,12 +26,11 @@ logs
|
|||||||
_.log
|
_.log
|
||||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
# dotenv environment variable files
|
# dotenv files may contain credentials; commit only example templates.
|
||||||
.env
|
.env
|
||||||
.env.development.local
|
.env.*
|
||||||
.env.test.local
|
!.env.example
|
||||||
.env.production.local
|
!.env.*.example
|
||||||
.env.local
|
|
||||||
|
|
||||||
# caches
|
# caches
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|||||||
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`.
|
- Hono endpoint: `GET /api/health`.
|
||||||
- Unknown API routes return JSON with a 404 status.
|
- 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
|
and shadcn configuration are available; Tailwind Preflight is omitted to preserve
|
||||||
native browser styling.
|
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.
|
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
|
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:
|
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
|
Commit the generated `drizzle/` files alongside schema changes. `bun dev` and
|
||||||
`bun start` apply pending migrations before starting the server. The initial
|
`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.
|
Import `db` from `src/db/index.ts` in backend code to query the database.
|
||||||
`GET /api/health` checks the database connection before returning success.
|
`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.
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# Package scripts load .env plus the selected environment file explicitly.
|
||||||
|
# Prevent the outer bun process from preloading development values for bun start.
|
||||||
|
env = false
|
||||||
|
|
||||||
[serve.static]
|
[serve.static]
|
||||||
plugins = ["bun-plugin-tailwind"]
|
plugins = ["bun-plugin-tailwind"]
|
||||||
env = "BUN_PUBLIC_*"
|
env = "BUN_PUBLIC_*"
|
||||||
|
|||||||
22
drizzle/0000_discord_auth.sql
Normal file
22
drizzle/0000_discord_auth.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE `sessions` (
|
||||||
|
`token_hash` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `sessions_user_id_idx` ON `sessions` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `sessions_expires_at_idx` ON `sessions` (`expires_at`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`discord_id` text NOT NULL,
|
||||||
|
`username` text NOT NULL,
|
||||||
|
`global_name` text,
|
||||||
|
`avatar_hash` text,
|
||||||
|
`timezone` text DEFAULT 'UTC' NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_discord_id_unique` ON `users` (`discord_id`);
|
||||||
160
drizzle/meta/0000_snapshot.json
Normal file
160
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "1d46cf45-94a2-434f-b5d7-bc0346cc1fdd",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"sessions_user_id_idx": {
|
||||||
|
"name": "sessions_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"sessions_expires_at_idx": {
|
||||||
|
"name": "sessions_expires_at_idx",
|
||||||
|
"columns": [
|
||||||
|
"expires_at"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"discord_id": {
|
||||||
|
"name": "discord_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"global_name": {
|
||||||
|
"name": "global_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_hash": {
|
||||||
|
"name": "avatar_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"name": "timezone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'UTC'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_discord_id_unique": {
|
||||||
|
"name": "users_discord_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"discord_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,13 @@
|
|||||||
{"version":"7","dialect":"sqlite","entries":[]}
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788501609255,
|
||||||
|
"tag": "0000_discord_auth",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
package.json
10
package.json
@@ -4,12 +4,14 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run db:migrate && bun --hot src/index.ts",
|
"dev": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts && NODE_ENV=development bun --env-file=.env --env-file=.env.development --hot src/index.ts",
|
||||||
"start": "bun run db:migrate && bun scripts/start.ts",
|
"start": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts && NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/start.ts",
|
||||||
"build": "bun run build.ts",
|
"build": "bun run build.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"db:generate": "bunx --bun drizzle-kit generate",
|
"db:generate": "NODE_ENV=development bun --env-file=.env --env-file=.env.development x --bun drizzle-kit generate",
|
||||||
"db:migrate": "bun scripts/migrate.ts"
|
"db:migrate": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts",
|
||||||
|
"test": "bun test",
|
||||||
|
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bun-plugin-tailwind": "^0.1.2",
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
|
|||||||
58
scripts/environment.test.ts
Normal file
58
scripts/environment.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
let directory: string;
|
||||||
|
const project = new URL("../", import.meta.url);
|
||||||
|
beforeEach(() => {
|
||||||
|
directory = mkdtempSync(join(tmpdir(), "minabot-env-test-"));
|
||||||
|
// Run the real package commands with disposable entrypoints that report only fixture values.
|
||||||
|
writeFileSync(join(directory, "package.json"), readFileSync(new URL("package.json", project)));
|
||||||
|
writeFileSync(join(directory, "bunfig.toml"), readFileSync(new URL("bunfig.toml", project)));
|
||||||
|
mkdirSync(join(directory, "scripts"));
|
||||||
|
mkdirSync(join(directory, "src"));
|
||||||
|
for (const [file, stage] of [["scripts/migrate.ts", "migration"], ["scripts/start.ts", "server"], ["src/index.ts", "server"]]) {
|
||||||
|
writeFileSync(join(directory, file!), `console.log(JSON.stringify({ stage: ${JSON.stringify(stage)}, mode: process.env.NODE_ENV, origin: process.env.APP_ORIGIN, database: process.env.DATABASE_PATH, shared: process.env.MINABOT_ENV_TEST_SHARED })); process.exit(0);`);
|
||||||
|
}
|
||||||
|
writeFileSync(join(directory, ".env"), "APP_ORIGIN=https://shared.example\nDATABASE_PATH=shared.sqlite\nMINABOT_ENV_TEST_SHARED=shared\n");
|
||||||
|
writeFileSync(join(directory, ".env.development"), "APP_ORIGIN=http://127.0.0.1:4000\nDATABASE_PATH=development.sqlite\n");
|
||||||
|
writeFileSync(join(directory, ".env.production"), "APP_ORIGIN=https://production.example\nDATABASE_PATH=production.sqlite\n");
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(directory, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
function run(command: string, overrides: Record<string, string> = {}) {
|
||||||
|
const env = { ...process.env };
|
||||||
|
for (const key of ["NODE_ENV", "APP_ORIGIN", "DATABASE_PATH", "MINABOT_ENV_TEST_SHARED", "BUN_OPTIONS"]) delete env[key];
|
||||||
|
const result = Bun.spawnSync([process.execPath, "run", command], {
|
||||||
|
cwd: directory, env: { ...env, ...overrides }, timeout: 10_000,
|
||||||
|
stdout: "pipe", stderr: "pipe",
|
||||||
|
});
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
return result.stdout.toString().trim().split("\n").map(line => JSON.parse(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
test.each(["development", "production"])("%s loads the same environment before migration and server startup", mode => {
|
||||||
|
const records = run(mode === "production" ? "start" : "dev");
|
||||||
|
expect(records).toEqual(["migration", "server"].map(stage => ({
|
||||||
|
stage, mode,
|
||||||
|
origin: mode === "production" ? "https://production.example" : "http://127.0.0.1:4000",
|
||||||
|
database: `${mode}.sqlite`, shared: "shared",
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("database commands select their corresponding environment", () => {
|
||||||
|
expect(run("db:migrate")[0].database).toBe("development.sqlite");
|
||||||
|
expect(run("db:migrate:production")[0].database).toBe("production.sqlite");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exported deployment variables override environment files", () => {
|
||||||
|
const records = run("start", { APP_ORIGIN: "https://deployed.example", DATABASE_PATH: "deployed.sqlite" });
|
||||||
|
expect(records.every(record => record.origin === "https://deployed.example" && record.database === "deployed.sqlite")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("production never preloads development values when its file is absent", () => {
|
||||||
|
rmSync(join(directory, ".env.production"));
|
||||||
|
const records = run("start");
|
||||||
|
expect(records.every(record => record.origin === "https://shared.example" && record.database === "shared.sqlite")).toBe(true);
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Route, Routes, useLocation, useNavigate } from "react-router";
|
import { Route, Routes, useLocation, useNavigate } from "react-router";
|
||||||
|
import { AuthControls } from "./components/AuthControls";
|
||||||
import { Home } from "./pages/Home";
|
import { Home } from "./pages/Home";
|
||||||
import { About } from "./pages/About";
|
import { About } from "./pages/About";
|
||||||
import { Settings } from "./pages/Settings";
|
import { Settings } from "./pages/Settings";
|
||||||
@@ -14,6 +15,7 @@ export function App() {
|
|||||||
<button type="button" disabled={pathname === "/about"} onClick={() => navigate("/about")}>About</button>{" "}
|
<button type="button" disabled={pathname === "/about"} onClick={() => navigate("/about")}>About</button>{" "}
|
||||||
<button type="button" disabled={pathname === "/settings"} onClick={() => navigate("/settings")}>Settings</button>
|
<button type="button" disabled={pathname === "/settings"} onClick={() => navigate("/settings")}>Settings</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
<AuthControls />
|
||||||
<main>
|
<main>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
|
|||||||
22
src/api.ts
Normal file
22
src/api.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { createAuth, type AppDatabase, type AuthEnv } from "./auth";
|
||||||
|
import type { AuthConfig } from "./auth/config";
|
||||||
|
import type { FetchDiscord } from "./auth/discord";
|
||||||
|
|
||||||
|
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number) {
|
||||||
|
const app = new Hono<AuthEnv>();
|
||||||
|
const auth = createAuth(db, config, request, now);
|
||||||
|
app.get("/api/health", c => {
|
||||||
|
db.get(sql`SELECT 1`);
|
||||||
|
return c.json({ status: "ok" });
|
||||||
|
});
|
||||||
|
app.route("/api/auth", auth.routes);
|
||||||
|
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
|
||||||
|
app.notFound(c => c.json({ error: "Not found" }, 404));
|
||||||
|
app.onError((_error, c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ error: "Internal server error" }, 500);
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
206
src/auth/auth.test.ts
Normal file
206
src/auth/auth.test.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { createApi } from "../api";
|
||||||
|
import * as schema from "../db/schema";
|
||||||
|
import { hashToken, normalizeTimezone } from "./index";
|
||||||
|
import { readAuthConfig, type AuthConfig } from "./config";
|
||||||
|
import type { DiscordProfile, FetchDiscord } from "./discord";
|
||||||
|
|
||||||
|
const origin = "http://127.0.0.1:3000";
|
||||||
|
const config: AuthConfig = { origin, clientId: "test-client", clientSecret: "test-secret", cookieSecret: "test-only-signing-secret-at-least-32-characters" };
|
||||||
|
let sqlite: Database;
|
||||||
|
let db: ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
let app: ReturnType<typeof createApi>;
|
||||||
|
let timestamp: number;
|
||||||
|
let calls: { url: string; init: RequestInit }[];
|
||||||
|
let profile: DiscordProfile;
|
||||||
|
let failure: "token" | "profile" | "malformed" | "throw" | undefined;
|
||||||
|
const request: FetchDiscord = async (url, init) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
if (failure === "throw") throw new Error("provider secret response");
|
||||||
|
if (url.endsWith("/token")) {
|
||||||
|
if (failure === "token") return new Response("provider secret response", { status: 400 });
|
||||||
|
return Response.json({ access_token: "secret-access", refresh_token: "secret-refresh", token_type: "Bearer", scope: "identify" });
|
||||||
|
}
|
||||||
|
if (failure === "profile") return new Response("provider secret response", { status: 503 });
|
||||||
|
return Response.json(failure === "malformed" ? { id: 123, username: "bad" } : profile);
|
||||||
|
};
|
||||||
|
const cookie = (response: Response, name: string) => response.headers.getSetCookie().find(value => value.startsWith(`${name}=`))?.split(";")[0] ?? "";
|
||||||
|
async function begin(timezone = "Europe/Belgrade", target = app, base = origin) {
|
||||||
|
const response = await target.request(`${base}/api/auth/discord?${new URLSearchParams({ timezone })}`);
|
||||||
|
const location = new URL(response.headers.get("Location")!);
|
||||||
|
return { response, state: location.searchParams.get("state")!, cookie: cookie(response, base.startsWith("https:") ? "__Host-minabot_oauth" : "minabot_oauth") };
|
||||||
|
}
|
||||||
|
async function login(timezone = "Europe/Belgrade", previous = "") {
|
||||||
|
const flow = await begin(timezone);
|
||||||
|
const response = await app.request(`${origin}/api/auth/discord/callback?${new URLSearchParams({ state: flow.state, code: "test-code" })}`, {
|
||||||
|
headers: { Cookie: [flow.cookie, previous].filter(Boolean).join("; ") },
|
||||||
|
});
|
||||||
|
return { response, cookie: cookie(response, "minabot_session") };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sqlite = new Database(":memory:");
|
||||||
|
sqlite.exec("PRAGMA foreign_keys = ON");
|
||||||
|
db = drizzle(sqlite, { schema });
|
||||||
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
timestamp = Date.now(); calls = []; failure = undefined;
|
||||||
|
profile = { id: "123456789012345678", username: "mina", global_name: "Mina", avatar: null };
|
||||||
|
app = createApi(db, config, request, () => timestamp);
|
||||||
|
});
|
||||||
|
afterEach(() => sqlite.close());
|
||||||
|
|
||||||
|
describe("Discord sign-in and sessions", () => {
|
||||||
|
test("me requires a valid session and health stays public", async () => {
|
||||||
|
for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) {
|
||||||
|
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } });
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(await response.json()).toEqual({ error: "Unauthorized" });
|
||||||
|
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||||
|
}
|
||||||
|
expect((await app.request(`${origin}/api/health`)).status).toBe(200);
|
||||||
|
expect((await app.request(`${origin}/api/missing`)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authorization uses identify, random state, and a signed expiring HttpOnly cookie", async () => {
|
||||||
|
const first = await begin(); const second = await begin();
|
||||||
|
const url = new URL(first.response.headers.get("Location")!);
|
||||||
|
expect(url.origin).toBe("https://discord.com");
|
||||||
|
expect(url.searchParams.get("scope")).toBe("identify");
|
||||||
|
expect(url.searchParams.get("redirect_uri")).toBe(`${origin}/api/auth/discord/callback`);
|
||||||
|
expect(first.state).not.toBe(second.state);
|
||||||
|
const header = first.response.headers.get("Set-Cookie")!;
|
||||||
|
expect(header).toContain("HttpOnly"); expect(header).toContain("SameSite=Lax"); expect(header).toContain("Max-Age=600");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("callback creates a user and hashed session, and me returns only public fields", async () => {
|
||||||
|
const result = await login();
|
||||||
|
expect(result.response.headers.get("Location")).toBe("/");
|
||||||
|
const user = db.select().from(schema.users).get()!;
|
||||||
|
const session = db.select().from(schema.sessions).get()!;
|
||||||
|
expect(user.timezone).toBe("Europe/Belgrade");
|
||||||
|
expect(session.tokenHash).toBe(hashToken(result.cookie.split("=")[1]!));
|
||||||
|
expect(session.expiresAt - session.createdAt).toBe(30 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(result.response.headers.getSetCookie().join(";")).toContain("Max-Age=2592000");
|
||||||
|
expect(result.response.headers.getSetCookie().join(";")).toContain("Max-Age=0");
|
||||||
|
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: result.cookie } });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ id: user.id, discordId: profile.id, username: "mina", displayName: "Mina", avatarUrl: null, timezone: "Europe/Belgrade" });
|
||||||
|
const body = calls[0]!.init.body as URLSearchParams;
|
||||||
|
expect(body.get("client_secret")).toBe(config.clientSecret);
|
||||||
|
expect(body.get("code")).toBe("test-code");
|
||||||
|
expect(calls[1]!.init.headers).toEqual({ Authorization: "Bearer secret-access" });
|
||||||
|
expect(JSON.stringify({ user, session })).not.toContain("secret-access");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeat login updates profile but preserves ID, creation time and timezone; rotates session", async () => {
|
||||||
|
const first = await login();
|
||||||
|
const oldUser = db.select().from(schema.users).get()!;
|
||||||
|
timestamp += 1000;
|
||||||
|
profile = { ...profile, username: "new-name", global_name: null, avatar: "a_abcdef" };
|
||||||
|
const second = await login("America/New_York", first.cookie);
|
||||||
|
expect(db.select().from(schema.users).all()).toHaveLength(1);
|
||||||
|
const user = db.select().from(schema.users).get()!;
|
||||||
|
expect(user.id).toBe(oldUser.id); expect(user.createdAt).toBe(oldUser.createdAt);
|
||||||
|
expect(user.updatedAt).toBe(timestamp); expect(user.timezone).toBe("Europe/Belgrade");
|
||||||
|
expect(db.select().from(schema.sessions).all()).toHaveLength(1);
|
||||||
|
expect((await app.request(`${origin}/api/me`, { headers: { Cookie: first.cookie } })).status).toBe(401);
|
||||||
|
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: second.cookie } });
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.displayName).toBe("new-name");
|
||||||
|
expect(data.avatarUrl).toBe(`https://cdn.discordapp.com/avatars/${profile.id}/a_abcdef.gif`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid or missing timezone falls back to UTC", async () => {
|
||||||
|
expect(normalizeTimezone(undefined)).toBe("UTC");
|
||||||
|
expect(normalizeTimezone("+02:00")).toBe("UTC");
|
||||||
|
expect(normalizeTimezone("Europe/Belgrade")).toBe("Europe/Belgrade");
|
||||||
|
await login("invalid/timezone");
|
||||||
|
expect(db.select().from(schema.users).get()!.timezone).toBe("UTC");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects missing, mismatched, tampered and expired OAuth state before contacting Discord", async () => {
|
||||||
|
const flow = await begin();
|
||||||
|
const cases = [
|
||||||
|
{ state: flow.state, cookie: "" },
|
||||||
|
{ state: "b".repeat(43), cookie: flow.cookie },
|
||||||
|
{ state: flow.state, cookie: `${flow.cookie}tampered` },
|
||||||
|
{ state: "", cookie: flow.cookie },
|
||||||
|
];
|
||||||
|
for (const value of cases) {
|
||||||
|
const response = await app.request(`${origin}/api/auth/discord/callback?${new URLSearchParams({ state: value.state, code: "test-code" })}`, { headers: { Cookie: value.cookie } });
|
||||||
|
expect(response.headers.get("Location")).toBe("/?auth_error=invalid_state");
|
||||||
|
}
|
||||||
|
timestamp += 600_000;
|
||||||
|
const response = await app.request(`${origin}/api/auth/discord/callback?state=${flow.state}&code=test-code`, { headers: { Cookie: flow.cookie } });
|
||||||
|
expect(response.headers.get("Location")).toBe("/?auth_error=invalid_state");
|
||||||
|
expect(calls).toHaveLength(0); expect(db.select().from(schema.users).all()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles consent denial and missing code without creating a session", async () => {
|
||||||
|
for (const [query, error] of [["error=access_denied", "denied"], ["", "invalid_code"]]) {
|
||||||
|
const flow = await begin();
|
||||||
|
const response = await app.request(`${origin}/api/auth/discord/callback?state=${flow.state}&${query}`, { headers: { Cookie: flow.cookie } });
|
||||||
|
expect(response.headers.get("Location")).toBe(`/?auth_error=${error}`);
|
||||||
|
}
|
||||||
|
expect(calls).toHaveLength(0); expect(db.select().from(schema.sessions).all()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["token", "profile", "malformed", "throw"] as const)("provider failure (%s) leaves no account or session", async value => {
|
||||||
|
failure = value;
|
||||||
|
const result = await login();
|
||||||
|
expect(result.response.headers.get("Location")).toBe("/?auth_error=discord_unavailable");
|
||||||
|
expect(result.cookie).toBe("");
|
||||||
|
expect(db.select().from(schema.users).all()).toHaveLength(0);
|
||||||
|
expect(db.select().from(schema.sessions).all()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired sessions are rejected at their exact expiry", async () => {
|
||||||
|
const result = await login();
|
||||||
|
timestamp = db.select().from(schema.sessions).get()!.expiresAt;
|
||||||
|
expect((await app.request(`${origin}/api/me`, { headers: { Cookie: result.cookie } })).status).toBe(401);
|
||||||
|
expect(db.select().from(schema.sessions).all()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logout rejects cross-origin or missing origins and revokes only the current session", async () => {
|
||||||
|
const first = await login(); const second = await login();
|
||||||
|
for (const headers of [new Headers({ Cookie: first.cookie }), new Headers({ Cookie: first.cookie, Origin: "https://evil.example" })]) {
|
||||||
|
expect((await app.request(`${origin}/api/auth/logout`, { method: "POST", headers })).status).toBe(403);
|
||||||
|
}
|
||||||
|
expect(db.select().from(schema.sessions).all()).toHaveLength(2);
|
||||||
|
const response = await app.request(`${origin}/api/auth/logout`, { method: "POST", headers: { Cookie: first.cookie, Origin: origin } });
|
||||||
|
expect(response.status).toBe(204); expect(response.headers.get("Set-Cookie")).toContain("Max-Age=0");
|
||||||
|
expect((await app.request(`${origin}/api/me`, { headers: { Cookie: first.cookie } })).status).toBe(401);
|
||||||
|
expect((await app.request(`${origin}/api/me`, { headers: { Cookie: second.cookie } })).status).toBe(200);
|
||||||
|
expect((await app.request(`${origin}/api/auth/logout`)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("foreign keys cascade session deletion and duplicate Discord IDs are rejected", async () => {
|
||||||
|
await login();
|
||||||
|
const user = db.select().from(schema.users).get()!;
|
||||||
|
expect(() => db.insert(schema.users).values({ ...user, id: "different" }).run()).toThrow();
|
||||||
|
db.delete(schema.users).where(eq(schema.users.id, user.id)).run();
|
||||||
|
expect(db.select().from(schema.sessions).all()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("HTTPS cookies are Secure and use host-only names", async () => {
|
||||||
|
const secureConfig = { ...config, origin: "https://habits.example" };
|
||||||
|
const secureApp = createApi(db, secureConfig, request, () => timestamp);
|
||||||
|
const flow = await begin("UTC", secureApp, secureConfig.origin);
|
||||||
|
const response = await secureApp.request(`${secureConfig.origin}/api/auth/discord/callback?state=${flow.state}&code=test-code`, { headers: { Cookie: flow.cookie } });
|
||||||
|
expect(response.headers.getSetCookie().find(value => value.startsWith("__Host-minabot_session="))).toContain("Secure");
|
||||||
|
expect(flow.response.headers.get("Set-Cookie")).toContain("Secure");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing credentials fail gracefully and unsafe configured origins fail fast", async () => {
|
||||||
|
const unconfigured = createApi(db, { ...config, clientSecret: "" }, request);
|
||||||
|
const response = await unconfigured.request(`${origin}/api/auth/discord`);
|
||||||
|
expect(response.headers.get("Location")).toBe("/?auth_error=not_configured");
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
expect(() => readAuthConfig({ APP_ORIGIN: "http://habits.example" })).toThrow();
|
||||||
|
expect(() => readAuthConfig({ APP_ORIGIN: "https://habits.example/path" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
src/auth/config.test.ts
Normal file
20
src/auth/config.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { readAuthConfig } from "./config";
|
||||||
|
|
||||||
|
test("reads the active environment's APP_ORIGIN and normalizes it", () => {
|
||||||
|
expect(readAuthConfig({ APP_ORIGIN: " http://127.0.0.1:4000/ " }).origin).toBe("http://127.0.0.1:4000");
|
||||||
|
expect(readAuthConfig({ NODE_ENV: "production", APP_ORIGIN: "https://habits.example.com" }).origin).toBe("https://habits.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only development defaults to the local port", () => {
|
||||||
|
expect(readAuthConfig({}).origin).toBe("http://127.0.0.1:3000");
|
||||||
|
expect(readAuthConfig({ PORT: "4000" }).origin).toBe("http://127.0.0.1:4000");
|
||||||
|
expect(() => readAuthConfig({ NODE_ENV: "production", APP_ORIGIN: " " })).toThrow("Set APP_ORIGIN");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid origins and allows explicitly configured local production testing", () => {
|
||||||
|
for (const value of ["not a URL", "http://habits.example.com", "https://habits.example.com/path"]) {
|
||||||
|
expect(() => readAuthConfig({ NODE_ENV: "production", APP_ORIGIN: value })).toThrow("APP_ORIGIN");
|
||||||
|
}
|
||||||
|
expect(readAuthConfig({ NODE_ENV: "production", APP_ORIGIN: "http://127.0.0.1:3000" }).origin).toBe("http://127.0.0.1:3000");
|
||||||
|
});
|
||||||
36
src/auth/config.ts
Normal file
36
src/auth/config.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export type AuthConfig = {
|
||||||
|
origin: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
cookieSecret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readAuthConfig(env = process.env): AuthConfig {
|
||||||
|
const production = env.NODE_ENV === "production";
|
||||||
|
const configuredOrigin = env.APP_ORIGIN?.trim();
|
||||||
|
if (production && !configuredOrigin) {
|
||||||
|
throw new Error("Set APP_ORIGIN in .env.production or the process environment before starting in production.");
|
||||||
|
}
|
||||||
|
const origin = configuredOrigin || `http://127.0.0.1:${env.PORT ?? 3000}`;
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(origin);
|
||||||
|
} catch {
|
||||||
|
throw new Error("APP_ORIGIN must be a valid origin.");
|
||||||
|
}
|
||||||
|
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
||||||
|
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash ||
|
||||||
|
(url.protocol !== "https:" && !(local && url.protocol === "http:"))) {
|
||||||
|
throw new Error("APP_ORIGIN must be an HTTPS origin (HTTP is allowed for localhost).");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
origin: url.origin,
|
||||||
|
clientId: env.DISCORD_CLIENT_ID ?? "",
|
||||||
|
clientSecret: env.DISCORD_CLIENT_SECRET ?? "",
|
||||||
|
cookieSecret: env.AUTH_COOKIE_SECRET ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthConfigured(config: AuthConfig) {
|
||||||
|
return Boolean(config.clientId && config.clientSecret && config.cookieSecret.length >= 32);
|
||||||
|
}
|
||||||
45
src/auth/discord.ts
Normal file
45
src/auth/discord.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { AuthConfig } from "./config";
|
||||||
|
|
||||||
|
export type DiscordProfile = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
global_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FetchDiscord = (url: string, init: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
export async function fetchDiscordProfile(code: string, config: AuthConfig, request: FetchDiscord): Promise<DiscordProfile> {
|
||||||
|
const tokenResponse = await request("https://discord.com/api/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
redirect_uri: `${config.origin}/api/auth/discord/callback`,
|
||||||
|
code,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
if (!tokenResponse.ok) throw new Error("Discord token exchange failed");
|
||||||
|
const token = await tokenResponse.json();
|
||||||
|
if (typeof token.access_token !== "string" || !token.access_token ||
|
||||||
|
typeof token.token_type !== "string" || token.token_type.toLowerCase() !== "bearer" ||
|
||||||
|
typeof token.scope !== "string" || !token.scope.split(" ").includes("identify")) {
|
||||||
|
throw new Error("Invalid Discord token response");
|
||||||
|
}
|
||||||
|
const profileResponse = await request("https://discord.com/api/v10/users/@me", {
|
||||||
|
headers: { Authorization: `Bearer ${token.access_token}` },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
if (!profileResponse.ok) throw new Error("Discord profile request failed");
|
||||||
|
const profile = await profileResponse.json();
|
||||||
|
if (!profile || typeof profile.id !== "string" || !/^\d{1,20}$/.test(profile.id) ||
|
||||||
|
typeof profile.username !== "string" || !profile.username ||
|
||||||
|
!(profile.global_name === null || typeof profile.global_name === "string") ||
|
||||||
|
!(profile.avatar === null || (typeof profile.avatar === "string" && /^(a_)?[a-f0-9]+$/.test(profile.avatar)))) {
|
||||||
|
throw new Error("Invalid Discord profile");
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
146
src/auth/index.ts
Normal file
146
src/auth/index.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
import { and, eq, gt, lte } from "drizzle-orm";
|
||||||
|
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { createMiddleware } from "hono/factory";
|
||||||
|
import { deleteCookie, getCookie, getSignedCookie, setCookie, setSignedCookie } from "hono/cookie";
|
||||||
|
import * as schema from "../db/schema";
|
||||||
|
import type { PublicUser } from "../shared/user";
|
||||||
|
import { isAuthConfigured, type AuthConfig } from "./config";
|
||||||
|
import { fetchDiscordProfile, type FetchDiscord } from "./discord";
|
||||||
|
|
||||||
|
const { users, sessions } = schema;
|
||||||
|
const SESSION_SECONDS = 30 * 24 * 60 * 60;
|
||||||
|
const STATE_SECONDS = 10 * 60;
|
||||||
|
export type AuthEnv = { Variables: { user: PublicUser } };
|
||||||
|
export type AppDatabase = BunSQLiteDatabase<typeof schema>;
|
||||||
|
export const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
|
||||||
|
const randomToken = () => randomBytes(32).toString("base64url");
|
||||||
|
|
||||||
|
export function normalizeTimezone(value: unknown): string {
|
||||||
|
if (typeof value !== "string" || !value || value.length > 100 || /^[+-]/.test(value)) return "UTC";
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone;
|
||||||
|
} catch {
|
||||||
|
return "UTC";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicUser(user: typeof users.$inferSelect): PublicUser {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
discordId: user.discordId,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.globalName ?? user.username,
|
||||||
|
avatarUrl: user.avatarHash
|
||||||
|
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatarHash}.${user.avatarHash.startsWith("a_") ? "gif" : "png"}`
|
||||||
|
: null,
|
||||||
|
timezone: user.timezone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDiscord = fetch, now = Date.now) {
|
||||||
|
const secure = config.origin.startsWith("https:");
|
||||||
|
const sessionName = secure ? "__Host-minabot_session" : "minabot_session";
|
||||||
|
const stateName = secure ? "__Host-minabot_oauth" : "minabot_oauth";
|
||||||
|
const cookieOptions = { path: "/", httpOnly: true, sameSite: "Lax" as const, secure };
|
||||||
|
const readToken = (value: string | undefined) => value && /^[A-Za-z0-9_-]{43}$/.test(value) ? value : undefined;
|
||||||
|
const routes = new Hono<AuthEnv>();
|
||||||
|
|
||||||
|
// Apply to all future cookie-authenticated mutations, including JSON requests.
|
||||||
|
const requireSameOrigin = createMiddleware<AuthEnv>(async (c, next) => {
|
||||||
|
if (c.req.header("Origin") !== config.origin) return c.json({ error: "Forbidden origin" }, 403);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
const requireAuth = createMiddleware<AuthEnv>(async (c, next) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const token = readToken(getCookie(c, sessionName));
|
||||||
|
const session = token ? db.select({ user: users }).from(sessions)
|
||||||
|
.innerJoin(users, eq(users.id, sessions.userId))
|
||||||
|
.where(and(eq(sessions.tokenHash, hashToken(token)), gt(sessions.expiresAt, now()))).get() : undefined;
|
||||||
|
if (!session) {
|
||||||
|
if (token) db.delete(sessions).where(and(eq(sessions.tokenHash, hashToken(token)), lte(sessions.expiresAt, now()))).run();
|
||||||
|
deleteCookie(c, sessionName, cookieOptions);
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
c.set("user", publicUser(session.user));
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.use("*", async (c, next) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
c.header("Referrer-Policy", "no-referrer");
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
routes.get("/discord", async c => {
|
||||||
|
if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured");
|
||||||
|
// Use one configured origin for redirects and cookie ownership.
|
||||||
|
if (new URL(c.req.url).origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`);
|
||||||
|
const state = randomToken();
|
||||||
|
await setSignedCookie(c, stateName, JSON.stringify({
|
||||||
|
state,
|
||||||
|
timezone: normalizeTimezone(c.req.query("timezone")),
|
||||||
|
expiresAt: now() + STATE_SECONDS * 1000,
|
||||||
|
}), config.cookieSecret, { ...cookieOptions, maxAge: STATE_SECONDS });
|
||||||
|
const url = new URL("https://discord.com/oauth2/authorize");
|
||||||
|
url.search = new URLSearchParams({
|
||||||
|
client_id: config.clientId,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "identify",
|
||||||
|
redirect_uri: `${config.origin}/api/auth/discord/callback`,
|
||||||
|
state,
|
||||||
|
}).toString();
|
||||||
|
return c.redirect(url.toString());
|
||||||
|
});
|
||||||
|
routes.get("/discord/callback", async c => {
|
||||||
|
if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured");
|
||||||
|
const signedState = await getSignedCookie(c, config.cookieSecret, stateName);
|
||||||
|
deleteCookie(c, stateName, cookieOptions);
|
||||||
|
let state: { state?: unknown; timezone?: unknown; expiresAt?: unknown } | null = null;
|
||||||
|
try { state = signedState ? JSON.parse(signedState) : null; } catch { /* Invalid cookie. */ }
|
||||||
|
const returnedState = c.req.query("state");
|
||||||
|
if (!state || typeof state.state !== "string" || typeof returnedState !== "string" ||
|
||||||
|
!/^[A-Za-z0-9_-]{43}$/.test(state.state) || !/^[A-Za-z0-9_-]{43}$/.test(returnedState) ||
|
||||||
|
!timingSafeEqual(Buffer.from(state.state), Buffer.from(returnedState)) ||
|
||||||
|
typeof state.expiresAt !== "number" || state.expiresAt <= now()) {
|
||||||
|
return c.redirect("/?auth_error=invalid_state");
|
||||||
|
}
|
||||||
|
if (c.req.query("error")) return c.redirect("/?auth_error=denied");
|
||||||
|
const code = c.req.query("code");
|
||||||
|
if (!code || code.length > 2048) return c.redirect("/?auth_error=invalid_code");
|
||||||
|
let profile;
|
||||||
|
try {
|
||||||
|
profile = await fetchDiscordProfile(code, config, request);
|
||||||
|
} catch {
|
||||||
|
// Never log authorization codes, provider responses, or credentials.
|
||||||
|
return c.redirect("/?auth_error=discord_unavailable");
|
||||||
|
}
|
||||||
|
const timestamp = now();
|
||||||
|
const token = randomToken();
|
||||||
|
const previousToken = readToken(getCookie(c, sessionName));
|
||||||
|
db.transaction(tx => {
|
||||||
|
const user = tx.insert(users).values({
|
||||||
|
id: crypto.randomUUID(), discordId: profile.id, username: profile.username,
|
||||||
|
globalName: profile.global_name, avatarHash: profile.avatar,
|
||||||
|
timezone: normalizeTimezone(state.timezone), createdAt: timestamp, updatedAt: timestamp,
|
||||||
|
}).onConflictDoUpdate({ target: users.discordId, set: {
|
||||||
|
username: profile.username, globalName: profile.global_name,
|
||||||
|
avatarHash: profile.avatar, updatedAt: timestamp,
|
||||||
|
// A browser or travel timezone must not overwrite an existing preference.
|
||||||
|
} }).returning().get();
|
||||||
|
if (previousToken) tx.delete(sessions).where(eq(sessions.tokenHash, hashToken(previousToken))).run();
|
||||||
|
tx.delete(sessions).where(lte(sessions.expiresAt, timestamp)).run();
|
||||||
|
tx.insert(sessions).values({ tokenHash: hashToken(token), userId: user.id,
|
||||||
|
createdAt: timestamp, expiresAt: timestamp + SESSION_SECONDS * 1000 }).run();
|
||||||
|
});
|
||||||
|
setCookie(c, sessionName, token, { ...cookieOptions, maxAge: SESSION_SECONDS });
|
||||||
|
return c.redirect("/");
|
||||||
|
});
|
||||||
|
routes.post("/logout", requireSameOrigin, c => {
|
||||||
|
const token = readToken(getCookie(c, sessionName));
|
||||||
|
if (token) db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token))).run();
|
||||||
|
deleteCookie(c, sessionName, cookieOptions);
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
|
return { routes, requireAuth, requireSameOrigin };
|
||||||
|
}
|
||||||
69
src/components/AuthControls.tsx
Normal file
69
src/components/AuthControls.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
|
import type { PublicUser } from "../shared/user";
|
||||||
|
|
||||||
|
const authErrors: Record<string, string> = {
|
||||||
|
not_configured: "Discord sign-in is not configured yet.",
|
||||||
|
invalid_state: "Your sign-in attempt expired or could not be verified. Please try again.",
|
||||||
|
denied: "Discord sign-in was cancelled. You can try again when ready.",
|
||||||
|
invalid_code: "Discord did not return a sign-in code. Please try again.",
|
||||||
|
discord_unavailable: "Could not complete Discord sign-in. Please try again.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthControls() {
|
||||||
|
const [user, setUser] = useState<PublicUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const { search } = useLocation();
|
||||||
|
const signInError = authErrors[new URLSearchParams(search).get("auth_error") ?? ""];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
async function loadUser() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/me", { signal: controller.signal });
|
||||||
|
if (response.status === 401) return;
|
||||||
|
if (!response.ok) throw new Error("Could not load your account. Please reload to try again.");
|
||||||
|
setUser(await response.json());
|
||||||
|
} catch (error) {
|
||||||
|
if (!controller.signal.aborted) setError(error instanceof Error ? error.message : "Could not load your account.");
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadUser();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function signIn() {
|
||||||
|
let timezone = "UTC";
|
||||||
|
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
|
||||||
|
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
if (!response.ok) throw new Error("Could not sign out. Please try again.");
|
||||||
|
setUser(null);
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : "Could not sign out.");
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Account">
|
||||||
|
{loading ? <p role="status">Loading account…</p> : user ? (
|
||||||
|
<p>
|
||||||
|
Signed in as <strong>{user.displayName}</strong> · {user.timezone}{" "}
|
||||||
|
<button type="button" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</button>{" "}
|
||||||
|
<a href="/api/me">View my profile</a>
|
||||||
|
</p>
|
||||||
|
) : <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
|
||||||
|
{(error || signInError) && <p role="alert">{error || signInError}</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,2 +1,22 @@
|
|||||||
// Define application tables here with drizzle-orm/sqlite-core.
|
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
export {};
|
|
||||||
|
export const users = sqliteTable("users", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
discordId: text("discord_id").notNull().unique(),
|
||||||
|
username: text("username").notNull(),
|
||||||
|
globalName: text("global_name"),
|
||||||
|
avatarHash: text("avatar_hash"),
|
||||||
|
timezone: text("timezone").notNull().default("UTC"),
|
||||||
|
createdAt: integer("created_at").notNull(),
|
||||||
|
updatedAt: integer("updated_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessions = sqliteTable("sessions", {
|
||||||
|
tokenHash: text("token_hash").primaryKey(),
|
||||||
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: integer("created_at").notNull(),
|
||||||
|
expiresAt: integer("expires_at").notNull(),
|
||||||
|
}, table => [
|
||||||
|
index("sessions_user_id_idx").on(table.userId),
|
||||||
|
index("sessions_expires_at_idx").on(table.expiresAt),
|
||||||
|
]);
|
||||||
|
|||||||
12
src/index.ts
12
src/index.ts
@@ -1,15 +1,9 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
|
import { createApi } from "./api";
|
||||||
|
import { readAuthConfig } from "./auth/config";
|
||||||
import index from "./index.html";
|
import index from "./index.html";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = createApi(db, readAuthConfig());
|
||||||
|
|
||||||
app.get("/api/health", c => {
|
|
||||||
db.get(sql`SELECT 1`);
|
|
||||||
return c.json({ status: "ok" });
|
|
||||||
});
|
|
||||||
app.notFound(c => c.json({ error: "Not found" }, 404));
|
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
hostname: "127.0.0.1",
|
hostname: "127.0.0.1",
|
||||||
|
|||||||
8
src/shared/user.ts
Normal file
8
src/shared/user.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type PublicUser = {
|
||||||
|
id: string;
|
||||||
|
discordId: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user