Skip to content

Commit

Permalink
feat: setup authentication (TT-1755) (#2)
Browse files Browse the repository at this point in the history
* feat: add auth provider and auth routes

* add simple header to display logged in user and logout button

* use env variables from k8s secrets in deployment

* add .env.example

* exclude AuthProvider from StrictMode in local development to prevent auth problems

* fix main page layout

* run lint --fix

* Apply suggestions
  • Loading branch information
fredrikmonsen authored Oct 17, 2024
1 parent c2ca7e1 commit 1659eea
Show file tree
Hide file tree
Showing 19 changed files with 507 additions and 34 deletions.
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
NEXT_PUBLIC_BASE_PATH=/ammo
NEXT_PUBLIC_KEYCLOAK_BASE_URL=https://your-keycloak-url.org
NEXT_PUBLIC_KEYCLOAK_REALM=your-realm
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=your-client-id
IMAGE_API_URL=https://your-image-server-example.com
AUTH_API_PATH=https://your-auth-server-example.com
47 changes: 18 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,25 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# AMMO
**A**lt **M**ulig **MO**ttak (AMMO) er en web app for behandling av materiale i produksjonsløype for tekst.

## Getting Started
## Lokalt oppsett
For å kjøre lokalt må du sette de nødvendige miljøvariablene:
```bash
cp .env.example .env.local
```

First, run the development server:
| Variabelnavn | Standardverdi | Beskrivelse |
|--------------------------------|---------------|------------------------------|
| NEXT_PUBLIC_BASE_PATH | /ammo | Base path for applikasjonen |
| NEXT_PUBLIC_KEYCLOAK_BASE_URL | _N/A_ | URL til keycloak |
| NEXT_PUBLIC_KEYCLOAK_REALM | _N/A_ | Keycloak-realmen |
| NEXT_PUBLIC_KEYCLOAK_CLIENT_ID | _N/A_ | Keycloak-klienten |
| AUTH_API_PATH | _N/A_ | Sti til autentiserings-APIet |
| IMAGE_API_URL | _N/A_ | Sti til bilde-APIet |

Deretter må du kjøre følgende kommandoer:
```bash
npm install
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!

## Deploy on Vercel

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Applikasjonen finner du nå i nettleseren på [http://localhost:3000/ammo](http://localhost:3000/ammo).
20 changes: 20 additions & 0 deletions k8s/stage/deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ spec:
containerPort: 3000
imagePullPolicy: Always
env:
- name: NEXT_PUBLIC_KEYCLOAK_BASE_URL
valueFrom:
secretKeyRef:
name: ammo-secrets
key: keycloak_base_url
- name: NEXT_PUBLIC_KEYCLOAK_REALM
valueFrom:
secretKeyRef:
name: ammo-secrets
key: keycloak_realm
- name: NEXT_PUBLIC_KEYCLOAK_CLIENT_ID
valueFrom:
secretKeyRef:
name: ammo-secrets
key: keycloak_client_id
- name: AUTH_API_PATH
valueFrom:
secretKeyRef:
name: ammo-secrets
key: auth_api_path
- name: IMAGE_API_PATH
valueFrom:
secretKeyRef:
Expand Down
1 change: 1 addition & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
output: "standalone",
basePath: process.env.NEXT_PUBLIC_BASE_PATH,
images: {
Expand Down
125 changes: 125 additions & 0 deletions src/app/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
'use client';

import {createContext, useCallback, useContext, useEffect, useState} from 'react';
import {useRouter} from 'next/navigation';
import keycloakConfig from '@/lib/keycloak';
import {User} from '@/models/UserToken';
import {refresh, signIn, signOut} from '@/services/auth.data';

interface IAuthContext {
authenticated: boolean;
user?: User;
logout?: () => void;
}

const AuthContext = createContext<IAuthContext>({
authenticated: false,
logout: () => {}
});

export const AuthProvider = ({children}: { children: React.ReactNode }) => {
const router = useRouter();

const [authenticated, setAuthenticated] = useState<boolean>(false);
const [user, setUser] = useState<User>();
const [intervalId, setIntervalId] = useState<number>();

const handleNotAuthenticated = useCallback(() => {
setAuthenticated(false);
setUser(undefined);
if (intervalId) {
clearInterval(intervalId);
}
let currentUrl = window.location.href;
// The app dislikes being redirected to a sub-path, redirecting to root to avoid issues
currentUrl = currentUrl.replace(/\/ammo.*/, '/ammo');
currentUrl = encodeURIComponent(currentUrl);

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]);

useEffect(() => {
const codeInParams = new URLSearchParams(window.location.search).get('code');
if (codeInParams) {
const redirectUrl = new URLSearchParams({redirectUrl: trimRedirectUrl(window.location.href)}).toString();
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()) {
handleIsAuthenticated(user);
}
} else {
handleNotAuthenticated();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const handleIsAuthenticated = (newUser: User) => {
if (newUser) {
setUser(newUser);
setAuthenticated(true);
}
};

const refreshToken = useCallback(async () => {
return refresh();
}, []);

const setIntervalToRefreshAccessToken = useCallback(async () => {
if (user?.expires && !intervalId) {
const expiryTime = new Date(user?.expires).getTime() - Date.now();
if (expiryTime < 1000 * 60 * 4.75) {
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 signOut()
.then(() => {
handleNotAuthenticated();
});
};

return (
<AuthContext.Provider
value={{
authenticated,
user,
// eslint-disable-next-line @typescript-eslint/no-misused-promises
logout
}}
>
{children}
</AuthContext.Provider>
);
};

export const useAuth = () => useContext<IAuthContext>(AuthContext);
30 changes: 30 additions & 0 deletions src/app/api/auth/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {NextResponse} from 'next/server';
import {User, UserToken} from '@/models/UserToken';
import {getRefreshToken, setUserCookie} from '@/utils/cookieUtils';

// POST api/auth/refresh
export async function POST(): Promise<NextResponse> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
return NextResponse.json({error: 'No user token found'}, {status: 401});
}

const data = await fetch(`${process.env.AUTH_API_PATH}/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});
}
43 changes: 43 additions & 0 deletions src/app/api/auth/signin/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}

