diff --git a/app/(account)/logout/page.tsx b/app/(account)/logout/page.tsx
new file mode 100644
index 0000000..c8e7b41
--- /dev/null
+++ b/app/(account)/logout/page.tsx
@@ -0,0 +1,21 @@
+"use client"
+
+import {useContext, useEffect} from "react";
+import {logout} from "../../../lib/auth/authorization";
+import {redirect} from "next/navigation";
+import {AuthorizerContext} from "../../../context/authorizerContext";
+
+const LogoutPage = () => {
+
+ const {revalidateAuth} = useContext(AuthorizerContext);
+
+ useEffect(() => {
+ logout().then(_ => {
+ revalidateAuth();
+ redirect("/");
+ }
+ );
+ }, []);
+}
+
+export default LogoutPage;
\ No newline at end of file
diff --git a/app/(account)/reset-password/page.tsx b/app/(account)/reset-password/page.tsx
index b88781d..6dba675 100644
--- a/app/(account)/reset-password/page.tsx
+++ b/app/(account)/reset-password/page.tsx
@@ -22,7 +22,7 @@ const ResetPasswordPage = () => {
Success!
An email has been sent with instructions to reset your password.
Please check your inbox and follow the instructions to complete the process.
- Didn't receive the email? Check your Spam folder or try resending the email. Ensure your email address is
+
Didn't receive the email? Check your Spam folder or try resending the email. Ensure your email address is
entered correctly.
);
diff --git a/app/common/defaultNavbar.tsx b/app/common/defaultNavbar.tsx
index bd02fec..0696800 100644
--- a/app/common/defaultNavbar.tsx
+++ b/app/common/defaultNavbar.tsx
@@ -9,8 +9,8 @@ const playerWiki = 'https://wiki.unitystation.org'
const devWiki = 'https://unitystation.github.io/unitystation/'
export default function DefaultNavbar() {
- const {isLoggedIn, encryptedToken, account} = useContext(AuthorizerContext);
- const username = account?.username;
+ const {isLoggedIn, authContext, error} = useContext(AuthorizerContext);
+ const username = authContext?.account.username;
return (
diff --git a/app/layout.tsx b/app/layout.tsx
index 8a2aaa0..6e22f44 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -3,8 +3,8 @@ import React from "react";
import Clown from "./clown/clown";
import {Metadata} from "next";
import DefaultNavbar from "./common/defaultNavbar";
-import { Analytics } from '@vercel/analytics/react';
-import type { Viewport } from 'next'
+import {Analytics} from '@vercel/analytics/react';
+import type {Viewport} from 'next'
import {Providers} from "./providers";
export const metadata: Metadata = {
@@ -49,12 +49,12 @@ export default function RootLayout({children,}: { children: React.ReactNode; })
return (
-
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
)
diff --git a/app/providers.tsx b/app/providers.tsx
index bced9d9..77c3b6d 100644
--- a/app/providers.tsx
+++ b/app/providers.tsx
@@ -2,11 +2,22 @@
import LayoutChildren from "../types/layoutChildren";
import {AuthorizerContext} from "../context/authorizerContext";
+import {useAuth} from "../lib/auth/useAuth";
+import {useState} from "react";
+
export const Providers = (props: LayoutChildren) => {
+ const {isLoggedIn, authContext, error} = useAuth();
+ const [authState, setAuthState] = useState({isLoggedIn, authContext, error});
+
+ const useRevalidateAuth = () => {
+ const {isLoggedIn, authContext, error} = useAuth();
+ setAuthState({isLoggedIn, authContext, error});
+ }
+
return (
-
+
{props.children}
)
diff --git a/context/authorizerContext.ts b/context/authorizerContext.ts
index 674fc14..872c3dd 100644
--- a/context/authorizerContext.ts
+++ b/context/authorizerContext.ts
@@ -2,6 +2,9 @@ import {createContext} from "react";
import {AuthContext} from "../types/authTypes";
export const AuthorizerContext = createContext({
- isLoggedIn: false
+ isLoggedIn: false,
+ authContext: undefined,
+ error: undefined,
+ revalidateAuth: () => {}
}
)
\ No newline at end of file
diff --git a/lib/auth/authorization.ts b/lib/auth/authorization.ts
new file mode 100644
index 0000000..1d5d2f4
--- /dev/null
+++ b/lib/auth/authorization.ts
@@ -0,0 +1,79 @@
+'use server'
+
+import {LoginResponse} from "../../types/authTypes";
+import {SignJWT, jwtVerify} from "jose";
+import {cookies} from "next/headers";
+import {isLoginResponse, parseError} from "../errors";
+import {setSessionCookie} from "./session";
+
+const secretKeyText = `${process.env.SECRET}`;
+const secretKey = new TextEncoder().encode(secretKeyText);
+
+
+export const encryptAuthContext = async (payload: LoginResponse) => {
+ return await new SignJWT(payload as any)
+ .setProtectedHeader({alg: "HS256"})
+ .setIssuedAt()
+ .setExpirationTime("one day from now")
+ .sign(secretKey);
+}
+
+export const decryptAuthContext = async (token: string) => {
+ const {payload} = await jwtVerify(token, secretKey, {algorithms: ["HS256"]});
+
+ if (isLoginResponse(payload)) {
+ return payload;
+ } else {
+ throw new Error("Invalid token payload structure.");
+ }
+}
+
+export const loginWithCredentials = async (email: string, password: string) => {
+ const response = await fetch(`${process.env.CC_API_URL}/accounts/login-credentials`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({email, password})
+ });
+
+ const parsed = evaluateResponse(response);
+
+ if (isLoginResponse(parsed)) {
+ await setSessionCookie(parsed)
+ }
+
+ return parsed;
+}
+
+export const loginWithToken = async (token: string) => {
+ const response = await fetch(`${process.env.CC_API_URL}/accounts/login-token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ credentials: "include",
+ body: JSON.stringify({token})
+ });
+
+ const parsed = evaluateResponse(response);
+
+ if (isLoginResponse(parsed)) {
+ await setSessionCookie(parsed);
+ }
+
+ return parsed;
+}
+
+export const logout = async () => {
+ cookies().set("session", "", {expires: new Date(0)});
+}
+
+const evaluateResponse = async (response: Response) => {
+ if (response.ok) {
+ return await response.json() as LoginResponse;
+ } else {
+ const errorData = await response.json();
+ return parseError(errorData);
+ }
+}
\ No newline at end of file
diff --git a/lib/auth/session.ts b/lib/auth/session.ts
new file mode 100644
index 0000000..c1fd3a4
--- /dev/null
+++ b/lib/auth/session.ts
@@ -0,0 +1,21 @@
+"use server"
+
+import {cookies} from "next/headers";
+import {LoginResponse} from "../../types/authTypes";
+import {encryptAuthContext} from "./authorization";
+
+export const tryGetSessionCookie = (): { success: boolean, value?: string } => {
+ const sessionCooke = cookies().get("session")?.value;
+ if (sessionCooke) {
+ return { success: true, value: sessionCooke };
+ }
+
+ return { success: false };
+}
+
+export const setSessionCookie = async (payload: LoginResponse) => {
+ cookies().set("session", await encryptAuthContext(payload), {
+ httpOnly: true,
+ expires: new Date(Date.now() + 1000 * 60 * 60 * 24)
+ });
+}
\ No newline at end of file
diff --git a/lib/auth/useAuth.ts b/lib/auth/useAuth.ts
new file mode 100644
index 0000000..ed23d75
--- /dev/null
+++ b/lib/auth/useAuth.ts
@@ -0,0 +1,78 @@
+"use client"
+
+import {useEffect, useState} from "react";
+import {LoginResponse} from "../../types/authTypes";
+import {FieldError, GeneralError, isLoginResponse} from "../errors";
+import {decryptAuthContext, loginWithToken, logout} from "./authorization";
+import {tryGetSessionCookie, setSessionCookie} from "./session";
+
+
+export const useAuth = () => {
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
+ const [authContext, setAuthContext] = useState();
+ const [error, setError] = useState();
+
+ const getLoggedInState = async (isMounted: boolean) => {
+ const sessionCookie = tryGetSessionCookie();
+ if (!sessionCookie.success) {
+ isMounted && setIsLoggedIn(false);
+ return;
+ }
+
+ try {
+ const decrypted = await decryptAuthContext(sessionCookie.value!);
+ console.log("session cookie", sessionCookie);
+ console.log("decrypted", decrypted);
+
+ const response = await loginWithToken(decrypted.token);
+ if (isLoginResponse(response)) {
+ isMounted && setIsLoggedIn(true);
+ isMounted && setAuthContext(response);
+ await refreshAuthContext(isMounted);
+ } else {
+ isMounted && setIsLoggedIn(false);
+ isMounted && setError(response);
+ await logout();
+ }
+ } catch (e) {
+ isMounted && setIsLoggedIn(false);
+ isMounted && setError({error: 'Unexpected error occurred', status: 500});
+ await logout();
+ }
+ };
+
+ const refreshAuthContext = async (isMounted: boolean) => {
+ if (!isMounted) {
+ return;
+ }
+
+ const sessionCookie = tryGetSessionCookie();
+ if (!sessionCookie.success) {
+ return;
+ }
+
+ const decrypted = await decryptAuthContext(sessionCookie.value!);
+ if (!isLoginResponse(decrypted)) {
+ return;
+ }
+
+ const response = await loginWithToken(decrypted.token);
+ if (!isLoginResponse(response)) {
+ return;
+ }
+
+ await setSessionCookie(response);
+ };
+
+ useEffect(() => {
+ let isMounted = true;
+
+ getLoggedInState(isMounted);
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ return {isLoggedIn, authContext, error};
+};
\ No newline at end of file
diff --git a/lib/errors.ts b/lib/errors.ts
new file mode 100644
index 0000000..bbcf6e3
--- /dev/null
+++ b/lib/errors.ts
@@ -0,0 +1,37 @@
+import {LoginResponse} from "../types/authTypes";
+
+interface ErrorBase {
+ status: number;
+}
+
+export interface GeneralError extends ErrorBase{
+ error: string;
+}
+
+export interface FieldError extends ErrorBase {
+ error: {
+ [field: string]: string[];
+ };
+}
+
+export const isLoginResponse = (response: any): response is LoginResponse =>
+ response && response.account && typeof response.token === 'string';
+
+export const isGeneralError = (error: any): error is GeneralError =>
+ typeof error.error === 'string';
+
+export const isFieldError = (error: any): error is FieldError =>
+ typeof error.error === 'object' && !Array.isArray(error.error) && error.error !== null
+
+export const parseError = (error: any) => {
+ if (isGeneralError(error)) {
+ return error as GeneralError;
+ } else if (isFieldError(error)) {
+ return error as FieldError;
+ } else {
+ return {
+ status: 500,
+ error: 'Unknown error structure'
+ } as GeneralError;
+ }
+};
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 72ac66c..b72ee01 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"classnames": "^2.5.1",
"flowbite": "^2.3.0",
"flowbite-react": "^0.7.2",
+ "jose": "^5.2.2",
"next": "^14.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -10509,6 +10510,14 @@
"@sideway/pinpoint": "^2.0.0"
}
},
+ "node_modules/jose": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.2.tgz",
+ "integrity": "sha512-/WByRr4jDcsKlvMd1dRJnPfS1GVO3WuKyaurJ/vvXcOaUQO8rnNObCQMlv/5uCceVQIq5Q4WLF44ohsdiTohdg==",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
diff --git a/package.json b/package.json
index 2d7bd5e..b90864a 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"classnames": "^2.5.1",
"flowbite": "^2.3.0",
"flowbite-react": "^0.7.2",
+ "jose": "^5.2.2",
"next": "^14.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
diff --git a/types/authTypes.ts b/types/authTypes.ts
index eee0ec7..6bf4af5 100644
--- a/types/authTypes.ts
+++ b/types/authTypes.ts
@@ -1,3 +1,5 @@
+import {FieldError, GeneralError} from "../lib/errors";
+
export interface AccountPublicData {
unique_identifier: string;
username: string;
@@ -10,13 +12,9 @@ export interface LoginResponse {
token: string;
}
-export interface LoginWithCredentialsRequest {
- email: string;
- password: string;
-}
-
export interface AuthContext {
isLoggedIn: boolean;
- account?: AccountPublicData;
- encryptedToken?: string;
+ authContext?: LoginResponse;
+ error?: GeneralError | FieldError;
+ revalidateAuth: () => void;
}
\ No newline at end of file