diff --git a/backend/src/api/app.ts b/backend/src/api/app.ts index 82ad377c..201b65c9 100644 --- a/backend/src/api/app.ts +++ b/backend/src/api/app.ts @@ -21,11 +21,19 @@ import * as reports from './reports'; import * as savedSearches from './saved-searches'; import rateLimit from 'express-rate-limit'; import { createProxyMiddleware } from 'http-proxy-middleware'; -import { UserType } from '../models'; +import { User, UserType, connectToDatabase } from '../models'; import * as assessments from './assessments'; +import * as jwt from 'jsonwebtoken'; +import { Request, Response, NextFunction } from 'express'; +import { CognitoIdentityServiceProvider } from 'aws-sdk'; +import fetch from 'node-fetch'; const sanitizer = require('sanitizer'); +const cognito = new CognitoIdentityServiceProvider({ + region: process.env.AWS_REGION +}); + if ( (process.env.IS_OFFLINE || process.env.IS_LOCAL) && typeof jest === 'undefined' @@ -107,10 +115,166 @@ app.use( app.use((req, res, next) => { res.setHeader('X-XSS-Protection', '0'); + // Okta header + res.setHeader('Access-Control-Allow-Credentials', 'true'); next(); }); +const setAuthorizationHeader = ( + req: Request, + res: Response, + next: NextFunction +) => { + const accessToken = req.cookies.access_token; + + if (accessToken) { + req.headers.authorization = `Bearer ${accessToken}`; + } + + next(); +}; + app.use(cookieParser()); +app.use(setAuthorizationHeader); + +app.get('/whoami', (req, res, next) => { + // TODO: Test and determine if this can be removed. + // if (!req.isAuthenticated()) { + // return res.status(401).json({ + // message: 'Unauthorized' + // }); + // } else { + + // // You can log other SAML attributes similarly + // // return res.status(200).json({ user: req.user }); + // } + return next(); +}); + +interface DecodedToken { + sub: string; + email: string; + 'cognito:username': string; + 'custom:OKTA_ID': string; + given_name: string; + family_name: string; + email_verified: boolean; + [key: string]: any; // Index signature for additional properties +} + +// Okta Callback Handler +app.post('/auth/okta-callback', async (req, res) => { + const { code } = req.body; + const clientId = process.env.REACT_APP_COGNITO_CLIENT_ID; + const callbackUrl = process.env.REACT_APP_COGNITO_CALLBACK_URL; + const domain = process.env.REACT_APP_COGNITO_DOMAIN; + + if (!code) { + return res.status(400).json({ message: 'Missing authorization code' }); + } + + try { + if (!callbackUrl) { + throw new Error('callbackUrl is required'); + } + + const tokenEndpoint = `https://${domain}/oauth2/token`; + const tokenData = `grant_type=authorization_code&client_id=${clientId}&code=${code}&redirect_uri=${callbackUrl}&scope=openid`; + + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: tokenData + }); + const { id_token, access_token, refresh_token } = await response.json(); + + if (!id_token) { + throw new Error('ID token is missing in the response'); + } + + const decodedToken = jwt.decode(id_token) as DecodedToken; + if (!decodedToken) { + throw new Error('Failed to decode ID token'); + } + + const cognitoUsername = decodedToken['cognito:username']; + const oktaId = decodedToken['custom:OKTA_ID']; + console.log('Cognito Username:', cognitoUsername); + console.log('Cognito OKTA_ID:', oktaId); + + console.log('ID Token:', id_token); + console.log('Decoded Token:', decodedToken); + + jwt.verify( + id_token, + auth.getOktaKey, + { algorithms: ['RS256'] }, + async (err, payload) => { + if (err) { + console.log('Error: ', err); + return res.status(401).json({ message: 'Invalid ID token' }); + } + + await connectToDatabase(); + + let user = await User.findOne({ email: decodedToken.email }); + + if (!user) { + user = User.create({ + email: decodedToken.email, + oktaId: oktaId, + firstName: decodedToken.given_name, + lastName: decodedToken.family_name, + invitePending: true + }); + await user.save(); + } else { + user.oktaId = oktaId; + await user.save(); + } + + res.cookie('access_token', access_token, { + httpOnly: true, + secure: true + }); + res.cookie('refresh_token', refresh_token, { + httpOnly: true, + secure: true + }); + + if (user) { + if (!process.env.JWT_SECRET) { + throw new Error('JWT_SECRET is not defined'); + } + + const signedToken = await jwt.sign( + { id: user.id, email: user.email }, + process.env.JWT_SECRET, + { expiresIn: '14m' } + ); + + res.cookie('id_token', signedToken, { httpOnly: true, secure: true }); + + return res.status(200).json({ + token: signedToken, + user: user + }); + } + } + ); + } catch (error) { + console.error( + 'Token exchange error:', + error.response ? error.response.data : error.message + ); + res.status(500).json({ + message: 'Authentication failed', + error: error.response ? error.response.data : error.message + }); + } +}); app.get('/', handlerToExpress(healthcheck)); app.post('/auth/login', handlerToExpress(auth.login)); @@ -121,18 +285,33 @@ app.post('/readysetcyber/register', handlerToExpress(users.RSCRegister)); app.get('/notifications', handlerToExpress(notifications.list)); const checkUserLoggedIn = async (req, res, next) => { - req.requestContext = { - authorizer: await auth.authorize({ - authorizationToken: req.headers.authorization - }) - }; - if ( - !req.requestContext.authorizer.id || - req.requestContext.authorizer.id === 'cisa:crossfeed:anonymous' - ) { + console.log('Checking if user is logged in.'); + + const authorizationHeader = req.headers.authorization; + + if (!authorizationHeader) { return res.status(401).send('Not logged in'); } - return next(); + + try { + req.requestContext = { + authorizer: await auth.authorize({ + authorizationToken: authorizationHeader + }) + }; + + if ( + !req.requestContext.authorizer.id || + req.requestContext.authorizer.id === 'cisa:crossfeed:anonymous' + ) { + return res.status(401).send('Not logged in'); + } + + return next(); + } catch (error) { + console.error('Error authorizing user:', error); + return res.status(500).send('Internal server error'); + } }; const checkUserSignedTerms = (req, res, next) => { diff --git a/backend/src/api/auth.ts b/backend/src/api/auth.ts index 52c60449..575708ff 100644 --- a/backend/src/api/auth.ts +++ b/backend/src/api/auth.ts @@ -51,13 +51,24 @@ const client = jwksClient({ jwksUri: `https://cognito-idp.us-east-1.amazonaws.com/${process.env.REACT_APP_USER_POOL_ID}/.well-known/jwks.json` }); -function getKey(header, callback) { +const oktaClient = jwksClient({ + jwksUri: `https://cognito-idp.us-east-1.amazonaws.com/${process.env.REACT_APP_COGNITO_USER_POOL_ID}/.well-known/jwks.json` +}); + +export function getKey(header, callback) { client.getSigningKey(header.kid, function (err, key) { const signingKey = key?.getPublicKey(); callback(null, signingKey); }); } +export function getOktaKey(header, callback) { + oktaClient.getSigningKey(header.kid, function (err, key) { + const signingKey = key?.getPublicKey(); + callback(null, signingKey); + }); +} + /** * @swagger * diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index 8e996447..36ac721d 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -32,6 +32,12 @@ export class User extends BaseEntity { }) cognitoId: string; + @Index({ unique: true }) + @Column({ + nullable: true + }) + oktaId: string; + @Index({ unique: true }) @Column({ nullable: true diff --git a/dev.env.example b/dev.env.example index 56dc76ad..fee1b25a 100644 --- a/dev.env.example +++ b/dev.env.example @@ -74,6 +74,12 @@ REACT_APP_USER_POOL_ID=us-east-1_uxiY8DOum REACT_APP_USER_POOL_CLIENT_ID=1qf4cii9v0t9hn1hnr54f2ao0j REACT_APP_TOTP_ISSUER=Local Crossfeed +AWS_REGION=us-east-1 +REACT_APP_COGNITO_DOMAIN=crossfeed-staging-okta-idp.auth.us-east-1.amazoncognito.com +REACT_APP_COGNITO_CLIENT_ID=481n0fqrjiouharsddrv94c1a2 +REACT_APP_COGNITO_USER_POOL_ID=us-east-1_iWciADuOe +REACT_APP_COGNITO_CALLBACK_URL=http://localhost/okta-callback + FARGATE_MAX_CONCURRENCY=100 SCHEDULER_ORGS_PER_SCANTASK=2 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 63cec033..b8b278df 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { Domains, Feeds, LoginGovCallback, + OktaCallback, RegionUsers, Reports, Risk, @@ -122,6 +123,7 @@ const App: React.FC = () => ( path="/login-gov-callback" component={LoginGovCallback} /> + { + // TODO: Capture default values here once determined + const domain = process.env.REACT_APP_COGNITO_DOMAIN || 'default_value'; + const clientId = process.env.REACT_APP_COGNITO_CLIENT_ID || 'default_value'; + const callbackUrl = + process.env.REACT_APP_COGNITO_CALLBACK_URL || 'default_value'; + const encodedCallbackUrl = encodeURIComponent(callbackUrl); + + const redirectToAuth = () => { + // Adjust this callback URL once determined + window.location.href = `https://${domain}/oauth2/authorize?client_id=${clientId}&response_type=code&scope=email+openid+profile&redirect_uri=${encodedCallbackUrl}`; + }; + + return ( + + ); +}; + interface Errors extends Partial { global?: string; } @@ -214,6 +236,11 @@ export const AuthLogin: React.FC<{ showSignUp?: boolean }> = ({ + + + + + diff --git a/frontend/src/pages/OktaCallback/OktaCallback.tsx b/frontend/src/pages/OktaCallback/OktaCallback.tsx new file mode 100644 index 00000000..f8a9c421 --- /dev/null +++ b/frontend/src/pages/OktaCallback/OktaCallback.tsx @@ -0,0 +1,55 @@ +import React, { useCallback, useEffect } from 'react'; +import { parse } from 'query-string'; +import { useAuthContext } from 'context'; +import { User } from 'types'; +import { useHistory } from 'react-router-dom'; + +type OktaCallbackResponse = { + token: string; + user: User; +}; + +export const OktaCallback: React.FC = () => { + const { apiPost, login } = useAuthContext(); + const history = useHistory(); + + const handleOktaCallback = useCallback(async () => { + const { code } = parse(window.location.search); + console.log('Code: ', code); + const nonce = localStorage.getItem('nonce'); + console.log('Nonce: ', nonce); + + try { + // Pass request to backend callback endpoint + const response = await apiPost( + '/auth/okta-callback', + { + body: { + code: code + } + } + ); + console.log('Response: ', response); + console.log('token ', response.token); + + // Login + await login(response.token); + + // Storage Management + localStorage.setItem('token', response.token); + localStorage.removeItem('nonce'); + localStorage.removeItem('state'); + + history.push('/'); + } catch (e) { + console.error(e); + history.push('/'); + } + }, [apiPost, history, login]); + + useEffect(() => { + handleOktaCallback(); + }, [handleOktaCallback]); + + return
Loading...
; +}; diff --git a/frontend/src/pages/OktaCallback/index.ts b/frontend/src/pages/OktaCallback/index.ts new file mode 100644 index 00000000..d060d4c8 --- /dev/null +++ b/frontend/src/pages/OktaCallback/index.ts @@ -0,0 +1 @@ +export * from './OktaCallback'; diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 1333568e..471a110b 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -4,6 +4,7 @@ export * from './AuthCreateAccount'; export * from './Domains'; export * from './Domain'; export * from './LoginGovCallback'; +export * from './OktaCallback'; export * from './Scans'; export * from './Search'; export * from './TermsOfUse';