Compare commits

...

6 Commits

Author SHA1 Message Date
Nawaz Dhandala
5e0d8b487c feat(api): register admin-dashboard auth refresh handler
Add AdminDashboard/src/Utils/API.ts to register a refreshSession handler on BaseAPI
that posts to IDENTITY_URL/refresh-session (using skipAuthRefresh). Handle HTTP error
responses and exceptions, and set a refresh-failure handler that logs and falls back
to logout. Export BaseAPI. Import the module in Index.tsx for side-effect initialization.
2025-11-06 15:01:32 +00:00
Nawaz Dhandala
c41b53dd2a feat(api): add automatic auth-refresh flow, retry handling and skip flag
- introduce RequestOptions.skipAuthRefresh and APIErrorRetryContext
- implement BaseAPI refresh handlers (setters), shouldRetryAfterError, refreshAuthSession with deduping promise and refreshFailure handler
- make API.handleError async and add default shouldRetryAfterError; extend fetchInternal with retryCount and retry after successful auth refresh
- register auth-refresh handlers for Dashboard and StatusPage and import API utils in Index entries for side-effects
- tighten StatusPage logout/refresh logic with logging and safety checks
2025-11-06 14:52:58 +00:00
Nawaz Dhandala
5fe445330b feat(statuspage): add jwtRefreshToken column migration for StatusPagePrivateUser
Add TypeORM migration 1762430566091 to add a jwtRefreshToken varchar(100) column
to StatusPagePrivateUser and register it in the migrations index.
2025-11-06 12:03:57 +00:00
Nawaz Dhandala
38c744ce8c Merge branch 'master' into refresh-sessions 2025-11-06 11:56:03 +00:00
Simon Larsen
b16743a669 feat(statuspage): add refreshable status-page sessions, namespaced cookies & session lifecycle
- Add CookieUtil.setStatusPageUserCookie and namespace user/refresh cookie keys by statusPageId
- Persist jwtRefreshToken on StatusPagePrivateUser (hashed session id) and update on login/refresh/logout
- Extend JsonWebToken to include statusPageId in refresh tokens and add signStatusPageUserLoginToken
- Implement tryRefreshStatusPageSession in StatusPageService to auto-refresh access tokens from valid refresh tokens (middleware-friendly)
- Update hasReadAccess to attempt automatic session refresh
- Propagate ExpressResponse through StatusPageAPI methods that perform cookie/session operations
- Improve StatusPageAuthentication: robust logout (invalidate by refresh or access token), login stores session tokens and hashed refresh token, add /refresh-session/:statuspageid endpoint to rotate session tokens
- Update tests to cover namespaced refresh token key
2025-11-06 10:07:31 +00:00
Simon Larsen
286c639857 feat(auth): add refresh token lifecycle, session refresh endpoint, and auto-refresh middleware
- Add refresh token signing and decoding (JSONWebToken.signRefreshToken, decodeRefreshToken)
- Persist hashed refresh token on user on signup, login and SSO flows
- Invalidate persisted refresh token on logout
- Add /refresh-session endpoint to validate refresh token, rotate session, and return refreshed session
- Implement middleware tryRefreshSession to auto-refresh expired access tokens using refresh token
- Make CookieUtil.setUserCookie return session details (access/refresh tokens, sessionId, expiries) and set both cookies
- Introduce secure default cookie options (path, sameSite, secure, httpOnly) and use IsProduction for secure flag
- Add CookieName.RefreshToken constant and update tests accordingly
2025-11-05 20:28:26 +00:00
21 changed files with 1432 additions and 83 deletions

View File

@@ -1,3 +1,4 @@
import "./Utils/API";
import App from "./App";
import Telemetry from "Common/UI/Utils/Telemetry/Telemetry";
import React from "react";

View File

@@ -0,0 +1,42 @@
import BaseAPI from "Common/UI/Utils/API/API";
import { IDENTITY_URL } from "Common/UI/Config";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import URL from "Common/Types/API/URL";
import { JSONObject } from "Common/Types/JSON";
import { Logger } from "Common/UI/Utils/Logger";
const registerAdminDashboardAuthRefresh = (): void => {
const refreshSession = async (): Promise<boolean> => {
try {
const response = await BaseAPI.post<JSONObject>({
url: URL.fromURL(IDENTITY_URL).addRoute("/refresh-session"),
options: {
skipAuthRefresh: true,
},
});
if (response instanceof HTTPErrorResponse) {
Logger.warn(
`Admin dashboard session refresh failed with status ${response.statusCode}.`,
);
return false;
}
return response.isSuccess();
} catch (err) {
Logger.error("Admin dashboard session refresh request failed.");
Logger.error(err as Error);
return false;
}
};
BaseAPI.setRefreshSessionHandler(refreshSession);
BaseAPI.setRefreshFailureHandler(() => {
Logger.warn("Admin dashboard session refresh failed; falling back to logout.");
});
};
registerAdminDashboardAuthRefresh();
export default BaseAPI;

View File

