Skip to content

Commit 02e3ade

Browse files
authored
refactor(auth): redesign authentication and identity models (#481)
* refactor(auth): restructure user schema for enhanced authentication * fix: Added inline comment * fix: add role to schema Signed-off-by: Harshit <harsxit04@gmail.com> * feat(auth): implement OAuth authentication flow * fix(auth): add account linking logic and resolve lint issues * test(auth): update logout tests for access token cookies * fix: Updated test file * fix: Lint issues --------- Signed-off-by: Harshit <harsxit04@gmail.com>
1 parent 4bd72ef commit 02e3ade

6 files changed

Lines changed: 578 additions & 101 deletions

File tree

apps/backend/prisma/schema.prisma

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ datasource db {
66
url = env("DATABASE_URL")
77
88
}
9-
9+
enum Role{
10+
SUPERADMIN
11+
ADMIN
12+
USER
13+
14+
}
1015
model User {
1116
id String @id @default(uuid())
1217
email String @unique
@@ -15,28 +20,64 @@ model User {
1520
bio String?
1621
pronouns String?
1722
role String?
23+
authRole Role @default(USER)
1824
company String?
1925
avatarUrl String? @map("avatar_url")
2026
accentColor String @default("#6366f1") @map("accent_color")
21-
provider String
22-
providerId String @map("provider_id")
27+
emailVerified Boolean @default(false) @map("email_verified")
28+
phoneNumber String? @unique @map("phone_number")
29+
lastSignInAt DateTime? @map("last_sign_in_at")
2330
createdAt DateTime @default(now()) @map("created_at")
2431
updatedAt DateTime @updatedAt @map("updated_at")
32+
isActive Boolean @default(false)
2533
34+
identities UserIdentity[]
35+
refreshTokens RefreshToken[]
2636
platformLinks PlatformLink[]
2737
cards Card[]
2838
oauthTokens OAuthToken[]
2939
ownedViews CardView[] @relation("cardOwner")
3040
viewedCards CardView[] @relation("cardViewer")
3141
followLogs FollowLog[]
32-
organizer Event[]
33-
attendedEvents EventAttendee[]
42+
organizer Event[]
43+
attendedEvents EventAttendee[]
44+
ownedTeams Team[] @relation("TeamOwner")
45+
teamMemberships TeamMember[] @relation("TeamMember")
3446
35-
ownedTeams Team[] @relation("TeamOwner")
36-
teamMemberships TeamMember[] @relation("TeamMember")
47+
@@map("users")
48+
}
49+
50+
model UserIdentity {
51+
id String @id @default(uuid())
52+
userId String @map("user_id")
53+
provider String // "google.com" | "apple.com" | "firebase" | "phone"
54+
providerId String @map("provider_id") // Google sub / Apple sub / Firebase UID
55+
createdAt DateTime @default(now()) @map("created_at")
56+
57+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
3758
3859
@@unique([provider, providerId])
39-
@@map("users")
60+
@@index([userId])
61+
@@map("user_identities")
62+
}
63+
64+
65+
model RefreshToken {
66+
id String @id @default(uuid())
67+
userId String @map("user_id")
68+
tokenHash String @unique @map("token_hash") //SHA-256 hash
69+
family String // token rotation
70+
expiresAt DateTime @map("expires_at")
71+
revokedAt DateTime? @map("revoked_at") // null = still valid
72+
createdAt DateTime @default(now()) @map("created_at")
73+
userAgent String? @map("user_agent")
74+
ip String? //hash
75+
76+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
77+
78+
@@index([userId])
79+
@@index([family])
80+
@@map("refresh_tokens")
4081
}
4182

4283
model PlatformLink {

apps/backend/src/__tests__/logout.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async function buildTestApp(mockRedis: MockRedis): Promise<FastifyInstance> {
4646
// in app.ts so that both Authorization header and token cookie are accepted.
4747
await app.register(jwtPlugin as any, {
4848
secret: TEST_JWT_SECRET,
49-
cookie: { cookieName: 'token', signed: false },
49+
cookie: { cookieName: 'access_Token', signed: false },
5050
});
5151

5252
// Minimal Prisma stub. The logout route does not touch the database, but
@@ -264,7 +264,7 @@ describe('DELETE /auth/logout', () => {
264264
const res = await app.inject({
265265
method: 'DELETE',
266266
url: '/auth/logout',
267-
headers: { Cookie: `token=${token}` },
267+
headers: { Cookie: `access_Token=${token}` },
268268
});
269269

270270
expect(res.statusCode).toBe(200);
@@ -284,7 +284,7 @@ describe('DELETE /auth/logout', () => {
284284
url: '/auth/logout',
285285
headers: {
286286
Authorization: `Bearer ${headerToken}`,
287-
Cookie: `token=${cookieToken}`,
287+
Cookie: `access_Token=${cookieToken}`,
288288
},
289289
});
290290

@@ -309,7 +309,7 @@ describe('DELETE /auth/logout', () => {
309309
const raw = res.headers['set-cookie'] as string | string[];
310310
const cookieStr = Array.isArray(raw) ? raw.join('; ') : (raw ?? '');
311311
// Value must be emptied.
312-
expect(cookieStr).toMatch(/token=;/);
312+
expect(cookieStr).toMatch(/access_Token=;/);
313313
// Path must be explicit so the browser clears the cookie on all routes.
314314
expect(cookieStr).toMatch(/Path=\//i);
315315
// Browser must be told to delete the cookie immediately.
@@ -350,7 +350,7 @@ describe('DELETE /auth/logout', () => {
350350
expect(mockRedis.set).not.toHaveBeenCalled();
351351
expect(warnMock).toHaveBeenCalledOnce();
352352
// Verify the message identifies the root cause clearly.
353-
const [, message] = warnMock.mock.calls[0] as [unknown, string];
353+
const [message] = warnMock.mock.calls[0] as [string];
354354
expect(message).toMatch(/missing exp/i);
355355
});
356356

@@ -471,7 +471,7 @@ describe('authenticate middleware', () => {
471471
const res = await app.inject({
472472
method: 'GET',
473473
url: '/protected',
474-
headers: { Cookie: `token=${token}` },
474+
headers: { Cookie: `access_Token=${token}` },
475475
});
476476

477477
expect(res.statusCode).toBe(200);
@@ -574,7 +574,7 @@ describe('revocation flow — end-to-end', () => {
574574
const logout = await app.inject({
575575
method: 'DELETE',
576576
url: '/auth/logout',
577-
headers: { Cookie: `token=${token}` },
577+
headers: { Cookie: `access_Token=${token}` },
578578
});
579579
expect(logout.statusCode).toBe(200);
580580
expect(mockRedis.set).toHaveBeenCalledOnce();
@@ -588,7 +588,7 @@ describe('revocation flow — end-to-end', () => {
588588
const after = await app.inject({
589589
method: 'GET',
590590
url: '/protected',
591-
headers: { Cookie: `token=${token}` },
591+
headers: { Cookie: `access_Token=${token}` },
592592
});
593593
expect(after.statusCode).toBe(401);
594594
expect(after.json().error).toBe('Token has been revoked');
@@ -637,14 +637,14 @@ describe('extractRawJwt', () => {
637637
});
638638

639639
it('returns token from cookie when no Authorization header', () => {
640-
const req = makeRequest({ cookies: { token: 'cookie.jwt.token' } });
640+
const req = makeRequest({ cookies: { access_Token: 'cookie.jwt.token' } });
641641
expect(extractRawJwt(req)).toBe('cookie.jwt.token');
642642
});
643643

644644
it('prefers Authorization header over cookie', () => {
645645
const req = makeRequest({
646646
authorization: 'Bearer header.jwt.token',
647-
cookies: { token: 'cookie.jwt.token' },
647+
cookies: { access_Token: 'cookie.jwt.token' },
648648
});
649649
expect(extractRawJwt(req)).toBe('header.jwt.token');
650650
});
@@ -666,7 +666,7 @@ describe('extractRawJwt', () => {
666666
});
667667

668668
it('returns null when the token cookie value is empty', () => {
669-
const req = makeRequest({ cookies: { token: '' } });
669+
const req = makeRequest({ cookies: { access_Token: '' } });
670670
// || null normalises the empty string to null, matching the return type.
671671
expect(extractRawJwt(req)).toBeNull();
672672
});

0 commit comments

Comments
 (0)