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 3 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
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
16 changes: 10 additions & 6 deletions app/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ 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';

const headerPlusFooterHeight = '152px';

Expand Down Expand Up @@ -136,7 +138,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 +148,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: any = 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 +181,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
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;
101 changes: 101 additions & 0 deletions app/pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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 {
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;
session.user.client_roles = token.client_roles || []; // Adding roles to session.user here
session.user.given_name = token.given_name;
session.user.family_name = token.family_name;
session.user.preferred_username = token.preferred_username;
session.user.email = token.email;
session.user.idir_username = token.idir_username;
}

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
10 changes: 6 additions & 4 deletions app/pages/api/users/idir.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { validateRequest } from 'utils/jwt';
import KeycloakCore from 'utils/keycloak-core';
import { getAllowedRealms, getAllowedRealmNames } from 'controllers/realm';
import { getAllowedRealmNames } from 'controllers/realm';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '../auth/[...nextauth]';

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

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: any = await getServerSession(req, res, authOptions);

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

const { search, env } = req.query;
const searchParam = String(search);
Expand Down
Loading
Loading