feat: implement auth stores

This commit is contained in:
mrjvs
2026-08-07 13:16:43 +02:00
parent 1c10ac1df0
commit fb0efeb4ed
17 changed files with 667 additions and 21 deletions

View File

@@ -1,8 +1,10 @@
<script lang="ts" setup>
const { locales, setLocale } = useI18n();
const me = useMeStore();
const user = computed(() => me.user);
const openDropdown = ref<boolean | string>(false);
const loggedIn = ref(false);
function handleDropdownButton(dropdown: boolean | string) {
if (!openDropdown.value) {
openDropdown.value = dropdown;
@@ -451,7 +453,7 @@ onMounted(() => {
</div>
<div
v-if="loggedIn"
v-if="user"
class="user-widget-wrapper logged-in"
>
<div class="user-widget-toggle">
@@ -491,7 +493,7 @@ onMounted(() => {
</div>
<div
v-if="!loggedIn"
v-if="!user"
class="user-widget-wrapper"
>
<a

View File

@@ -0,0 +1,27 @@
import type { GetApiAuthMe } from "~/server/api/auth/me.get";
export default defineNuxtRouteMiddleware(async () => {
const meStore = useMeStore();
if (meStore.loaded) return; // Already loaded
const authStore = useAuthStore();
const token = authStore.getToken();
if (!token) {
meStore.setMe(null);
return; // No token
}
try {
const res = await $fetch<GetApiAuthMe>('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
})
meStore.setMe({
pid: res.pid,
username: res.username,
});
} catch {
meStore.setMe(null);
}
});

View File

@@ -0,0 +1,13 @@
function notAllowed() {
return navigateTo("/");
}
export default defineNuxtRouteMiddleware(async (to) => {
const meStore = useMeStore();
if (!meStore.loaded) throw new Error("Mestore must be loaded before reaching this middleware");
// NeedsAuth
if (to.meta.needsAuth) {
if (!meStore.user) return notAllowed();
}
});

View File

@@ -1,5 +1,15 @@
<script setup lang="ts">
definePageMeta({
needsAuth: true,
})
const me = useMeStore();
</script>
<template>
<div>
<h1>Hello {{ me.user?.username }}</h1>
<p>TODO: account stub page</p>
</div>
</template>

7
src/plugins/types.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare module "#app" {
interface PageMeta {
needsAuth?: boolean;
}
}
export { };

View File

@@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
try {
const apiResponse = await $fetch<LoginCCResponse>(`/v1/login`, {
method: 'POST',
baseURL: useRuntimeConfig(event).apiBase,
baseURL: useRuntimeConfig(event).public.apiBase,
body: { ...body, grant_type: 'password' }
});

View File

@@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
try {
const apiResponse = await $fetch<RegisterCCResponse>(`/v1/register`, {
method: 'POST',
baseURL: useRuntimeConfig(event).apiBase,
baseURL: useRuntimeConfig(event).public.apiBase,
body: body
});

View File

@@ -0,0 +1,18 @@
import { enforceLoggedIn } from '~/server/utils/enforceAuth';
import { useGrpc } from '~/server/utils/useGrpc';
export interface GetApiAuthMe {
pid: number;
username: string;
}
export default defineEventHandler(async (event): Promise<GetApiAuthMe> => {
const auth = enforceLoggedIn(event);
const grpc = useGrpc(event);
const data = await grpc.getUserData({ pid: auth.pid });
return {
pid: data.pid,
username: data.username,
}
});

View File

@@ -0,0 +1,28 @@
import { Metadata } from "nice-grpc";
import { useGrpc } from "../utils/useGrpc";
export default defineEventHandler(async (event) => {
const authHeader = getRequestHeader(event, "authorization");
event.context.auth = null;
if (authHeader) {
const grpc = useGrpc(event);
try {
const [type, token] = authHeader.split(" ", 2);
if (type !== "Bearer") throw new Error("Invalid token type");
if (!token) throw new Error("Invalid token");
const userData = await grpc.getUserData({}, {
metadata: new Metadata({
'X-Token': token
})
});
event.context.auth = {
pid: userData.pid,
username: userData.username,
}
} catch (err) {
console.error("Failed to request user data: ", err);
return; // Continue like nothing happened, further steps will validate if authed
}
}
})

View File

@@ -0,0 +1,22 @@
export type AuthContext = {
pid: number;
}
export function setAuthContext(event: H3Event, context: AuthContext) {
event.context.auth = context;
}
export function getAuthContext(event: H3Event): AuthContext | null {
return event.context.auth ?? null;
}
export function enforceLoggedIn(event: H3Event): AuthContext {
const context = getAuthContext(event);
if (!context) throw createError({
status: 401,
statusText: "This action requires authentication",
})
return context;
}

View File

@@ -0,0 +1,31 @@
import { APIDefinition } from "@pretendonetwork/grpc/api/api_service";
import { createChannel, createClient, Channel, type Client, Metadata } from "nice-grpc";
let grpc: { channel: Channel, client: Client<APIDefinition> } | null = null;
function getGrpcClient(event: H3Event): Client<APIDefinition> {
if (!grpc) {
const config = useRuntimeConfig();
if (!config.grpcHost || !config.grpcApiKey) {
throw new Error("GRPC not configured");
}
const channel = createChannel(config.grpcHost);
const metadata = new Metadata();
metadata.append("X-API-Key", config.grpcApiKey);
grpc = {
channel,
client: createClient(APIDefinition, channel, {
"*": {
metadata
}
})
}
}
return grpc.client;
}
export function useGrpc(event: H3Event) {
return getGrpcClient(event);
}

74
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,74 @@
import type { CookieOptions } from "#app";
type AuthState = {
accessToken: string;
refreshToken: string;
};
// Cookies on pretendo are weird:
// `pretendo::auth` has the real authentication state that the website uses.
// This has been made as secure as possible and must only be used in the website codebase.
//
// Then we have the rest of the cookies:
// - `access_token`
// - `refresh_token`
// - `token_type`
// These are for backwards compatibility. They must be configured exactly as implemented below
// because other services use them (and overwrite them)
// These are not used by the website codebase for security purposes.
const oldCookieTokenType = "Bearer"
const cookieExpirySec = 7 * 24 * 60 * 60 // 7 days
const oldCookieOptions = {
domain: '.pretendo.network',
secure: false,
httpOnly: false,
maxAge: cookieExpirySec,
refresh: true,
readonly: false,
} satisfies CookieOptions<string> & { readonly: false }
// Not actually a store, but may as well be
export function useAuthStore() {
const authState = useCookie<AuthState | null>("pretendo::auth", {
sameSite: "strict",
secure: useRuntimeConfig().public.cookieSecure,
refresh: true,
default: () => null,
});
const accessTokenCookie = useCookie<string | null>("access_token", oldCookieOptions);
const refreshTokenCookie = useCookie<string | null>("refresh_token", oldCookieOptions);
const tokenTypeCookie = useCookie<string | null>("token_type", oldCookieOptions);
function getToken() {
return authState.value?.accessToken ?? null;
}
function refresh() {
authState.value = authState.value;
accessTokenCookie.value = accessTokenCookie.value;
refreshTokenCookie.value = refreshTokenCookie.value;
tokenTypeCookie.value = tokenTypeCookie.value;
}
function set(val: AuthState | null) {
if (val) {
authState.value = val
accessTokenCookie.value = val.accessToken;
refreshTokenCookie.value = val.refreshToken;
tokenTypeCookie.value = oldCookieTokenType;
}
else {
authState.value = null;
accessTokenCookie.value = null;
refreshTokenCookie.value = null;
tokenTypeCookie.value = null;
}
}
return {
getToken,
refresh,
set,
};
}

25
src/stores/me.ts Normal file
View File

@@ -0,0 +1,25 @@
export type Me = {
pid: number;
username: string;
};
export const useMeStore = defineStore("me", () => {
const data = ref<Me | null>(null);
const loaded = ref(false);
function setMe(input: Me | null) {
data.value = input;
loaded.value = true;
}
function clear() {
data.value = null;
}
return {
user: computed(() => data.value),
loaded: computed(() => loaded.value),
setMe,
clear,
};
});