From 4efd88a89d96b40c1a3d542870fef0681abca696 Mon Sep 17 00:00:00 2001 From: Fredrik Monsen Date: Thu, 12 Sep 2024 09:23:10 +0200 Subject: [PATCH 01/10] temp --- next.config.mjs | 6 ++ src/app/AuthProvider.tsx | 154 ++++++++++++++++++++++++++++++++ src/app/providers.tsx | 9 +- src/components/Header.tsx | 19 ++-- src/components/LogoutButton.tsx | 19 ++++ src/components/UserDetails.tsx | 30 +++++++ src/lib/keycloak.ts | 7 ++ src/middleware.ts | 27 ++++++ src/models/User.ts | 10 +++ src/utils/storageUtil.ts | 16 ++++ 10 files changed, 285 insertions(+), 12 deletions(-) create mode 100644 src/app/AuthProvider.tsx create mode 100644 src/components/LogoutButton.tsx create mode 100644 src/components/UserDetails.tsx create mode 100644 src/lib/keycloak.ts create mode 100644 src/middleware.ts create mode 100644 src/models/User.ts create mode 100644 src/utils/storageUtil.ts diff --git a/next.config.mjs b/next.config.mjs index 26a3478..3a82950 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,7 @@ import {withSentryConfig} from "@sentry/nextjs"; /** @type {import('next').NextConfig} */ const nextConfig = { + reactStrictMode: false, output: "standalone", basePath: process.env.NEXT_PUBLIC_BASE_PATH, async rewrites() { @@ -10,6 +11,11 @@ const nextConfig = { destination: `${process.env.CATALOGUE_API_PATH}/:path*`, basePath: false }, + { + source: `${process.env.NEXT_PUBLIC_BASE_PATH}/api/ext/auth/:path*`, + destination: 'http://localhost:8087/tekst-auth/v1/auth/:path*', + basePath: false + } ]; } }; diff --git a/src/app/AuthProvider.tsx b/src/app/AuthProvider.tsx new file mode 100644 index 0000000..8f4c6ce --- /dev/null +++ b/src/app/AuthProvider.tsx @@ -0,0 +1,154 @@ +'use client'; + +import {createContext, useCallback, useContext, useEffect, useState} from 'react'; +import {useRouter} from 'next/navigation'; +import keycloakConfig from '@/lib/keycloak'; +import {removeSessionStorageItem, setSessionStorageItem} from '@/utils/storageUtil'; +// import {cookies} from 'next/headers'; + +interface User { + groups?: string[]; + name?: string; + accessToken?: string; + expires?: Date; + refreshToken?: string; + refreshExpires?: Date; +} + +interface IAuthContext { + authenticated: boolean; + user?: User; + logout?: () => void; +} + +const AuthContext = createContext({ + authenticated: false, + logout: () => {} +}); + +export const AuthProvider = ({children}: { children: React.ReactNode }) => { + const router = useRouter(); + + const [authenticated, setAuthenticated] = useState(false); + const [user, setUser] = useState(); + const [intervalId, setIntervalId] = useState(); + + useEffect(() => { + const codeInParams = new URLSearchParams(window.location.search).get('code'); + if (codeInParams) { + const redirectUrl = new URLSearchParams({redirectUrl: trimRedirectUrl(window.location.href)}).toString(); + const fetchToken = async (): Promise => { + const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/ext/auth/login?${redirectUrl}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: codeInParams + + }); + return await data.json() as User; + }; + + void fetchToken().then((token: User) => { + handleIsAuthenticated(token); + router.push('/'); + }); + } else if (user) { + if (user.expires && new Date(user.expires) > new Date()) { + handleIsAuthenticated(user); + } + } else { + handleNotAuthenticated(); + const currentUrl = window.location.href; + router.push( + `${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` + + `?client_id=${keycloakConfig.clientId}&redirect_uri=${currentUrl}&response_type=code&scope=openid`); + } + }, []); + + const handleIsAuthenticated = (token: User) => { + if (token) { + setUser(token); + setAuthenticated(true); + setSessionStorageItem('accessToken', token?.accessToken ?? ''); + } + }; + + const handleNotAuthenticated = useCallback(() => { + setAuthenticated(false); + setUser(undefined); + if (intervalId) { + clearInterval(intervalId); + } + removeSessionStorageItem('accessToken'); + }, [intervalId]); + + const refreshToken = useCallback(async () => { + const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/ext/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: user?.refreshToken + }); + return await data.json() as User; + }, []); + + const setIntervalToRefreshAccessToken = useCallback(async () => { + if (user?.expires && !intervalId) { + const expiryTime = new Date(user?.expires).getTime() - Date.now(); + if (expiryTime < 1000 * 60 * 4) { + await refreshToken(); + } + setIntervalId(window.setInterval(() => { + void refreshToken().then((newUser: User) => { + handleIsAuthenticated(newUser); + }) + .catch((e: Error) => { + console.error('Failed to refresh token: ', e.message); + handleNotAuthenticated(); + }); + }, (1000 * 60 * 4.75))); // Refresh every 4.75 minutes (fifteen seconds before expiry) + } + }, [handleNotAuthenticated, intervalId, refreshToken, user?.expires]); + + useEffect(() => { + void setIntervalToRefreshAccessToken(); + }, [setIntervalToRefreshAccessToken]); + + const trimRedirectUrl= (returnUrl: string): string => { + returnUrl = returnUrl.split('?')[0]; + if (returnUrl.at(-1) === '/') { + returnUrl = returnUrl.slice(0, -1); + } + return returnUrl; + }; + + const logout = async () => { + await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/ext/auth/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: user?.refreshToken + }).then(() => { + handleNotAuthenticated(); + window.location.reload(); + }); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => useContext(AuthContext); \ No newline at end of file diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 8b01cc5..26220aa 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,11 +1,14 @@ 'use client'; import {NextUIProvider} from '@nextui-org/react'; +import {AuthProvider} from '@/app/AuthProvider'; export function Providers({children}: { children: React.ReactNode }) { return ( - - {children} - + + + {children} + + ); } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6c4e42f..330621b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -2,12 +2,15 @@ import {Link, Navbar, NavbarBrand, NavbarContent, NavbarItem} from '@nextui-org/react'; import React from 'react'; -import {Button} from '@nextui-org/button'; import {usePathname, useRouter} from 'next/navigation'; import SearchBar from '@/components/SearchBar'; import Image from 'next/image'; +import LogoutButton from '@/components/LogoutButton'; +import {useAuth} from '@/app/AuthProvider'; +import {UserDetails} from '@/components/UserDetails'; export default function Header() { + const { authenticated , user } = useAuth(); const router = useRouter(); const pathname = usePathname() || ''; @@ -33,14 +36,12 @@ export default function Header() { } - + { authenticated ? ( + <> + + + + ) : <>} diff --git a/src/components/LogoutButton.tsx b/src/components/LogoutButton.tsx new file mode 100644 index 0000000..f923975 --- /dev/null +++ b/src/components/LogoutButton.tsx @@ -0,0 +1,19 @@ +import {useAuth} from '@/app/AuthProvider'; +import {Button} from '@nextui-org/react'; +import {FaSignOutAlt} from 'react-icons/fa'; + +const LogoutButton = () => { + const { logout } = useAuth(); + + return ( + + ); +}; + +export default LogoutButton; \ No newline at end of file diff --git a/src/components/UserDetails.tsx b/src/components/UserDetails.tsx new file mode 100644 index 0000000..3b66c64 --- /dev/null +++ b/src/components/UserDetails.tsx @@ -0,0 +1,30 @@ +'use client'; + +import {User} from '@nextui-org/user'; +import {useEffect, useState} from 'react'; + +interface UserDetailsProps { + name: string; + className?: string; +} + +export const UserDetails: React.FC = ({ name, className }) => { + const [initials, setInitials] = useState(''); + + useEffect(() => { + const tempInitials = name.split(' ').map(n => n[0]?.toUpperCase()).join(''); + setInitials(tempInitials); + }, [name]); + + return ( +
+ +
+ ); +}; \ No newline at end of file diff --git a/src/lib/keycloak.ts b/src/lib/keycloak.ts new file mode 100644 index 0000000..7cf876a --- /dev/null +++ b/src/lib/keycloak.ts @@ -0,0 +1,7 @@ +const keycloakConfig = { + url: process.env.NEXT_PUBLIC_KEYCLOAK_BASE_URL!, + realm: process.env.NEXT_PUBLIC_KEYCLOAK_REALM!, + clientId: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID! +}; + +export default keycloakConfig; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..6380e57 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import {getSessionStorageItem} from '@/utils/storageUtil'; + + +export default function middleware(req: NextRequest) { + if (req.method === 'GET') { + return NextResponse.next(); + } + + console.log(`Middleware 1: ${req.method}: ${req.url}`); + + const requestHeaders = new Headers(req.headers); + const bearerToken = getSessionStorageItem('accessToken'); + if (bearerToken) { + requestHeaders.set('Authorization', `Bearer ${bearerToken}`); + } + return NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); +} + +export const config = { + matcher: 'shamalamadingdong/api/:path*', +}; \ No newline at end of file diff --git a/src/models/User.ts b/src/models/User.ts new file mode 100644 index 0000000..02df875 --- /dev/null +++ b/src/models/User.ts @@ -0,0 +1,10 @@ +interface User { + groups?: string[]; + name?: string; + accessToken?: string; + expires?: Date; + refreshToken?: string; + refreshExpires?: Date; +} + +export default User; \ No newline at end of file diff --git a/src/utils/storageUtil.ts b/src/utils/storageUtil.ts new file mode 100644 index 0000000..3f38eac --- /dev/null +++ b/src/utils/storageUtil.ts @@ -0,0 +1,16 @@ + +export const getSessionStorageItem = (key: string): string | null => { + return sessionStorage.getItem(prefixedKey(key)); +}; + +export const setSessionStorageItem = (key: string, value: string): void => { + sessionStorage.setItem(prefixedKey(key), value); +}; + +export const removeSessionStorageItem = (key: string): void => { + sessionStorage.removeItem(prefixedKey(key)); +}; + +const prefixedKey = (key: string): string => { + return `hugin_${key}`; +}; From 64698057ddf2c6db9d7462a7349e70f2d0ea5aed Mon Sep 17 00:00:00 2001 From: Fredrik Monsen Date: Thu, 12 Sep 2024 15:04:27 +0200 Subject: [PATCH 02/10] Store usertoken in cookie. Abstract data fetching from provider. --- next.config.mjs | 5 --- src/app/AuthProvider.tsx | 63 +++++++--------------------- src/app/api/auth/refresh/route.ts | 29 +++++++++++++ src/app/api/auth/signin/route.ts | 43 +++++++++++++++++++ src/app/api/auth/signout/route.ts | 26 ++++++++++++ src/models/ProblemDetail.ts | 10 +++++ src/models/{User.ts => UserToken.ts} | 9 +++- src/services/auth.data.ts | 49 ++++++++++++++++++++++ src/utils/cookieUtils.ts | 20 +++++++++ src/utils/storageUtil.ts | 16 ------- 10 files changed, 198 insertions(+), 72 deletions(-) create mode 100644 src/app/api/auth/refresh/route.ts create mode 100644 src/app/api/auth/signin/route.ts create mode 100644 src/app/api/auth/signout/route.ts create mode 100644 src/models/ProblemDetail.ts rename src/models/{User.ts => UserToken.ts} (62%) create mode 100644 src/services/auth.data.ts create mode 100644 src/utils/cookieUtils.ts delete mode 100644 src/utils/storageUtil.ts diff --git a/next.config.mjs b/next.config.mjs index 3a82950..52fdd83 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -10,11 +10,6 @@ const nextConfig = { source: `${process.env.NEXT_PUBLIC_BASE_PATH}/api/catalog/:path*`, destination: `${process.env.CATALOGUE_API_PATH}/:path*`, basePath: false - }, - { - source: `${process.env.NEXT_PUBLIC_BASE_PATH}/api/ext/auth/:path*`, - destination: 'http://localhost:8087/tekst-auth/v1/auth/:path*', - basePath: false } ]; } diff --git a/src/app/AuthProvider.tsx b/src/app/AuthProvider.tsx index 8f4c6ce..06a9abd 100644 --- a/src/app/AuthProvider.tsx +++ b/src/app/AuthProvider.tsx @@ -3,21 +3,12 @@ import {createContext, useCallback, useContext, useEffect, useState} from 'react'; import {useRouter} from 'next/navigation'; import keycloakConfig from '@/lib/keycloak'; -import {removeSessionStorageItem, setSessionStorageItem} from '@/utils/storageUtil'; -// import {cookies} from 'next/headers'; - -interface User { - groups?: string[]; - name?: string; - accessToken?: string; - expires?: Date; - refreshToken?: string; - refreshExpires?: Date; -} +import {UserToken, User} from '@/models/UserToken'; +import {refresh, signIn, signOut} from '@/services/auth.data'; interface IAuthContext { authenticated: boolean; - user?: User; + user?: UserToken; logout?: () => void; } @@ -37,19 +28,7 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { const codeInParams = new URLSearchParams(window.location.search).get('code'); if (codeInParams) { const redirectUrl = new URLSearchParams({redirectUrl: trimRedirectUrl(window.location.href)}).toString(); - const fetchToken = async (): Promise => { - const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/ext/auth/login?${redirectUrl}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: codeInParams - - }); - return await data.json() as User; - }; - - void fetchToken().then((token: User) => { + void signIn(codeInParams, redirectUrl).then((token: User) => { handleIsAuthenticated(token); router.push('/'); }); @@ -66,11 +45,10 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { } }, []); - const handleIsAuthenticated = (token: User) => { - if (token) { - setUser(token); + const handleIsAuthenticated = (newUser: User) => { + if (newUser) { + setUser(newUser); setAuthenticated(true); - setSessionStorageItem('accessToken', token?.accessToken ?? ''); } }; @@ -80,24 +58,16 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { if (intervalId) { clearInterval(intervalId); } - removeSessionStorageItem('accessToken'); }, [intervalId]); const refreshToken = useCallback(async () => { - const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/ext/auth/refresh`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: user?.refreshToken - }); - return await data.json() as User; + return refresh(); }, []); const setIntervalToRefreshAccessToken = useCallback(async () => { if (user?.expires && !intervalId) { const expiryTime = new Date(user?.expires).getTime() - Date.now(); - if (expiryTime < 1000 * 60 * 4) { + if (expiryTime < 1000 * 60 * 4.75) { await refreshToken(); } setIntervalId(window.setInterval(() => { @@ -125,16 +95,11 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { }; const logout = async () => { - await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/ext/auth/logout`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: user?.refreshToken - }).then(() => { - handleNotAuthenticated(); - window.location.reload(); - }); + await signOut() + .then(() => { + handleNotAuthenticated(); + window.location.reload(); + }); }; return ( diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..000b45c --- /dev/null +++ b/src/app/api/auth/refresh/route.ts @@ -0,0 +1,29 @@ +import {NextRequest, NextResponse} from 'next/server'; +import {User, UserToken} from '@/models/UserToken'; +import {getRefreshToken, setUserCookie} from '@/utils/cookieUtils'; + +export async function POST(req: NextRequest) { + const refreshToken = getRefreshToken(); + if (!refreshToken) { + return NextResponse.json({error: 'No user token found'}, {status: 401}); + } + + const data = await fetch(`${process.env.AUTH_API}/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: refreshToken + }); + + const newToken = await data.json() as UserToken; + + if (!newToken || !newToken.name || !newToken.expires) { + return NextResponse.json({error: 'Failed to refresh token'}, {status: 500}); + } + + setUserCookie(newToken); + + const user: User = {name: newToken.name, expires: newToken.expires}; + return NextResponse.json(user, {status: 200}); +} \ No newline at end of file diff --git a/src/app/api/auth/signin/route.ts b/src/app/api/auth/signin/route.ts new file mode 100644 index 0000000..6b16603 --- /dev/null +++ b/src/app/api/auth/signin/route.ts @@ -0,0 +1,43 @@ +import {NextRequest, NextResponse} from 'next/server'; +import {User, UserToken} from '@/models/UserToken'; +import {ProblemDetail} from '@/models/ProblemDetail'; +import {setUserCookie} from '@/utils/cookieUtils'; + +interface LoginRequest { + code: string; + redirectUrl: string; +} + +export async function POST(req: NextRequest) { + const {code, redirectUrl} = await req.json() as LoginRequest; + const data = await fetch(`${process.env.AUTH_API}/login?${redirectUrl}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: code + }) + .then(async response => { + if (!response.ok) { + const problemDetail = await response.json() as ProblemDetail; + return NextResponse.json({error: problemDetail.detail}, {status: problemDetail.status}); + } + return response; + }); + + if (data instanceof NextResponse) { + return data; + } + + const userToken = await data.json() as UserToken; + + if (!userToken || !userToken.name || !userToken.expires) { + return NextResponse.json({error: 'Failed to authenticate'}, {status: 500}); + } + + setUserCookie(userToken); + + const user: User = {name: userToken.name, expires: userToken.expires}; + return NextResponse.json(user, {status: 200}); + +} \ No newline at end of file diff --git a/src/app/api/auth/signout/route.ts b/src/app/api/auth/signout/route.ts new file mode 100644 index 0000000..66acb8d --- /dev/null +++ b/src/app/api/auth/signout/route.ts @@ -0,0 +1,26 @@ +import {NextRequest, NextResponse} from 'next/server'; +import {cookies} from 'next/headers'; +import {getRefreshToken} from '@/utils/cookieUtils'; + +export async function POST(req: NextRequest) { + const refreshToken = getRefreshToken(); + if (!refreshToken) { + return NextResponse.json({error: 'No user token found'}, {status: 401}); + } + + return await fetch(`${process.env.AUTH_API}/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: refreshToken + }).then(res => { + if (!res.ok) { + return NextResponse.json({error: 'Failed to logout'}, {status: res.status}); + } + cookies().delete('user'); + return NextResponse.json({message: 'Logged out successfully'}, {status: 200}); + }).catch((error: Error) => { + return NextResponse.json({error: `Failed to logout: ${error.message}`}, {status: 500}); + }); +} diff --git a/src/models/ProblemDetail.ts b/src/models/ProblemDetail.ts new file mode 100644 index 0000000..ca4748f --- /dev/null +++ b/src/models/ProblemDetail.ts @@ -0,0 +1,10 @@ +interface ProblemDetail { + type: string; + title: string; + status: number; + detail: string; + instance: string; + timestamp: string; +} + +export type { ProblemDetail }; \ No newline at end of file diff --git a/src/models/User.ts b/src/models/UserToken.ts similarity index 62% rename from src/models/User.ts rename to src/models/UserToken.ts index 02df875..083581f 100644 --- a/src/models/User.ts +++ b/src/models/UserToken.ts @@ -1,4 +1,4 @@ -interface User { +interface UserToken { groups?: string[]; name?: string; accessToken?: string; @@ -7,4 +7,9 @@ interface User { refreshExpires?: Date; } -export default User; \ No newline at end of file +interface User { + name: string; + expires: Date; +} + +export type { User, UserToken }; \ No newline at end of file diff --git a/src/services/auth.data.ts b/src/services/auth.data.ts new file mode 100644 index 0000000..bbefa86 --- /dev/null +++ b/src/services/auth.data.ts @@ -0,0 +1,49 @@ +import {User} from '@/models/UserToken'; +import {ProblemDetail} from '@/models/ProblemDetail'; +import {NextResponse} from 'next/server'; + +export async function signIn(code: string, redirectUrl: string): Promise { + const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/auth/signin`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({code, redirectUrl}) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to authenticate'); + } + return response; + }); + return await data.json() as User; +} + +export async function signOut(): Promise { + return await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/auth/signout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }) + .then(async response => { + if (!response.ok) { + const problemDetail = await response.json() as ProblemDetail; + return NextResponse.json({error: problemDetail.detail}, {status: problemDetail.status}); + } + return NextResponse.json({message: 'Logged out successfully'}, {status: 204}); + }) + .catch((error: Error) => { + return NextResponse.json({error: `Failed to logout: ${error.message}`}, {status: 500}); + }); +} + +export async function refresh(): Promise { + const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + return await data.json() as User; +} \ No newline at end of file diff --git a/src/utils/cookieUtils.ts b/src/utils/cookieUtils.ts new file mode 100644 index 0000000..3cf250d --- /dev/null +++ b/src/utils/cookieUtils.ts @@ -0,0 +1,20 @@ +import {cookies} from 'next/headers'; +import {UserToken} from '@/models/UserToken'; + +export function getRefreshToken(): string | undefined { + const userCookieValue = cookies().get('user')?.value; + if (!userCookieValue) { + return undefined; + } + const userToken = JSON.parse(userCookieValue) as UserToken; + return userToken.refreshToken; +} + +export function setUserCookie(user: UserToken) { + cookies().set('user', JSON.stringify(user), { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/' + }); +} \ No newline at end of file diff --git a/src/utils/storageUtil.ts b/src/utils/storageUtil.ts deleted file mode 100644 index 3f38eac..0000000 --- a/src/utils/storageUtil.ts +++ /dev/null @@ -1,16 +0,0 @@ - -export const getSessionStorageItem = (key: string): string | null => { - return sessionStorage.getItem(prefixedKey(key)); -}; - -export const setSessionStorageItem = (key: string, value: string): void => { - sessionStorage.setItem(prefixedKey(key), value); -}; - -export const removeSessionStorageItem = (key: string): void => { - sessionStorage.removeItem(prefixedKey(key)); -}; - -const prefixedKey = (key: string): string => { - return `hugin_${key}`; -}; From 2e36812fd921aaebeb8a0550378de7267352961e Mon Sep 17 00:00:00 2001 From: Fredrik Monsen Date: Fri, 13 Sep 2024 12:10:34 +0200 Subject: [PATCH 03/10] Protect routes in middleware --- src/app/AuthProvider.tsx | 11 +++++--- src/app/api/auth/signin/route.ts | 3 +-- src/app/api/auth/signout/route.ts | 5 ++-- src/app/api/title/[id]/box/route.ts | 1 - src/middleware.ts | 41 ++++++++++++++++++----------- src/models/UserToken.ts | 26 +++++++++++++----- src/utils/cookieUtils.ts | 15 ++++++++--- 7 files changed, 66 insertions(+), 36 deletions(-) diff --git a/src/app/AuthProvider.tsx b/src/app/AuthProvider.tsx index 06a9abd..ac29295 100644 --- a/src/app/AuthProvider.tsx +++ b/src/app/AuthProvider.tsx @@ -3,12 +3,12 @@ import {createContext, useCallback, useContext, useEffect, useState} from 'react'; import {useRouter} from 'next/navigation'; import keycloakConfig from '@/lib/keycloak'; -import {UserToken, User} from '@/models/UserToken'; +import {User} from '@/models/UserToken'; import {refresh, signIn, signOut} from '@/services/auth.data'; interface IAuthContext { authenticated: boolean; - user?: UserToken; + user?: User; logout?: () => void; } @@ -58,7 +58,11 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { if (intervalId) { clearInterval(intervalId); } - }, [intervalId]); + const currentUrl = window.location.href; + router.push( + `${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` + + `?client_id=${keycloakConfig.clientId}&redirect_uri=${currentUrl}&response_type=code&scope=openid`); + }, [intervalId, router]); const refreshToken = useCallback(async () => { return refresh(); @@ -98,7 +102,6 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { await signOut() .then(() => { handleNotAuthenticated(); - window.location.reload(); }); }; diff --git a/src/app/api/auth/signin/route.ts b/src/app/api/auth/signin/route.ts index 6b16603..6202d7f 100644 --- a/src/app/api/auth/signin/route.ts +++ b/src/app/api/auth/signin/route.ts @@ -39,5 +39,4 @@ export async function POST(req: NextRequest) { const user: User = {name: userToken.name, expires: userToken.expires}; return NextResponse.json(user, {status: 200}); - -} \ No newline at end of file +} diff --git a/src/app/api/auth/signout/route.ts b/src/app/api/auth/signout/route.ts index 66acb8d..45398e9 100644 --- a/src/app/api/auth/signout/route.ts +++ b/src/app/api/auth/signout/route.ts @@ -1,6 +1,5 @@ import {NextRequest, NextResponse} from 'next/server'; -import {cookies} from 'next/headers'; -import {getRefreshToken} from '@/utils/cookieUtils'; +import {deleteUserToken, getRefreshToken} from '@/utils/cookieUtils'; export async function POST(req: NextRequest) { const refreshToken = getRefreshToken(); @@ -18,7 +17,7 @@ export async function POST(req: NextRequest) { if (!res.ok) { return NextResponse.json({error: 'Failed to logout'}, {status: res.status}); } - cookies().delete('user'); + deleteUserToken(); return NextResponse.json({message: 'Logged out successfully'}, {status: 200}); }).catch((error: Error) => { return NextResponse.json({error: `Failed to logout: ${error.message}`}, {status: 500}); diff --git a/src/app/api/title/[id]/box/route.ts b/src/app/api/title/[id]/box/route.ts index bc24f96..e00081f 100644 --- a/src/app/api/title/[id]/box/route.ts +++ b/src/app/api/title/[id]/box/route.ts @@ -59,7 +59,6 @@ export async function POST(req: NextRequest, params: IdParams): Promise { - console.log('PATCH /title/[id]/box'); const id = +params.params.id; const { boxId } = await req.json() as {boxId: string; startDate: string}; diff --git a/src/middleware.ts b/src/middleware.ts index 6380e57..07c6db2 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,27 +1,38 @@ -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; -import {getSessionStorageItem} from '@/utils/storageUtil'; +import {NextRequest, NextResponse} from 'next/server'; +import {getUserToken} from '@/utils/cookieUtils'; +import {UserToken} from '@/models/UserToken'; +const protectedPaths = ['/api/title', '/api/box', '/api/newspaper']; +const requiredRoles = ['T_relation_avis']; // TODO: Fiks rolle når den er opprettet export default function middleware(req: NextRequest) { - if (req.method === 'GET') { + const path = req.nextUrl.pathname; + const isProtected = protectedPaths.some(protectedPath => path.includes(protectedPath)); + const userToken = getUserToken(); + const authorized = isAuthorized(userToken); + + if (!isProtected) { + return NextResponse.next(); + } + + if (isProtected && authorized) { return NextResponse.next(); } - console.log(`Middleware 1: ${req.method}: ${req.url}`); + return NextResponse.json({error: 'Unauthorized'}, {status: 401}); +} - const requestHeaders = new Headers(req.headers); - const bearerToken = getSessionStorageItem('accessToken'); - if (bearerToken) { - requestHeaders.set('Authorization', `Bearer ${bearerToken}`); +function isAuthorized(token?: UserToken) { + if (token) { + if (token.refreshExpires.getTime() > Date.now()) { + return requiredRoles.some(role => token.groups.includes(role)); + } } - return NextResponse.next({ - request: { - headers: requestHeaders, - }, - }); + return false; } + export const config = { - matcher: 'shamalamadingdong/api/:path*', + // Run on all routes except these + matcher: ['/((?!_next/static|_next/image|.*\\.png$|.*\\.ico$|.*\\.svg$|api/auth).*)'] }; \ No newline at end of file diff --git a/src/models/UserToken.ts b/src/models/UserToken.ts index 083581f..242e622 100644 --- a/src/models/UserToken.ts +++ b/src/models/UserToken.ts @@ -1,15 +1,27 @@ interface UserToken { - groups?: string[]; - name?: string; - accessToken?: string; - expires?: Date; - refreshToken?: string; - refreshExpires?: Date; + groups: string[]; + name: string; + accessToken: string; + expires: Date; + refreshToken: string; + refreshExpires: Date; } +const userTokenBuilder = (userToken: UserToken): UserToken => { + return { + groups: userToken.groups, + name: userToken.name, + accessToken: userToken.accessToken, + expires: new Date(userToken.expires), + refreshToken: userToken.refreshToken, + refreshExpires: new Date(userToken.refreshExpires) + }; +}; + interface User { name: string; expires: Date; } -export type { User, UserToken }; \ No newline at end of file +export type { User, UserToken }; +export { userTokenBuilder }; \ No newline at end of file diff --git a/src/utils/cookieUtils.ts b/src/utils/cookieUtils.ts index 3cf250d..7a00699 100644 --- a/src/utils/cookieUtils.ts +++ b/src/utils/cookieUtils.ts @@ -1,13 +1,20 @@ import {cookies} from 'next/headers'; -import {UserToken} from '@/models/UserToken'; +import {UserToken, userTokenBuilder} from '@/models/UserToken'; -export function getRefreshToken(): string | undefined { +export function getUserToken(): UserToken | undefined { const userCookieValue = cookies().get('user')?.value; if (!userCookieValue) { return undefined; } - const userToken = JSON.parse(userCookieValue) as UserToken; - return userToken.refreshToken; + return userTokenBuilder(JSON.parse(userCookieValue) as UserToken); +} + +export function getRefreshToken(): string | undefined { + return getUserToken()?.refreshToken; +} + +export function deleteUserToken() { + cookies().delete('user'); } export function setUserCookie(user: UserToken) { From 6a9c70a4a21ad8d2fca355c5e53ca06aef40c4f3 Mon Sep 17 00:00:00 2001 From: Fredrik Monsen Date: Fri, 13 Sep 2024 12:39:14 +0200 Subject: [PATCH 04/10] update readme, cleanup --- .env.example | 1 + README.md | 17 +++++++++-------- next.config.mjs | 1 - src/app/api/auth/refresh/route.ts | 2 +- src/app/api/auth/signin/route.ts | 2 +- src/app/api/auth/signout/route.ts | 2 +- 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 331c5a7..352d8c1 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ NEXT_PUBLIC_BASE_PATH=/hugin CATALOGUE_API_PATH=http://localhost:8087/bikube DATABASE_URL='' +AUTH_API=http://localhost:8088/auth diff --git a/README.md b/README.md index 25411a0..f7589a4 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,15 @@ For å kjøre lokalt må du sette de nødvendige miljøvariablene: cp .env.example .env.local ``` -| Variabelnavn | Standardverdi | Beskrivelse | -|------------------------------|------------------------------|-----------------------------------------------------------------------------------------------------------------------------| -| NEXT_PUBLIC_BASE_PATH | /hugin | Base path for applikasjonen | -| CATALOGUE_API_PATH | http://localhost:8087/bikube | Sti til [katalog APIet ](https://github.com/NationalLibraryOfNorway/bikube)
Må starte med `http://` eller `https://` | -| DATABASE_URL | | URL til databasen (se mer info i eget avsnitt under) | -| KEYCLOAK_TEKST_URL | | Url til keycloak-tekst (inkl. realm om open-idconnect, eks. https://mysite.com/authn/realms/myRealm/protocol/openid-connect | -| KEYCLOAK_TEKST_CLIENT_ID | | Client ID i keycloak-tekst | -| KEYCLOAK_TEKST_CLIENT_SECRET | | Client secret i keycloak-tekst | +| Variabelnavn | Standardverdi | Beskrivelse | +|------------------------------|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------| +| NEXT_PUBLIC_BASE_PATH | /hugin | Base path for applikasjonen | +| CATALOGUE_API_PATH | http://localhost:8087/bikube | Sti til [katalog APIet ](https://github.com/NationalLibraryOfNorway/bikube)
Må starte med `http://` eller `https://` | +| AUTH_API_PATH | http://localhost:8080/tekst-auth | Sti til [autentiserings APIet](https://github.com/NationalLibraryOfNorway/tekst-auth)
Må starte med `http://` eller `https://` | +| DATABASE_URL | | URL til databasen (se mer info i eget avsnitt under) | +| KEYCLOAK_TEKST_URL | | Url til keycloak-tekst (inkl. realm om open-idconnect, eks. https://mysite.com/authn/realms/myRealm/protocol/openid-connect | +| KEYCLOAK_TEKST_CLIENT_ID | | Client ID i keycloak-tekst | +| KEYCLOAK_TEKST_CLIENT_SECRET | | Client secret i keycloak-tekst | Deretter må du kjøre følgende kommandoer: ```bash diff --git a/next.config.mjs b/next.config.mjs index 52fdd83..195286c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,7 +1,6 @@ import {withSentryConfig} from "@sentry/nextjs"; /** @type {import('next').NextConfig} */ const nextConfig = { - reactStrictMode: false, output: "standalone", basePath: process.env.NEXT_PUBLIC_BASE_PATH, async rewrites() { diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index 000b45c..ee5f9ba 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -8,7 +8,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({error: 'No user token found'}, {status: 401}); } - const data = await fetch(`${process.env.AUTH_API}/refresh`, { + const data = await fetch(`${process.env.AUTH_API_PATH}/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/src/app/api/auth/signin/route.ts b/src/app/api/auth/signin/route.ts index 6202d7f..4bdb471 100644 --- a/src/app/api/auth/signin/route.ts +++ b/src/app/api/auth/signin/route.ts @@ -10,7 +10,7 @@ interface LoginRequest { export async function POST(req: NextRequest) { const {code, redirectUrl} = await req.json() as LoginRequest; - const data = await fetch(`${process.env.AUTH_API}/login?${redirectUrl}`, { + const data = await fetch(`${process.env.AUTH_API_PATH}/login?${redirectUrl}`, { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/src/app/api/auth/signout/route.ts b/src/app/api/auth/signout/route.ts index 45398e9..a18104c 100644 --- a/src/app/api/auth/signout/route.ts +++ b/src/app/api/auth/signout/route.ts @@ -7,7 +7,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({error: 'No user token found'}, {status: 401}); } - return await fetch(`${process.env.AUTH_API}/logout`, { + return await fetch(`${process.env.AUTH_API_PATH}/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json' From 7b7e7e4abe0c3992e3c7bdd24464845dea612fba Mon Sep 17 00:00:00 2001 From: Fredrik Monsen Date: Fri, 13 Sep 2024 12:48:54 +0200 Subject: [PATCH 05/10] update pipeline env variables --- .env.example | 6 +++++- .github/workflows/ci_pipeline.yml | 6 +++++- README.md | 21 ++++++++++++--------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 352d8c1..9a7002f 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ NEXT_PUBLIC_BASE_PATH=/hugin +NEXT_PUBLIC_KEYCLOAK_BASE_URL=https://your-keycloak-url.org +NEXT_PUBLIC_KEYCLOAK_REALM=your-realm +NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=your-client-id + CATALOGUE_API_PATH=http://localhost:8087/bikube DATABASE_URL='' -AUTH_API=http://localhost:8088/auth +AUTH_API_PATH=http://localhost:8088/auth diff --git a/.github/workflows/ci_pipeline.yml b/.github/workflows/ci_pipeline.yml index 79f9854..132cb47 100644 --- a/.github/workflows/ci_pipeline.yml +++ b/.github/workflows/ci_pipeline.yml @@ -71,7 +71,8 @@ jobs: secretId: ${{ secrets.VAULT_SECRET_ID }} secrets: | kv/team/text/data/harbor * ; - kv/team/text/data/hugin-stage * + kv/team/text/data/hugin-stage * ; + kv/team/text/data/keycloak-nbauth-tekst * - name: Log in to Harbor uses: docker/login-action@v3 @@ -102,6 +103,9 @@ jobs: echo "KEYCLOAK_TEKST_CLIENT_ID=${{ steps.import-secrets.outputs.KEYCLOAK_TEKST_CLIENT_ID }}" >> .env.production echo "KEYCLOAK_TEKST_CLIENT_SECRET=${{ steps.import-secrets.outputs.KEYCLOAK_TEKST_CLIENT_SECRET }}" >> .env.production echo "KEYCLOAK_TEKST_URL=${{ steps.import-secrets.outputs.KEYCLOAK_TEKST_URL }}" >> .env.production + echo "NEXT_PUBLIC_KEYCLOAK_BASE_URL=${{ steps.import-secrets.outputs.KEYCLOAK_BASE_URL }}" >> .env.production + echo "NEXT_PUBLIC_KEYCLOAK_REALM=${{ steps.import-secrets.outputs.KEYCLOAK_REALM }}" >> .env.production + echo "NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=${{ steps.import-secrets.outputs.KEYCLOAK_CLIENT_ID }}" >> .env.production - name: Build and push image uses: docker/build-push-action@v5 diff --git a/README.md b/README.md index f7589a4..5fe88cb 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,18 @@ For å kjøre lokalt må du sette de nødvendige miljøvariablene: cp .env.example .env.local ``` -| Variabelnavn | Standardverdi | Beskrivelse | -|------------------------------|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------| -| NEXT_PUBLIC_BASE_PATH | /hugin | Base path for applikasjonen | -| CATALOGUE_API_PATH | http://localhost:8087/bikube | Sti til [katalog APIet ](https://github.com/NationalLibraryOfNorway/bikube)
Må starte med `http://` eller `https://` | -| AUTH_API_PATH | http://localhost:8080/tekst-auth | Sti til [autentiserings APIet](https://github.com/NationalLibraryOfNorway/tekst-auth)
Må starte med `http://` eller `https://` | -| DATABASE_URL | | URL til databasen (se mer info i eget avsnitt under) | -| KEYCLOAK_TEKST_URL | | Url til keycloak-tekst (inkl. realm om open-idconnect, eks. https://mysite.com/authn/realms/myRealm/protocol/openid-connect | -| KEYCLOAK_TEKST_CLIENT_ID | | Client ID i keycloak-tekst | -| KEYCLOAK_TEKST_CLIENT_SECRET | | Client secret i keycloak-tekst | +| Variabelnavn | Standardverdi | Beskrivelse | +|--------------------------------|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------| +| NEXT_PUBLIC_BASE_PATH | /hugin | Base path for applikasjonen | +| CATALOGUE_API_PATH | http://localhost:8087/bikube | Sti til [katalog APIet ](https://github.com/NationalLibraryOfNorway/bikube)
Må starte med `http://` eller `https://` | +| DATABASE_URL | | URL til databasen (se mer info i eget avsnitt under) | +| AUTH_API_PATH | http://localhost:8080/tekst-auth | Sti til [autentiserings APIet](https://github.com/NationalLibraryOfNorway/tekst-auth)
Må starte med `http://` eller `https://` | +| NEXT_PUBLIC_KEYCLOAK_BASE_URL | | URL til keycloak | +| NEXT_PUBLIC_KEYCLOAK_REALM | | Keycloak-realmen | +| NEXT_PUBLIC_KEYCLOAK_CLIENT_ID | | Keycloak-klienten | +| KEYCLOAK_TEKST_URL | | Url til keycloak-tekst (inkl. realm om open-idconnect, eks. https://mysite.com/authn/realms/myRealm/protocol/openid-connect | +| KEYCLOAK_TEKST_CLIENT_ID | | Client ID i keycloak-tekst | +| KEYCLOAK_TEKST_CLIENT_SECRET | | Client secret i keycloak-tekst | Deretter må du kjøre følgende kommandoer: ```bash From 370d95b1737ef81fbabf0944e2a0c6c3c5ed60c8 Mon Sep 17 00:00:00 2001 From: Fredrik Monsen Date: Fri, 13 Sep 2024 13:06:44 +0200 Subject: [PATCH 06/10] fix lint errors and warnings --- src/app/AuthProvider.tsx | 27 ++++++++++++++------------- src/app/api/auth/refresh/route.ts | 4 ++-- src/app/api/auth/signout/route.ts | 4 ++-- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/app/AuthProvider.tsx b/src/app/AuthProvider.tsx index ac29295..bdc2b4e 100644 --- a/src/app/AuthProvider.tsx +++ b/src/app/AuthProvider.tsx @@ -24,6 +24,18 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { const [user, setUser] = useState(); const [intervalId, setIntervalId] = useState(); + const handleNotAuthenticated = useCallback(() => { + setAuthenticated(false); + setUser(undefined); + if (intervalId) { + clearInterval(intervalId); + } + const currentUrl = window.location.href; + router.push( + `${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` + + `?client_id=${keycloakConfig.clientId}&redirect_uri=${currentUrl}&response_type=code&scope=openid`); + }, [intervalId, router]); + useEffect(() => { const codeInParams = new URLSearchParams(window.location.search).get('code'); if (codeInParams) { @@ -43,7 +55,8 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { `${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` + `?client_id=${keycloakConfig.clientId}&redirect_uri=${currentUrl}&response_type=code&scope=openid`); } - }, []); + + }, [handleNotAuthenticated, router, user]); const handleIsAuthenticated = (newUser: User) => { if (newUser) { @@ -52,18 +65,6 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { } }; - const handleNotAuthenticated = useCallback(() => { - setAuthenticated(false); - setUser(undefined); - if (intervalId) { - clearInterval(intervalId); - } - const currentUrl = window.location.href; - router.push( - `${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` + - `?client_id=${keycloakConfig.clientId}&redirect_uri=${currentUrl}&response_type=code&scope=openid`); - }, [intervalId, router]); - const refreshToken = useCallback(async () => { return refresh(); }, []); diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index ee5f9ba..dd6737f 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -1,8 +1,8 @@ -import {NextRequest, NextResponse} from 'next/server'; +import {NextResponse} from 'next/server'; import {User, UserToken} from '@/models/UserToken'; import {getRefreshToken, setUserCookie} from '@/utils/cookieUtils'; -export async function POST(req: NextRequest) { +export async function POST() { const refreshToken = getRefreshToken(); if (!refreshToken) { return NextResponse.json({error: 'No user token found'}, {status: 401}); diff --git a/src/app/api/auth/signout/route.ts b/src/app/api/auth/signout/route.ts index a18104c..a7fe8ba 100644 --- a/src/app/api/auth/signout/route.ts +++ b/src/app/api/auth/signout/route.ts @@ -1,7 +1,7 @@ -import {NextRequest, NextResponse} from 'next/server'; +import {NextResponse} from 'next/server'; import {deleteUserToken, getRefreshToken} from '@/utils/cookieUtils'; -export async function POST(req: NextRequest) { +export async function POST() { const refreshToken = getRefreshToken(); if (!refreshToken) { return NextResponse.json({error: 'No user token found'}, {status: 401}); From 49809fd7a69fc1f71a2b998b2e2f98f0b948d3da Mon Sep 17 00:00:00 2001 From: Fredrik Monsen Date: Fri, 13 Sep 2024 15:14:02 +0200 Subject: [PATCH 07/10] Remove test that is no longer valid --- __tests__/components/Header.test.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/__tests__/components/Header.test.tsx b/__tests__/components/Header.test.tsx index a0e5848..3369426 100644 --- a/__tests__/components/Header.test.tsx +++ b/__tests__/components/Header.test.tsx @@ -12,10 +12,6 @@ test('Header should have logo and Hugin-text', () => { expect(screen.getByRole('img')).toBeTruthy(); }); -test('Header should have login button', () => { - expect(screen.getByText('Logg inn')).toBeTruthy(); -}); - test('Header should not as default not show search bar', () => { expect(screen.queryByRole('searchbox')).toBeFalsy(); }); From a0aea07db7fd2386f8568dde9b4f539266162f33 Mon Sep 17 00:00:00 2001 From: Fredrik Monsen Date: Mon, 16 Sep 2024 09:22:20 +0200 Subject: [PATCH 08/10] post items with username from cookie --- src/models/CatalogMissingNewspaperDto.ts | 3 ++- src/models/CatalogNewspaperDto.ts | 3 ++- src/models/CatalogNewspaperEditDto.ts | 3 ++- src/utils/cookieUtils.ts | 4 ++++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/models/CatalogMissingNewspaperDto.ts b/src/models/CatalogMissingNewspaperDto.ts index 28b4f63..95a84c8 100644 --- a/src/models/CatalogMissingNewspaperDto.ts +++ b/src/models/CatalogMissingNewspaperDto.ts @@ -1,5 +1,6 @@ import {newspaper} from '@prisma/client'; import {createCatalogDateString} from '@/utils/dateUtils'; +import {getUserName} from '@/utils/cookieUtils'; export interface CatalogMissingNewspaperDto { titleCatalogueId: string; @@ -17,7 +18,7 @@ export function createCatalogMissingNewspaperDtoFromIssue( return { titleCatalogueId: titleId, date: createCatalogDateString(issue.date), - username: 'hugin stage', // TODO replace with actual username when auth is present + username: getUserName() ?? '', notes: issue.notes ?? '', // eslint-disable-next-line id-denylist number: issue.edition ?? '' diff --git a/src/models/CatalogNewspaperDto.ts b/src/models/CatalogNewspaperDto.ts index 113b68f..e1971bf 100644 --- a/src/models/CatalogNewspaperDto.ts +++ b/src/models/CatalogNewspaperDto.ts @@ -1,5 +1,6 @@ import {newspaper} from '@prisma/client'; import {createCatalogDateString} from '@/utils/dateUtils'; +import {getUserName} from '@/utils/cookieUtils'; export interface CatalogNewspaperDto { titleCatalogueId: string; @@ -21,7 +22,7 @@ export function createCatalogNewspaperDtoFromIssue( return { titleCatalogueId: titleId, date: createCatalogDateString(issue.date), - username: 'hugin stage', // TODO replace with actual username when auth is present + username: getUserName() ?? '', digital: false, containerId: issue.box_id, notes: issue.notes ?? '', diff --git a/src/models/CatalogNewspaperEditDto.ts b/src/models/CatalogNewspaperEditDto.ts index b416eab..77af047 100644 --- a/src/models/CatalogNewspaperEditDto.ts +++ b/src/models/CatalogNewspaperEditDto.ts @@ -1,4 +1,5 @@ import {newspaper} from '@prisma/client'; +import {getUserName} from '@/utils/cookieUtils'; export interface CatalogNewspaperEditDto { @@ -14,7 +15,7 @@ export function createCatalogNewspaperEditDtoFromIssue( ): CatalogNewspaperEditDto { return { manifestationId: issue.catalog_id, - username: 'Hugin stage', // TODO replace with actual username when auth is present + username: getUserName() ?? '', notes: issue.notes ?? '', // eslint-disable-next-line id-denylist number: issue.edition ?? '' diff --git a/src/utils/cookieUtils.ts b/src/utils/cookieUtils.ts index 7a00699..ccd4685 100644 --- a/src/utils/cookieUtils.ts +++ b/src/utils/cookieUtils.ts @@ -13,6 +13,10 @@ export function getRefreshToken(): string | undefined { return getUserToken()?.refreshToken; } +export function getUserName(): string | undefined { + return getUserToken()?.name; +} + export function deleteUserToken() { cookies().delete('user'); } From 555348f10db20590121560ed5d0b497ac823639d Mon Sep 17 00:00:00 2001 From: Fredrik Monsen Date: Tue, 17 Sep 2024 09:14:19 +0200 Subject: [PATCH 09/10] Apply suggestions --- src/app/AuthProvider.tsx | 8 +++----- src/app/api/auth/refresh/route.ts | 3 ++- src/app/api/auth/signin/route.ts | 3 ++- src/app/api/auth/signout/route.ts | 3 ++- src/models/UserToken.ts | 13 +++++++++++-- src/utils/cookieUtils.ts | 4 ++-- 6 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/app/AuthProvider.tsx b/src/app/AuthProvider.tsx index bdc2b4e..9b18630 100644 --- a/src/app/AuthProvider.tsx +++ b/src/app/AuthProvider.tsx @@ -31,10 +31,9 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { clearInterval(intervalId); } const currentUrl = window.location.href; - router.push( - `${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` + + window.location.assign(`${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` + `?client_id=${keycloakConfig.clientId}&redirect_uri=${currentUrl}&response_type=code&scope=openid`); - }, [intervalId, router]); + }, [intervalId]); useEffect(() => { const codeInParams = new URLSearchParams(window.location.search).get('code'); @@ -51,8 +50,7 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { } else { handleNotAuthenticated(); const currentUrl = window.location.href; - router.push( - `${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` + + window.location.assign(`${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` + `?client_id=${keycloakConfig.clientId}&redirect_uri=${currentUrl}&response_type=code&scope=openid`); } diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index dd6737f..58b5609 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -2,7 +2,8 @@ import {NextResponse} from 'next/server'; import {User, UserToken} from '@/models/UserToken'; import {getRefreshToken, setUserCookie} from '@/utils/cookieUtils'; -export async function POST() { +// POST api/auth/refresh +export async function POST(): Promise { const refreshToken = getRefreshToken(); if (!refreshToken) { return NextResponse.json({error: 'No user token found'}, {status: 401}); diff --git a/src/app/api/auth/signin/route.ts b/src/app/api/auth/signin/route.ts index 4bdb471..3297b86 100644 --- a/src/app/api/auth/signin/route.ts +++ b/src/app/api/auth/signin/route.ts @@ -8,7 +8,8 @@ interface LoginRequest { redirectUrl: string; } -export async function POST(req: NextRequest) { +// POST api/auth/signin +export async function POST(req: NextRequest): Promise { const {code, redirectUrl} = await req.json() as LoginRequest; const data = await fetch(`${process.env.AUTH_API_PATH}/login?${redirectUrl}`, { method: 'POST', diff --git a/src/app/api/auth/signout/route.ts b/src/app/api/auth/signout/route.ts index a7fe8ba..331af02 100644 --- a/src/app/api/auth/signout/route.ts +++ b/src/app/api/auth/signout/route.ts @@ -1,7 +1,8 @@ import {NextResponse} from 'next/server'; import {deleteUserToken, getRefreshToken} from '@/utils/cookieUtils'; -export async function POST() { +// POST api/auth/signout +export async function POST(): Promise { const refreshToken = getRefreshToken(); if (!refreshToken) { return NextResponse.json({error: 'No user token found'}, {status: 401}); diff --git a/src/models/UserToken.ts b/src/models/UserToken.ts index 242e622..2975de0 100644 --- a/src/models/UserToken.ts +++ b/src/models/UserToken.ts @@ -1,3 +1,12 @@ +interface SerializedUserToken { + groups: string[]; + name: string; + accessToken: string; + expires: string; + refreshToken: string; + refreshExpires: string; +} + interface UserToken { groups: string[]; name: string; @@ -7,7 +16,7 @@ interface UserToken { refreshExpires: Date; } -const userTokenBuilder = (userToken: UserToken): UserToken => { +const userTokenBuilder = (userToken: SerializedUserToken): UserToken => { return { groups: userToken.groups, name: userToken.name, @@ -23,5 +32,5 @@ interface User { expires: Date; } -export type { User, UserToken }; +export type { User, UserToken, SerializedUserToken }; export { userTokenBuilder }; \ No newline at end of file diff --git a/src/utils/cookieUtils.ts b/src/utils/cookieUtils.ts index ccd4685..bf11d75 100644 --- a/src/utils/cookieUtils.ts +++ b/src/utils/cookieUtils.ts @@ -1,12 +1,12 @@ import {cookies} from 'next/headers'; -import {UserToken, userTokenBuilder} from '@/models/UserToken'; +import {SerializedUserToken, UserToken, userTokenBuilder} from '@/models/UserToken'; export function getUserToken(): UserToken | undefined { const userCookieValue = cookies().get('user')?.value; if (!userCookieValue) { return undefined; } - return userTokenBuilder(JSON.parse(userCookieValue) as UserToken); + return userTokenBuilder(JSON.parse(userCookieValue) as SerializedUserToken); } export function getRefreshToken(): string | undefined { From a7d8c73e99f4bfa5b66a0c671bf22f1f2b4c3340 Mon Sep 17 00:00:00 2001 From: Fredrik Monsen Date: Tue, 17 Sep 2024 10:42:25 +0200 Subject: [PATCH 10/10] Catch and log error. Run only once --- src/app/AuthProvider.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/AuthProvider.tsx b/src/app/AuthProvider.tsx index 9b18630..880d444 100644 --- a/src/app/AuthProvider.tsx +++ b/src/app/AuthProvider.tsx @@ -42,6 +42,9 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { void signIn(codeInParams, redirectUrl).then((token: User) => { handleIsAuthenticated(token); router.push('/'); + }).catch((e: Error) => { + console.error('Failed to sign in: ', e.message); + handleNotAuthenticated(); }); } else if (user) { if (user.expires && new Date(user.expires) > new Date()) { @@ -53,8 +56,8 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { window.location.assign(`${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` + `?client_id=${keycloakConfig.clientId}&redirect_uri=${currentUrl}&response_type=code&scope=openid`); } - - }, [handleNotAuthenticated, router, user]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const handleIsAuthenticated = (newUser: User) => { if (newUser) {