mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat(Cookie): enhance cookie management with refresh token support and default access token expiry
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user