diff --git a/@types/next-auth.d.ts b/@types/next-auth.d.ts index 352e48e..b4b5e3e 100644 --- a/@types/next-auth.d.ts +++ b/@types/next-auth.d.ts @@ -8,7 +8,10 @@ declare module "next-auth" { * or the second parameter of the `session` callback, when using a database. */ interface User { - access_token?: string; + accessToken?: string; + accessTokenExpiration?: number; + refreshTokenExpiration?: number; + apiCookies?: string[]; apiUser?: APIUser; } @@ -17,7 +20,9 @@ declare module "next-auth" { */ interface Session { user: { - access_token?: string; + accessToken?: string; + accessTokenExpiration?: number; + refreshTokenExpiration?: number; apiUser?: APIUser; } & DefaultSession["user"]; } @@ -26,7 +31,10 @@ declare module "next-auth" { declare module "next-auth/jwt" { /** Returned by the `jwt` callback and `getToken`, when using JWT sessions */ interface JWT { - access_token?: string; + accessToken?: string; + accessTokenExpiration?: number; + refreshTokenExpiration?: number; + apiCookies?: string[]; apiUser?: APIUser; } } diff --git a/@types/processenv.d.ts b/@types/processenv.d.ts new file mode 100644 index 0000000..2d75315 --- /dev/null +++ b/@types/processenv.d.ts @@ -0,0 +1,8 @@ +declare namespace NodeJS { + interface ProcessEnv { + NEXTAUTH_SECRET?: string; + NEXTAUTH_URL?: string; + NEXT_PUBLIC_API_BASE_URL?: string; + SERVICE_API_BASE_URL?: string; + } +} diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 84b8e75..527e84f 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -42,9 +42,7 @@ export default async function RootLayout({ children: React.ReactNode; params: { locale: AvailableLanguagesType }; }) { - // eslint-disable-next-line react-hooks/rules-of-hooks - const uncheckedLocale = useLocale(); - const locale = checkLanguage(uncheckedLocale); + const locale = checkLanguage(params.locale); const messages = await getMessages(locale); // Show a 404 error if the user requests an unknown locale diff --git a/app/[locale]/login/components/Form.tsx b/app/[locale]/login/components/Form.tsx index 08098b7..012ef4a 100644 --- a/app/[locale]/login/components/Form.tsx +++ b/app/[locale]/login/components/Form.tsx @@ -3,7 +3,7 @@ import { classNames } from "@/utils/classNames"; import { signIn } from "next-auth/react"; import { useTranslations } from "next-intl"; -import { FormEvent, useRef, useState } from "react"; +import { FormEvent, useLayoutEffect, useRef, useState } from "react"; import LoadingDotsSpinner from "./LoadingDotsSpinner"; import { useRouter } from "next/navigation"; @@ -69,6 +69,12 @@ const Form = ({ callbackUrl }: { callbackUrl?: string }) => { router.push("/register"); }; + useLayoutEffect(() => { + if (emailRef.current) { + emailRef.current.focus(); + } + }, []); + return (
{ )} ref={emailRef} tabIndex={1} - defaultValue="test@test.com" /> {errors.email &&

{errors.email}

} @@ -116,7 +121,6 @@ const Form = ({ callbackUrl }: { callbackUrl?: string }) => { )} ref={passwordRef} tabIndex={2} - defaultValue="abcabcabc123" /> {errors.password &&

{errors.password}

} diff --git a/app/api/radio/system/list/route.ts b/app/api/radio/system/list/route.ts new file mode 100644 index 0000000..d1c7d97 --- /dev/null +++ b/app/api/radio/system/list/route.ts @@ -0,0 +1,28 @@ +import { getAuthFetchHeaders } from "@/lib/server/auth"; + +import { type NextRequest, NextResponse } from "next/server"; + +export async function GET(req: NextRequest) { + const fetchHeaders = await getAuthFetchHeaders(req); + + if (!fetchHeaders) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const systemsRes = await fetch( + `${process.env.SERVICE_API_BASE_URL}/radio/system/list`, + { + method: "GET", + headers: fetchHeaders, + } + ); + + if (!systemsRes.ok) { + return NextResponse.json( + { error: systemsRes.statusText }, + { status: systemsRes.status } + ); + } + + return NextResponse.json(await systemsRes.json()); +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 5616a32..250daa0 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -5,13 +5,6 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; import { Cross2Icon } from "@radix-ui/react-icons"; import { cn } from "@/lib/utils"; -import type { DismissableLayerProps } from "@radix-ui/react-dialog"; - -export type DialogOnEscapeKeyDown = DismissableLayerProps["onEscapeKeyDown"]; -export type DialogOnPointerDown = DismissableLayerProps["onPointerDown"]; -export type DialogOnInteractOutside = - DismissableLayerProps["onInteractOutside"]; - const Dialog = DialogPrimitive.Root; const DialogTrigger = DialogPrimitive.Trigger; diff --git a/config/nextAuthOptions.ts b/config/nextAuthOptions.ts index 9444173..fc1e494 100644 --- a/config/nextAuthOptions.ts +++ b/config/nextAuthOptions.ts @@ -11,7 +11,7 @@ export const OPTIONS: AuthOptions = { CredentialsProvider({ name: "Credentials", credentials: { - username: { + email: { label: "Email", type: "text", placeholder: "jsmith@gmail.com", @@ -24,21 +24,29 @@ export const OPTIONS: AuthOptions = { return null; } - // const tokenRes = await axios.post( - // "https://api/auth/token/", - // { - // email: credentials.username, - // password: credentials.password, - // }, - // { - // withCredentials: true, - // } - // ); + const tokenRes = await fetch( + `${process.env.SERVICE_API_BASE_URL}/auth/token/`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: credentials.email, + password: credentials.password, + }), + } + ); - const tokenRes = { - data: { - access_token: "abc123", - }, + if (!tokenRes.ok) { + return Promise.resolve(null); + } + + const apiToken = (await tokenRes.json()) as { + CSRF_TOKEN: string; + access_token_expiration: string; + refresh_token_expiration: string; + access_token: string; }; const userRes: { data: APIUser } = { @@ -56,18 +64,6 @@ export const OPTIONS: AuthOptions = { }, }; - // if (tokenRes.status === 200) { - // const userRes = await axios.get("https://api/auth/user", { - // headers: { - // Authorization: `Bearer ${tokenRes.data.access_token}`, - // }, - // withCredentials: true, - // }); - - // if (userRes.status === 200) { - // This user object will be returned in the JWT, and will be available in the session - // Include the access_token here so it can be used by your server on future requests - const userData = userRes.data; const user: User = { @@ -75,14 +71,15 @@ export const OPTIONS: AuthOptions = { name: `${userData.first_name} ${userData.last_name}`, email: userData.email, apiUser: userData, - access_token: tokenRes.data.access_token, + accessToken: apiToken.access_token, + accessTokenExpiration: Date.parse(apiToken.access_token_expiration), + refreshTokenExpiration: Date.parse( + apiToken.refresh_token_expiration + ), + apiCookies: tokenRes.headers.getSetCookie(), }; return Promise.resolve(user); - // } - // } - - // return Promise.resolve(null); } catch (err) { console.error(err); return Promise.resolve(null); @@ -100,9 +97,11 @@ export const OPTIONS: AuthOptions = { }, callbacks: { jwt: async ({ token, user }) => { - // if user is defined, it's the sign in process if (user) { - token.access_token = user.access_token; + token.accessToken = user.accessToken; + token.accessTokenExpiration = user.accessTokenExpiration; + token.refreshTokenExpiration = user.refreshTokenExpiration; + token.apiCookies = user.apiCookies; token.apiUser = user.apiUser; token.name = user.name; } @@ -110,8 +109,9 @@ export const OPTIONS: AuthOptions = { return token; }, session: async ({ session, token }) => { - // add access_token to session - session.user.access_token = token.access_token; + session.user.accessToken = token.accessToken; + session.user.accessTokenExpiration = token.accessTokenExpiration; + session.user.refreshTokenExpiration = token.refreshTokenExpiration; session.user.apiUser = token.apiUser; return session; }, diff --git a/lib/server/auth.ts b/lib/server/auth.ts new file mode 100644 index 0000000..2789968 --- /dev/null +++ b/lib/server/auth.ts @@ -0,0 +1,53 @@ +import { getServerSession as nextAuthGetServerSession } from "next-auth"; +import { OPTIONS } from "@/config/nextAuthOptions"; +import { getToken } from "next-auth/jwt"; +import { parseRefreshToken } from "@/utils/fetchUtils"; + +import type { + GetServerSidePropsContext, + NextApiRequest, + NextApiResponse, +} from "next"; +import type { NextRequest } from "next/server"; + +export async function getServerSession( + ...args: + | [GetServerSidePropsContext["req"], GetServerSidePropsContext["res"]] + | [NextApiRequest, NextApiResponse] + | [] +) { + return await nextAuthGetServerSession(...args, OPTIONS); +} + +export async function getServerJWT( + req: GetServerSidePropsContext["req"] | NextRequest | NextApiRequest +) { + return await getToken({ req, secret: process.env.JWT_SECRET }); +} + +export type AuthFetchHeaders = + | { Authorization: string } + | { Authorization: string; Cookie: string } + | undefined; + +export async function getAuthFetchHeaders( + req: GetServerSidePropsContext["req"] | NextRequest | NextApiRequest +): Promise { + const session = await getServerSession(); + const token = await getServerJWT(req); + + if (!session || !token) { + return undefined; + } + + const refreshTokenCookie = parseRefreshToken(token.apiCookies); + + return refreshTokenCookie + ? { + Authorization: `Bearer ${session.user.accessToken}`, + Cookie: refreshTokenCookie, + } + : { + Authorization: `Bearer ${token.accessToken}`, + }; +} diff --git a/middleware.ts b/middleware.ts index df21bca..46387fa 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,23 +1,165 @@ import { withAuth } from "next-auth/middleware"; import createIntlMiddleware from "next-intl/middleware"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { availableLanguages } from "./config/i18nProperties"; +import { JWT, encode, getToken } from "next-auth/jwt"; +import { parseRefreshToken } from "./utils/fetchUtils"; const locales = [...availableLanguages]; const publicPages = ["/login", "/register"]; +export const authSecret = process.env.NEXTAUTH_SECRET; +export const signinSubUrl = "/login"; +export const sessionTimeout = 60 * 60 * 24 * 30; // 30 days +export const tokenRefreshBufferSeconds = 300; // 5 minutes +export const sessionSecure = + process.env.NEXTAUTH_URL?.startsWith("https://") ?? false; +export const sessionCookieName = sessionSecure + ? "__Secure-next-auth.session-token" + : "next-auth.session-token"; + +let isRefreshing = false; + const intlMiddleware = createIntlMiddleware({ locales, localePrefix: "as-needed", defaultLocale: "en", }); +const requireEnvVars = () => { + if (process.env.NEXTAUTH_SECRET === undefined) { + throw new Error("Environmental variable NEXTAUTH_SECRET is not defined!"); + } + + if (process.env.NEXTAUTH_URL === undefined) { + throw new Error("Environmental variable NEXTAUTH_URL is not defined!"); + } + + if (process.env.NEXT_PUBLIC_API_BASE_URL === undefined) { + throw new Error( + "Environmental variable NEXT_PUBLIC_API_BASE_URL is not defined!" + ); + } + + if (process.env.SERVICE_API_BASE_URL === undefined) { + throw new Error( + "Environmental variable SERVICE_API_BASE_URL is not defined!" + ); + } +}; + +const shouldUpdateToken = (token: JWT) => + !token.accessTokenExpiration || Date.now() >= token.accessTokenExpiration; + +const refreshAccessToken = async (token: JWT): Promise => { + if (isRefreshing) { + return token; + } + + isRefreshing = true; + + try { + const refreshTokenCookie = parseRefreshToken(token.apiCookies); + + if ( + !token.refreshTokenExpiration || + Date.now() >= token.refreshTokenExpiration || + !refreshTokenCookie + ) { + throw new Error("Access token has expired with no valid refresh token."); + } + + const tokenRenewalRes = await fetch( + `${process.env.SERVICE_API_BASE_URL}/auth/token/refresh-token/`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.accessToken}`, + Cookie: refreshTokenCookie, + }, + } + ); + + if (!tokenRenewalRes.ok) { + throw new Error( + `Token refresh failed with status: ${tokenRenewalRes.status}` + ); + } + + const apiToken = (await tokenRenewalRes.json()) as { + CSRF_TOKEN: string; + access_token: string; + access_token_expiration: string; + }; + + const newToken = { + ...token, + accessToken: apiToken.access_token, + accessTokenExpiration: Date.parse(apiToken.access_token_expiration), + }; + + return newToken; + } catch { + // There was an error refreshing the token + } finally { + isRefreshing = false; + } + + return token; +}; + +const updateCookie = ( + sessionToken: string | null, + req: NextRequest, + res: NextResponse +) => { + if (sessionToken) { + // Set the session token in the request and response cookies for a valid session + req.cookies.set(sessionCookieName, sessionToken); + res.cookies.set(sessionCookieName, sessionToken, { + httpOnly: true, + maxAge: sessionTimeout, + secure: sessionSecure, + sameSite: "lax", + }); + } else { + req.cookies.delete(sessionCookieName); + return NextResponse.redirect(new URL(signinSubUrl, req.url)); + } + + return res; +}; + const authMiddleware = withAuth( // Note that this callback is only invoked if // the `authorized` callback has returned `true` // and not for pages listed in `pages`. - function onSuccess(req) { - return intlMiddleware(req); + async function onSuccess(req) { + const token = await getToken({ req, secret: authSecret }); + + if (!token) { + return NextResponse.redirect(new URL(signinSubUrl, req.url)); + } + + const reponseNext = intlMiddleware(req); + + if (shouldUpdateToken(token)) { + try { + const newToken = await refreshAccessToken(token); + + const newSessionToken = await encode({ + secret: authSecret as string, + token: newToken, + maxAge: sessionTimeout, + }); + + return updateCookie(newSessionToken, req, reponseNext); + } catch (error) { + return updateCookie(null, req, reponseNext); + } + } + + return reponseNext; }, { callbacks: { @@ -26,7 +168,9 @@ const authMiddleware = withAuth( } ); -export default function middleware(req: NextRequest) { +export default async function middleware(req: NextRequest) { + requireEnvVars(); + const publicPathnameRegex = RegExp( `^(/(${locales.join("|")}))?(${publicPages.join("|")})/?$`, "i" diff --git a/utils/fetchUtils.ts b/utils/fetchUtils.ts new file mode 100644 index 0000000..d42b186 --- /dev/null +++ b/utils/fetchUtils.ts @@ -0,0 +1,13 @@ +export const parseRefreshToken = (cookies: string[] | undefined) => { + if (!cookies) { + return undefined; + } + + for (const cookie of cookies) { + if (cookie.trim().startsWith("refresh-token")) { + return cookie; + } + } + + return undefined; +};