startx 1.0.4 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/_gitignore +40 -0
  2. package/apps/core-server/Dockerfile +7 -2
  3. package/apps/core-server/src/middlewares/auth-middleware.ts +74 -30
  4. package/apps/queue-worker/Dockerfile +15 -8
  5. package/apps/queue-worker/package.json +5 -1
  6. package/apps/queue-worker/src/bullmq/board.ts +28 -0
  7. package/apps/queue-worker/src/index.ts +2 -0
  8. package/apps/startx-cli/dist/index.mjs +2 -2
  9. package/apps/startx-cli/src/commands/init.ts +2 -1
  10. package/apps/startx-cli/src/configs/scripts.ts +40 -0
  11. package/apps/web-client/react-router.config.ts +1 -0
  12. package/package.json +8 -2
  13. package/packages/@db/drizzle/drizzle.config.ts +1 -1
  14. package/packages/@db/drizzle/src/index.ts +4 -15
  15. package/packages/@repo/lib/src/cookie-module/cookie-module.ts +94 -38
  16. package/packages/@repo/lib/src/extra/index.ts +1 -0
  17. package/packages/@repo/lib/src/extra/token-module.ts +50 -21
  18. package/packages/@repo/lib/src/mail-module/nodemailer.ts +33 -21
  19. package/packages/@repo/lib/src/session-module/i-session.ts +132 -59
  20. package/packages/@repo/lib/src/session-module/index.ts +8 -2
  21. package/packages/@repo/lib/src/session-module/redis-session.ts +53 -23
  22. package/packages/@repo/lib/src/validation-module/index.ts +50 -78
  23. package/packages/@repo/model/eslint.config.ts +4 -0
  24. package/packages/@repo/model/package.json +41 -0
  25. package/packages/@repo/model/src/index.ts +0 -0
  26. package/packages/@repo/model/tsconfig.json +7 -0
  27. package/packages/@repo/model/vitest.config.ts +3 -0
  28. package/packages/common/src/time.ts +95 -22
  29. package/packages/queue/src/adapter/bullmq-adapter.ts +138 -47
  30. package/packages/queue/src/index.ts +3 -0
  31. package/packages/queue/src/queue-interface.ts +12 -5
  32. package/packages/queue/src/registry.ts +2 -2
  33. package/packages/queue/tsconfig.json +1 -1
  34. package/packages/ui/src/api/use-api/react-query/types.ts +3 -3
  35. package/packages/ui/src/api/use-api/react-query/use-api.ts +10 -11
  36. package/pnpm-workspace.yaml +4 -2
  37. package/turbo.json +20 -0
  38. /package/apps/web-client/{app → src}/app.css +0 -0
  39. /package/apps/web-client/{app → src}/components.json +0 -0
  40. /package/apps/web-client/{app → src}/config/auth/auth-state.ts +0 -0
  41. /package/apps/web-client/{app → src}/config/axios-client.ts +0 -0
  42. /package/apps/web-client/{app → src}/config/env.ts +0 -0
  43. /package/apps/web-client/{app → src}/entry.client.tsx +0 -0
  44. /package/apps/web-client/{app → src}/eslint.config.ts +0 -0
  45. /package/apps/web-client/{app → src}/root.tsx +0 -0
  46. /package/apps/web-client/{app → src}/routes/home.tsx +0 -0
  47. /package/apps/web-client/{app → src}/routes.ts +0 -0
@@ -1,26 +1,15 @@
1
1
  import { defineEnv } from "@repo/env";
2
- import type { ExtractTablesWithRelations } from "drizzle-orm";
3
2
  import { drizzle, type NodePgQueryResultHKT } from "drizzle-orm/node-postgres";
4
- import { type PgTransaction } from "drizzle-orm/pg-core";
5
- import Pg from "pg";
3
+ import { PgAsyncTransaction } from "drizzle-orm/pg-core";
6
4
  import z from "zod";
7
5
 
8
- import * as schema from "./schema/index.js";
9
-
10
6
  const env = defineEnv({
11
7
  DATABASE_URL: z.string(),
12
8
  });
