fix(cloudly): invalidate expired dashboard sessions

This commit is contained in:
2026-05-24 13:14:49 +00:00
parent 057af996aa
commit fd7c7b4313
5 changed files with 271 additions and 74 deletions
+4
View File
@@ -9,6 +9,10 @@
- Stores Spark metrics and runtime info on cluster node records - Stores Spark metrics and runtime info on cluster node records
- Extends jump onboarding with per-node Spark telemetry credentials - Extends jump onboarding with per-node Spark telemetry credentials
### Fixes
- invalidate expired dashboard sessions and return admins to login
### Maintenance ### Maintenance
- refresh release tooling dependencies - refresh release tooling dependencies
+48 -29
View File
@@ -11,6 +11,17 @@ export interface IJwtData {
expiresAt: number; expiresAt: number;
} }
interface IReq_AdminValidateIdentity {
method: 'adminValidateIdentity';
request: {
identity: plugins.servezoneInterfaces.data.IIdentity;
};
response: {
valid: boolean;
reason?: string;
};
}
export class CloudlyAuthManager { export class CloudlyAuthManager {
cloudlyRef: Cloudly; cloudlyRef: Cloudly;
public get db() { public get db() {
@@ -82,6 +93,16 @@ export class CloudlyAuthManager {
}, },
), ),
); );
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<IReq_AdminValidateIdentity>('adminValidateIdentity', async (dataArg) => {
const valid = await this.adminIdentityGuard.exec(dataArg).catch(() => false);
return {
valid,
reason: valid ? undefined : 'identity is not valid',
};
}),
);
} }
private async bootstrapInitialAdmin() { private async bootstrapInitialAdmin() {
@@ -126,28 +147,22 @@ export class CloudlyAuthManager {
identity: plugins.servezoneInterfaces.data.IIdentity; identity: plugins.servezoneInterfaces.data.IIdentity;
}>( }>(
async (dataArg) => { async (dataArg) => {
const jwt = dataArg.identity.jwt; try {
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt); const jwt = dataArg.identity?.jwt;
const expired = jwtData.expiresAt < Date.now(); if (!jwt) {
plugins.smartexpect return false;
.expect(jwtData.status) }
.setFailMessage('user not logged in') const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
.toEqual('loggedIn'); const expired = jwtData.expiresAt < Date.now();
plugins.smartexpect.expect(expired).setFailMessage(`jwt expired`).toBeFalse(); return (
plugins.smartexpect jwtData.status === 'loggedIn' &&
.expect(dataArg.identity.expiresAt) !expired &&
.setFailMessage( dataArg.identity.expiresAt === jwtData.expiresAt &&
`expiresAt >>identity valid until:${dataArg.identity.expiresAt}, but jwt says: ${jwtData.expiresAt}<< has been tampered with`, dataArg.identity.userId === jwtData.userId
) );
.toEqual(jwtData.expiresAt); } catch {
plugins.smartexpect return false;
.expect(dataArg.identity.userId)
.setFailMessage('userId has been tampered with')
.toEqual(jwtData.userId);
if (expired) {
throw new Error('identity is expired');
} }
return true;
}, },
{ {
failedHint: 'identity is not valid.', failedHint: 'identity is not valid.',
@@ -159,16 +174,17 @@ export class CloudlyAuthManager {
identity: plugins.servezoneInterfaces.data.IIdentity; identity: plugins.servezoneInterfaces.data.IIdentity;
}>( }>(
async (dataArg) => { async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [this.validIdentityGuard]); const validIdentity = await this.validIdentityGuard.exec(dataArg);
if (!validIdentity) {
return false;
}
const jwt = dataArg.identity.jwt; const jwt = dataArg.identity.jwt;
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt); const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
const user = await this.CUser.getInstance({ id: jwtData.userId }); const user = await this.CUser.getInstance({ id: jwtData.userId });
const isAdminBool = user.data.role === 'admin'; return user?.data.role === 'admin';
console.log(`user is admin: ${isAdminBool}`);
return isAdminBool;
}, },
{ {
failedHint: 'user is not admin.', failedHint: 'identity is not valid or user is not admin.',
name: 'adminIdentityGuard', name: 'adminIdentityGuard',
}, },
); );
@@ -177,14 +193,17 @@ export class CloudlyAuthManager {
identity: plugins.servezoneInterfaces.data.IIdentity; identity: plugins.servezoneInterfaces.data.IIdentity;
}>( }>(
async (dataArg) => { async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [this.validIdentityGuard]); const validIdentity = await this.validIdentityGuard.exec(dataArg);
if (!validIdentity) {
return false;
}
const jwt = dataArg.identity.jwt; const jwt = dataArg.identity.jwt;
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt); const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
const user = await this.CUser.getInstance({ id: jwtData.userId }); const user = await this.CUser.getInstance({ id: jwtData.userId });
return user.data.role === 'admin' || user.data.role === 'cluster'; return user?.data.role === 'admin' || user?.data.role === 'cluster';
}, },
{ {
failedHint: 'user is not admin or cluster.', failedHint: 'identity is not valid or user is not admin or cluster.',
name: 'adminOrClusterIdentityGuard', name: 'adminOrClusterIdentityGuard',
}, },
); );
+164 -37
View File
@@ -16,6 +16,55 @@ export interface IUiState {
activeSubview: string | null; activeSubview: string | null;
} }
export interface IDataState {
secretGroups?: plugins.interfaces.data.ISecretGroup[];
secretBundles?: plugins.interfaces.data.ISecretBundle[];
clusters?: plugins.interfaces.data.ICluster[];
externalRegistries?: plugins.interfaces.data.IExternalRegistry[];
images?: any[];
services?: plugins.interfaces.data.IService[];
deployments?: plugins.interfaces.data.IDeployment[];
domains?: plugins.interfaces.data.IDomain[];
dnsEntries?: plugins.interfaces.data.IDnsEntry[];
tasks?: any[];
taskExecutions?: plugins.interfaces.data.ITaskExecution[];
mails?: any[];
logs?: any[];
s3?: any[];
dbs?: any[];
backups?: any[];
}
const emptyDataState: IDataState = {
secretGroups: [],
secretBundles: [],
clusters: [],
externalRegistries: [],
images: [],
services: [],
deployments: [],
domains: [],
dnsEntries: [],
tasks: [],
taskExecutions: [],
mails: [],
logs: [],
s3: [],
dbs: [],
backups: [],
};
interface IReq_AdminValidateIdentity {
method: 'adminValidateIdentity';
request: {
identity: plugins.interfaces.data.IIdentity;
};
response: {
valid: boolean;
reason?: string;
};
}
const getInitialView = (): string => { const getInitialView = (): string => {
const path = typeof window !== 'undefined' ? window.location.pathname : '/'; const path = typeof window !== 'undefined' ? window.location.pathname : '/';
const validViews = ['overview', 'platform', 'runtime', 'registry', 'secrets', 'domains', 'storage', 'logs']; const validViews = ['overview', 'platform', 'runtime', 'registry', 'secrets', 'domains', 'storage', 'logs'];
@@ -49,7 +98,7 @@ export const loginAction = loginStatePart.createAction<{ username: string; passw
} }
const newState = { const newState = {
...currentState, ...currentState,
...(identity ? { identity } : {}), identity,
}; };
try { try {
// Keep shared API client in sync and establish WS for modules using sockets // Keep shared API client in sync and establish WS for modules using sockets
@@ -67,50 +116,19 @@ export const loginAction = loginStatePart.createAction<{ username: string; passw
export const logoutAction = loginStatePart.createAction(async (statePartArg) => { export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
const currentState = statePartArg.getState() || { identity: null }; const currentState = statePartArg.getState() || { identity: null };
try {
apiClient.identity = null;
dataState.setState({ ...emptyDataState });
} catch {}
return { return {
...currentState, ...currentState,
identity: null, identity: null,
}; };
}); });
export interface IDataState {
secretGroups?: plugins.interfaces.data.ISecretGroup[];
secretBundles?: plugins.interfaces.data.ISecretBundle[];
clusters?: plugins.interfaces.data.ICluster[];
externalRegistries?: plugins.interfaces.data.IExternalRegistry[];
images?: any[];
services?: plugins.interfaces.data.IService[];
deployments?: plugins.interfaces.data.IDeployment[];
domains?: plugins.interfaces.data.IDomain[];
dnsEntries?: plugins.interfaces.data.IDnsEntry[];
tasks?: any[];
taskExecutions?: plugins.interfaces.data.ITaskExecution[];
mails?: any[];
logs?: any[];
s3?: any[];
dbs?: any[];
backups?: any[];
}
export const dataState = await appstate.getStatePart<IDataState>( export const dataState = await appstate.getStatePart<IDataState>(
'data', 'data',
{ { ...emptyDataState },
secretGroups: [],
secretBundles: [],
clusters: [],
externalRegistries: [],
images: [],
services: [],
deployments: [],
domains: [],
dnsEntries: [],
tasks: [],
taskExecutions: [],
mails: [],
logs: [],
s3: [],
dbs: [],
backups: [],
},
'soft' 'soft'
); );
@@ -124,6 +142,115 @@ export const apiClient = new plugins.servezoneApi.CloudlyApiClient({
cloudlyUrl: (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : undefined, cloudlyUrl: (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : undefined,
}) as TCloudlyApiClientWithNullableIdentity; }) as TCloudlyApiClientWithNullableIdentity;
let identityExpiryTimer: number | undefined;
let identityInvalidationRunning = false;
const getErrorText = (errorArg: unknown): string => {
if (!errorArg) return '';
if (typeof errorArg === 'string') return errorArg;
const errorLike = errorArg as { errorText?: string; message?: string; text?: string };
return errorLike.errorText || errorLike.message || errorLike.text || '';
};
const isAuthRejectionText = (errorTextArg: string): boolean => {
const errorText = errorTextArg.toLowerCase();
return [
'identity is not valid',
'jwt expired',
'identity is expired',
'user not logged in',
'has been tampered with',
'invalid jwt',
'invalid signature',
].some((textPart) => errorText.includes(textPart));
};
export const isIdentityExpired = (identityArg: plugins.interfaces.data.IIdentity | null | undefined): boolean => {
return typeof identityArg?.expiresAt === 'number' && identityArg.expiresAt <= Date.now();
};
export const invalidateIdentity = async (reasonArg = 'identity is not valid'): Promise<void> => {
if (identityInvalidationRunning) return;
identityInvalidationRunning = true;
try {
const currentLoginState = loginStatePart.getState() || { identity: null };
if (currentLoginState.identity) {
console.warn(`Cloudly session invalidated: ${reasonArg}`);
}
apiClient.identity = null;
try { await apiClient.typedsocketClient?.setTag('identity', null); } catch {}
loginStatePart.setState({
...currentLoginState,
identity: null,
});
dataState.setState({ ...emptyDataState });
} finally {
identityInvalidationRunning = false;
}
};
export const validateStoredIdentity = async (): Promise<boolean> => {
const identity = loginStatePart.getState()?.identity ?? null;
if (!identity) return false;
if (isIdentityExpired(identity)) {
await invalidateIdentity('identity expired');
return false;
}
const validateIdentityRequest = new plugins.typedrequest.TypedRequest<IReq_AdminValidateIdentity>(
'/typedrequest',
'adminValidateIdentity',
);
try {
const response = await validateIdentityRequest.fire({ identity });
if (!response?.valid) {
await invalidateIdentity(response?.reason || 'identity rejected by server');
return false;
}
} catch (error) {
const errorText = getErrorText(error);
if (isAuthRejectionText(errorText)) {
await invalidateIdentity(errorText);
return false;
}
console.warn('Could not validate stored identity:', error);
}
return !!loginStatePart.getState()?.identity;
};
const scheduleIdentityExpiryTimer = () => {
if (identityExpiryTimer) {
window.clearTimeout(identityExpiryTimer);
identityExpiryTimer = undefined;
}
const identity = loginStatePart.getState()?.identity ?? null;
if (!identity?.expiresAt) return;
const msUntilExpiry = identity.expiresAt - Date.now();
if (msUntilExpiry <= 0) {
void invalidateIdentity('identity expired');
return;
}
identityExpiryTimer = window.setTimeout(() => {
void invalidateIdentity('identity expired');
}, Math.min(msUntilExpiry, 2147483647));
};
plugins.typedrequest.TypedRouter.setGlobalHooks({
onIncomingResponse: (entryArg) => {
if (entryArg.error && isAuthRejectionText(entryArg.error)) {
void invalidateIdentity(entryArg.error);
}
},
});
loginStatePart.select((stateArg) => stateArg?.identity ?? null).subscribe(() => {
scheduleIdentityExpiryTimer();
});
scheduleIdentityExpiryTimer();
// Getting data // Getting data
export const getAllDataAction = dataState.createAction(async (statePartArg) => { export const getAllDataAction = dataState.createAction(async (statePartArg) => {
let currentState = statePartArg.getState() || {}; let currentState = statePartArg.getState() || {};
+50 -3
View File
@@ -169,6 +169,17 @@ export class CloudlyDashboard extends DeesElement {
this.syncAppdashView(uiState.activeView, uiState.activeSubview); this.syncAppdashView(uiState.activeView, uiState.activeSubview);
}); });
this.rxSubscriptions.push(uiSubscription); this.rxSubscriptions.push(uiSubscription);
const loginSubscription = appstate.loginStatePart
.select((stateArg) => stateArg?.identity ?? null)
.subscribe((identityArg) => {
const hadIdentity = !!this.identity;
this.identity = identityArg ?? null;
if (!identityArg && hadIdentity) {
void this.switchToLoginContent('Session expired. Please sign in again.');
}
});
this.rxSubscriptions.push(loginSubscription);
} }
private syncAppdashView(viewSlug: string, subviewSlug: string | null): void { private syncAppdashView(viewSlug: string, subviewSlug: string | null): void {
@@ -267,9 +278,16 @@ export class CloudlyDashboard extends DeesElement {
const domtools = await this.domtoolsPromise; const domtools = await this.domtoolsPromise;
const loginState = appstate.loginStatePart.getState(); const loginState = appstate.loginStatePart.getState();
if (loginState?.identity) { if (loginState?.identity) {
this.identity = loginState.identity; const identityValid = await appstate.validateStoredIdentity();
const currentIdentity = appstate.loginStatePart.getState()?.identity ?? null;
if (!identityValid || !currentIdentity) {
await this.switchToLoginContent('Session expired. Please sign in again.');
return;
}
this.identity = currentIdentity;
try { try {
appstate.apiClient.identity = loginState.identity; appstate.apiClient.identity = currentIdentity;
if (!appstate.apiClient['typedsocketClient']) { if (!appstate.apiClient['typedsocketClient']) {
await appstate.apiClient.start(); await appstate.apiClient.start();
} }
@@ -301,5 +319,34 @@ export class CloudlyDashboard extends DeesElement {
} }
} }
private async logout() {} private async switchToLoginContent(statusMessageArg?: string) {
const simpleLogin = this.shadowRoot?.querySelector('dees-simple-login') as any;
if (!simpleLogin?.shadowRoot) return;
const loginDiv = simpleLogin.shadowRoot.querySelector('.login') as HTMLDivElement | null;
const loginContainerDiv = simpleLogin.shadowRoot.querySelector('.loginContainer') as HTMLDivElement | null;
const slotContainerDiv = simpleLogin.shadowRoot.querySelector('.slotContainer') as HTMLDivElement | null;
const form = simpleLogin.shadowRoot.querySelector('dees-form') as any;
if (loginDiv) {
loginDiv.style.opacity = '1';
loginDiv.style.transform = 'translateY(0px)';
}
if (loginContainerDiv) {
loginContainerDiv.style.pointerEvents = 'all';
}
if (slotContainerDiv) {
slotContainerDiv.style.opacity = '0';
slotContainerDiv.style.transform = 'translateY(20px)';
slotContainerDiv.style.pointerEvents = 'none';
}
if (form && statusMessageArg) {
form.setStatus('error', statusMessageArg);
}
}
private async logout() {
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
await this.switchToLoginContent();
}
} }
+5 -5
View File
@@ -4,6 +4,11 @@ export {
interfaces interfaces
} }
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
export { typedrequest };
// @design.estate scope // @design.estate scope
import * as deesDomtools from '@design.estate/dees-domtools'; import * as deesDomtools from '@design.estate/dees-domtools';
import * as deesElement from '@design.estate/dees-element'; import * as deesElement from '@design.estate/dees-element';
@@ -11,11 +16,6 @@ import * as deesCatalog from '@design.estate/dees-catalog';
export { deesDomtools, deesElement, deesCatalog }; export { deesDomtools, deesElement, deesCatalog };
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
export { typedrequest };
// @push.rocks scope // @push.rocks scope
import * as webjwt from '@push.rocks/webjwt'; import * as webjwt from '@push.rocks/webjwt';
import * as smartstate from '@push.rocks/smartstate'; import * as smartstate from '@push.rocks/smartstate';