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.
- package/_gitignore +40 -0
- package/apps/core-server/Dockerfile +7 -2
- package/apps/core-server/src/middlewares/auth-middleware.ts +74 -30
- package/apps/queue-worker/Dockerfile +15 -8
- package/apps/queue-worker/package.json +5 -1
- package/apps/queue-worker/src/bullmq/board.ts +28 -0
- package/apps/queue-worker/src/index.ts +2 -0
- package/apps/startx-cli/dist/index.mjs +2 -2
- package/apps/startx-cli/src/commands/init.ts +2 -1
- package/apps/startx-cli/src/configs/scripts.ts +40 -0
- package/apps/web-client/react-router.config.ts +1 -0
- package/package.json +8 -2
- package/packages/@db/drizzle/drizzle.config.ts +1 -1
- package/packages/@db/drizzle/src/index.ts +4 -15
- package/packages/@repo/lib/src/cookie-module/cookie-module.ts +94 -38
- package/packages/@repo/lib/src/extra/index.ts +1 -0
- package/packages/@repo/lib/src/extra/token-module.ts +50 -21
- package/packages/@repo/lib/src/mail-module/nodemailer.ts +33 -21
- package/packages/@repo/lib/src/session-module/i-session.ts +132 -59
- package/packages/@repo/lib/src/session-module/index.ts +8 -2
- package/packages/@repo/lib/src/session-module/redis-session.ts +53 -23
- package/packages/@repo/lib/src/validation-module/index.ts +50 -78
- package/packages/@repo/model/eslint.config.ts +4 -0
- package/packages/@repo/model/package.json +41 -0
- package/packages/@repo/model/src/index.ts +0 -0
- package/packages/@repo/model/tsconfig.json +7 -0
- package/packages/@repo/model/vitest.config.ts +3 -0
- package/packages/common/src/time.ts +95 -22
- package/packages/queue/src/adapter/bullmq-adapter.ts +138 -47
- package/packages/queue/src/index.ts +3 -0
- package/packages/queue/src/queue-interface.ts +12 -5
- package/packages/queue/src/registry.ts +2 -2
- package/packages/queue/tsconfig.json +1 -1
- package/packages/ui/src/api/use-api/react-query/types.ts +3 -3
- package/packages/ui/src/api/use-api/react-query/use-api.ts +10 -11
- package/pnpm-workspace.yaml +4 -2
- package/turbo.json +20 -0
- /package/apps/web-client/{app → src}/app.css +0 -0
- /package/apps/web-client/{app → src}/components.json +0 -0
- /package/apps/web-client/{app → src}/config/auth/auth-state.ts +0 -0
- /package/apps/web-client/{app → src}/config/axios-client.ts +0 -0
- /package/apps/web-client/{app → src}/config/env.ts +0 -0
- /package/apps/web-client/{app → src}/entry.client.tsx +0 -0
- /package/apps/web-client/{app → src}/eslint.config.ts +0 -0
- /package/apps/web-client/{app → src}/root.tsx +0 -0
- /package/apps/web-client/{app → src}/routes/home.tsx +0 -0
- /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 {
|
|
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
|
|
14
|
-
|
|
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()
|
|
7
|
+
COOKIE_DOMAIN: z.string().optional(),
|
|
7
8
|
});
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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,41 +1,70 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
14
|
-
const
|
|
26
|
+
|
|
27
|
+
const JWT_CONFIG = {
|
|
28
|
+
algorithm: "HS256" as const,
|
|
29
|
+
};
|
|
15
30
|
|
|
16
31
|
export class TokenModule {
|
|
17
|
-
static
|
|
18
|
-
return jwt.sign(payload,
|
|
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
|
-
|
|
38
|
+
|
|
39
|
+
static verifyAccessToken(token: string): AccessTokenPayload | null {
|
|
21
40
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
41
|
+
return jwt.verify(token, env.ACCESS_TOKEN_SECRET, {
|
|
42
|
+
algorithms: ["HS256"],
|
|
43
|
+
}) as AccessTokenPayload;
|
|
24
44
|
} catch (error) {
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
return jwt.sign(payload,
|
|
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
|
-
|
|
58
|
+
|
|
59
|
+
static verifyRefreshToken(token: string): RefreshTokenPayload | null {
|
|
34
60
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
61
|
+
return jwt.verify(token, env.REFRESH_TOKEN_SECRET, {
|
|
62
|
+
algorithms: ["HS256"],
|
|
63
|
+
}) as RefreshTokenPayload;
|
|
37
64
|
} catch (error) {
|
|
38
|
-
|
|
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(
|
|
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.
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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:
|
|
28
|
-
pass:
|
|
31
|
+
user: config.SMTP_USER,
|
|
32
|
+
pass: config.SMTP_PASSWORD,
|
|
29
33
|
},
|
|
30
34
|
});
|
|
35
|
+
this.transporters.set(cacheKey, transporter);
|
|
31
36
|
}
|
|
32
|
-
|
|
37
|
+
|
|
38
|
+
return this.transporters.get(cacheKey)!;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
|
-
static async verifyConnection(): Promise<
|
|
36
|
-
const
|
|
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(
|
|
47
|
+
logger.info(`SMTP Connection verified successfully for ${config.SMTP_HOST}`);
|
|
48
|
+
return true;
|
|
40
49
|
} catch (error) {
|
|
41
|
-
logger.error(
|
|
42
|
-
|
|
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(
|
|
47
|
-
|
|
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: `"${
|
|
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
|
-
|
|
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:
|
|
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
|
|
19
|
-
|
|
41
|
+
protected abstract type: SessionType;
|
|
42
|
+
|
|
43
|
+
protected sessionKey(sessionId: string) {
|
|
44
|
+
return `session:${sessionId}`;
|
|
20
45
|
}
|
|
21
|
-
|
|
22
|
-
|
|
46
|
+
|
|
47
|
+
protected userSessionsKey(userId: string) {
|
|
48
|
+
return `user:sessions:${userId}`;
|
|
23
49
|
}
|
|
24
|
-
|
|
25
|
-
|
|
50
|
+
|
|
51
|
+
protected hashToken(token: string) {
|
|
52
|
+
return crypto.createHash("sha256").update(token).digest("hex");
|
|
26
53
|
}
|
|
27
54
|
|
|
28
|
-
protected abstract
|
|
29
|
-
|
|
30
|
-
protected abstract
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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:
|
|
45
|
-
email:
|
|
95
|
+
userID: user.id,
|
|
96
|
+
email: user.email,
|
|
97
|
+
sessionID: sessionId,
|
|
46
98
|
});
|
|
47
99
|
|
|
48
100
|
const refreshToken = TokenModule.signRefreshToken({
|
|
49
|
-
userID:
|
|
50
|
-
email:
|
|
101
|
+
userID: user.id,
|
|
102
|
+
email: user.email,
|
|
103
|
+
sessionID: sessionId,
|
|
104
|
+
jti: refreshJti,
|
|
51
105
|
});
|
|
52
106
|
|
|
53
|
-
const
|
|
54
|
-
|
|
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.
|
|
58
|
-
this.
|
|
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
|
|
120
|
+
return {
|
|
121
|
+
accessToken,
|
|
122
|
+
refreshToken,
|
|
123
|
+
};
|
|
64
124
|
}
|
|
65
125
|
|
|
66
|
-
public async
|
|
67
|
-
return await this.
|
|
126
|
+
public async validateSession(sessionId: string): Promise<SessionRecord | null> {
|
|
127
|
+
return await this.getSession(sessionId);
|
|
68
128
|
}
|
|
69
129
|
|
|
70
|
-
public async
|
|
71
|
-
const
|
|
72
|
-
if (!
|
|
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
|
|
75
|
-
const sessionData: SessionUser = { ...payload, accessToken };
|
|
134
|
+
const incomingHash = this.hashToken(refreshToken);
|
|
76
135
|
|
|
77
|
-
|
|
78
|
-
this.
|
|
79
|
-
|
|
80
|
-
|
|
136
|
+
if (incomingHash !== session.refreshTokenHash) {
|
|
137
|
+
await this.endSession(sessionId);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
81
140
|
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
90
|
-
const session = await this.
|
|
91
|
-
if (!session) return
|
|
167
|
+
public async endSession(sessionId: string): Promise<void> {
|
|
168
|
+
const session = await this.getSession(sessionId);
|
|
169
|
+
if (!session) return;
|
|
92
170
|
|
|
93
|
-
await this.
|
|
94
|
-
return null;
|
|
171
|
+
await Promise.all([this.deleteSession(sessionId), this.removeUserSession(session.user.id, sessionId)]);
|
|
95
172
|
}
|
|
96
173
|
|
|
97
|
-
public async
|
|
98
|
-
const
|
|
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
|
-
|
|
103
|
-
|
|
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
|
+
});
|