13
- export const client = new Pg.Pool({
14
- connectionString: env.DATABASE_URL,
15
- });
16
- const db = drizzle({ client, schema });
17
- export type DrizzleTransaction = PgTransaction<
18
- NodePgQueryResultHKT,
19
- typeof schema,
20
- ExtractTablesWithRelations<typeof schema>
21
- >;
9
+ export type DrizzleTransaction = PgAsyncTransaction<NodePgQueryResultHKT>;
10
+ const db = drizzle(env.DATABASE_URL);
11
+
22
12
  export type DrizzleDB = typeof db;
23
13
  export { db };
24
14
  export * from "drizzle-orm";
25
15
  export * from "./functions.js";
26
- export * from "./schema/index.js";
@@ -1,48 +1,104 @@
1
+ import { Time } from "@repo/common/time";
1
2
  import { defineEnv, ENV } from "@repo/env";
2
3
  import type { CookieOptions } from "express";
3
4
  import z from "zod";
4
5
 
5
6
  const credentials = defineEnv({
6
- COOKIE_DOMAIN: z.string().optional().default(".localhost"),
7
+ COOKIE_DOMAIN: z.string().optional(),
7
8
  });
8
9
 
9
- const constants = {
10
- refreshToken: {
11
- cookie: "",
12
- cookieDomain: "",
13
- secureCookie: false,
14
- maxAge: 0,
15
- },
16
- initialize() {
17
- const prodMaxAge = 1000 * 60 * 60 * 24 * 30;
18
- const stagingMaxAge = 1000 * 60 * 60 * 24 * 365;
19
- const prodEnv = ENV.NODE_ENV === "production";
20
- const stagingEnv = ENV.NODE_ENV === "staging";
21
-
22
- if (prodEnv) {
23
- this.refreshToken.cookie = "refresh-token";
24
- this.refreshToken.cookieDomain = credentials.COOKIE_DOMAIN;
25
- this.refreshToken.secureCookie = true;
26
- this.refreshToken.maxAge = prodMaxAge;
27
- } else {
28
- this.refreshToken.cookie = "refresh-token-staging";
29
- this.refreshToken.cookieDomain = stagingEnv
30
- ? credentials.COOKIE_DOMAIN
31
- : credentials.COOKIE_DOMAIN || "localhost";
32
- this.refreshToken.secureCookie = stagingEnv ? true : false;
33
- this.refreshToken.maxAge = stagingMaxAge;
34
- }
35
- },
10
+ type RuntimeEnv = "development" | "staging" | "production";
11
+
12
+ type CookieDescriptor = {
13
+ name: string;
14
+ options: CookieOptions;
36
15
  };
37
- constants.initialize();
38
-
39
- export function getCookieOptions(refreshToken: string): [string, string, CookieOptions] {
40
- const cookieOptions: CookieOptions = {
41
- domain: constants.refreshToken.cookieDomain,
42
- maxAge: constants.refreshToken.maxAge,
43
- sameSite: "lax",
44
- httpOnly: true,
45
- secure: constants.refreshToken.secureCookie,
16
+
17
+ const COOKIE_NAMES = {
18
+ production: "refresh-token",
19
+ nonProduction: "refresh-token-staging",
20
+ } as const;
21
+
22
+ const COOKIE_TTL = {
23
+ development: Time.days(90).milliseconds, // 3 months
24
+ staging: Time.days(90).milliseconds, // 3 months
25
+ production: Time.days(30).milliseconds, // 30 days
26
+ } as const;
27
+
28
+ function getRuntimeEnv(): RuntimeEnv {
29
+ switch (ENV.NODE_ENV) {
30
+ case "production":
31
+ return "production";
32
+ case "staging":
33
+ return "staging";
34
+ default:
35
+ return "development";
36
+ }
37
+ }
38
+
39
+ function resolveCookieDomain(env: RuntimeEnv): string | undefined {
40
+ if (env === "development") {
41
+ return undefined;
42
+ }
43
+
44
+ if (!credentials.COOKIE_DOMAIN) {
45
+ throw new Error("COOKIE_DOMAIN must be configured in staging/production environments");
46
+ }
47
+
48
+ return credentials.COOKIE_DOMAIN;
49
+ }
50
+
51
+ function resolveSameSite(env: RuntimeEnv): CookieOptions["sameSite"] {
52
+ /**
53
+ * If frontend and API are on different origins:
54
+ * use "none" + secure=true
55
+ *
56
+ * Otherwise lax is safer.
57
+ */
58
+ return env === "production" || env === "staging" ? "lax" : "lax";
59
+ }
60
+
61
+ function createRefreshTokenCookie(): CookieDescriptor {
62
+ const env = getRuntimeEnv();
63
+ const secure = env !== "development";
64
+
65
+ return {
66
+ name: env === "production" ? COOKIE_NAMES.production : COOKIE_NAMES.nonProduction,
67
+ options: {
68
+ httpOnly: true,
69
+ secure,
70
+ sameSite: resolveSameSite(env),
71
+ maxAge: COOKIE_TTL[env],
72
+ domain: resolveCookieDomain(env),
73
+ path: "/",
74
+ },
46
75
  };
47
- return [constants.refreshToken.cookie, refreshToken, cookieOptions];
48
76
  }
77
+
78
+ const refreshTokenCookie = createRefreshTokenCookie();
79
+
80
+ export const CookieModule = Object.freeze({
81
+ refreshTokenName(): string {
82
+ return refreshTokenCookie.name;
83
+ },
84
+
85
+ setRefreshToken(token: string): [string, string, CookieOptions] {
86
+ return [refreshTokenCookie.name, token, refreshTokenCookie.options];
87
+ },
88
+
89
+ clearRefreshToken(): [string, string, CookieOptions] {
90
+ return [
91
+ refreshTokenCookie.name,
92
+ "",
93
+ {
94
+ ...refreshTokenCookie.options,
95
+ maxAge: 0,
96
+ expires: new Date(0),
97
+ },
98
+ ];
99
+ },
100
+
101
+ getRefreshTokenOptions(): CookieOptions {
102
+ return refreshTokenCookie.options;
103
+ },
104
+ });
@@ -1 +1,2 @@
1
1
  export * from "./pagination-module.js";
2
+ export * from "./token-module.js"
@@ -1,41 +1,70 @@
1
- import { defineEnv, ENV } from "@repo/env";
2
- import * as jwt from "jsonwebtoken";
1
+ import { Time } from "@repo/common/time";
2
+ import { defineEnv } from "@repo/env";
3
+ import { logger } from "@repo/logger";
4
+ import jwt from "jsonwebtoken";
3
5
  import z from "zod";
4
- export type AuthTokenPayload = {
6
+
7
+ export type AccessTokenPayload = {
8
+ userID: string;
9
+ email: string;
10
+ sessionID: string;
11
+ };
12
+
13
+ export type RefreshTokenPayload = {
5
14
  userID: string;
6
15
  email: string;
16
+ sessionID: string;
17
+ jti: string;
7
18
  };
8
19
 
9
- const credentials = defineEnv({
10
- ACCESS_TOKEN_SECRET: z.string(),
11
- REFRESH_TOKEN_SECRET: z.string(),
20
+ const env = defineEnv({
21
+ ACCESS_TOKEN_SECRET: z.string().min(32),
22
+ REFRESH_TOKEN_SECRET: z.string().min(32),
23
+ ACCESS_TOKEN_EXPIRY: z.coerce.number().default(Time.hours(1).milliseconds),
24
+ REFRESH_TOKEN_EXPIRY: z.coerce.number().default(Time.days(30).milliseconds),
12
25
  });
13
- const accessTokenSecret = credentials.ACCESS_TOKEN_SECRET;
14
- const refreshTokenSecret = credentials.REFRESH_TOKEN_SECRET;
26
+
27
+ const JWT_CONFIG = {
28
+ algorithm: "HS256" as const,
29
+ };
15
30
 
16
31
  export class TokenModule {
17
- static signRefreshToken(payload: AuthTokenPayload) {
18
- return jwt.sign(payload, refreshTokenSecret, { expiresIn: "365d" });
32
+ static signAccessToken(payload: AccessTokenPayload): string {
33
+ return jwt.sign(payload, env.ACCESS_TOKEN_SECRET, {
34
+ ...JWT_CONFIG,
35
+ expiresIn: env.ACCESS_TOKEN_EXPIRY,
36
+ });
19
37
  }
20
- static verifyRefreshToken(refreshToken: string) {
38
+
39
+ static verifyAccessToken(token: string): AccessTokenPayload | null {
21
40
  try {
22
- const payload = jwt.verify(refreshToken, refreshTokenSecret) as AuthTokenPayload;
23
- return payload;
41
+ return jwt.verify(token, env.ACCESS_TOKEN_SECRET, {
42
+ algorithms: ["HS256"],
43
+ }) as AccessTokenPayload;
24
44
  } catch (error) {
25
- console.error(error);
45
+ logger.warn("Access token verification failed", {
46
+ error: error instanceof Error ? error.message : "unknown",
47
+ });
26
48
  return null;
27
49
  }
28
50
  }
29
- static signAccessToken(payload: AuthTokenPayload) {
30
- const expiration = ENV.NODE_ENV === "development" ? "30d" : "1d";
31
- return jwt.sign(payload, accessTokenSecret, { expiresIn: expiration });
51
+
52
+ static signRefreshToken(payload: RefreshTokenPayload): string {
53
+ return jwt.sign(payload, env.REFRESH_TOKEN_SECRET, {
54
+ ...JWT_CONFIG,
55
+ expiresIn: env.REFRESH_TOKEN_EXPIRY,
56
+ });
32
57
  }
33
- static verifyAccessToken(accessToken: string) {
58
+
59
+ static verifyRefreshToken(token: string): RefreshTokenPayload | null {
34
60
  try {
35
- const payload = jwt.verify(accessToken, accessTokenSecret) as AuthTokenPayload;
36
- return payload;
61
+ return jwt.verify(token, env.REFRESH_TOKEN_SECRET, {
62
+ algorithms: ["HS256"],
63
+ }) as RefreshTokenPayload;
37
64
  } catch (error) {
38
- console.error(error);
65
+ logger.warn("Refresh token verification failed", {
66
+ error: error instanceof Error ? error.message : "unknown",
67
+ });
39
68
  return null;
40
69
  }
41
70
  }
@@ -6,48 +6,61 @@ import z from "zod";
6
6
 
7
7
  const credentials = defineEnv({
8
8
  SMTP_HOST: z.string(),
9
- SMTP_PORT: z.coerce.number().default(587),
9
+ SMTP_PORT: z.coerce.number().default(465),
10
10
  SMTP_USER: z.string(),
11
11
  SMTP_PASSWORD: z.string(),
12
- SMTP_MAIL_ENCRYPTION: z.string().default("tls"),
12
+ SMTP_MAIL_ENCRYPTION: z.enum(["ssl", "tls", "starttls"]).default("ssl"),
13
13
  SMTP_SENDER_MAIL: z.string().email(),
14
14
  SMTP_SENDER_NAME: z.string().default("Startx"),
15
15
  });
16
16
 
17
+ export type SMTPConfig = typeof credentials;
18
+
17
19
  class SMTPMailService {
18
- private static transporter: Transporter | null = null;
20
+ private static transporters = new Map<string, Transporter>();
21
+
22
+ private static getTransporter(config: SMTPConfig): Transporter {
23
+ const cacheKey = `${config.SMTP_HOST}:${config.SMTP_USER}`;
19
24
 
20
- private static initializeTransporter(): Transporter {
21
- if (!this.transporter) {
22
- this.transporter = nodemailer.createTransport({
23
- host: credentials.SMTP_HOST,
24
- port: credentials.SMTP_PORT,
25
- secure: credentials.SMTP_MAIL_ENCRYPTION === "ssl",
25
+ if (!this.transporters.has(cacheKey)) {
26
+ const transporter = nodemailer.createTransport({
27
+ host: config.SMTP_HOST,
28
+ port: config.SMTP_PORT,
29
+ secure: config.SMTP_MAIL_ENCRYPTION === "ssl",
26
30
  auth: {
27
- user: credentials.SMTP_USER,
28
- pass: credentials.SMTP_PASSWORD,
31
+ user: config.SMTP_USER,
32
+ pass: config.SMTP_PASSWORD,
29
33
  },
30
34
  });
35
+ this.transporters.set(cacheKey, transporter);
31
36
  }
32
- return this.transporter;
37
+
38
+ return this.transporters.get(cacheKey)!;
33
39
  }
34
40
 
35
- static async verifyConnection(): Promise<void> {
36
- const transporter = this.initializeTransporter();
41
+ static async verifyConnection(customConfig?: SMTPConfig): Promise<boolean> {
42
+ const config = customConfig || credentials;
43
+ const transporter = this.getTransporter(config);
44
+
37
45
  try {
38
46
  await transporter.verify();
39
- logger.info("SMTP Connection verified successfully.");
47
+ logger.info(`SMTP Connection verified successfully for ${config.SMTP_HOST}`);
48
+ return true;
40
49
  } catch (error) {
41
- logger.error("SMTP Connection verification failed:", error);
42
- throw error;
50
+ logger.error(`SMTP Connection verification failed for ${config.SMTP_HOST}:`, error);
51
+ return false;
43
52
  }
44
53
  }
45
54
 
46
- static async sendMail(props: { to: string; subject: string; text: string; html?: string }) {
47
- const transporter = this.initializeTransporter();
55
+ static async sendMail(
56
+ props: { to: string; subject: string; text: string; html?: string },
57
+ customConfig?: SMTPConfig
58
+ ) {
59
+ const config = customConfig || credentials;
60
+ const transporter = this.getTransporter(config);
48
61
 
49
62
  const mailOptions: SendMailOptions = {
50
- from: `"${credentials.SMTP_SENDER_NAME}" <${credentials.SMTP_SENDER_MAIL}>`,
63
+ from: `"${config.SMTP_SENDER_NAME}" <${config.SMTP_SENDER_MAIL}>`,
51
64
  to: props.to,
52
65
  subject: props.subject,
53
66
  text: props.text,
@@ -60,7 +73,6 @@ class SMTPMailService {
60
73
  return info;
61
74
  } catch (error) {
62
75
  logger.error("Error sending email:", error);
63
- // Retain the original error context for debugging
64
76
  throw new Error("Failed to send email", { cause: error });
65
77
  }
66
78
  }
@@ -1,12 +1,18 @@
1
+ import { Time } from "@repo/common/time";
1
2
  import type { SessionUser } from "@repo/common/types/users";
2
3
  import { defineEnv } from "@repo/env";
4
+ import crypto from "node:crypto";
3
5
  import { z } from "zod";
4
6
  import { TokenModule } from "../extra/token-module.js";
5
- const env = defineEnv({
6
- SESSION_DURATION: z.number().default(60 * 60 * 6),
7
+
8
+ export const sessionEnv = defineEnv({
9
+ SESSION_DURATION: z.number().default(Time.days(30).milliseconds),
10
+ SESSION_TYPE: z.enum(["single", "multi"]).default("multi"),
11
+ MAX_CONCURRENT_SESSIONS: z.number().default(1),
7
12
  });
13
+
8
14
  export const constants = {
9
- sessionDuration: env.SESSION_DURATION,
15
+ sessionDuration: sessionEnv.SESSION_DURATION,
10
16
  };
11
17
 
12
18
  export type TokenPair = {
@@ -14,95 +20,162 @@ export type TokenPair = {
14
20
  refreshToken: string;
15
21
  };
16
22
 
23
+ export type SessionType =
24
+ | {
25
+ type: "single";
26
+ }
27
+ | {
28
+ type: "multi";
29
+ maxConcurrentSessions: number;
30
+ };
31
+
32
+ export type SessionRecord = {
33
+ sessionId: string;
34
+ user: Omit<SessionUser, "accessToken">;
35
+ refreshTokenHash: string;
36
+ createdAt: number;
37
+ lastSeenAt: number;
38
+ };
39
+
17
40
  export abstract class IUserSession {
18
- protected accessTokenKey(key: string) {
19
- return `access_token:${key}`;
41
+ protected abstract type: SessionType;
42
+
43
+ protected sessionKey(sessionId: string) {
44
+ return `session:${sessionId}`;
20
45
  }
21
- protected refreshTokenKey(key: string) {
22
- return `refresh_token:${key}`;
46
+
47
+ protected userSessionsKey(userId: string) {
48
+ return `user:sessions:${userId}`;
23
49
  }
24
- protected userTokensKey(userId: string) {
25
- return `session:${userId}`;
50
+
51
+ protected hashToken(token: string) {
52
+ return crypto.createHash("sha256").update(token).digest("hex");
26
53
  }
27
54
 
28
- protected abstract setSessionData(key: string, data: SessionUser, ttl: number): Promise<void>;
29
- protected abstract getSessionData(key: string): Promise<SessionUser | null>;
30
- protected abstract deleteSessionData(key: string): Promise<void>;
55
+ protected abstract setSession(sessionId: string, data: SessionRecord, ttl: number): Promise<void>;
56
+
57
+ protected abstract getSession(sessionId: string): Promise<SessionRecord | null>;
31
58
 
32
- protected abstract setTokenData(key: string, data: TokenPair, ttl: number): Promise<void>;
33
- protected abstract getTokenData(key: string): Promise<TokenPair | null>;
34
- protected abstract deleteTokenData(key: string): Promise<void>;
59
+ protected abstract deleteSession(sessionId: string): Promise<void>;
35
60
 
36
- public async getSessionUser(token: string): Promise<SessionUser | null> {
37
- return await this.getSessionData(this.accessTokenKey(token));
61
+ protected abstract addUserSession(userId: string, sessionId: string): Promise<void>;
62
+
63
+ protected abstract removeUserSession(userId: string, sessionId: string): Promise<void>;
64
+
65
+ protected abstract getUserSessions(userId: string): Promise<string[]>;
66
+
67
+ protected abstract clearUserSessions(userId: string): Promise<void>;
68
+
69
+ protected generateSessionId() {
70
+ return crypto.randomUUID();
38
71
  }
39
72
 
40
- public async startSession(payload: Omit<SessionUser, "accessToken">): Promise<TokenPair> {
41
- await this.endSession(payload.id);
73
+ protected generateRefreshJti() {
74
+ return crypto.randomUUID();
75
+ }
76
+
77
+ public async startSession(user: Omit<SessionUser, "accessToken">): Promise<TokenPair> {
78
+ if (this.type.type === "single") {
79
+ await this.endAllSessions(user.id);
80
+ }
81
+
82
+ if (this.type.type === "multi") {
83
+ const existing = await this.getUserSessions(user.id);
84
+
85
+ if (existing.length >= this.type.maxConcurrentSessions) {
86
+ const oldest = existing[0];
87
+ await this.endSession(oldest);
88
+ }
89
+ }
90
+
91
+ const sessionId = this.generateSessionId();
92
+ const refreshJti = this.generateRefreshJti();
42
93
 
43
94
  const accessToken = TokenModule.signAccessToken({
44
- userID: payload.id,
45
- email: payload.email,
95
+ userID: user.id,
96
+ email: user.email,
97
+ sessionID: sessionId,
46
98
  });
47
99
 
48
100
  const refreshToken = TokenModule.signRefreshToken({
49
- userID: payload.id,
50
- email: payload.email,
101
+ userID: user.id,
102
+ email: user.email,
103
+ sessionID: sessionId,
104
+ jti: refreshJti,
51
105
  });
52
106
 
53
- const sessionData: SessionUser = { ...payload, accessToken };
54
- const tokens: TokenPair = { accessToken, refreshToken };
107
+ const record: SessionRecord = {
108
+ sessionId,
109
+ user,
110
+ refreshTokenHash: this.hashToken(refreshToken),
111
+ createdAt: Date.now(),
112
+ lastSeenAt: Date.now(),
113
+ };
55
114
 
56
115
  await Promise.all([
57
- this.setSessionData(this.accessTokenKey(accessToken), sessionData, constants.sessionDuration),
58
- this.setSessionData(payload.id, sessionData, constants.sessionDuration),
59
- this.setTokenData(this.refreshTokenKey(refreshToken), tokens, constants.sessionDuration),
60
- this.setTokenData(this.userTokensKey(payload.id), tokens, constants.sessionDuration),
116
+ this.setSession(sessionId, record, constants.sessionDuration),
117
+ this.addUserSession(user.id, sessionId),
61
118
  ]);
62
119
 
63
- return tokens;
120
+ return {
121
+ accessToken,
122
+ refreshToken,
123
+ };
64
124
  }
65
125
 
66
- public async checkRefreshToken(refreshToken: string): Promise<TokenPair | null> {
67
- return await this.getTokenData(this.refreshTokenKey(refreshToken));
126
+ public async validateSession(sessionId: string): Promise<SessionRecord | null> {
127
+ return await this.getSession(sessionId);
68
128
  }
69
129
 
70
- public async updateAccessToken(payload: Omit<SessionUser, "accessToken">): Promise<string | null> {
71
- const tokens = await this.getTokens(payload.id);
72
- if (!tokens) return null;
130
+ public async rotateRefreshToken(sessionId: string, refreshToken: string): Promise<TokenPair | null> {
131
+ const session = await this.getSession(sessionId);
132
+ if (!session) return null;
73
133
 
74
- const accessToken = tokens.accessToken;
75
- const sessionData: SessionUser = { ...payload, accessToken };
134
+ const incomingHash = this.hashToken(refreshToken);
76
135
 
77
- await Promise.all([
78
- this.setSessionData(this.accessTokenKey(accessToken), sessionData, constants.sessionDuration),
79
- this.setSessionData(payload.id, sessionData, constants.sessionDuration),
80
- ]);
136
+ if (incomingHash !== session.refreshTokenHash) {
137
+ await this.endSession(sessionId);
138
+ return null;
139
+ }
81
140
 
82
- return accessToken;
83
- }
141
+ const newRefreshJti = this.generateRefreshJti();
142
+
143
+ const accessToken = TokenModule.signAccessToken({
144
+ userID: session.user.id,
145
+ email: session.user.email,
146
+ sessionID: sessionId,
147
+ });
84
148
 
85
- public async getTokens(userId: string): Promise<TokenPair | null> {
86
- return await this.getTokenData(this.userTokensKey(userId));
149
+ const newRefreshToken = TokenModule.signRefreshToken({
150
+ userID: session.user.id,
151
+ email: session.user.email,
152
+ sessionID: sessionId,
153
+ jti: newRefreshJti,
154
+ });
155
+
156
+ session.refreshTokenHash = this.hashToken(newRefreshToken);
157
+ session.lastSeenAt = Date.now();
158
+
159
+ await this.setSession(sessionId, session, constants.sessionDuration);
160
+
161
+ return {
162
+ accessToken,
163
+ refreshToken: newRefreshToken,
164
+ };
87
165
  }
88
166
 
89
- public async logout(accessToken: string): Promise<null> {
90
- const session = await this.getSessionUser(accessToken);
91
- if (!session) return null;
167
+ public async endSession(sessionId: string): Promise<void> {
168
+ const session = await this.getSession(sessionId);
169
+ if (!session) return;
92
170
 
93
- await this.endSession(session.id);
94
- return null;
171
+ await Promise.all([this.deleteSession(sessionId), this.removeUserSession(session.user.id, sessionId)]);
95
172
  }
96
173
 
97
- public async endSession(userId: string): Promise<void> {
98
- const tokens = await this.getTokens(userId);
99
- if (!tokens) return;
174
+ public async endAllSessions(userId: string): Promise<void> {
175
+ const sessions = await this.getUserSessions(userId);
100
176
 
101
- await Promise.all([
102
- this.deleteTokenData(this.refreshTokenKey(tokens.refreshToken)),
103
- this.deleteSessionData(this.accessTokenKey(tokens.accessToken)),
104
- this.deleteTokenData(this.userTokensKey(userId)),
105
- this.deleteSessionData(userId),
106
- ]);
177
+ await Promise.all(sessions.map(sid => this.deleteSession(sid)));
178
+
179
+ await this.clearUserSessions(userId);
107
180
  }
108
181
  }
@@ -1,10 +1,16 @@
1
+ import { sessionEnv, type SessionType } from "./i-session.js";
1
2
  import { RedisUserSession } from "./redis-session.js";
2
3
 
3
- export const userSession = (type: "redis" | "pg") => {
4
+ export const userSession = (type: "redis" | "pg", options: SessionType) => {
4
5
  switch (type) {
5
6
  case "redis":
6
- return new RedisUserSession();
7
+ return new RedisUserSession(options);
7
8
  default:
8
9
  throw new Error("Unknown session type");
9
10
  }
10
11
  };
12
+
13
+ export const defaultUserSession = userSession("redis", {
14
+ type: sessionEnv.SESSION_TYPE,
15
+ maxConcurrentSessions: sessionEnv.MAX_CONCURRENT_SESSIONS,
16
+ });