From 8a5461827edb9e37a8bda886141643794a962d37 Mon Sep 17 00:00:00 2001 From: Kevin FEDYNA Date: Wed, 22 Jan 2025 11:15:43 +0100 Subject: [PATCH] Optimized code and wrote documentation --- defaults/interfaces.ts | 13 ++++-- routes/_middleware.ts | 29 +++++++++---- routes/apps.tsx | 27 ++++++++++--- routes/login.tsx | 92 +++++++++++++++++++++++------------------- routes/logout.tsx | 13 ++++-- 5 files changed, 113 insertions(+), 61 deletions(-) diff --git a/defaults/interfaces.ts b/defaults/interfaces.ts index 24a5076..a5a6268 100644 --- a/defaults/interfaces.ts +++ b/defaults/interfaces.ts @@ -1,11 +1,18 @@ import { type RegularTagNode, type TextNode } from "@melvdouc/xml-parser"; import { AsyncRoute } from "$fresh/src/server/types.ts"; -export interface State { - isAuthenticated: boolean; +interface AuthenticatedState { + isAuthenticated: true; session: CasContent; } +interface UnauthenticatedState { + isAuthenticated: false; + session: undefined; +} + +export type State = AuthenticatedState | UnauthenticatedState; + export interface AppProperties { name: string; icon: string; @@ -56,4 +63,4 @@ export interface LoginJWT { export type EmptyObject = Record; // deno-lint-ignore no-explicit-any -export type Route = AsyncRoute; \ No newline at end of file +export type Route = AsyncRoute; diff --git a/routes/_middleware.ts b/routes/_middleware.ts index fbaca27..01b449e 100644 --- a/routes/_middleware.ts +++ b/routes/_middleware.ts @@ -1,4 +1,4 @@ -import { FreshContext } from "$fresh/server.ts"; +import { FreshContext, MiddlewareHandler } from "$fresh/server.ts"; import { getCookies } from "$std/http/cookie.ts"; import { getJwtPayload, isJwtValid } from "@popov/jwt"; import { CasContent, LoginJWT, State } from "$root/defaults/interfaces.ts"; @@ -41,11 +41,17 @@ export function getKey(user: string): string { return jwtKeyCache[user]; } -export const handler = [ +export const handler: MiddlewareHandler[] = [ + /** + * Check if user is authenticated and add session to context accordingly. + * @param request The HTTP incomming request. + * @param context The Fresh context object with custom `State`. + * @returns The response from the next middleware. + */ async function checkAuthentication( request: Request, context: FreshContext, - ) { + ): Promise { const cookies = getCookies(request.headers); if (!cookies["sessionToken"]) { context.state.isAuthenticated = false; @@ -59,17 +65,26 @@ export const handler = [ cookies["sessionToken"], key, ); - const session: CasContent = - (getJwtPayload(cookies["sessionToken"]) as LoginJWT).user; - context.state.session = session; + if (context.state.isAuthenticated) { + const session: CasContent = + (getJwtPayload(cookies["sessionToken"]) as LoginJWT).user; + context.state.session = session; + } return await context.next(); }, + /** + * Check if page can be accessed with or without authentication. + * Redirect if the page is private and the user isn't authenticated. + * @param request The HTTP incomming request. + * @param context The Fresh context object with `State` set up. + * @returns The response from the next middleware or from the page. + */ async function ensureAuthentication( request: Request, context: FreshContext, - ) { + ): Promise { const url = new URL(request.url); if (!isRoutePublic(url.pathname) && !context.state.isAuthenticated) { diff --git a/routes/apps.tsx b/routes/apps.tsx index 54c4e44..d64cabb 100644 --- a/routes/apps.tsx +++ b/routes/apps.tsx @@ -1,10 +1,24 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; -import { AppProperties } from "$root/defaults/interfaces.ts"; +import { AppProperties, State } from "$root/defaults/interfaces.ts"; import AppNavigator from "$root/routes/(_islands)/AppNavigator.tsx"; -export const handler: Handlers = { - async GET(_request, context) { - const apps: Record = {}; +const apps: Record = {}; + +export const handler: Handlers, State> = { + /** + * Generate the app catalog page from pages. + * Catalog is only computed once, then the cached version is used. + * @param _request The HTTP incomming request. + * @param context The Fresh context with `State`. + * @returns The rendered page with all apps as catalog. + */ + async GET( + _request: Request, + context: FreshContext>, + ): Promise { + if (Object.keys(apps).length != 0) { + return context.render(apps); + } for await (const appDir of Deno.readDir("routes/(apps)")) { if (appDir.isFile) { @@ -26,7 +40,10 @@ export const handler: Handlers = { }; // deno-lint-ignore require-await -export default async function Apps(_request: Request, context: FreshContext) { +export default async function Apps( + _request: Request, + context: FreshContext>, +) { return ( <> diff --git a/routes/login.tsx b/routes/login.tsx index 4bbd1c7..3b1da1e 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -1,4 +1,4 @@ -import { Handlers } from "$fresh/server.ts"; +import { FreshContext, Handlers } from "$fresh/server.ts"; import { State } from "$root/defaults/interfaces.ts"; import { parse, type RegularTagNode } from "@melvdouc/xml-parser"; import { @@ -13,13 +13,23 @@ import { getKey } from "$root/routes/_middleware.ts"; const CAS = "https://ident.univ-amu.fr/cas"; -function getTag(tag: CasTagNode): [string, string] { +/** + * Get the tag node value without "cas:" prefix in name. + * @param tag The CAS tag node. + * @returns The `[name, value]` pair. + */ +function getTag(tag: CasTagNode): [name: string, value: string] { return [ tag.tagName.replace("cas:", ""), tag.children[0].value, ]; } +/** + * Gets the user JWT token with a validity period of one hour. + * @param casResponse The CAS reponse parsed from XML. + * @returns The user JWT session token. + */ function createUserJWT(casResponse: CasResponse): Promise { const nodes = casResponse.children[1].children.map(getTag); const fullUserInfos: Record = {}; @@ -28,7 +38,6 @@ function createUserJWT(casResponse: CasResponse): Promise { if (typeof fullUserInfos[key] == "string") { fullUserInfos[key] = [fullUserInfos[key]]; } - if (Array.isArray(fullUserInfos[key])) { fullUserInfos[key].push(value); } else { @@ -37,12 +46,10 @@ function createUserJWT(casResponse: CasResponse): Promise { }); const now = Math.floor(Date.now() / 1000); - const oneHour = 60 * 60; - const payload: LoginJWT = { iss: "PolyMPR", iat: now, - exp: now + oneHour, + exp: now + 0xe10, aud: "PolyMPR", user: fullUserInfos as unknown as CasContent, }; @@ -51,51 +58,39 @@ function createUserJWT(casResponse: CasResponse): Promise { return createJwt(payload, key); } -// deno-lint-ignore no-explicit-any -export const handler: Handlers = { - async GET(request, context) { +export const handler: Handlers = { + /** + * Handles all CAS protocol requests. + * @param request The incomming HTTP request. + * @param context The Fresh context with `State`. + * @returns The redirect corresponding to each step of the CAS protocol. + */ + async GET( + request: Request, + context: FreshContext, + ): Promise { const url = new URL(request.url); const ticket = url.searchParams.get("ticket"); const service = `${context.url.origin}/login`; - if (ticket) { - const response = await fetch( - `${CAS}/serviceValidate?service=${service}&ticket=${ticket}`, - ); - const body = parse(await response.text()) as [RegularTagNode]; - const casResponse = body[0].children[0] as CasResponse; - - if (casResponse.tagName != "cas:authenticationSuccess") { - return new Response(null, { - status: 302, - headers: { - Location: `${CAS}/login?service=${service}`, - }, - }); - } - - const headers = new Headers(); - - setCookie(headers, { - name: "sessionToken", - value: await createUserJWT(casResponse), - }); - headers.set("Location", "/apps"); - - return new Response(null, { - status: 302, - headers, - }); - } - - if (context.state.isAuthenticated) { + if (!ticket) { return new Response(null, { status: 302, headers: { - Location: "/apps", + Location: context.state.isAuthenticated + ? "/apps" + : `${CAS}/login?service=${service}`, }, }); - } else { + } + + const response = await fetch( + `${CAS}/serviceValidate?service=${service}&ticket=${ticket}`, + ); + const body = parse(await response.text()) as [RegularTagNode]; + const casResponse = body[0].children[0] as CasResponse; + + if (casResponse.tagName != "cas:authenticationSuccess") { return new Response(null, { status: 302, headers: { @@ -103,5 +98,18 @@ export const handler: Handlers = { }, }); } + + const headers = new Headers(); + + setCookie(headers, { + name: "sessionToken", + value: await createUserJWT(casResponse), + }); + headers.set("Location", "/apps"); + + return new Response(null, { + status: 302, + headers, + }); }, }; diff --git a/routes/logout.tsx b/routes/logout.tsx index 8ebc21b..1547942 100644 --- a/routes/logout.tsx +++ b/routes/logout.tsx @@ -1,12 +1,17 @@ -import { Handlers } from "$fresh/server.ts"; +import { FreshContext, Handlers } from "$fresh/server.ts"; import { State } from "$root/defaults/interfaces.ts"; import { deleteCookie } from "$std/http/cookie.ts"; const CAS = "https://ident.univ-amu.fr/cas"; -// deno-lint-ignore no-explicit-any -export const handler: Handlers = { - GET(_request, context) { +export const handler: Handlers = { + /** + * Logout of amU CAS SSO system. + * @param _request The HTTP incomming request. + * @param context The Fresh context with `State`. + * @returns A redirect response to either CAS logout or home. + */ + GET(_request: Request, context: FreshContext): Response { if (context.state.isAuthenticated) { const headers = new Headers();