Skip to content
This repository has been archived by the owner on Sep 18, 2023. It is now read-only.

Commit

Permalink
feat: implement oidc login (#43)
Browse files Browse the repository at this point in the history
  • Loading branch information
sbolel authored Jun 18, 2023
1 parent 5aad780 commit 979bea5
Show file tree
Hide file tree
Showing 14 changed files with 244 additions and 50 deletions.
13 changes: 10 additions & 3 deletions .env.test
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Dummy values for local testing to avoid errors
VITE_CF_DOMAIN='https://localhost:3000'
VITE_USER_POOL_ID='us-east-1_123456789'
VITE_USER_POOL_CLIENT_ID='1234567890123456789012'

# AWS
VITE_AWS_REGION='us-east-1'
VITE_CF_DOMAIN='https://localhost:3000'

# AWS Cognito
VITE_COGNITO_DOMAIN='https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abcd1234'
VITE_USER_POOL_ID='us-east-1_abcd1234'
VITE_USER_POOL_CLIENT_ID='12345678901234567890123456'
VITE_COGNITO_REDIRECT_SIGN_IN='http://localhost:3000/signin'
VITE_COGNITO_REDIRECT_SIGN_OUT='http://localhost:3000/signout'
110 changes: 110 additions & 0 deletions src/actions/loginUserFederated.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { AuthActions } from '@/actions/actionTypes'
import loginUserFederated from '@/actions/loginUserFederated'
import { Auth } from 'aws-amplify'

jest.mock('aws-amplify')

describe('loginUserFederated', () => {
let mockDispatch: jest.Mock

beforeEach(() => {
mockDispatch = jest.fn()
})

afterEach(() => {
jest.clearAllMocks()
})

it('should dispatch LOGIN_REQUEST action', async () => {
const mockUser = {
accessKeyId: 'testAccessKeyId',
sessionToken: 'testSessionToken',
secretAccessKey: 'testSecretAccessKey',
identityId: 'testIdentityId',
authenticated: true,
expiration: new Date(),
}

const mockToken = 'testToken'

;(Auth.federatedSignIn as jest.Mock).mockResolvedValue(mockUser)
;(Auth.currentSession as jest.Mock).mockResolvedValue({
getAccessToken: () => ({
getJwtToken: () => mockToken,
}),
})

await loginUserFederated(mockDispatch)

expect(mockDispatch).toHaveBeenCalledWith({
type: AuthActions.LOGIN_REQUEST,
})
})

it('should dispatch LOGIN_SUCCESS action when login is successful', async () => {
const mockUser = {
accessKeyId: 'testAccessKeyId',
sessionToken: 'testSessionToken',
secretAccessKey: 'testSecretAccessKey',
identityId: 'testIdentityId',
authenticated: true,
expiration: new Date(),
}

const mockToken = 'testToken'

;(Auth.federatedSignIn as jest.Mock).mockResolvedValue(mockUser)
;(Auth.currentSession as jest.Mock).mockResolvedValue({
getAccessToken: () => ({
getJwtToken: () => mockToken,
}),
})

await loginUserFederated(mockDispatch)

expect(mockDispatch).toHaveBeenCalledWith({
type: AuthActions.LOGIN_SUCCESS,
payload: mockUser,
})
})

it('should dispatch LOGIN_FAILURE action when login fails', async () => {
const mockError = new Error('login failed')

;(Auth.federatedSignIn as jest.Mock).mockRejectedValue(mockError)

await loginUserFederated(mockDispatch)

expect(mockDispatch).toHaveBeenCalledWith({
type: AuthActions.LOGIN_FAILURE,
error: mockError,
})
})

it('should dispatch LOGIN_FAILURE action when no JWT token is found', async () => {
const mockUser = {
accessKeyId: 'testAccessKeyId',
sessionToken: 'testSessionToken',
secretAccessKey: 'testSecretAccessKey',
identityId: 'testIdentityId',
authenticated: true,
expiration: new Date(),
}

const mockToken = ''

;(Auth.federatedSignIn as jest.Mock).mockResolvedValue(mockUser)
;(Auth.currentSession as jest.Mock).mockResolvedValue({
getAccessToken: () => ({
getJwtToken: () => mockToken,
}),
})

await loginUserFederated(mockDispatch)

expect(mockDispatch).toHaveBeenCalledWith({
type: AuthActions.LOGIN_FAILURE,
error: new Error('No JWT token found'),
})
})
})
49 changes: 49 additions & 0 deletions src/actions/loginUserFederated.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* @module @sbom-harbor-ui/dashboard/actions/loginUserFederated
*/
import React from 'react'
import { Auth } from 'aws-amplify'
import { AuthActions } from '@/actions/actionTypes'

export default async function loginUserFederated(
dispatch: React.Dispatch<{
type: AuthActions
payload?: unknown
error?: Error
}>
) {
try {
dispatch({ type: AuthActions.LOGIN_REQUEST })
const user = await Auth.federatedSignIn()
const jwtToken = await (await Auth.currentSession())
.getAccessToken()
.getJwtToken()

if (!jwtToken) {
throw new Error('No JWT token found')
}

const {
accessKeyId,
sessionToken,
secretAccessKey,
identityId,
authenticated,
expiration,
} = user

const data = {
accessKeyId,
sessionToken,
secretAccessKey,
identityId,
authenticated,
expiration,
}

dispatch({ type: AuthActions.LOGIN_SUCCESS, payload: data })
return data
} catch (error) {
dispatch({ type: AuthActions.LOGIN_FAILURE, error: error as Error })
}
}
2 changes: 1 addition & 1 deletion src/api/getUsersSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* @module sbom-harbor-ui/api/getUsersSearch
*/
import { Auth } from 'aws-amplify'
import { CONFIG } from '@/utils/constants'
import { CONFIG } from '@/utils/config'

const { USER_API_SEARCH_URL } = CONFIG

Expand Down
3 changes: 1 addition & 2 deletions src/hooks/useAlert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
*/
import * as React from 'react'
import { AlertColor } from '@mui/material/Alert'

export const DEFAULT_ALERT_TIMEOUT = 3000
import { DEFAULT_ALERT_TIMEOUT } from '@/constants'

export type AlertProps = {
severity?: AlertColor
Expand Down
11 changes: 5 additions & 6 deletions src/hooks/useAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const INITIAL_STATE = {
/**
* @type {AuthValuesType} The initial values provided by AuthContext.
*/
export const defaultProvider: AuthValuesType = {
const defaultProvider: AuthValuesType = {
...INITIAL_STATE,
}

Expand Down Expand Up @@ -56,7 +56,6 @@ export const AuthReducer = (
...initialState,
jwtToken: '',
}

case AuthActions.LOGIN_FAILURE:
return {
...initialState,
Expand All @@ -79,7 +78,6 @@ export function useAuthState() {
if (context === undefined) {
throw new Error('useAuthState must be used within a AuthProvider')
}

return context
}

Expand All @@ -88,7 +86,6 @@ export function useAuthDispatch() {
if (context === undefined) {
throw new Error('useAuthDispatch must be used within a AuthProvider')
}

return context
}

Expand Down Expand Up @@ -116,8 +113,10 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
*/
const init = React.useCallback(async () => {
try {
const user = await Auth.currentAuthenticatedUser()
const session = await Auth.currentSession()
const [user, session] = await Promise.all([
Auth.currentAuthenticatedUser(),
Auth.currentSession(),
])

if (!user || !session) {
throw new Error('No user or session')
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import * as React from 'react'
import { useAuthState } from '@/hooks/useAuth'
import { CONFIG } from '@/utils/constants'
import { CONFIG } from '@/utils/config'
import { TeamEntity } from '@/types'

type DataState = {
Expand Down
2 changes: 1 addition & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as ReactDOMClient from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import CssBaseline from '@mui/material/CssBaseline'
import router from '@/router/router'
import { CONFIG } from '@/utils/constants'
import { CONFIG } from '@/utils/config'
import configureCognito from '@/utils/configureCognito'

// IIFE that initializes the root node and renders the application.
Expand Down
16 changes: 0 additions & 16 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,6 @@ import type {
//* Application Types
//*--------------------------------------------------------/

/**
* The shape of the global CONFIG object provided by craco (webpack).
* @see {@link @sbom-harbor-ui/dashboard/craco.config.js}
*/
export type AppConfig = {
AWS_REGION: string | 'us-east-1'
CF_DOMAIN: string
API_URL: string
TEAM_API_URL: string
TEAMS_API_URL: string
USER_API_URL: string
USER_API_SEARCH_URL: string
USER_POOL_ID: string
USER_POOL_CLIENT_ID: string
}

export enum RouteIds {
AUTHED_APP = 'authed-app',
DASHBOARD = 'dashboard',
Expand Down
34 changes: 28 additions & 6 deletions src/utils/constants.ts → src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,24 @@
* @exports CONFIG
* @exports storageTokenKeyName
*/
import { AppConfig } from '@/types'
type AppConfig = {
AWS_REGION: string | 'us-east-1'
CF_DOMAIN: string

// Cognito
USER_POOL_ID: string
USER_POOL_CLIENT_ID: string
COGNITO_DOMAIN: string
COGNITO_REDIRECT_SIGN_IN: string
COGNITO_REDIRECT_SIGN_OUT: string

// API URLs
API_URL: string
TEAM_API_URL: string
TEAMS_API_URL: string
USER_API_URL: string
USER_API_SEARCH_URL: string
}

// parse `CONFIG` from environment variables
const apiUrl = `${process.env.VITE_CF_DOMAIN}/api`
Expand All @@ -13,15 +30,20 @@ const apiUrl = `${process.env.VITE_CF_DOMAIN}/api`
* @see {@link @sbom-harbor-ui/dashboard/craco.config.js}.
*/
export const CONFIG = {
AWS_REGION: process.env.VITE_AWS_REGION,
CF_DOMAIN: process.env.VITE_CF_DOMAIN,

// Cognito
USER_POOL_ID: process.env.VITE_USER_POOL_ID,
USER_POOL_CLIENT_ID: process.env.VITE_USER_POOL_CLIENT_ID,
COGNITO_DOMAIN: process.env.VITE_COGNITO_DOMAIN,
COGNITO_REDIRECT_SIGN_IN: process.env.VITE_COGNITO_REDIRECT_SIGN_IN,
COGNITO_REDIRECT_SIGN_OUT: process.env.VITE_COGNITO_REDIRECT_SIGN_OUT,

// API URLs
API_URL: apiUrl,
TEAM_API_URL: `${apiUrl}/v1/team`,
TEAMS_API_URL: `${apiUrl}/v1/teams`,
USER_API_URL: `${apiUrl}/v1/user`,
USER_API_SEARCH_URL: `${apiUrl}/v1/user/search`,
USER_POOL_ID: process.env.VITE_USER_POOL_ID,
USER_POOL_CLIENT_ID: process.env.VITE_USER_POOL_CLIENT_ID,
AWS_REGION: process.env.VITE_AWS_REGION,
} as AppConfig

export const DEFAULT_ALERT_TIMEOUT = 3000
28 changes: 16 additions & 12 deletions src/utils/configureCognito.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,32 @@
* @see {@link @sbom-harbor-ui/dashboard/router/routes.tsx}
*/
import { Amplify } from 'aws-amplify'
import { CONFIG } from '@/utils/constants'
import { CONFIG } from '@/utils/config'

export function configureCognito(): null {
// TODO: remove this once Cognito is configured
if (
!CONFIG.AWS_REGION ||
!CONFIG.USER_POOL_ID ||
!CONFIG.USER_POOL_CLIENT_ID
) {
return null
}

const configObject = {
const options = {
region: CONFIG.AWS_REGION,
userPoolId: CONFIG.USER_POOL_ID || new Error('USER_POOL_ID is not defined'),
userPoolWebClientId:
CONFIG.USER_POOL_CLIENT_ID ||
new Error('USER_POOL_CLIENT_ID is not defined'),
}

const oauth = {
domain: CONFIG.COGNITO_DOMAIN || new Error('COGNITO_DOMAIN is not defined'),
scope: ['openid', 'email', 'profile'],
redirectSignIn: CONFIG.COGNITO_REDIRECT_SIGN_IN,
redirectSignOut: CONFIG.COGNITO_REDIRECT_SIGN_OUT,
responseType: 'code',
}

// Configure Amplify Auth with the Cognito User Pool
Amplify.configure(configObject)
Amplify.configure({
Auth: {
...options,
oauth,
},
})

// a loader has to return something or null
return null
Expand Down
2 changes: 1 addition & 1 deletion src/utils/harborRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Factory to create a function that will make a request to the Harbor API
* @module sbom-harbor-ui/utils/harborRequest
*/
import { CONFIG } from '@/utils/constants'
import { CONFIG } from '@/utils/config'
import sanitizeUrl from '@/utils/sanitizeUrl'

type HarborRequestParams = {
Expand Down
Loading

0 comments on commit 979bea5

Please sign in to comment.