Skip to content

Commit

Permalink
feat: add custom authn/authz (TT-1547) (#52)
Browse files Browse the repository at this point in the history
Før deployment av denne må auth API deployes (se relatert Jira-ticket).
  • Loading branch information
fredrikmonsen authored Sep 19, 2024
1 parent 2c81847 commit f4dec32
Show file tree
Hide file tree
Showing 23 changed files with 488 additions and 30 deletions.
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +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_PATH=http://localhost:8088/auth
6 changes: 5 additions & 1 deletion .github/workflows/ci_pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +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)<br/>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)<br/>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)<br/>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
Expand Down
4 changes: 0 additions & 4 deletions __tests__/components/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
2 changes: 1 addition & 1 deletion next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const nextConfig = {
source: `${process.env.NEXT_PUBLIC_BASE_PATH}/api/catalog/:path*`,
destination: `${process.env.CATALOGUE_API_PATH}/:path*`,
basePath: false
},
}
];
}
};
Expand Down
124 changes: 124 additions & 0 deletions src/app/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
'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);
}
const currentUrl = window.location.href;
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();
const currentUrl = window.location.href;
window.location.assign(`${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` +
`?client_id=${keycloakConfig.clientId}&redirect_uri=${currentUrl}&response_type=code&scope=openid`);
}
// 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});
});
}
1 change: 0 additions & 1 deletion src/app/api/title/[id]/box/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ export async function POST(req: NextRequest, params: IdParams): Promise<NextResp

// PATCH /title/[id]/box set active box for title
export async function PATCH(req: NextRequest, params: IdParams): Promise<NextResponse> {
console.log('PATCH /title/[id]/box');
const id = +params.params.id;
const { boxId } = await req.json() as {boxId: string; startDate: string};

Expand Down
9 changes: 6 additions & 3 deletions src/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<NextUIProvider locale='nb-NO'>
{children}
</NextUIProvider>
<AuthProvider>
<NextUIProvider locale='nb-NO'>
{children}
</NextUIProvider>
</AuthProvider>
);
}
19 changes: 10 additions & 9 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() || '';
Expand All @@ -33,14 +36,12 @@ export default function Header() {
</NavbarContent> }
<NavbarContent justify="end">
<NavbarItem className="lg:flex">
<Button
as={Link}
variant="light"
color="primary"
className="edit-button-style"
>
Logg inn
</Button>
{ authenticated ? (
<>
<UserDetails name={user?.name ?? ''} className="px-2.5"/>
<LogoutButton/>
</>
) : <></>}
</NavbarItem>
</NavbarContent>
</Navbar>
Expand Down
19 changes: 19 additions & 0 deletions src/components/LogoutButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
className="edit-button-style"
endContent={<FaSignOutAlt size={25} />}
onClick={logout}
>
Logg ut
</Button>
);
};

export default LogoutButton;
Loading

0 comments on commit f4dec32

Please sign in to comment.