diff options
| -rw-r--r-- | docs/dev/roadmap.md | 2 | ||||
| -rw-r--r-- | pkgs/server/drizzle/0001_spotty_jane_foster.sql | 9 | ||||
| -rw-r--r-- | pkgs/server/drizzle/meta/0001_snapshot.json | 462 | ||||
| -rw-r--r-- | pkgs/server/drizzle/meta/_journal.json | 7 | ||||
| -rw-r--r-- | pkgs/server/src/db/schema.ts | 12 | ||||
| -rw-r--r-- | pkgs/server/src/routes/auth.test.ts | 15 | ||||
| -rw-r--r-- | pkgs/server/src/routes/auth.ts | 116 | ||||
| -rw-r--r-- | pkgs/shared/src/schemas/index.ts | 6 |
8 files changed, 625 insertions, 4 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 33ec80b..17bac7d 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -29,7 +29,7 @@ ### Authentication - [x] User registration endpoint - [x] Login endpoint (JWT) -- [ ] Refresh token endpoint +- [x] Refresh token endpoint - [ ] Auth middleware - [ ] Add tests diff --git a/pkgs/server/drizzle/0001_spotty_jane_foster.sql b/pkgs/server/drizzle/0001_spotty_jane_foster.sql new file mode 100644 index 0000000..417408f --- /dev/null +++ b/pkgs/server/drizzle/0001_spotty_jane_foster.sql @@ -0,0 +1,9 @@ +CREATE TABLE "refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "token_hash" varchar(255) NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
\ No newline at end of file diff --git a/pkgs/server/drizzle/meta/0001_snapshot.json b/pkgs/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..55c3999 --- /dev/null +++ b/pkgs/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,462 @@ +{ + "id": "d3d0fb16-7e44-4217-916e-a5edc9ab7a16", + "prevId": "d2f779a2-d302-4fe3-91bb-a541025dbe4a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "front": { + "name": "front", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "back": { + "name": "back", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "due": { + "name": "due", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stability": { + "name": "stability", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "difficulty": { + "name": "difficulty", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "elapsed_days": { + "name": "elapsed_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_days": { + "name": "scheduled_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reps": { + "name": "reps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lapses": { + "name": "lapses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_review": { + "name": "last_review", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "cards_deck_id_decks_id_fk": { + "name": "cards_deck_id_decks_id_fk", + "tableFrom": "cards", + "tableTo": "decks", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.decks": { + "name": "decks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_cards_per_day": { + "name": "new_cards_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "decks_user_id_users_id_fk": { + "name": "decks_user_id_users_id_fk", + "tableFrom": "decks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_logs": { + "name": "review_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "scheduled_days": { + "name": "scheduled_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "elapsed_days": { + "name": "elapsed_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "review_logs_card_id_cards_id_fk": { + "name": "review_logs_card_id_cards_id_fk", + "tableFrom": "review_logs", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "review_logs_user_id_users_id_fk": { + "name": "review_logs_user_id_users_id_fk", + "tableFrom": "review_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +}
\ No newline at end of file diff --git a/pkgs/server/drizzle/meta/_journal.json b/pkgs/server/drizzle/meta/_journal.json index 6448a8c..f245fa6 100644 --- a/pkgs/server/drizzle/meta/_journal.json +++ b/pkgs/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1764706245996, "tag": "0000_cynical_zeigeist", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1764708169736, + "tag": "0001_spotty_jane_foster", + "breakpoints": true } ] }
\ No newline at end of file diff --git a/pkgs/server/src/db/schema.ts b/pkgs/server/src/db/schema.ts index 23f19d1..4b9631f 100644 --- a/pkgs/server/src/db/schema.ts +++ b/pkgs/server/src/db/schema.ts @@ -37,6 +37,18 @@ export const users = pgTable("users", { .defaultNow(), }); +export const refreshTokens = pgTable("refresh_tokens", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: varchar("token_hash", { length: 255 }).notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + export const decks = pgTable("decks", { id: uuid("id").primaryKey().defaultRandom(), userId: uuid("user_id") diff --git a/pkgs/server/src/routes/auth.test.ts b/pkgs/server/src/routes/auth.test.ts index 1dfba46..28bd558 100644 --- a/pkgs/server/src/routes/auth.test.ts +++ b/pkgs/server/src/routes/auth.test.ts @@ -43,6 +43,13 @@ vi.mock("../db", () => { username: "username", createdAt: "created_at", }, + refreshTokens: { + id: "id", + userId: "user_id", + tokenHash: "token_hash", + expiresAt: "expires_at", + createdAt: "created_at", + }, }; }); @@ -67,6 +74,7 @@ interface RegisterResponse { interface LoginResponse { accessToken?: string; + refreshToken?: string; user?: { id: string; username: string; @@ -188,6 +196,11 @@ describe("POST /login", () => { }), } as unknown as ReturnType<typeof db.select>); + // Mock the insert call for refresh token + vi.mocked(db.insert).mockReturnValueOnce({ + values: vi.fn().mockResolvedValue(undefined), + } as unknown as ReturnType<typeof db.insert>); + const res = await app.request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -201,6 +214,8 @@ describe("POST /login", () => { const body = (await res.json()) as LoginResponse; expect(body.accessToken).toBeDefined(); expect(typeof body.accessToken).toBe("string"); + expect(body.refreshToken).toBeDefined(); + expect(typeof body.refreshToken).toBe("string"); expect(body.user).toEqual({ id: "user-uuid-123", username: "testuser", diff --git a/pkgs/server/src/routes/auth.ts b/pkgs/server/src/routes/auth.ts index ed497b1..a2e6c8e 100644 --- a/pkgs/server/src/routes/auth.ts +++ b/pkgs/server/src/routes/auth.ts @@ -1,9 +1,14 @@ -import { createUserSchema, loginSchema } from "@kioku/shared"; +import { createHash, randomBytes } from "node:crypto"; +import { + createUserSchema, + loginSchema, + refreshTokenSchema, +} from "@kioku/shared"; import * as argon2 from "argon2"; -import { eq } from "drizzle-orm"; +import { and, eq, gt } from "drizzle-orm"; import { Hono } from "hono"; import { sign } from "hono/jwt"; -import { db, users } from "../db"; +import { db, refreshTokens, users } from "../db"; import { Errors } from "../middleware"; const JWT_SECRET = process.env.JWT_SECRET; @@ -11,6 +16,15 @@ if (!JWT_SECRET) { throw new Error("JWT_SECRET environment variable is required"); } const ACCESS_TOKEN_EXPIRES_IN = 60 * 15; // 15 minutes +const REFRESH_TOKEN_EXPIRES_IN = 60 * 60 * 24 * 7; // 7 days + +function generateRefreshToken(): string { + return randomBytes(32).toString("hex"); +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} const auth = new Hono(); @@ -102,8 +116,104 @@ auth.post("/login", async (c) => { JWT_SECRET, ); + // Generate refresh token + const refreshToken = generateRefreshToken(); + const tokenHash = hashToken(refreshToken); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); + + // Store refresh token in database + await db.insert(refreshTokens).values({ + userId: user.id, + tokenHash, + expiresAt, + }); + + return c.json({ + accessToken, + refreshToken, + user: { + id: user.id, + username: user.username, + }, + }); +}); + +auth.post("/refresh", async (c) => { + const body = await c.req.json(); + + const parsed = refreshTokenSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { refreshToken } = parsed.data; + const tokenHash = hashToken(refreshToken); + + // Find valid refresh token + const [storedToken] = await db + .select({ + id: refreshTokens.id, + userId: refreshTokens.userId, + expiresAt: refreshTokens.expiresAt, + }) + .from(refreshTokens) + .where( + and( + eq(refreshTokens.tokenHash, tokenHash), + gt(refreshTokens.expiresAt, new Date()), + ), + ) + .limit(1); + + if (!storedToken) { + throw Errors.unauthorized( + "Invalid or expired refresh token", + "INVALID_REFRESH_TOKEN", + ); + } + + // Get user info + const [user] = await db + .select({ + id: users.id, + username: users.username, + }) + .from(users) + .where(eq(users.id, storedToken.userId)) + .limit(1); + + if (!user) { + throw Errors.unauthorized("User not found", "USER_NOT_FOUND"); + } + + // Delete old refresh token (rotation) + await db.delete(refreshTokens).where(eq(refreshTokens.id, storedToken.id)); + + // Generate new access token + const now = Math.floor(Date.now() / 1000); + const accessToken = await sign( + { + sub: user.id, + iat: now, + exp: now + ACCESS_TOKEN_EXPIRES_IN, + }, + JWT_SECRET, + ); + + // Generate new refresh token (rotation) + const newRefreshToken = generateRefreshToken(); + const newTokenHash = hashToken(newRefreshToken); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); + + await db.insert(refreshTokens).values({ + userId: user.id, + tokenHash: newTokenHash, + expiresAt, + }); + return c.json({ accessToken, + refreshToken: newRefreshToken, user: { id: user.id, username: user.username, diff --git a/pkgs/shared/src/schemas/index.ts b/pkgs/shared/src/schemas/index.ts index 28b5f55..05b926a 100644 --- a/pkgs/shared/src/schemas/index.ts +++ b/pkgs/shared/src/schemas/index.ts @@ -37,6 +37,11 @@ export const loginSchema = z.object({ password: z.string().min(1), }); +// Refresh token input schema +export const refreshTokenSchema = z.object({ + refreshToken: z.string().min(1), +}); + // Deck schema export const deckSchema = z.object({ id: z.string().uuid(), @@ -124,6 +129,7 @@ export const submitReviewSchema = z.object({ export type UserSchema = z.infer<typeof userSchema>; export type CreateUserSchema = z.infer<typeof createUserSchema>; export type LoginSchema = z.infer<typeof loginSchema>; +export type RefreshTokenSchema = z.infer<typeof refreshTokenSchema>; export type DeckSchema = z.infer<typeof deckSchema>; export type CreateDeckSchema = z.infer<typeof createDeckSchema>; export type UpdateDeckSchema = z.infer<typeof updateDeckSchema>; |
