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: utilize next auth to manage the auth and sessions #110

Merged
merged 5 commits into from
Sep 12, 2023
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
1 change: 1 addition & 0 deletions app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ BCEID_SERVICE_ID=
BCEID_SERVICE_BASIC_AUTH=
IDIR_JWKS_URI=
IDIR_ISSUER=
NEXTAUTH_URL=http://localhost:3000
8 changes: 4 additions & 4 deletions app/controllers/realm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { runQuery } from 'utils/db';
import KeycloakCore from 'utils/keycloak-core';

export async function getAllowedRealms(session: any) {
const username = session?.idir_username || '';
const roles = session?.client_roles || [];
const username = session?.user?.idir_username || '';
const roles = session?.user?.client_roles || [];
const isAdmin = roles.includes('sso-admin');
let result: any = null;

Expand Down Expand Up @@ -54,8 +54,8 @@ export async function getAllowedRealms(session: any) {
}

export async function getAllowedRealmNames(session: any) {
const username = session?.idir_username || '';
const roles = session?.client_roles || [];
const username = session?.user?.idir_username || '';
const roles = session?.user?.client_roles || [];
const isAdmin = roles.includes('sso-admin');
let result: any = null;

Expand Down
23 changes: 14 additions & 9 deletions app/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { startCase } from 'lodash';
import BCSans from './BCSans';
import Navigation from './Navigation';
import BottomAlertProvider from './BottomAlert';
import { useSession } from 'next-auth/react';
import { useEffect } from 'react';
import { User } from 'next-auth';

const headerPlusFooterHeight = '152px';

Expand Down Expand Up @@ -106,11 +109,11 @@ const routes: Route[] = [
{ path: '/realm', label: 'Realm Profile', roles: ['user'], hide: true },
];

const LeftMenuItems = ({ currentUser, currentPath }: { currentUser: any; currentPath: string }) => {
let roles = ['guest'];
const LeftMenuItems = ({ currentUser, currentPath }: { currentUser: Partial<User>; currentPath: string }) => {
let roles: string[] = ['guest'];

if (currentUser) {
roles = currentUser?.client_roles?.length > 0 ? currentUser.client_roles : ['user'];
roles = currentUser?.client_roles?.length! > 0 ? currentUser.client_roles! : ['user'];
}

const isCurrent = (path: string) => currentPath === path || currentPath.startsWith(`${path}/`);
Expand All @@ -136,7 +139,7 @@ const RightMenuItems = () => (
<>
<li>Need help?</li>
<HoverItem>
<a href="https://chat.developer.gov.bc.ca/channel/sso" target="_blank" title="Rocket Chat">
<a href="https://chat.developer.gov.bc.ca/channel/sso" target="_blank" title="Rocket Chat" rel="noreferrer">
<FontAwesomeIcon size="2x" icon={faCommentDots} />
</a>
</HoverItem>
Expand All @@ -146,20 +149,22 @@ const RightMenuItems = () => (
</a>
</HoverItem>
<HoverItem>
<a href="https://github.com/bcgov/ocp-sso/wiki" target="_blank" title="Documentation">
<a href="https://github.com/bcgov/ocp-sso/wiki" target="_blank" title="Documentation" rel="noreferrer">
<FontAwesomeIcon size="2x" icon={faFileAlt} />
</a>
</HoverItem>
</>
);
// identity_provider, idir_userid, client_roles, family_name, given_name
function Layout({ children, currentUser, onLoginClick, onLogoutClick }: any) {
function Layout({ children, onLoginClick, onLogoutClick }: any) {
const router = useRouter();
const { data } = useSession();
const currentUser: Partial<User> = data?.user!;
const pathname = router.pathname;

const rightSide = currentUser ? (
<LoggedUser>
<div className="welcome">Welcome {`${currentUser.given_name} ${currentUser.family_name}`}</div>
<div className="welcome">Welcome {`${currentUser?.given_name} ${currentUser?.family_name}`}</div>
&nbsp;&nbsp;
<Button variant="secondary-inverse" size="medium" onClick={onLogoutClick}>
Log out
Expand All @@ -177,15 +182,15 @@ function Layout({ children, currentUser, onLoginClick, onLogoutClick }: any) {

<li>
Need help?&nbsp;&nbsp;
<a href="https://chat.developer.gov.bc.ca/" target="_blank" title="Rocket Chat">
<a href="https://chat.developer.gov.bc.ca/" target="_blank" title="Rocket Chat" rel="noreferrer">
<FontAwesomeIcon size="2x" icon={faCommentDots} />
</a>
&nbsp;&nbsp;
<a href="mailto:[email protected]" title="SSO Team">
<FontAwesomeIcon size="2x" icon={faEnvelope} />
</a>
&nbsp;&nbsp;
<a href="https://github.com/bcgov/ocp-sso/wiki" target="_blank" title="Wiki">
<a href="https://github.com/bcgov/ocp-sso/wiki" target="_blank" title="Wiki" rel="noreferrer">
<FontAwesomeIcon size="2x" icon={faFileAlt} />
</a>
</li>
Expand Down
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"lodash.get": "^4.4.2",
"lodash.map": "^4.6.0",
"next": "12.1.6",
"next-auth": "^4.23.1",
"node-cron": "^3.0.2",
"pg": "^8.7.3",
"pg-format": "^1.0.4",
Expand Down
5 changes: 3 additions & 2 deletions app/page-partials/my-dashboard/RealmLeftPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,16 @@ function RealmLeftPanel({ realms, onEditClick, onCancel }: Props) {
<a className={`nav-link ${tab === 'dashboard' ? 'active' : ''}`} onClick={() => setTab('dashboard')}>
My Dashboard
</a>
<a
{/* Disabling this feature as part of migration to gold */}
{/* <a
className={`nav-link ${tab === 'duplicate' ? 'active' : ''}`}
onClick={() => {
setTab('duplicate');
onCancel();
}}
>
Duplicate Users
</a>
</a> */}
</Tabs>
{tab === 'dashboard' ? <RealmTable realms={realms} onEditClick={onEditClick} /> : <DuplicateIDIR />}
</>
Expand Down
14 changes: 3 additions & 11 deletions app/page-partials/my-dashboard/RealmRightPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form';
import Loader from 'react-loader-spinner';
import ResponsiveContainer, { MediaRule } from 'components/ResponsiveContainer';
import Button from '@button-inc/bcgov-theme/Button';
import Checkbox from '@button-inc/bcgov-theme/Checkbox';
import React, { useState } from 'react';
import Tabs from 'components/Tabs';
import { withBottomAlert, BottomAlert } from 'layout/BottomAlert';
import { UserSession } from 'types/user-session';
import styled from 'styled-components';
import { RealmProfile } from 'types/realm-profile';
import RealmEdit from './RealmEdit';
import RealmURIs from './RealmURIs';
import RealmIDIR from './RealmIDIR';
import { User } from 'next-auth';

const Container = styled.div`
font-size: 1rem;
Expand Down Expand Up @@ -50,7 +42,7 @@ const Container = styled.div`

interface Props {
realm: RealmProfile;
currentUser: UserSession;
currentUser: Partial<User>;
onUpdate: (realm: RealmProfile) => void;
onCancel: () => void;
}
Expand Down
51 changes: 22 additions & 29 deletions app/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,41 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import '../styles/globals.css';
import React, { useState, useEffect } from 'react';
import React from 'react';
import { useRouter } from 'next/router';
import type { AppProps } from 'next/app';
import Head from 'next/head';
import store2 from 'store2';
import Layout from 'layout/Layout';
import { SessionProvider, signOut, signIn } from 'next-auth/react';

// store2('app-session', { name, preferred_username, email });

function MyApp({ Component, pageProps }: AppProps) {
function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
const router = useRouter();
const [currentUser, setCurrentUser] = useState(null);

useEffect(() => setCurrentUser(store2.session.get('app-session')), []);

useEffect(() => {
const redirect = async () => {
if (currentUser) await router.push('/my-dashboard');
};

redirect();
}, [currentUser]);

const handleLogin = async () => {
window.location.href = '/api/oidc/keycloak/login';
signIn('keycloak', {
callbackUrl: '/my-dashboard',
redirect: true,
});
};

const handleLogout = async () => {
store2.session.remove('app-token');
store2.session.remove('app-session');
window.location.href = '/api/oidc/keycloak/logout';
signOut({
redirect: true,
callbackUrl: '/api/oidc/keycloak/logout',
});
};

return (
<Layout currentUser={currentUser} onLoginClick={handleLogin} onLogoutClick={handleLogout}>
<Head>
<html lang="en" />
<title>Keycloak Realm Registry</title>
<meta name="description" content="Keycloak Realm Registry" />
<link rel="icon" href="/bcid-favicon-32x32.png" />
</Head>
<Component {...pageProps} currentUser={currentUser} onLoginClick={handleLogin} onLogoutClick={handleLogout} />
</Layout>
<SessionProvider session={session}>
<Layout onLoginClick={handleLogin} onLogoutClick={handleLogout}>
<Head>
<html lang="en" />
<title>Keycloak Realm Registry</title>
<meta name="description" content="Keycloak Realm Registry" />
<link rel="icon" href="/bcid-favicon-32x32.png" />
</Head>
<Component {...pageProps} onLoginClick={handleLogin} onLogoutClick={handleLogout} />
</Layout>
</SessionProvider>
);
}
export default MyApp;
96 changes: 96 additions & 0 deletions app/pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import NextAuth, { User, Account, NextAuthOptions } from 'next-auth';
import KeycloakProvider from 'next-auth/providers/keycloak';
import { JWT } from 'next-auth/jwt';
import jwt from 'jsonwebtoken';
import axios from 'axios';

async function refreshAccessToken(token: any) {
try {
const url = `${process.env.SSO_URL}/protocol/openid-connect/token?`;
const response = await axios.post(
url,
new URLSearchParams({
client_id: process.env.SSO_CLIENT_ID || '',
client_secret: process.env.SSO_CLIENT_SECRET || '',
grant_type: 'refresh_token',
refresh_token: token.refreshToken,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
const refreshedTokens = await response.data;
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpired: Date.now() + (refreshedTokens.expires_in - 15) * 1000,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
refreshTokenExpired: Date.now() + (refreshedTokens.refresh_expires_in - 15) * 1000,
};
} catch (error) {
console.error('refresh token error', error);
return {
...token,
error: 'RefreshAccessTokenError',
};
}
}

export const authOptions: NextAuthOptions = {
providers: [
KeycloakProvider({
clientId: process.env.SSO_CLIENT_ID || '',
clientSecret: process.env.SSO_CLIENT_SECRET || '',
issuer: process.env.SSO_URL,
profile(profile) {
return {
...profile,
id: profile.sub,
name: profile.name,
email: profile.email,
image: null,
};
},
}),
],
secret: process.env.JWT_SECRET,
callbacks: {
async jwt({ token, account, user }: { token: any; account: any; user: any }) {
if (account) {
token.accessToken = account?.access_token;
token.refreshToken = account.refresh_token;
token.accessTokenExpired = Date.now() + (account?.expires_at - 15) * 1000;
token.refreshTokenExpired = Date.now() + (account?.refresh_expires_in - 15) * 1000;
token.user = user;
}

const decodedToken = jwt.decode(token.accessToken || '') as any;

token.client_roles = decodedToken?.client_roles;
token.given_name = decodedToken?.given_name;
token.family_name = decodedToken?.family_name;
token.preferred_username = decodedToken?.preferred_username;
token.email = decodedToken?.email;
token.idir_username = decodedToken?.idir_username;

if (Date.now() < token.accessTokenExpired) {
return refreshAccessToken(token);
}

return token;
},
async session({ session, token }: { session: any; token: JWT }) {
// Send properties to the client, like an access_token from a provider.
if (token) {
session.accessToken = token.accessToken;
session.user = token.user;
}

return session;
},
},
};

export default NextAuth(authOptions);
11 changes: 6 additions & 5 deletions app/pages/api/realms/all.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Realms } from 'keycloak-admin/lib/resources/realms';
import type { NextApiRequest, NextApiResponse } from 'next';
import { runQuery } from 'utils/db';
import { validateRequest } from 'utils/jwt';
import { getAllowedRealms } from 'controllers/realm';
import { getSession } from 'next-auth/react';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '../auth/[...nextauth]';

interface ErrorData {
success: boolean;
Expand All @@ -13,8 +13,9 @@ type Data = ErrorData | string;

export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
try {
const session = await validateRequest(req, res);
if (!session) return res.status(401).json({ success: false, error: 'jwt expired' });
const session = await getServerSession(req, res, authOptions);

if (!session) return res.status(401).json({ success: false, error: 'unauthorized' });

const realms = await getAllowedRealms(session);
return res.send(realms);
Expand Down
13 changes: 8 additions & 5 deletions app/pages/api/realms/one.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { runQuery } from 'utils/db';
import { validateRequest } from 'utils/jwt';
import KeycloakCore from 'utils/keycloak-core';
import { sendUpdateEmail } from 'utils/mailer';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '../auth/[...nextauth]';

interface ErrorData {
success: boolean;
Expand All @@ -15,12 +17,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
try {
const { id } = req.query;

const session = await validateRequest(req, res);
const username = session?.idir_username || '';
const roles = session?.client_roles || [];
const isAdmin = roles.includes('sso-admin');
const session: any = await getServerSession(req, res, authOptions);

if (!session) return res.status(401).json({ success: false, error: 'unauthorized' });

if (!username) return res.status(401).json({ success: false, error: 'jwt expired' });
const username = session?.user?.idir_username || '';
const roles = session?.user?.client_roles || [];
const isAdmin = roles.includes('sso-admin');

const kcCore = new KeycloakCore('prod');

Expand Down
4 changes: 2 additions & 2 deletions app/pages/api/surveys/1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
const session = await validateRequest(req, res);
if (!session) return res.status(401).json({ success: false, error: 'jwt expired' });

const username = session?.idir_username || '';
const roles = session?.client_roles || [];
const username = session?.user?.idir_username || '';
const roles = session?.user?.client_roles || [];
const isAdmin = roles.includes('sso-admin');

if (req.method === 'GET') {
Expand Down
Loading
Loading