feat(Cookie): enhance cookie management with refresh token support and default access token expiry

This commit is contained in:
Nawaz Dhandala
2025-11-11 21:11:34 +00:00
parent 3f99b9680f
commit c02ab56477
6 changed files with 184 additions and 12 deletions

View File

@@ -12,6 +12,9 @@ import CaptureSpan from "./Telemetry/CaptureSpan";
export default class CookieUtil {
// set cookie with express response
private static readonly DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: number =
15 * 60;
@CaptureSpan()
public static getCookiesFromCookieString(
cookieString: string,
@@ -58,8 +61,23 @@ export default class CookieUtil {
expressResponse: ExpressResponse;
user: User;
isGlobalLogin: boolean;
sessionId: ObjectID;
refreshToken: string;
refreshTokenExpiresAt: Date;
accessTokenExpiresInSeconds?: number;
}): void {
const { expressResponse: res, user, isGlobalLogin } = data;
const {
expressResponse: res,
user,
isGlobalLogin,
sessionId,
refreshToken,
refreshTokenExpiresAt,
} = data;
const accessTokenExpiresInSeconds: number =
data.accessTokenExpiresInSeconds ||
CookieUtil.DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS;
const token: string = JSONWebToken.signUserLoginToken({
tokenData: {
@@ -69,13 +87,24 @@ export default class CookieUtil {
timezone: user.timezone || null,
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.
sessionId: sessionId,
},
expiresInSeconds: OneUptimeDate.getSecondsInDays(new PositiveNumber(30)),
expiresInSeconds: accessTokenExpiresInSeconds,
});
// Set a cookie with token.
CookieUtil.setCookie(res, CookieUtil.getUserTokenKey(), token, {
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
maxAge: accessTokenExpiresInSeconds * 1000,
httpOnly: true,
});
const refreshTokenTtl: number = Math.max(
refreshTokenExpiresAt.getTime() - Date.now(),
0,
);
CookieUtil.setCookie(res, CookieUtil.getRefreshTokenKey(), refreshToken, {
maxAge: refreshTokenTtl,
httpOnly: true,
});
@@ -155,7 +184,13 @@ export default class CookieUtil {
value: string,
options: CookieOptions,
): void {
res.cookie(name, value, options);
const cookieOptions: CookieOptions = {
path: "/",
sameSite: "lax",
...options,
};
res.cookie(name, value, cookieOptions);
}
// get cookie with express request
@@ -168,11 +203,24 @@ export default class CookieUtil {
return req.cookies[name];
}
@CaptureSpan()
public static getRefreshTokenFromExpressRequest(
req: ExpressRequest,
): string | undefined {
return CookieUtil.getCookieFromExpressRequest(
req,
CookieUtil.getRefreshTokenKey(),
);
}
// delete cookie with express response
@CaptureSpan()
public static removeCookie(res: ExpressResponse, name: string): void {
res.clearCookie(name);
res.clearCookie(name, {
path: "/",
sameSite: "lax",
});
}
// get all cookies with express request
@@ -190,6 +238,11 @@ export default class CookieUtil {
return `${CookieName.Token}-${id.toString()}`;
}
@CaptureSpan()
public static getRefreshTokenKey(): string {
return CookieName.RefreshToken;
}
@CaptureSpan()
public static getUserSSOKey(id: ObjectID): string {
return `${this.getSSOKey()}${id.toString()}`;
@@ -210,5 +263,8 @@ export default class CookieUtil {
for (const key in cookies) {
this.removeCookie(res, key);
}
// Always attempt to remove refresh token cookie even if not parsed.
this.removeCookie(res, this.getRefreshTokenKey());
}
}

View File

@@ -24,6 +24,7 @@ class JSONWebToken {
isMasterAdmin: boolean;
// If this is OneUptime username and password login. This is true, if this is SSO login. Then, this is false.
isGlobalLogin: boolean;
sessionId: ObjectID;
};
expiresInSeconds: number;
}): string {
@@ -60,6 +61,7 @@ class JSONWebToken {
statusPageId: data.statusPageId?.toString(),
};
} else {
jsonObj = {
...data,
userId: data.userId?.toString(),
@@ -67,9 +69,9 @@ class JSONWebToken {
name: data.name?.toString() || "",
projectId: data.projectId?.toString() || "",
isMasterAdmin: data.isMasterAdmin,
sessionId: data.sessionId?.toString() || undefined,
};
}
return JSONWebToken.signJsonPayload(jsonObj, expiresInSeconds);
}
@@ -106,6 +108,9 @@ class JSONWebToken {
isMasterAdmin: false,
name: new Name("User"),
isGlobalLogin: Boolean(decoded["isGlobalLogin"]),
sessionId: decoded["sessionId"]
? new ObjectID(decoded["sessionId"] as string)
: undefined,
};
}
@@ -118,6 +123,9 @@ class JSONWebToken {
: undefined,
isMasterAdmin: Boolean(decoded["isMasterAdmin"]),
isGlobalLogin: Boolean(decoded["isGlobalLogin"]),
sessionId: decoded["sessionId"]
? new ObjectID(decoded["sessionId"] as string)
: undefined,
};
} catch (e) {
logger.error(e);

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

@@ -11,4 +11,5 @@ export default interface JSONWebTokenData extends JSONObject {
statusPageId?: ObjectID | undefined; // for status page logins.
projectId?: ObjectID | undefined; // for SSO logins.
isGlobalLogin: boolean; // If this is OneUptime username and password login. This is true, if this is SSO login. Then, this is false.
sessionId?: ObjectID | undefined;
}

View File

@@ -3,6 +3,8 @@ import Navigation from "../Navigation";
import PermissionUtil from "../Permission";
import User from "../User";
import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
import HTTPMethod from "../../../Types/API/HTTPMethod";
import HTTPResponse from "../../../Types/API/HTTPResponse";
import Headers from "../../../Types/API/Headers";
import Hostname from "../../../Types/API/Hostname";
import Protocol from "../../../Types/API/Protocol";
@@ -11,14 +13,18 @@ import URL from "../../../Types/API/URL";
import Dictionary from "../../../Types/Dictionary";
import APIException from "../../../Types/Exception/ApiException";
import Exception from "../../../Types/Exception/Exception";
import { JSONObject } from "../../../Types/JSON";
import JSONFunctions from "../../../Types/JSONFunctions";
import {
UserGlobalAccessPermission,
UserTenantAccessPermission,
} from "../../../Types/Permission";
import API from "../../../Utils/API";
import API, { AuthRetryContext } from "../../../Utils/API";
import { IDENTITY_URL } from "../../Config";
class BaseAPI extends API {
private static refreshPromise: Promise<boolean> | null = null;
public constructor(protocol: Protocol, hostname: Hostname, route?: Route) {
super(protocol, hostname, route);
}
@@ -134,6 +140,37 @@ class BaseAPI extends API {
return error;
}
protected static override async tryRefreshAuth(
_context: AuthRetryContext,
): Promise<boolean> {
if (!this.refreshPromise) {
this.refreshPromise = (async () => {
const refreshUrl: URL = URL.fromString(
IDENTITY_URL.toString(),
).addRoute("/refresh-token");
const result = await super.fetch<JSONObject>({
method: HTTPMethod.POST,
url: refreshUrl,
options: {
skipAuthRefresh: true,
hasAttemptedAuthRefresh: true,
},
});
if (result instanceof HTTPResponse && result.isSuccess()) {
return true;
}
return false;
})().finally(() => {
this.refreshPromise = null;
});
}
return await this.refreshPromise;
}
protected static getLoginRoute(): Route {
return new Route("/accounts/login");
}

View File

@@ -24,6 +24,8 @@ export interface RequestOptions {
// Per-request proxy agent support (Probe supplies these instead of mutating global axios defaults)
httpAgent?: HttpAgent | undefined;
httpsAgent?: HttpsAgent | undefined;
skipAuthRefresh?: boolean | undefined;
hasAttemptedAuthRefresh?: boolean | undefined;
}
export interface APIRequestOptions {
@@ -43,6 +45,18 @@ export interface APIFetchOptions {
options?: RequestOptions;
}
export interface AuthRetryContext {
error: HTTPErrorResponse;
request: {
method: HTTPMethod;
url: URL;
data?: JSONObject | JSONArray;
headers?: Headers;
params?: Dictionary<string>;
options?: RequestOptions;
};
}
export default class API {
private _protocol: Protocol = Protocol.HTTPS;
public get protocol(): Protocol {
@@ -83,6 +97,12 @@ export default class API {
}
}
protected static async tryRefreshAuth(
_context: AuthRetryContext,
): Promise<boolean> {
return false;
}
public async get<
T extends JSONObject | JSONArray | BaseModel | Array<BaseModel>,
>(options: APIRequestOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
@@ -421,14 +441,63 @@ export default class API {
return response;
} catch (e) {
const error: Error | AxiosError = e as Error | AxiosError;
let errorResponse: HTTPErrorResponse;
if (axios.isAxiosError(error)) {
// Do whatever you want with native error
errorResponse = this.getErrorResponse(error);
} else {
if (!axios.isAxiosError(error)) {
throw new APIException(error.message);
}
const errorResponse: HTTPErrorResponse = this.getErrorResponse(error);
if (
error.response?.status === 401 &&
!(options?.skipAuthRefresh) &&
!(options?.hasAttemptedAuthRefresh)
) {
const retryUrl: URL = URL.fromString(url.toString());
const requestContext: AuthRetryContext["request"] = {
method,
url: retryUrl,
};
if (data) {
requestContext.data = data;
}
if (headers) {
requestContext.headers = headers;
}
if (params) {
requestContext.params = params;
}
if (options) {
requestContext.options = options;
}
const refreshed: boolean = await this.tryRefreshAuth({
error: errorResponse,
request: requestContext,
});
if (refreshed) {
const nextOptions: RequestOptions = {
...(options || {}),
hasAttemptedAuthRefresh: true,
};
return await this.fetchInternal(
method,
retryUrl,
data,
headers,
params,
nextOptions,
);
}
}
this.handleError(errorResponse);
return errorResponse;
}