-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: user auth and related endpoints (#47)
* Env var typing * Move migrations folder into db, migrate on startup * Fix migrations folder * Add error handling * Add jwt plugin and generate token function * Update user schema * Update user repository * Update user controller and add endpoints * Use bun env instead of process * Working user login and protected routes * Fix token payload * Use biome as default formatter in vscode * Bring back db seed and migration * Refactor error code handling * Format files * Fix biome max file size for bun types * Use jose directly instead of elysia jwt plugin * Rename things * Update src/users/users.plugin.ts Co-authored-by: Yam Borodetsky <[email protected]> * Refactor error handling * Use Type instead of t * Move users table to separate file --------- Co-authored-by: Yam Borodetsky <[email protected]>
- Loading branch information
Showing
22 changed files
with
338 additions
and
50 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,3 +3,4 @@ POSTGRES_PASSWORD=postgres | |
POSTGRES_DB=medium | ||
POSTGRES_HOST=0.0.0.0 | ||
POSTGRES_PORT=5432 | ||
JWT_SECRET=supersecretkey |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,6 @@ | ||
{ | ||
"editor.tabSize": 2 | ||
"editor.tabSize": 2, | ||
"[javascript]": { | ||
"editor.defaultFormatter": "biomejs.biome" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,5 +18,8 @@ | |
"rules": { | ||
"recommended": true | ||
} | ||
}, | ||
"files": { | ||
"maxSize": 3145728 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,9 @@ | ||
import { exit } from 'process'; | ||
import { drizzle } from 'drizzle-orm/postgres-js'; | ||
import { migrate } from 'drizzle-orm/postgres-js/migrator'; | ||
import { migrationsClient } from '@/database.providers'; | ||
|
||
await migrate(drizzle(migrationsClient), { | ||
migrationsFolder: `${import.meta.dir}`, | ||
migrationsFolder: `${import.meta.dir}/migrations`, | ||
}); | ||
exit(0); |
7 changes: 4 additions & 3 deletions
7
db/migrations/0000_bored_warstar.sql → ...grations/0000_perpetual_blazing_skull.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,11 @@ | ||
CREATE TABLE IF NOT EXISTS "users" ( | ||
"id" serial PRIMARY KEY NOT NULL, | ||
"email" text NOT NULL, | ||
"bio" text NOT NULL, | ||
"image" text NOT NULL, | ||
"bio" text, | ||
"image" text, | ||
"password" text NOT NULL, | ||
"username" text NOT NULL, | ||
"created_at" date DEFAULT CURRENT_DATE, | ||
"updated_at" date DEFAULT CURRENT_DATE | ||
"updated_at" date DEFAULT CURRENT_DATE, | ||
CONSTRAINT "users_email_unique" UNIQUE("email") | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
import * as jose from 'jose'; | ||
import { Type } from '@sinclair/typebox'; | ||
import { Value } from '@sinclair/typebox/value'; | ||
import { UserInDb } from '@/users/users.schema'; | ||
import { env } from '@/config'; | ||
import { AuthenticationError } from '@/errors'; | ||
|
||
export const ALG = 'HS256'; | ||
|
||
const VerifiedJwtSchema = Type.Object({ | ||
payload: Type.Object({ | ||
user: Type.Object({ | ||
id: Type.Number(), | ||
email: Type.String(), | ||
username: Type.String(), | ||
}), | ||
iat: Type.Number(), | ||
iss: Type.String(), | ||
aud: Type.String(), | ||
exp: Type.Number(), | ||
}), | ||
protectedHeader: Type.Object({ | ||
alg: Type.Literal(ALG), | ||
}), | ||
}); | ||
|
||
export async function generateToken(user: UserInDb) { | ||
const encoder = new TextEncoder(); | ||
const secret = encoder.encode(env.JWT_SECRET); | ||
|
||
return await new jose.SignJWT({ | ||
user: { id: user.id, email: user.email, username: user.username }, | ||
}) | ||
.setProtectedHeader({ alg: ALG }) | ||
.setIssuedAt() | ||
.setIssuer('agnyz') | ||
.setAudience(user.email) | ||
.setExpirationTime('24h') | ||
.sign(secret); | ||
} | ||
|
||
export async function verifyToken(token: string) { | ||
const encoder = new TextEncoder(); | ||
const secret = encoder.encode(env.JWT_SECRET); | ||
|
||
let verifiedToken; | ||
try { | ||
verifiedToken = await jose.jwtVerify(token, secret, { | ||
algorithms: [ALG], | ||
}); | ||
} catch (err) { | ||
throw new AuthenticationError('Invalid token'); | ||
} | ||
// I'm not sure if this is a good idea, but it at least makes sure that the token is 100% correct | ||
// Also adds typings to the token | ||
if (!Value.Check(VerifiedJwtSchema, verifiedToken)) | ||
throw new AuthenticationError('Invalid token'); | ||
const userToken = Value.Cast(VerifiedJwtSchema, verifiedToken); | ||
return userToken; | ||
} | ||
|
||
export async function getUserFromHeaders(headers: Headers) { | ||
const rawHeader = headers.get('Authorization'); | ||
if (!rawHeader) throw new AuthenticationError('Missing authorization header'); | ||
|
||
const tokenParts = rawHeader?.split(' '); | ||
const tokenType = tokenParts?.[0]; | ||
if (tokenType !== 'Token') | ||
throw new AuthenticationError( | ||
"Invalid token type. Expected header format: 'Token jwt'", | ||
); | ||
|
||
const token = tokenParts?.[1]; | ||
const userToken = await verifyToken(token); | ||
return userToken.payload.user; | ||
} | ||
|
||
export async function requireLogin({ | ||
request: { headers }, | ||
}: { | ||
request: Request; | ||
}) { | ||
await getUserFromHeaders(headers); | ||
} | ||
|
||
export async function getUserEmailFromHeader(headers: Headers) { | ||
const user = await getUserFromHeaders(headers); | ||
return user.email; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { Type } from '@sinclair/typebox'; | ||
import { Value } from '@sinclair/typebox/value'; | ||
|
||
const envSchema = Type.Object({ | ||
POSTGRES_DB: Type.String(), | ||
POSTGRES_USER: Type.String(), | ||
POSTGRES_PASSWORD: Type.String(), | ||
POSTGRES_HOST: Type.String(), | ||
POSTGRES_PORT: Type.String(), | ||
JWT_SECRET: Type.String(), | ||
}); | ||
// TODO: this is ugly, find a better way to do this | ||
if (!Value.Check(envSchema, Bun.env)) throw new Error('Invalid env variables'); | ||
export const env = Value.Cast(envSchema, Bun.env); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { DEFAULT, MapWithDefault } from '@/utils/defaultmap'; | ||
|
||
export class AuthenticationError extends Error { | ||
public status = 401; | ||
public type = 'authentication'; | ||
constructor(public message: string) { | ||
super(message); | ||
} | ||
} | ||
|
||
export class AuthorizationError extends Error { | ||
public status = 403; | ||
public type = 'authorization'; | ||
constructor(public message: string) { | ||
super(message); | ||
} | ||
} | ||
|
||
export const ERROR_CODE_STATUS_MAP = new MapWithDefault<string, number>([ | ||
['PARSE', 400], | ||
['VALIDATION', 422], | ||
['NOT_FOUND', 404], | ||
['INVALID_COOKIE_SIGNATURE', 401], | ||
['AUTHENTICATION', 401], | ||
['AUTHORIZATION', 403], | ||
['INTERNAL_SERVER_ERROR', 500], | ||
['UNKNOWN', 500], | ||
[DEFAULT, 500], | ||
]); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { sql } from 'drizzle-orm'; | ||
import { date, pgTable, serial, text } from 'drizzle-orm/pg-core'; | ||
|
||
export const users = pgTable('users', { | ||
id: serial('id').primaryKey(), | ||
email: text('email').unique().notNull(), | ||
bio: text('bio'), | ||
image: text('image'), | ||
password: text('password').notNull(), | ||
username: text('username').notNull(), | ||
created_at: date('created_at').default(sql`CURRENT_DATE`), | ||
updated_at: date('updated_at').default(sql`CURRENT_DATE`), | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,15 +1,43 @@ | ||
import { Elysia } from 'elysia'; | ||
import { setupUsers } from '@/users/users.module'; | ||
import { | ||
InsertUserSchema, | ||
UserAuthSchema, | ||
UserLoginSchema, | ||
} from '@/users/users.schema'; | ||
import { getUserEmailFromHeader, requireLogin } from '@/auth'; | ||
|
||
export const usersPlugin = new Elysia().use(setupUsers).group( | ||
'/users', | ||
{ | ||
detail: { | ||
tags: ['Users'], | ||
}, | ||
}, | ||
(app) => | ||
export const usersPlugin = new Elysia() | ||
.use(setupUsers) | ||
.group('/users', (app) => | ||
app | ||
.post('', ({ store }) => store.usersService.findAll()) | ||
.post('/login', ({ store }) => store.usersService.findAll()), | ||
); | ||
.post('', ({ body, store }) => store.usersService.createUser(body.user), { | ||
body: InsertUserSchema, | ||
response: UserAuthSchema, | ||
detail: { | ||
summary: 'Create a user', | ||
}, | ||
}) | ||
.post( | ||
'/login', | ||
({ body, store }) => | ||
store.usersService.loginUser(body.user.email, body.user.password), | ||
{ | ||
body: UserLoginSchema, | ||
response: UserAuthSchema, | ||
}, | ||
), | ||
) | ||
.group('/user', (app) => | ||
app.get( | ||
'', | ||
async ({ request, store }) => | ||
store.usersService.findByEmail( | ||
await getUserEmailFromHeader(request.headers), | ||
), | ||
{ | ||
beforeHandle: requireLogin, | ||
response: UserAuthSchema, | ||
}, | ||
), | ||
); |
Oops, something went wrong.