Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add custom authn/authz (TT-1547) #52

Merged
merged 10 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}, []);
Sindrir marked this conversation as resolved.
Show resolved Hide resolved

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