Skip to content

Commit de7a10c

Browse files
committed
feat(auth): add JWT /refresh endpoint
1 parent 0b569cd commit de7a10c

File tree

3 files changed

+116
-0
lines changed

3 files changed

+116
-0
lines changed

components/Layout.vue

+18
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,22 @@ useHead({
1919
title: title.value,
2020
meta: [{ name: "description", content: description.value }],
2121
});
22+
23+
const userStore = useAuthStore();
24+
const { user } = storeToRefs(userStore);
25+
26+
const pollRefresh = async () => {
27+
// eslint-disable-next-line no-useless-return
28+
if (!user.value.id) return;
29+
else {
30+
const { requestState } = await userStore.refresh(
31+
user.value.refreshToken,
32+
);
33+
if (requestState.error) throw requestState.error;
34+
}
35+
};
36+
37+
onMounted(() => {
38+
setInterval(pollRefresh, 30_000); // poll refresh token API every 30s
39+
});
2240
</script>

server/api/auth/refresh.post.ts

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import jwt from "jsonwebtoken";
2+
import { v4 } from "uuid";
3+
import { z } from "zod";
4+
5+
const schema = z.object({
6+
refreshToken: z.string(),
7+
});
8+
9+
export default defineEventHandler(async (event) => {
10+
const result = await readValidatedBody(event, (body) =>
11+
schema.safeParse(body),
12+
);
13+
14+
// eslint-disable-next-line @typescript-eslint/no-throw-literal
15+
if (!result.success) throw result.error.issues;
16+
17+
const runtimeConfig = useRuntimeConfig();
18+
19+
const payload = jwt.verify(
20+
result.data.refreshToken,
21+
runtimeConfig.jwtRefreshSecret,
22+
) as jwt.JwtPayload;
23+
24+
const savedRefreshToken = await findRefreshTokenById(payload.jti!);
25+
26+
if (!savedRefreshToken || savedRefreshToken.revoked)
27+
throw createError({
28+
statusCode: 401,
29+
statusMessage: "Unauthorized",
30+
});
31+
32+
const hashedToken = hashToken(result.data.refreshToken);
33+
if (hashedToken !== savedRefreshToken.hashedToken)
34+
throw createError({
35+
statusCode: 401,
36+
statusMessage: "Unauthorized",
37+
});
38+
39+
const user = await db.user.findFirst({
40+
where: {
41+
id: payload.userId,
42+
},
43+
});
44+
45+
if (!user)
46+
throw createError({
47+
statusCode: 401,
48+
statusMessage: "Unauthorized",
49+
});
50+
51+
await deleteRefreshToken(savedRefreshToken.id);
52+
const jti = v4();
53+
const { accessToken, refreshToken: newRefreshToken } = generateTokens(
54+
user,
55+
jti,
56+
);
57+
await addRefreshToken(jti, newRefreshToken, user);
58+
59+
return {
60+
accessToken,
61+
refreshToken: newRefreshToken,
62+
};
63+
});

utils/authStore.ts

+35
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ interface RequestState {
55
error: Error | null;
66
}
77

8+
interface RefreshState {
9+
accessToken: string;
10+
refreshToken: string;
11+
}
12+
813
interface UserState {
914
id: string;
1015
username: string;
@@ -104,5 +109,35 @@ export const useAuthStore = defineStore("auth", {
104109
return { data: {} as UserState, requestState };
105110
}
106111
},
112+
async refresh(
113+
refreshToken: string,
114+
): Promise<{ data: RefreshState; requestState: RequestState }> {
115+
const requestState: RequestState = {
116+
loading: true,
117+
error: null,
118+
};
119+
try {
120+
const data: RefreshState = await $fetch(
121+
"/api/auth/refresh",
122+
{
123+
method: "post",
124+
headers: { "Content-Type": "application/json" },
125+
body: {
126+
refreshToken,
127+
},
128+
},
129+
);
130+
requestState.loading = false;
131+
132+
this.user.refreshToken = data.refreshToken;
133+
this.user.accessToken = data.accessToken;
134+
135+
return { data, requestState };
136+
} catch (error) {
137+
requestState.loading = false;
138+
requestState.error = error as Error;
139+
return { data: {} as RefreshState, requestState };
140+
}
141+
},
107142
},
108143
});

0 commit comments

Comments
 (0)