// POST api/auth/signin
export async function POST(req: NextRequest): Promise<NextResponse> {
const {code, redirectUrl} = await req.json() as LoginRequest;
const data = await fetch(`${process.env.AUTH_API_PATH}/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});
}
26 changes: 26 additions & 0 deletions src/app/api/auth/signout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {NextResponse} from 'next/server';
import {deleteUserToken, getRefreshToken} from '@/utils/cookieUtils';

// POST api/auth/signout
export async function POST(): Promise<NextResponse> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
return NextResponse.json({error: 'No user token found'}, {status: 401});
}

return await fetch(`${process.env.AUTH_API_PATH}/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});
}
deleteUserToken();
return NextResponse.json({message: 'Logged out successfully'}, {status: 200});
}).catch((error: Error) => {
return NextResponse.json({error: `Failed to logout: ${error.message}`}, {status: 500});
});
}
8 changes: 8 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@
line-height: normal;
line-break: anywhere;
}

.header {
@apply flex flex-row items-center justify-between p-4;
}

.button-style {
@apply bg-blue-500 text-white font-bold py-2 px-4 rounded;
}
13 changes: 12 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {Metadata} from 'next';
import './globals.css';
import {Providers} from '@/app/providers';
import Header from '@/components/Header';

export const metadata: Metadata = {
title: 'AMMO',
Expand All @@ -16,7 +17,17 @@ export default function RootLayout({
<html lang="en" className="light">
<body>
<Providers>
{children}
<main>
<div className="min-h-screen flex flex-col text-center">
<Header/>
<div className="flex justify-center pt-6">
{children}
</div>
<footer className="flex-grow py-3 flex justify-center items-end">
<p className="text-sm">Nasjonalbiblioteket &copy; 2024</p>
</footer>
</div>
</main>
</Providers>
</body>
</html>
Expand Down
14 changes: 10 additions & 4 deletions src/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
'use client';

import {AuthProvider} from '@/app/AuthProvider';
import {NextUIProvider} from '@nextui-org/react';
import React from 'react';

export function Providers({children}: { children: React.ReactNode }) {
return (
<NextUIProvider>
{children}
</NextUIProvider>
<AuthProvider>
<React.StrictMode>
<NextUIProvider>
{children}
</NextUIProvider>
</React.StrictMode>
</AuthProvider>
);
}
}
Loading

0 comments on commit 1659eea

Please sign in to comment.