Skip to content

Commit d57a38e

Browse files
authored
Merge pull request #8284 from systeminit/secure-bearer-tokens
feat(auth-api): Implement secure bearer tokens behind feature flag
2 parents 66cb495 + f4b4a56 commit d57a38e

File tree

9 files changed

+345
-47
lines changed

9 files changed

+345
-47
lines changed

app/web/src/newhotness/logic_composables/tokens.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,23 @@ import jwtDecode from "jwt-decode";
44

55
// token logic pulled from authstore
66

7-
type TokenData = {
7+
// V1 token format (legacy)
8+
type TokenDataV1 = {
89
user_pk: string;
910
workspace_pk: string;
10-
// isImpersonating?: boolean;
1111
};
1212

13+
// V2 token format (new secure tokens)
14+
type TokenDataV2 = {
15+
version: "2";
16+
userId: string;
17+
workspaceId: string;
18+
role: string;
19+
jti: string;
20+
};
21+
22+
type TokenData = TokenDataV1 | TokenDataV2;
23+
1324
const AUTH_LOCAL_STORAGE_KEYS = {
1425
USER_TOKENS: "si-auth",
1526
};
@@ -29,8 +40,16 @@ export const readTokens = () => {
2940
};
3041

3142
export const getUserPkFromToken = (token: string): string => {
32-
const { user_pk: userPk } = jwtDecode<TokenData>(token);
33-
return userPk;
43+
const decoded = jwtDecode<TokenData>(token);
44+
45+
// Check if V2 token format
46+
if ("version" in decoded && decoded.version === "2") {
47+
return decoded.userId;
48+
}
49+
50+
// V1 token format
51+
const v1Token = decoded as TokenDataV1;
52+
return v1Token.user_pk;
3453
};
3554

3655
readTokens();

app/web/src/router.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,9 +272,9 @@ const routes: RouteRecordRaw[] = [
272272
{
273273
path: "/logout",
274274
name: "logout",
275-
beforeEnter: () => {
275+
beforeEnter: async () => {
276276
const authStore = useAuthStore();
277-
authStore.localLogout();
277+
await authStore.logout();
278278
},
279279
component: () => import("@/pages/auth/LogoutPage.vue"), // just need something here for TS, but guard always redirects
280280
},

app/web/src/store/apis.web.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Axios, {
55
InternalAxiosRequestConfig,
66
} from "axios";
77
import { useToast } from "vue-toastification";
8+
import { trackEvent } from "@/utils/tracking";
89
import { useAuthStore } from "@/store/auth.store";
910
import { useChangeSetsStore } from "@/store/change_sets.store";
1011
import FiveHundredError from "@/components/toasts/FiveHundredError.vue";
@@ -13,9 +14,9 @@ import UnscheduledDowntime from "@/components/toasts/UnscheduledDowntime.vue";
1314

1415
// api base url - can use a proxy or set a full url
1516
let apiUrl: string;
16-
if (import.meta.env.VITE_API_PROXY_PATH)
17+
if (import.meta.env.VITE_API_PROXY_PATH) {
1718
apiUrl = `${window.location.origin}${import.meta.env.VITE_API_PROXY_PATH}`;
18-
else throw new Error("Invalid API env var config");
19+
} else throw new Error("Invalid API env var config");
1920
export const API_HTTP_URL = apiUrl;
2021

2122
// set up websocket url, by replacing protocol and appending /ws
@@ -129,11 +130,39 @@ async function handleOutageModes(error: AxiosError) {
129130
return Promise.reject(error);
130131
}
131132

133+
async function handle401(error: AxiosError) {
134+
if (error?.response?.status === 401) {
135+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
136+
const errorKind = (error?.response?.data as any)?.kind;
137+
if (
138+
errorKind === "AuthTokenRevoked" ||
139+
errorKind === "AuthTokenInvalid" ||
140+
errorKind === "AuthTokenCorrupt"
141+
) {
142+
const authStore = useAuthStore();
143+
144+
// Track automatic logout event
145+
if (authStore.user) {
146+
trackEvent("automatic_logout_forced", {
147+
reason: errorKind,
148+
requestUrl: error?.config?.url,
149+
userEmail: authStore.user.email,
150+
logoutTriggeredAt: new Date().toISOString(),
151+
});
152+
}
153+
154+
authStore.localLogout(true);
155+
}
156+
}
157+
return Promise.reject(error);
158+
}
159+
132160
sdfApiInstance.interceptors.response.use(handleProxyTimeouts, handle500);
133161
sdfApiInstance.interceptors.response.use(
134162
handleForcedChangesetRedirection,
135163
handleOutageModes,
136164
);
165+
sdfApiInstance.interceptors.response.use((r) => r, handle401);
137166

138167
export const authApiInstance = Axios.create({
139168
headers: {
@@ -143,6 +172,7 @@ export const authApiInstance = Axios.create({
143172
withCredentials: true, // needed to attach the cookie
144173
});
145174
authApiInstance.interceptors.request.use(injectBearerTokenAuth);
175+
authApiInstance.interceptors.response.use((r) => r, handle401);
146176

147177
export const moduleIndexApiInstance = Axios.create({
148178
headers: {

app/web/src/store/auth.store.ts

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,23 @@ const AUTH_LOCAL_STORAGE_KEYS = {
1919
USER_TOKENS: "si-auth",
2020
};
2121

22-
type TokenData = {
22+
// V1 token format (legacy)
23+
type TokenDataV1 = {
2324
user_pk: string;
2425
workspace_pk: string;
25-
// isImpersonating?: boolean;
2626
};
2727

28+
// V2 token format (new secure tokens)
29+
type TokenDataV2 = {
30+
version: "2";
31+
userId: string;
32+
workspaceId: string;
33+
role: string;
34+
jti: string;
35+
};
36+
37+
type TokenData = TokenDataV1 | TokenDataV2;
38+
2839
interface LoginResponse {
2940
user: User;
3041
workspace: Workspace;
@@ -38,6 +49,27 @@ export interface WorkspaceUser {
3849
email: string;
3950
}
4051

52+
// Helper function to normalize token data from V1 or V2 format
53+
function normalizeTokenData(token: TokenData): {
54+
userPk: string;
55+
workspacePk: string;
56+
} {
57+
if ("version" in token && token.version === "2") {
58+
// V2 token format
59+
return {
60+
userPk: token.userId,
61+
workspacePk: token.workspaceId,
62+
};
63+
}
64+
65+
// V1 token format (explicit cast since TypeScript doesn't narrow automatically)
66+
const v1Token = token as TokenDataV1;
67+
return {
68+
userPk: v1Token.user_pk,
69+
workspacePk: v1Token.workspace_pk,
70+
};
71+
}
72+
4173
export const useAuthStore = () => {
4274
const WORKSPACE_API_PREFIX = ["v2", "workspaces"];
4375

@@ -213,10 +245,11 @@ export const useAuthStore = () => {
213245
const tokens = _.values(tokensByWorkspacePk);
214246
if (!tokens.length) return [];
215247

216-
// token contains user pk and biling account pk
217-
const { user_pk: userPk } =
218-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
219-
jwtDecode<TokenData>(tokens[0]!);
248+
// token contains user pk and workspace pk (normalize V1/V2 format)
249+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
250+
const decodedToken = jwtDecode<TokenData>(tokens[0]!);
251+
const { userPk } = normalizeTokenData(decodedToken);
252+
220253
this.$patch({
221254
tokens: tokensByWorkspacePk,
222255
userPk,
@@ -265,6 +298,29 @@ export const useAuthStore = () => {
265298
}
266299
}
267300
},
301+
async logout() {
302+
// Call backend to revoke token
303+
// Use fetch directly with the workspace token from localStorage
304+
try {
305+
const workspaceToken = this.selectedWorkspaceToken;
306+
if (workspaceToken) {
307+
const AUTH_API_URL = import.meta.env.VITE_AUTH_API_URL;
308+
await fetch(`${AUTH_API_URL}/session/logout`, {
309+
method: "POST",
310+
headers: {
311+
Authorization: `Bearer ${workspaceToken}`,
312+
"Content-Type": "application/json",
313+
},
314+
});
315+
}
316+
} catch (error) {
317+
// Silently fail - logout will still clear local state
318+
}
319+
320+
// Clear local state and redirect
321+
this.localLogout(true);
322+
},
323+
268324
localLogout(redirectToAuthPortal = true) {
269325
storage.removeItem(AUTH_LOCAL_STORAGE_KEYS.USER_TOKENS);
270326
this.$patch({
@@ -281,11 +337,13 @@ export const useAuthStore = () => {
281337
// split out so we can reuse for different login methods (password, oauth, magic link, signup, etc)
282338
finishUserLogin(loginResponse: LoginResponse) {
283339
const decodedJwt = jwtDecode<TokenData>(loginResponse.token);
340+
const { userPk, workspacePk } = normalizeTokenData(decodedJwt);
341+
284342
this.$patch({
285-
userPk: decodedJwt.user_pk,
343+
userPk,
286344
tokens: {
287345
...this.tokens,
288-
[decodedJwt.workspace_pk]: loginResponse.token,
346+
[workspacePk]: loginResponse.token,
289347
},
290348
user: loginResponse.user,
291349
userWorkspaceFlags: loginResponse.userWorkspaceFlags,

0 commit comments

Comments
 (0)