@@ -26,6 +26,9 @@ import MailService from "Common/Server/Services/MailService";
import UserService from "Common/Server/Services/UserService";
import UserTotpAuthService from "Common/Server/Services/UserTotpAuthService";
import CookieUtil from "Common/Server/Utils/Cookie";
import JSONWebToken, {
RefreshTokenData,
} from "Common/Server/Utils/JsonWebToken";
import Express, {
ExpressRequest,
ExpressResponse,
@@ -40,6 +43,10 @@ import User from "Common/Models/DatabaseModels/User";
import UserTotpAuth from "Common/Models/DatabaseModels/UserTotpAuth";
import UserWebAuthn from "Common/Models/DatabaseModels/UserWebAuthn";
import UserWebAuthnService from "Common/Server/Services/UserWebAuthnService";
import HashedString from "Common/Types/HashedString";
import NotAuthenticatedException from "Common/Types/Exception/NotAuthenticatedException";
import Dictionary from "Common/Types/Dictionary";
import JSONWebTokenData from "Common/Types/JsonWebTokenData";
const router: ExpressRouter = Express.getRouter();
@@ -186,12 +193,29 @@ router.post(
// Refresh Permissions for this user here.
await AccessTokenService.refreshUserAllPermissions(savedUser.id!);
CookieUtil.setUserCookie({
const session = CookieUtil.setUserCookie({
expressResponse: res,
user: savedUser,
isGlobalLogin: true,
});
const hashedSessionId: string = await HashedString.hashValue(
session.sessionId,
EncryptionSecret,
);
await UserService.updateOneBy({
query: {
_id: savedUser.id!,
},
data: {
jwtRefreshToken: hashedSessionId,
},
props: {
isRoot: true,
},
});
logger.info("User signed up: " + savedUser.email?.toString());
return Response.sendEntityResponse(req, res, savedUser, User);
@@ -495,6 +519,67 @@ router.post(
next: NextFunction,
): Promise<void> => {
try {
const refreshToken: string | undefined =
CookieUtil.getCookieFromExpressRequest(
req,
CookieUtil.getRefreshTokenKey(),
);
let userIdToInvalidate: ObjectID | null = null;
if (refreshToken) {
try {
const refreshTokenData: RefreshTokenData =
JSONWebToken.decodeRefreshToken(refreshToken);
userIdToInvalidate = refreshTokenData.userId;
} catch (err) {
const error: Error = err as Error;
logger.warn(
`Failed to decode refresh token during logout: ${
error.message || "unknown error"
}`,
);
logger.debug(error);
}
}
if (!userIdToInvalidate) {
const accessToken: string | undefined =
CookieUtil.getCookieFromExpressRequest(
req,
CookieUtil.getUserTokenKey(),
);
if (accessToken) {
try {
const decoded: JSONWebTokenData = JSONWebToken.decode(accessToken);
userIdToInvalidate = decoded.userId;
} catch (err) {
const error: Error = err as Error;
logger.warn(
`Failed to decode access token during logout: ${
error.message || "unknown error"
}`,
);
logger.debug(error);
}
}
}
if (userIdToInvalidate) {
await UserService.updateOneBy({
query: {
_id: userIdToInvalidate,
},
data: {
jwtRefreshToken: null!,
},
props: {
isRoot: true,
},
});
}
CookieUtil.removeAllCookies(req, res);
return Response.sendEmptySuccessResponse(req, res);
@@ -555,6 +640,122 @@ router.post(
},
);
router.post(
"/refresh-session",
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
const refreshToken: string | undefined =
CookieUtil.getCookieFromExpressRequest(
req,
CookieUtil.getRefreshTokenKey(),
);
if (!refreshToken) {
CookieUtil.removeAllCookies(req, res);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Refresh token missing"),
);
}
let refreshTokenData: RefreshTokenData;
try {
refreshTokenData = JSONWebToken.decodeRefreshToken(refreshToken);
} catch (err) {
const error: Error = err as Error;
logger.warn(
`Failed to decode refresh token: ${error.message || "unknown error"}`,
);
logger.debug(error);
CookieUtil.removeAllCookies(req, res);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Refresh token is invalid"),
);
}
const hashedSessionId: string = await HashedString.hashValue(
refreshTokenData.sessionId,
EncryptionSecret,
);
const user: User | null = await UserService.findOneBy({
query: {
_id: refreshTokenData.userId,
jwtRefreshToken: hashedSessionId,
},
select: {
_id: true,
email: true,
name: true,
isMasterAdmin: true,
profilePictureId: true,
timezone: true,
enableTwoFactorAuth: true,
},
props: {
isRoot: true,
},
});
if (!user) {
CookieUtil.removeAllCookies(req, res);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Refresh token does not match"),
);
}
const session = CookieUtil.setUserCookie({
expressResponse: res,
user: user,
isGlobalLogin: refreshTokenData.isGlobalLogin,
});
if (!req.cookies) {
req.cookies = {} as Dictionary<string>;
}
req.cookies[CookieUtil.getUserTokenKey()] = session.accessToken;
req.cookies[CookieUtil.getRefreshTokenKey()] = session.refreshToken;
const hashedNewSessionId: string = await HashedString.hashValue(
session.sessionId,
EncryptionSecret,
);
await UserService.updateOneBy({
query: {
_id: user.id!,
},
data: {
jwtRefreshToken: hashedNewSessionId,
},
props: {
isRoot: true,
},
});
logger.info(
`User session refreshed: ${
user.email?.toString() || user.id?.toString() || "unknown"
}`,
);
return Response.sendEntityResponse(req, res, user, User);
} catch (err) {
return next(err);
}
},
);
type FetchTotpAuthListFunction = (userId: ObjectID) => Promise<{
totpAuthList: Array<UserTotpAuth>;
webAuthnList: Array<UserWebAuthn>;
@@ -788,12 +989,29 @@ const login: LoginFunction = async (options: {
if (alreadySavedUser.password.toString() === user.password!.toString()) {
logger.info("User logged in: " + alreadySavedUser.email?.toString());
CookieUtil.setUserCookie({
const session = CookieUtil.setUserCookie({
expressResponse: res,
user: alreadySavedUser,
isGlobalLogin: true,
});
const hashedSessionId: string = await HashedString.hashValue(
session.sessionId,
EncryptionSecret,
);
await UserService.updateOneBy({
query: {
_id: alreadySavedUser.id!,
},
data: {
jwtRefreshToken: hashedSessionId,
},
props: {
isRoot: true,
},
});
return Response.sendEntityResponse(req, res, alreadySavedUser, User);
}
}

View File

@@ -15,7 +15,7 @@ import { JSONObject } from "Common/Types/JSON";
import ObjectID from "Common/Types/ObjectID";
import PositiveNumber from "Common/Types/PositiveNumber";
import DatabaseConfig from "Common/Server/DatabaseConfig";
import { Host, HttpProtocol } from "Common/Server/EnvironmentConfig";
import { EncryptionSecret, Host, HttpProtocol } from "Common/Server/EnvironmentConfig";
import AccessTokenService from "Common/Server/Services/AccessTokenService";
import ProjectSSOService from "Common/Server/Services/ProjectSsoService";
import TeamMemberService from "Common/Server/Services/TeamMemberService";
@@ -37,6 +37,7 @@ import TeamMember from "Common/Models/DatabaseModels/TeamMember";
import User from "Common/Models/DatabaseModels/User";
import xml2js from "xml2js";
import Name from "Common/Types/Name";
import HashedString from "Common/Types/HashedString";
const router: ExpressRouter = Express.getRouter();
@@ -539,12 +540,29 @@ const loginUserWithSso: LoginUserWithSsoFunction = async (
expressResponse: res,
});
CookieUtil.setUserCookie({
const session = CookieUtil.setUserCookie({
expressResponse: res,
user: alreadySavedUser,
isGlobalLogin: false,
});
const hashedSessionId: string = await HashedString.hashValue(
session.sessionId,
EncryptionSecret,
);
await UserService.updateOneBy({
query: {
_id: alreadySavedUser.id!,
},
data: {
jwtRefreshToken: hashedSessionId,
},
props: {
isRoot: true,
},
});
// Refresh Permissions for this user here.
await AccessTokenService.refreshUserAllPermissions(alreadySavedUser.id!);

View File

@@ -6,6 +6,7 @@ import URL from "Common/Types/API/URL";
import OneUptimeDate from "Common/Types/Date";
import EmailTemplateType from "Common/Types/Email/EmailTemplateType";
import BadDataException from "Common/Types/Exception/BadDataException";
import NotAuthenticatedException from "Common/Types/Exception/NotAuthenticatedException";
import { JSONObject } from "Common/Types/JSON";
import JSONFunctions from "Common/Types/JSONFunctions";
import ObjectID from "Common/Types/ObjectID";
@@ -22,11 +23,16 @@ import Express, {
ExpressRouter,
NextFunction,
} from "Common/Server/Utils/Express";
import JSONWebToken from "Common/Server/Utils/JsonWebToken";
import JSONWebToken, {
RefreshTokenData,
} from "Common/Server/Utils/JsonWebToken";
import logger from "Common/Server/Utils/Logger";
import Response from "Common/Server/Utils/Response";
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
import StatusPagePrivateUser from "Common/Models/DatabaseModels/StatusPagePrivateUser";
import HashedString from "Common/Types/HashedString";
import Dictionary from "Common/Types/Dictionary";
import JSONWebTokenData from "Common/Types/JsonWebTokenData";
const router: ExpressRouter = Express.getRouter();
@@ -46,7 +52,79 @@ router.post(
req.params["statuspageid"].toString(),
);
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey(statusPageId)); // remove the cookie.
const refreshTokenKey: string = CookieUtil.getRefreshTokenKey(statusPageId);
const accessTokenKey: string = CookieUtil.getUserTokenKey(statusPageId);
const refreshToken: string | undefined =
CookieUtil.getCookieFromExpressRequest(req, refreshTokenKey);
let userIdToInvalidate: ObjectID | null = null;
if (refreshToken) {
try {
const refreshData: RefreshTokenData =
JSONWebToken.decodeRefreshToken(refreshToken);
if (
refreshData.statusPageId &&
refreshData.statusPageId.toString() === statusPageId.toString()
) {
userIdToInvalidate = refreshData.userId;
}
} catch (err) {
const error: Error = err as Error;
logger.warn(
`Failed to decode status page refresh token during logout: ${
error.message || "unknown error"
}`,
);
logger.debug(error);
}
}
if (!userIdToInvalidate) {
const accessToken: string | undefined =
CookieUtil.getCookieFromExpressRequest(req, accessTokenKey);
if (accessToken) {
try {
const decoded: JSONWebTokenData = JSONWebToken.decode(accessToken);
if (
decoded.statusPageId &&
decoded.statusPageId.toString() === statusPageId.toString()
) {
userIdToInvalidate = decoded.userId;
}
} catch (err) {
const error: Error = err as Error;
logger.warn(
`Failed to decode status page access token during logout: ${
error.message || "unknown error"
}`,
);
logger.debug(error);
}
}
}
if (userIdToInvalidate) {
await StatusPagePrivateUserService.updateOneBy({
query: {
_id: userIdToInvalidate,
statusPageId: statusPageId,
},
data: {
jwtRefreshToken: null!,
},
props: {
isRoot: true,
},
});
}
CookieUtil.removeCookie(res, accessTokenKey);
CookieUtil.removeCookie(res, refreshTokenKey);
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
@@ -383,23 +461,41 @@ router.post(
});
if (alreadySavedUser) {
const token: string = JSONWebToken.sign({
data: alreadySavedUser,
expiresInSeconds: OneUptimeDate.getSecondsInDays(
new PositiveNumber(30),
),
const session = CookieUtil.setStatusPageUserCookie({
expressResponse: res,
user: alreadySavedUser,
statusPageId: alreadySavedUser.statusPageId!,
});
CookieUtil.setCookie(
res,
CookieUtil.getUserTokenKey(alreadySavedUser.statusPageId!),
token,
{
httpOnly: true,
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
},
if (!req.cookies) {
req.cookies = {} as Dictionary<string>;
}
req.cookies[CookieUtil.getUserTokenKey(alreadySavedUser.statusPageId!)] =
session.accessToken;
req.cookies[
CookieUtil.getRefreshTokenKey(alreadySavedUser.statusPageId!)
] = session.refreshToken;
const hashedSessionId: string = await HashedString.hashValue(
session.sessionId,
EncryptionSecret,
);
await StatusPagePrivateUserService.updateOneBy({
query: {
_id: alreadySavedUser.id!,
statusPageId: alreadySavedUser.statusPageId!,
},
data: {
jwtRefreshToken: hashedSessionId,
lastActive: OneUptimeDate.getCurrentDate(),
},
props: {
isRoot: true,
},
});
return Response.sendEntityResponse(
req,
res,
@@ -407,7 +503,11 @@ router.post(
StatusPagePrivateUser,
{
miscData: {
token: token,
accessToken: session.accessToken,
refreshToken: session.refreshToken,
accessTokenExpiresInSeconds: session.accessTokenExpiresInSeconds,
refreshTokenExpiresInSeconds:
session.refreshTokenExpiresInSeconds,
},
},
);
@@ -421,4 +521,160 @@ router.post(
},
);
router.post(
"/refresh-session/:statuspageid",
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
if (!req.params["statuspageid"]) {
throw new BadDataException("Status Page ID is required.");
}
const statusPageId: ObjectID = new ObjectID(
req.params["statuspageid"].toString(),
);
const refreshTokenKey: string = CookieUtil.getRefreshTokenKey(statusPageId);
const accessTokenKey: string = CookieUtil.getUserTokenKey(statusPageId);
const refreshToken: string | undefined =
CookieUtil.getCookieFromExpressRequest(req, refreshTokenKey);
if (!refreshToken) {
CookieUtil.removeCookie(res, refreshTokenKey);
CookieUtil.removeCookie(res, accessTokenKey);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Refresh token missing."),
);
}
let refreshTokenData: RefreshTokenData;
try {
refreshTokenData = JSONWebToken.decodeRefreshToken(refreshToken);
} catch (err) {
const error: Error = err as Error;
logger.warn(
`Failed to decode status page refresh token: ${
error.message || "unknown error"
}`,
);
logger.debug(error);
CookieUtil.removeCookie(res, refreshTokenKey);
CookieUtil.removeCookie(res, accessTokenKey);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Refresh token is invalid."),
);
}
if (
!refreshTokenData.statusPageId ||
refreshTokenData.statusPageId.toString() !== statusPageId.toString()
) {
CookieUtil.removeCookie(res, refreshTokenKey);
CookieUtil.removeCookie(res, accessTokenKey);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Refresh token status page mismatch."),
);
}
const hashedSessionId: string = await HashedString.hashValue(
refreshTokenData.sessionId,
EncryptionSecret,
);
const user: StatusPagePrivateUser | null =
await StatusPagePrivateUserService.findOneBy({
query: {
_id: refreshTokenData.userId,
statusPageId: statusPageId,
jwtRefreshToken: hashedSessionId,
},
select: {
_id: true,
email: true,
statusPageId: true,
},
props: {
isRoot: true,
},
});
if (!user) {
CookieUtil.removeCookie(res, refreshTokenKey);
CookieUtil.removeCookie(res, accessTokenKey);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Refresh token does not match."),
);
}
const session = CookieUtil.setStatusPageUserCookie({
expressResponse: res,
user: user,
statusPageId: statusPageId,
});
if (!req.cookies) {
req.cookies = {} as Dictionary<string>;
}
req.cookies[accessTokenKey] = session.accessToken;
req.cookies[refreshTokenKey] = session.refreshToken;
const hashedNewSessionId: string = await HashedString.hashValue(
session.sessionId,
EncryptionSecret,
);
await StatusPagePrivateUserService.updateOneBy({
query: {
_id: user.id!,
statusPageId: statusPageId,
},
data: {
jwtRefreshToken: hashedNewSessionId,
lastActive: OneUptimeDate.getCurrentDate(),
},
props: {
isRoot: true,
},
});
logger.info(
`Status page session refreshed: ${
user.email?.toString() || user.id?.toString() || "unknown"
} for status page ${statusPageId.toString()}`,
);
return Response.sendEntityResponse(
req,
res,
user,
StatusPagePrivateUser,
{
miscData: {
accessToken: session.accessToken,
refreshToken: session.refreshToken,
accessTokenExpiresInSeconds: session.accessTokenExpiresInSeconds,
refreshTokenExpiresInSeconds: session.refreshTokenExpiresInSeconds,
},
},
);
} catch (err) {
return next(err);
}
},
);
export default router;

View File

@@ -290,6 +290,21 @@ export default class StatusPagePrivateUser extends BaseModel {
nullable: true,
unique: false,
})
public jwtRefreshToken?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({ type: TableColumnType.ShortText })
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
unique: false,
})
public resetPasswordToken?: string = undefined;
@ColumnAccessControl({

View File

@@ -809,6 +809,7 @@ export default class StatusPageAPI extends BaseAPI<
await this.checkHasReadAccess({
statusPageId: statusPageId,
req: req,
res: res,
});
const resources: Array<StatusPageResource> =
@@ -869,6 +870,7 @@ export default class StatusPageAPI extends BaseAPI<
await this.checkHasReadAccess({
statusPageId: statusPageId,
req: req,
res: res,
});
/*
@@ -1161,6 +1163,7 @@ export default class StatusPageAPI extends BaseAPI<
await this.checkHasReadAccess({
statusPageId: statusPageId,
req: req,
res: res,
});
const startDate: Date = OneUptimeDate.getSomeDaysAgo(90);
@@ -1608,7 +1611,7 @@ export default class StatusPageAPI extends BaseAPI<
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
await this.subscribeToStatusPage(req);
await this.subscribeToStatusPage(req, res);
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
next(err);
@@ -1645,7 +1648,7 @@ export default class StatusPageAPI extends BaseAPI<
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
await this.subscribeToStatusPage(req);
await this.subscribeToStatusPage(req, res);
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
@@ -1661,7 +1664,7 @@ export default class StatusPageAPI extends BaseAPI<
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
await this.manageExistingSubscription(req);
await this.manageExistingSubscription(req, res);
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
@@ -1685,6 +1688,7 @@ export default class StatusPageAPI extends BaseAPI<
objectId,
null,
req,
res,
);
return Response.sendJsonObjectResponse(req, res, response);
@@ -1708,8 +1712,8 @@ export default class StatusPageAPI extends BaseAPI<
const response: JSONObject = await this.getScheduledMaintenanceEvents(
objectId,
null,
req,
res,
);
return Response.sendJsonObjectResponse(req, res, response);
@@ -1733,8 +1737,8 @@ export default class StatusPageAPI extends BaseAPI<
const response: JSONObject = await this.getAnnouncements(
objectId,
null,
req,
res,
);
return Response.sendJsonObjectResponse(req, res, response);
@@ -1763,6 +1767,7 @@ export default class StatusPageAPI extends BaseAPI<
objectId,
incidentId,
req,
res,
);
return Response.sendJsonObjectResponse(req, res, response);
@@ -1790,8 +1795,8 @@ export default class StatusPageAPI extends BaseAPI<
const response: JSONObject = await this.getScheduledMaintenanceEvents(
objectId,
scheduledMaintenanceId,
req,
res,
);
return Response.sendJsonObjectResponse(req, res, response);
@@ -1819,8 +1824,8 @@ export default class StatusPageAPI extends BaseAPI<
const response: JSONObject = await this.getAnnouncements(
objectId,
announcementId,
req,
res,
);
return Response.sendJsonObjectResponse(req, res, response);
@@ -1836,10 +1841,12 @@ export default class StatusPageAPI extends BaseAPI<
statusPageId: ObjectID,
scheduledMaintenanceId: ObjectID | null,
req: ExpressRequest,
res: ExpressResponse,
): Promise<JSONObject> {
await this.checkHasReadAccess({
statusPageId: statusPageId,
req: req,
res: res,
});
const statusPage: StatusPage | null = await StatusPageService.findOneBy({
@@ -2153,10 +2160,12 @@ export default class StatusPageAPI extends BaseAPI<
statusPageId: ObjectID,
announcementId: ObjectID | null,
req: ExpressRequest,
res: ExpressResponse,
): Promise<JSONObject> {
await this.checkHasReadAccess({
statusPageId: statusPageId,
req: req,
res: res,
});
const statusPage: StatusPage | null = await StatusPageService.findOneBy({
@@ -2328,7 +2337,10 @@ export default class StatusPageAPI extends BaseAPI<
}
@CaptureSpan()
public async manageExistingSubscription(req: ExpressRequest): Promise<void> {
public async manageExistingSubscription(
req: ExpressRequest,
res: ExpressResponse,
): Promise<void> {
const statusPageId: ObjectID = new ObjectID(
req.params["statusPageId"] as string,
);
@@ -2340,6 +2352,7 @@ export default class StatusPageAPI extends BaseAPI<
await this.checkHasReadAccess({
statusPageId: statusPageId,
req: req,
res: res,
});
const statusPage: StatusPage | null = await StatusPageService.findOneBy({
@@ -2603,7 +2616,10 @@ export default class StatusPageAPI extends BaseAPI<
}
@CaptureSpan()
public async subscribeToStatusPage(req: ExpressRequest): Promise<void> {
public async subscribeToStatusPage(
req: ExpressRequest,
res: ExpressResponse,
): Promise<void> {
const objectId: ObjectID = new ObjectID(
req.params["statusPageId"] as string,
);
@@ -2613,6 +2629,7 @@ export default class StatusPageAPI extends BaseAPI<
await this.checkHasReadAccess({
statusPageId: objectId,
req: req,
res: res,
});
const statusPage: StatusPage | null = await StatusPageService.findOneBy({
@@ -2980,10 +2997,12 @@ export default class StatusPageAPI extends BaseAPI<
statusPageId: ObjectID,
incidentId: ObjectID | null,
req: ExpressRequest,
res: ExpressResponse,
): Promise<JSONObject> {
await this.checkHasReadAccess({
statusPageId: statusPageId,
req: req,
res: res,
});
const statusPage: StatusPage | null = await StatusPageService.findOneBy({
@@ -3492,6 +3511,7 @@ export default class StatusPageAPI extends BaseAPI<
public async checkHasReadAccess(data: {
statusPageId: ObjectID;
req: ExpressRequest;
res: ExpressResponse;
}): Promise<void> {
const accessResult: {
hasReadAccess: boolean;
@@ -3499,6 +3519,7 @@ export default class StatusPageAPI extends BaseAPI<
} = await this.service.hasReadAccess({
statusPageId: data.statusPageId,
req: data.req,
res: data.res,
});
if (!accessResult.hasReadAccess) {

View File

@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1762430566091 implements MigrationInterface {
public name = 'MigrationName1762430566091'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "StatusPagePrivateUser" ADD "jwtRefreshToken" character varying(100)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "StatusPagePrivateUser" DROP COLUMN "jwtRefreshToken"`);
}
}

View File

@@ -180,6 +180,7 @@ import { MigrationName1760357680881 } from "./1760357680881-MigrationName";
import { MigrationName1761232578396 } from "./1761232578396-MigrationName";
import { MigrationName1761834523183 } from "./1761834523183-MigrationName";
import { MigrationName1762181014879 } from "./1762181014879-MigrationName";
import { MigrationName1762430566091 } from "./1762430566091-MigrationName";
export default [
InitialMigration,
@@ -364,4 +365,5 @@ export default [
MigrationName1761232578396,
MigrationName1761834523183,
MigrationName1762181014879,
MigrationName1762430566091
];

View File

@@ -10,7 +10,9 @@ import {
OneUptimeRequest,
} from "../Utils/Express";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import JSONWebToken from "../Utils/JsonWebToken";
import JSONWebToken, {
RefreshTokenData,
} from "../Utils/JsonWebToken";
import logger from "../Utils/Logger";
import Response from "../Utils/Response";
import ProjectMiddleware from "./ProjectAuthorization";
@@ -33,6 +35,8 @@ import {
import UserType from "../../Types/UserType";
import Project from "../../Models/DatabaseModels/Project";
import UserPermissionUtil from "../Utils/UserPermission/UserPermission";
import User from "../../Models/DatabaseModels/User";
import { EncryptionSecret } from "../EnvironmentConfig";
export default class UserMiddleware {
/*
@@ -161,22 +165,44 @@ export default class UserMiddleware {
);
}
const accessToken: string | undefined =
let accessToken: string | undefined =
UserMiddleware.getAccessTokenFromExpressRequest(req);
let userAuthorization: JSONWebTokenData | null = null;
if (!accessToken) {
if (accessToken) {
try {
userAuthorization = JSONWebToken.decode(accessToken);
} catch (err) {
const error: Error = err as Error;
logger.warn(
`Invalid access token, attempting refresh: ${
error.message || "unknown error"
}`,
);
logger.debug(error);
}
}
if (!userAuthorization) {
const refreshedSession:
| {
accessToken: string;
userAuthorization: JSONWebTokenData;
}
| null = await UserMiddleware.tryRefreshSession(req, res);
if (refreshedSession) {
accessToken = refreshedSession.accessToken;
userAuthorization = refreshedSession.userAuthorization;
}
}
if (!userAuthorization) {
oneuptimeRequest.userType = UserType.Public;
return next();
}
try {
oneuptimeRequest.userAuthorization = JSONWebToken.decode(accessToken);
} catch (err) {
// if the token is invalid or expired, it'll throw this error.
logger.error(err);
oneuptimeRequest.userType = UserType.Public;
return next();
}
oneuptimeRequest.userAuthorization = userAuthorization;
if (oneuptimeRequest.userAuthorization.isMasterAdmin) {
oneuptimeRequest.userType = UserType.MasterAdmin;
@@ -184,7 +210,7 @@ export default class UserMiddleware {
oneuptimeRequest.userType = UserType.User;
}
const userId: string = oneuptimeRequest.userAuthorization.userId.toString();
const userId: string = userAuthorization.userId.toString();
await UserService.updateOneBy({
query: {
@@ -290,6 +316,113 @@ export default class UserMiddleware {
return next();
}
@CaptureSpan()
private static async tryRefreshSession(
req: ExpressRequest,
res: ExpressResponse,
): Promise<
| {
accessToken: string;
userAuthorization: JSONWebTokenData;
}
| null
> {
const refreshToken: string | undefined =
CookieUtil.getCookieFromExpressRequest(
req,
CookieUtil.getRefreshTokenKey(),
);
if (!refreshToken) {
return null;
}
let refreshTokenData: RefreshTokenData;
try {
refreshTokenData = JSONWebToken.decodeRefreshToken(refreshToken);
} catch (err) {
const error: Error = err as Error;
logger.warn(
`Failed to decode refresh token during middleware refresh: ${
error.message || "unknown error"
}`,
);
logger.debug(error);
CookieUtil.removeCookie(res, CookieUtil.getRefreshTokenKey());
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey());
return null;
}
const hashedSessionId: string = await HashedString.hashValue(
refreshTokenData.sessionId,
EncryptionSecret,
);
const user: User | null = await UserService.findOneBy({
query: {
_id: refreshTokenData.userId,
jwtRefreshToken: hashedSessionId,
},
select: {
_id: true,
email: true,
name: true,
isMasterAdmin: true,
profilePictureId: true,
timezone: true,
},
props: {
isRoot: true,
},
});
if (!user) {
CookieUtil.removeCookie(res, CookieUtil.getRefreshTokenKey());
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey());
return null;
}
const session = CookieUtil.setUserCookie({
expressResponse: res,
user: user,
isGlobalLogin: refreshTokenData.isGlobalLogin,
});
if (!req.cookies) {
req.cookies = {} as Dictionary<string>;
}
req.cookies[CookieUtil.getUserTokenKey()] = session.accessToken;
req.cookies[CookieUtil.getRefreshTokenKey()] = session.refreshToken;
const hashedNewSessionId: string = await HashedString.hashValue(
session.sessionId,
EncryptionSecret,
);
await UserService.updateOneBy({
query: {
_id: user.id!,
},
data: {
jwtRefreshToken: hashedNewSessionId,
},
props: {
isRoot: true,
},
});
const userAuthorization: JSONWebTokenData = JSONWebToken.decode(
session.accessToken,
);
return {
accessToken: session.accessToken,
userAuthorization,
};
}
@CaptureSpan()
public static async getUserTenantAccessPermissionWithTenantId(data: {
req: ExpressRequest;

View File

@@ -3,8 +3,10 @@ import CreateBy from "../Types/Database/CreateBy";
import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
import UpdateBy from "../Types/Database/UpdateBy";
import CookieUtil from "../Utils/Cookie";
import { ExpressRequest } from "../Utils/Express";
import JSONWebToken from "../Utils/JsonWebToken";
import { ExpressRequest, ExpressResponse } from "../Utils/Express";
import JSONWebToken, {
RefreshTokenData,
} from "../Utils/JsonWebToken";
import logger from "../Utils/Logger";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import DatabaseService from "./DatabaseService";
@@ -24,6 +26,7 @@ import BadDataException from "../../Types/Exception/BadDataException";
import JSONWebTokenData from "../../Types/JsonWebTokenData";
import ObjectID from "../../Types/ObjectID";
import PositiveNumber from "../../Types/PositiveNumber";
import HashedString from "../../Types/HashedString";
import Typeof from "../../Types/Typeof";
import MonitorStatus from "../../Models/DatabaseModels/MonitorStatus";
import StatusPage from "../../Models/DatabaseModels/StatusPage";
@@ -61,6 +64,9 @@ import IP from "../../Types/IP/IP";
import NotAuthenticatedException from "../../Types/Exception/NotAuthenticatedException";
import ForbiddenException from "../../Types/Exception/ForbiddenException";
import CommonAPI from "../API/CommonAPI";
import StatusPagePrivateUserService from "./StatusPagePrivateUserService";
import StatusPagePrivateUser from "../../Models/DatabaseModels/StatusPagePrivateUser";
import { EncryptionSecret } from "../EnvironmentConfig";
export interface StatusPageReportItem {
resourceName: string;
@@ -369,12 +375,14 @@ export class Service extends DatabaseService<StatusPage> {
public async hasReadAccess(data: {
statusPageId: ObjectID;
req: ExpressRequest;
res: ExpressResponse;
}): Promise<{
hasReadAccess: boolean;
error?: NotAuthenticatedException | ForbiddenException;
}> {
const statusPageId: ObjectID = data.statusPageId;
const req: ExpressRequest = data.req;
const res: ExpressResponse = data.res;
const props: DatabaseCommonInteractionProps =
await CommonAPI.getDatabaseCommonInteractionProps(req);
@@ -446,20 +454,37 @@ export class Service extends DatabaseService<StatusPage> {
CookieUtil.getUserTokenKey(statusPageId),
);
let decoded: JSONWebTokenData | null = null;
if (token) {
try {
const decoded: JSONWebTokenData = JSONWebToken.decode(
token as string,
);
if (decoded.statusPageId?.toString() === statusPageId.toString()) {
return {
hasReadAccess: true,
};
}
decoded = JSONWebToken.decode(token as string);
} catch (err) {
logger.error(err);
const error: Error = err as Error;
logger.warn(
`Invalid status page access token, attempting refresh: ${
error.message || "unknown error"
}`,
);
logger.debug(error);
decoded = await this.tryRefreshStatusPageSession({
statusPageId,
req,
res,
});
}
} else {
decoded = await this.tryRefreshStatusPageSession({
statusPageId,
req,
res,
});
}
if (decoded && decoded.statusPageId?.toString() === statusPageId.toString()) {
return {
hasReadAccess: true,
};
}
// if it does not have public access, check if this user has access.
@@ -493,6 +518,121 @@ export class Service extends DatabaseService<StatusPage> {
};
}
@CaptureSpan()
private async tryRefreshStatusPageSession(data: {
statusPageId: ObjectID;
req: ExpressRequest;
res: ExpressResponse;
}): Promise<JSONWebTokenData | null> {
const { statusPageId, req, res } = data;
const refreshTokenKey: string = CookieUtil.getRefreshTokenKey(statusPageId);
const accessTokenKey: string = CookieUtil.getUserTokenKey(statusPageId);
const refreshToken: string | undefined = CookieUtil.getCookieFromExpressRequest(
req,
refreshTokenKey,
);
if (!refreshToken) {
return null;
}
let refreshTokenData: RefreshTokenData;
try {
refreshTokenData = JSONWebToken.decodeRefreshToken(refreshToken);
} catch (err) {
const error: Error = err as Error;
logger.warn(
`Failed to decode status page refresh token during middleware refresh: ${
error.message || "unknown error"
}`,
);
logger.debug(error);
CookieUtil.removeCookie(res, refreshTokenKey);
CookieUtil.removeCookie(res, accessTokenKey);
return null;
}
if (
!refreshTokenData.statusPageId ||
refreshTokenData.statusPageId.toString() !== statusPageId.toString()
) {
CookieUtil.removeCookie(res, refreshTokenKey);
CookieUtil.removeCookie(res, accessTokenKey);
return null;
}
const hashedSessionId: string = await HashedString.hashValue(
refreshTokenData.sessionId,
EncryptionSecret,
);
const user: StatusPagePrivateUser | null =
await StatusPagePrivateUserService.findOneBy({
query: {
_id: refreshTokenData.userId,
statusPageId: statusPageId,
jwtRefreshToken: hashedSessionId,
},
select: {
_id: true,
email: true,
statusPageId: true,
},
props: {
isRoot: true,
},
});
if (!user) {
CookieUtil.removeCookie(res, refreshTokenKey);
CookieUtil.removeCookie(res, accessTokenKey);
return null;
}
const session = CookieUtil.setStatusPageUserCookie({
expressResponse: res,
user: user,
statusPageId: statusPageId,
});
if (!req.cookies) {
req.cookies = {} as Dictionary<string>;
}
req.cookies[accessTokenKey] = session.accessToken;
req.cookies[refreshTokenKey] = session.refreshToken;
const hashedNewSessionId: string = await HashedString.hashValue(
session.sessionId,
EncryptionSecret,
);
await StatusPagePrivateUserService.updateOneBy({
query: {
_id: user.id!,
statusPageId: statusPageId,
},
data: {
jwtRefreshToken: hashedNewSessionId,
lastActive: OneUptimeDate.getCurrentDate(),
},
props: {
isRoot: true,
},
});
logger.info(
`Status page session refreshed automatically for ${
user.email?.toString() || user.id?.toString() || "unknown"
} on status page ${statusPageId.toString()}`,
);
return JSONWebToken.decode(session.accessToken);
}
@CaptureSpan()
public async getMonitorStatusTimelineForStatusPage(data: {
monitorIds: Array<ObjectID>;

View File

@@ -4,10 +4,20 @@ import ObjectID from "../../Types/ObjectID";
import { CookieOptions } from "express";
import JSONWebToken from "./JsonWebToken";
import User from "../../Models/DatabaseModels/User";
import StatusPagePrivateUser from "../../Models/DatabaseModels/StatusPagePrivateUser";
import OneUptimeDate from "../../Types/Date";
import PositiveNumber from "../../Types/PositiveNumber";
import CookieName from "../../Types/CookieName";
import CaptureSpan from "./Telemetry/CaptureSpan";
import { IsProduction } from "../EnvironmentConfig";
export interface UserSessionCookieResult {
accessToken: string;
refreshToken: string;
sessionId: string;
accessTokenExpiresInSeconds: number;
refreshTokenExpiresInSeconds: number;
}
export default class CookieUtil {
// set cookie with express response
@@ -58,9 +68,16 @@ export default class CookieUtil {
expressResponse: ExpressResponse;
user: User;
isGlobalLogin: boolean;
}): void {
}): UserSessionCookieResult {
const { expressResponse: res, user, isGlobalLogin } = data;
const accessTokenExpiresInSeconds: number = 15 * 60; // 15 minutes
const refreshTokenExpiresInSeconds: number = OneUptimeDate.getSecondsInDays(
new PositiveNumber(30),
);
const sessionId: string = ObjectID.generate().toString();
const token: string = JSONWebToken.signUserLoginToken({
tokenData: {
userId: user.id!,
@@ -70,19 +87,39 @@ export default class CookieUtil {
isMasterAdmin: user.isMasterAdmin!,
isGlobalLogin: isGlobalLogin, // This is a general login without SSO. So, we will set this to true. This will give access to all the projects that dont require SSO.
},
expiresInSeconds: OneUptimeDate.getSecondsInDays(new PositiveNumber(30)),
expiresInSeconds: accessTokenExpiresInSeconds,
});
const refreshToken: string = JSONWebToken.signRefreshToken({
userId: user.id!,
sessionId: sessionId,
isGlobalLogin: isGlobalLogin,
statusPageId: null,
expiresInSeconds: refreshTokenExpiresInSeconds,
});
// Set a cookie with token.
CookieUtil.setCookie(res, CookieUtil.getUserTokenKey(), token, {
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
maxAge: accessTokenExpiresInSeconds * 1000,
httpOnly: true,
});
CookieUtil.setCookie(
res,
CookieUtil.getRefreshTokenKey(),
refreshToken,
{
maxAge: refreshTokenExpiresInSeconds * 1000,
httpOnly: true,
},
);
const persistentCookieMaxAge: number = refreshTokenExpiresInSeconds * 1000;
if (user.id) {
// set user id cookie
CookieUtil.setCookie(res, CookieName.UserID, user.id!.toString(), {
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
maxAge: persistentCookieMaxAge,
httpOnly: false,
});
}
@@ -94,7 +131,7 @@ export default class CookieUtil {
CookieName.Email,
user.email?.toString() || "",
{
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
maxAge: persistentCookieMaxAge,
httpOnly: false,
},
);
@@ -103,7 +140,7 @@ export default class CookieUtil {
if (user.name) {
// set user name cookie
CookieUtil.setCookie(res, CookieName.Name, user.name?.toString() || "", {
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
maxAge: persistentCookieMaxAge,
httpOnly: false,
});
}
@@ -115,7 +152,7 @@ export default class CookieUtil {
CookieName.Timezone,
user.timezone?.toString() || "",
{
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
maxAge: persistentCookieMaxAge,
httpOnly: false,
},
);
@@ -128,7 +165,7 @@ export default class CookieUtil {
CookieName.IsMasterAdmin,
user.isMasterAdmin?.toString() || "",
{
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
maxAge: persistentCookieMaxAge,
httpOnly: false,
},
);
@@ -141,11 +178,78 @@ export default class CookieUtil {
CookieName.ProfilePicID,
user.profilePictureId?.toString() || "",
{
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
maxAge: persistentCookieMaxAge,
httpOnly: false,
},
);
}
return {
accessToken: token,
refreshToken: refreshToken,
sessionId: sessionId,
accessTokenExpiresInSeconds,
refreshTokenExpiresInSeconds,
};
}
@CaptureSpan()
public static setStatusPageUserCookie(data: {
expressResponse: ExpressResponse;
user: StatusPagePrivateUser;
statusPageId: ObjectID;
}): UserSessionCookieResult {
const { expressResponse: res, user, statusPageId } = data;
const accessTokenExpiresInSeconds: number = 15 * 60; // 15 minutes
const refreshTokenExpiresInSeconds: number = OneUptimeDate.getSecondsInDays(
new PositiveNumber(30),
);
const sessionId: string = ObjectID.generate().toString();
const accessToken: string = JSONWebToken.signStatusPageUserLoginToken({
userId: user.id!,
email: user.email!,
statusPageId: statusPageId,
expiresInSeconds: accessTokenExpiresInSeconds,
});
const refreshToken: string = JSONWebToken.signRefreshToken({
userId: user.id!,
sessionId: sessionId,
isGlobalLogin: false,
statusPageId: statusPageId,
expiresInSeconds: refreshTokenExpiresInSeconds,
});
CookieUtil.setCookie(
res,
CookieUtil.getUserTokenKey(statusPageId),
accessToken,
{
maxAge: accessTokenExpiresInSeconds * 1000,
httpOnly: true,
},
);
CookieUtil.setCookie(
res,
CookieUtil.getRefreshTokenKey(statusPageId),
refreshToken,
{
maxAge: refreshTokenExpiresInSeconds * 1000,
httpOnly: true,
},
);
return {
accessToken: accessToken,
refreshToken: refreshToken,
sessionId: sessionId,
accessTokenExpiresInSeconds,
refreshTokenExpiresInSeconds,
};
}
@CaptureSpan()
@@ -155,7 +259,27 @@ export default class CookieUtil {
value: string,
options: CookieOptions,
): void {
res.cookie(name, value, options);
const finalOptions: CookieOptions = {
...options,
};
if (finalOptions.path === undefined) {
finalOptions.path = "/";
}
if (finalOptions.sameSite === undefined) {
finalOptions.sameSite = "lax";
}
if (finalOptions.secure === undefined) {
finalOptions.secure = IsProduction;
}
if (finalOptions.httpOnly === undefined) {
finalOptions.httpOnly = true;
}
res.cookie(name, value, finalOptions);
}
// get cookie with express request
@@ -190,6 +314,15 @@ export default class CookieUtil {
return `${CookieName.Token}-${id.toString()}`;
}
@CaptureSpan()
public static getRefreshTokenKey(id?: ObjectID): string {
if (!id) {
return CookieName.RefreshToken;
}
return `${CookieName.RefreshToken}-${id.toString()}`;
}
@CaptureSpan()
public static getUserSSOKey(id: ObjectID): string {
return `${this.getSSOKey()}${id.toString()}`;

View File

@@ -13,6 +13,13 @@ import jwt from "jsonwebtoken";
import logger from "./Logger";
import CaptureSpan from "./Telemetry/CaptureSpan";
export interface RefreshTokenData {
userId: ObjectID;
sessionId: string;
isGlobalLogin: boolean;
statusPageId?: ObjectID;
}
class JSONWebToken {
@CaptureSpan()
public static signUserLoginToken(data: {
@@ -33,6 +40,46 @@ class JSONWebToken {
});
}
@CaptureSpan()
public static signRefreshToken(data: {
userId: ObjectID;
sessionId: string;
isGlobalLogin: boolean;
statusPageId?: ObjectID | null;
expiresInSeconds: number;
}): string {
const payload: JSONObject = {
tokenType: "refresh",
userId: data.userId.toString(),
sessionId: data.sessionId,
isGlobalLogin: data.isGlobalLogin,
};
if (data.statusPageId) {
payload["statusPageId"] = data.statusPageId.toString();
}
return JSONWebToken.signJsonPayload(payload, data.expiresInSeconds);
}
@CaptureSpan()
public static signStatusPageUserLoginToken(data: {
userId: ObjectID;
email: Email;
statusPageId: ObjectID;
expiresInSeconds: number;
}): string {
return JSONWebToken.signJsonPayload(
{
userId: data.userId.toString(),
email: data.email.toString(),
statusPageId: data.statusPageId.toString(),
isMasterAdmin: false,
},
data.expiresInSeconds,
);
}
@CaptureSpan()
public static sign(props: {
data: JSONWebTokenData | User | StatusPagePrivateUser | string | JSONObject;
@@ -124,6 +171,42 @@ class JSONWebToken {
throw new BadDataException("AccessToken is invalid or expired");
}
}
@CaptureSpan()
public static decodeRefreshToken(token: string): RefreshTokenData {
try {
const decoded: JSONObject = JSONWebToken.decodeJsonPayload(token);
if (decoded["tokenType"] !== "refresh") {
throw new BadDataException("Invalid refresh token");
}
if (!decoded["sessionId"] || typeof decoded["sessionId"] !== "string") {
throw new BadDataException("Invalid refresh token session");
}
if (!decoded["userId"] || typeof decoded["userId"] !== "string") {
throw new BadDataException("Invalid refresh token user");
}
const refreshData: RefreshTokenData = {
userId: new ObjectID(decoded["userId"] as string),
sessionId: decoded["sessionId"] as string,
isGlobalLogin: Boolean(decoded["isGlobalLogin"]),
};
if (decoded["statusPageId"]) {
refreshData.statusPageId = new ObjectID(
decoded["statusPageId"] as string,
);
}
return refreshData;
} catch (e) {
logger.error(e);
throw new BadDataException("RefreshToken is invalid or expired");
}
}
}
export default JSONWebToken;

View File

@@ -38,7 +38,11 @@ describe("CookieUtils", () => {
expect(mockResponse.cookie).toHaveBeenCalledWith(
cookie["name"] as string,
cookie["value"] as string,
cookie["options"] as JSONObject,
expect.objectContaining({
path: "/",
sameSite: "lax",
secure: expect.any(Boolean),
}),
);
});
@@ -88,6 +92,15 @@ describe("CookieUtils", () => {
expect(keyWithoutId).toBe("user-token");
});
test("Should return refresh token key", () => {
const baseKey: string = CookieUtil.getRefreshTokenKey();
const id: ObjectID = ObjectID.generate();
const namespacedKey: string = CookieUtil.getRefreshTokenKey(id);
expect(baseKey).toBe("user-refresh-token");
expect(namespacedKey).toBe(`user-refresh-token-${id.toString()}`);
});
test("Should return SSO key", () => {
const ssoKey: string = CookieUtil.getSSOKey();

View File

@@ -2,6 +2,7 @@ enum CookieName {
UserID = "user-id",
Email = "user-email",
Token = "user-token",
RefreshToken = "user-refresh-token",
Name = "user-name",
Timezone = "user-timezone",
IsMasterAdmin = "user-is-master-admin",

View File

@@ -16,9 +16,23 @@ import {
UserGlobalAccessPermission,
UserTenantAccessPermission,
} from "../../../Types/Permission";
import API from "../../../Utils/API";
import API, { APIErrorRetryContext } from "../../../Utils/API";
type RefreshSessionHandler = () => Promise<boolean>;
type ShouldAttemptRefreshHandler = (
error: HTTPErrorResponse,
context: APIErrorRetryContext,
) => boolean | Promise<boolean>;
type RefreshFailureHandler = (error: HTTPErrorResponse) => void;
class BaseAPI extends API {
private static refreshSessionHandler: RefreshSessionHandler | null = null;
private static shouldAttemptRefreshHandler:
| ShouldAttemptRefreshHandler
| null = null;
private static refreshFailureHandler: RefreshFailureHandler | null = null;
private static refreshSessionPromise: Promise<boolean> | null = null;
public constructor(protocol: Protocol, hostname: Hostname, route?: Route) {
super(protocol, hostname, route);
}
@@ -27,6 +41,24 @@ class BaseAPI extends API {
return new BaseAPI(url.protocol, url.hostname, url.route);
}
public static setRefreshSessionHandler(
handler: RefreshSessionHandler | null,
): void {
this.refreshSessionHandler = handler;
}
public static setShouldAttemptRefreshHandler(
handler: ShouldAttemptRefreshHandler | null,
): void {
this.shouldAttemptRefreshHandler = handler;
}
public static setRefreshFailureHandler(
handler: RefreshFailureHandler | null,
): void {
this.refreshFailureHandler = handler;
}
protected static override async onResponseSuccessHeaders(
headers: Dictionary<string>,
): Promise<Dictionary<string>> {
@@ -95,9 +127,35 @@ class BaseAPI extends API {
return User.logout();
}
public static override handleError(
protected static override async shouldRetryAfterError(
error: HTTPErrorResponse,
context: APIErrorRetryContext,
): Promise<boolean> {
if (context.options?.skipAuthRefresh) {
return false;
}
if (context.retryCount > 0) {
return false;
}
if (!(await this.shouldAttemptAuthRefresh(error, context))) {
return false;
}
const refreshed: boolean = await this.refreshAuthSession();
if (refreshed) {
return true;
}
this.handleRefreshFailure(error);
return false;
}
public static override async handleError(
error: HTTPErrorResponse | APIException,
): HTTPErrorResponse | APIException {
): Promise<HTTPErrorResponse | APIException> {
/*
* 405 Status - Tenant not found. If Project was deleted.
* 401 Status - User is not logged in.
@@ -134,6 +192,60 @@ class BaseAPI extends API {
return error;
}
private static async shouldAttemptAuthRefresh(
error: HTTPErrorResponse,
context: APIErrorRetryContext,
): Promise<boolean> {
if (!this.refreshSessionHandler) {
return false;
}
if (this.shouldAttemptRefreshHandler) {
try {
return await this.shouldAttemptRefreshHandler(error, context);
} catch {
return false;
}
}
return error.statusCode === 401 || error.statusCode === 405;
}
private static async refreshAuthSession(): Promise<boolean> {
if (!this.refreshSessionHandler) {
return false;
}
if (!this.refreshSessionPromise) {
this.refreshSessionPromise = this.refreshSessionHandler()
.then((result: boolean) => {
return result;
})
.catch(() => {
return false;
})
.finally(() => {
this.refreshSessionPromise = null;
});
}
try {
return await this.refreshSessionPromise;
} catch {
return false;
}
}
private static handleRefreshFailure(error: HTTPErrorResponse): void {
if (this.refreshFailureHandler) {
try {
this.refreshFailureHandler(error);
} catch {
// no-op if handler throws
}
}
}
protected static getLoginRoute(): Route {
return new Route("/accounts/login");
}

View File

@@ -21,6 +21,7 @@ export interface RequestOptions {
exponentialBackoff?: boolean | undefined;
timeout?: number | undefined;
doNotFollowRedirects?: boolean | undefined;
skipAuthRefresh?: boolean | undefined;
// Per-request proxy agent support (Probe supplies these instead of mutating global axios defaults)
httpAgent?: HttpAgent | undefined;
httpsAgent?: HttpsAgent | undefined;
@@ -43,6 +44,17 @@ export interface APIFetchOptions {
options?: RequestOptions;
}
export interface APIErrorRetryContext {
method: HTTPMethod;
url: URL;
data?: JSONObject | JSONArray | undefined;
headers?: Headers | undefined;
resolvedHeaders: Headers;
params?: Dictionary<string> | undefined;
options?: RequestOptions | undefined;
retryCount: number;
}
export default class API {
private _protocol: Protocol = Protocol.HTTPS;
public get protocol(): Protocol {
@@ -113,12 +125,19 @@ export default class API {
return await API.patch<T>(options);
}
public static handleError(
public static async handleError(
error: HTTPErrorResponse | APIException,
): HTTPErrorResponse | APIException {
): Promise<HTTPErrorResponse | APIException> {
return error;
}
protected static async shouldRetryAfterError(
_error: HTTPErrorResponse,
_context: APIErrorRetryContext,
): Promise<boolean> {
return false;
}
protected static async onResponseSuccessHeaders(
headers: Dictionary<string>,
): Promise<Dictionary<string>> {
@@ -330,24 +349,25 @@ export default class API {
headers?: Headers,
params?: Dictionary<string>,
options?: RequestOptions,
retryCount: number = 0,
): Promise<HTTPResponse<T> | HTTPErrorResponse> {
const baseUrl: URL = URL.fromURL(url);
const requestUrl: URL = URL.fromURL(url);
const apiHeaders: Headers = this.getHeaders(headers);
if (params) {
url.addQueryParams(params);
requestUrl.addQueryParams(params);
}
let finalHeaders: Dictionary<string> = {
...apiHeaders,
...(headers || {}),
};
let finalBody: JSONObject | JSONArray | URLSearchParams | undefined = data;
try {
const finalHeaders: Dictionary<string> = {
...apiHeaders,
...headers,
};
let finalBody: JSONObject | JSONArray | URLSearchParams | undefined =
data;
// if content-type is form-url-encoded, then stringify the data
if (
finalHeaders["Content-Type"] === "application/x-www-form-urlencoded" &&
data
@@ -366,7 +386,7 @@ export default class API {
try {
const axiosOptions: AxiosRequestConfig = {
method: method,
url: url.toString(),
url: requestUrl.toString(),
headers: finalHeaders,
data: finalBody,
};
@@ -429,7 +449,32 @@ export default class API {
throw new APIException(error.message);
}
this.handleError(errorResponse);
const shouldRetry: boolean =
errorResponse instanceof HTTPErrorResponse &&
(await this.shouldRetryAfterError(errorResponse, {
method,
url: baseUrl,
data,
headers,
resolvedHeaders: finalHeaders as Headers,
params,
options,
retryCount,
}));
if (shouldRetry) {
return await this.fetchInternal(
method,
URL.fromURL(baseUrl),
data,
headers,
params,
options,
retryCount + 1,
);
}
await this.handleError(errorResponse);
return errorResponse;
}
}

View File

@@ -1,3 +1,4 @@
import "./Utils/API";
import App from "./App";
import Telemetry from "Common/UI/Utils/Telemetry/Telemetry";
import React from "react";

View File

@@ -0,0 +1,42 @@
import BaseAPI from "Common/UI/Utils/API/API";
import { IDENTITY_URL } from "Common/UI/Config";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import URL from "Common/Types/API/URL";
import { JSONObject } from "Common/Types/JSON";
import { Logger } from "Common/UI/Utils/Logger";
const registerDashboardAuthRefresh = (): void => {
const refreshSession = async (): Promise<boolean> => {
try {
const response = await BaseAPI.post<JSONObject>({
url: URL.fromURL(IDENTITY_URL).addRoute("/refresh-session"),
options: {
skipAuthRefresh: true,
},
});
if (response instanceof HTTPErrorResponse) {
Logger.warn(
`Dashboard session refresh failed with status ${response.statusCode}.`,
);
return false;
}
return response.isSuccess();
} catch (err) {
Logger.error("Dashboard session refresh request failed.");
Logger.error(err as Error);
return false;
}
};
BaseAPI.setRefreshSessionHandler(refreshSession);
BaseAPI.setRefreshFailureHandler(() => {
Logger.warn("Dashboard session refresh failed. Logging out user.");
});
};
registerDashboardAuthRefresh();
export default BaseAPI;

View File

@@ -1,3 +1,4 @@
import "./Utils/API";
import App from "./App";
import Telemetry from "Common/UI/Utils/Telemetry/Telemetry";
import React from "react";

View File

@@ -1,8 +1,13 @@
import StatusPageUtil from "./StatusPage";
import Headers from "Common/Types/API/Headers";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import Route from "Common/Types/API/Route";
import URL from "Common/Types/API/URL";
import ObjectID from "Common/Types/ObjectID";
import { JSONObject } from "Common/Types/JSON";
import { IDENTITY_URL } from "Common/UI/Config";
import BaseAPI from "Common/UI/Utils/API/API";
import { Logger } from "Common/UI/Utils/Logger";
import UserUtil from "./User";
export default class API extends BaseAPI {
@@ -25,7 +30,13 @@ export default class API extends BaseAPI {
}
public static override logoutUser(): void {
UserUtil.logout(StatusPageUtil.getStatusPageId()!);
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
if (!statusPageId) {
return;
}
void UserUtil.logout(statusPageId);
}
public static override getForbiddenRoute(): Route {
@@ -36,3 +47,50 @@ export default class API extends BaseAPI {
);
}
}
const registerStatusPageAuthRefresh = (): void => {
const refreshSession = async (): Promise<boolean> => {
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
if (!statusPageId) {
Logger.warn("Skipping status page session refresh: missing status page id.");
return false;
}
try {
const response = await API.post<JSONObject>({
url: URL.fromURL(IDENTITY_URL)
.addRoute("/status-page/refresh-session")
.addRoute(`/${statusPageId.toString()}`),
options: {
skipAuthRefresh: true,
},
});
if (response instanceof HTTPErrorResponse) {
Logger.warn(
`Status page session refresh failed with status ${response.statusCode}.`,
);
return false;
}
return response.isSuccess();
} catch (err) {
Logger.error("Status page session refresh request failed.");
Logger.error(err as Error);
return false;
}
};
API.setRefreshSessionHandler(refreshSession);
API.setRefreshFailureHandler(() => {
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
if (statusPageId) {
void UserUtil.logout(statusPageId);
}
});
};
registerStatusPageAuthRefresh();