diff --git a/src/index.ts b/src/index.ts index 6d4f77f9..0df2ed9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,10 @@ import { readdirSync } from 'fs'; import { join } from 'path'; import yargs, { Argv } from 'yargs'; +import { parseUrl } from './scripts/services/okta-service'; +import * as E from 'fp-ts/lib/Either'; +const organisationURL = 'organisation-url'; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace NodeJS { @@ -52,14 +55,24 @@ const rootCommand = yargs describe: 'Okta private key as string form of JSON', demandOption: true, }) - .option('organisation-url', { + .option(organisationURL, { type: 'string', alias: ['org-url', 'ou'], describe: 'Okta URL for Organisation', demandOption: true, }) + // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types + .check(async (argv) => { + const result = await parseUrl(argv[organisationURL])(); + // eslint-disable-next-line functional/no-conditional-statement + if (E.isLeft(result)) { + // eslint-disable-next-line functional/no-throw-statement + throw result.left; + } + return true; + }) .group( - ['client-id', 'private-key', 'organisation-url'], + ['client-id', 'private-key', organisationURL], 'Okta connection settings:' ) .help(); diff --git a/src/schema.ts b/src/schema.ts index f95ff1f2..7fd678cd 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,7 +1,21 @@ import { z } from 'zod'; -export const oktaAPIError = z.object({ +export const oktaAPIErrorSchema = z.object({ status: z.number(), }); - -export type OktaAPIError = z.infer; +const urlStart = 'https://'; +const urlEnd = '.okta.com'; +export const urlSchema = z + .string() + .url() + .startsWith(urlStart, `URL must start with [${urlStart}].`) + //.co will cause fetch to time out + .endsWith(urlEnd, `URL must end with [${urlEnd}].`) + .min( + urlStart.length + urlEnd.length + 1, + 'Domain name must be at least 1 character long.' + ) + .refine( + (url) => !url.endsWith('-admin.okta.com'), + 'Organisation URL should not be the admin URL. Please remove "-admin" and try again.' + ); diff --git a/src/scripts/ping.test.ts b/src/scripts/ping.test.ts index b24c53a4..39ba2e8a 100644 --- a/src/scripts/ping.test.ts +++ b/src/scripts/ping.test.ts @@ -43,16 +43,18 @@ describe('Pinging', () => { organisationUrl: 'organisation url', privateKey: '', }); + const clientId = '123'; + const urlTemplate = 'https://template.okta.com'; it.each([200, 299])( - 'should return a right when the okta server returns status [200-299] and credentials are correct', + 'should return a right when the okta server returns status [200-299], and credentials along with organisation url are correct', async (statusCode) => { mockFetchRequest.mockResolvedValue({ status: statusCode }); mockOktaRequest.mockResolvedValue('resolved value'); const result = await validateOktaServerAndCredentials( oktaClient, - '', - '' + clientId, + urlTemplate )(); expect(result).toEqualRight(true); expect(mockFetchRequest).toHaveBeenCalledTimes(1); @@ -66,8 +68,8 @@ describe('Pinging', () => { mockFetchRequest.mockResolvedValue({ status: statusCode }); const result = await validateOktaServerAndCredentials( oktaClient, - '', - '' + clientId, + urlTemplate )(); expect(result).toEqualLeft( new Error('Server error. Please wait and try again later.') @@ -87,12 +89,12 @@ describe('Pinging', () => { mockFetchRequest.mockResolvedValue({ status: statusCode }); const result = await validateOktaServerAndCredentials( oktaClient, - '', - '' + clientId, + urlTemplate )(); expect(result).toEqualLeft( new Error( - 'Client error. Please check your client id and the URL of your organisation.' + `Client error. Please check your client id [${clientId}] and the URL of your organisation [${urlTemplate}].` ) ); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions @@ -110,8 +112,8 @@ describe('Pinging', () => { mockFetchRequest.mockResolvedValue({ status: statusCode }); const result = await validateOktaServerAndCredentials( oktaClient, - '', - '' + clientId, + urlTemplate )(); expect(result).toEqualLeft( new Error('Unexpected response from pinging okta server.') @@ -127,7 +129,11 @@ describe('Pinging', () => { it('should return a left when the ping request fails', async () => { mockFetchRequest.mockRejectedValue('rejected value'); - const result = await validateOktaServerAndCredentials(oktaClient, '', '')(); + const result = await validateOktaServerAndCredentials( + oktaClient, + clientId, + urlTemplate + )(); expect(result).toEqualLeft(new Error('Failed to ping okta server.')); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions expect((result as Left).left.cause).toEqual('rejected value'); @@ -152,11 +158,11 @@ describe('Pinging', () => { mockOktaRequest.mockRejectedValue(error); const result = await validateOktaServerAndCredentials( oktaClient, - '', - '' + clientId, + urlTemplate )(); expect(result).toEqualLeft( - new Error('Failed to decode the private key.') + new Error('Client error. Please check your private key.') ); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions expect((result as Left).left.cause).toEqual(error); @@ -168,7 +174,11 @@ describe('Pinging', () => { it('should return a left when the okta server returns status [200-299] but credentials return an unknown error', async () => { mockFetchRequest.mockResolvedValue({ status: 200 }); mockOktaRequest.mockRejectedValue('rejected value'); - const result = await validateOktaServerAndCredentials(oktaClient, '', '')(); + const result = await validateOktaServerAndCredentials( + oktaClient, + clientId, + urlTemplate + )(); expect(result).toEqualLeft(new Error('Failed to get access token.')); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions expect((result as Left).left.cause).toEqual('rejected value'); diff --git a/src/scripts/ping.ts b/src/scripts/ping.ts index 4ce675f1..26e77599 100644 --- a/src/scripts/ping.ts +++ b/src/scripts/ping.ts @@ -67,7 +67,7 @@ export default ( rootCommand.command( 'ping', // eslint-disable-next-line quotes - "Pings the okta server to see if it's running and check user credentials", + "Pings the okta server to see if it's running and check user credentials along with organisation url", // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types (yargs) => yargs.argv, async (args: { diff --git a/src/scripts/services/okta-service.test.ts b/src/scripts/services/okta-service.test.ts new file mode 100644 index 00000000..74447ab3 --- /dev/null +++ b/src/scripts/services/okta-service.test.ts @@ -0,0 +1,65 @@ +/* eslint-disable functional/no-return-void */ +/* eslint-disable functional/no-expression-statement */ + +import { Left } from 'fp-ts/lib/Either'; +import { parseUrl } from './okta-service'; + +/* eslint-disable functional/functional-parameters */ +describe('Parsing url', () => { + it.each([ + [ + '', + [ + { + code: 'invalid_string', + message: 'Invalid url', + path: [], + validation: 'url', + }, + { + code: 'invalid_string', + message: 'URL must start with [https://].', + path: [], + validation: { startsWith: 'https://' }, + }, + { + code: 'invalid_string', + message: 'URL must end with [.okta.com].', + path: [], + validation: { endsWith: '.okta.com' }, + }, + { + code: 'too_small', + exact: false, + inclusive: true, + message: 'Domain name must be at least 1 character long.', + minimum: 18, + path: [], + type: 'string', + }, + ], + ], + [ + 'https://trial-admin.okta.com', + [ + { + code: 'custom', + message: + 'Organisation URL should not be the admin URL. Please remove "-admin" and try again.', + path: [], + }, + ], + ], + ])( + 'should return a left when the url is invalid', + // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types + async (url, issues) => { + const result = await parseUrl(url)(); + expect(result).toEqualLeft( + new Error(`Client error. Invalid URL [${url}].`) + ); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + expect((result as Left).left.cause).toEqual(issues); + } + ); +}); diff --git a/src/scripts/services/okta-service.ts b/src/scripts/services/okta-service.ts index 5d13c231..16f2d7f7 100644 --- a/src/scripts/services/okta-service.ts +++ b/src/scripts/services/okta-service.ts @@ -2,9 +2,24 @@ import * as okta from '@okta/okta-sdk-nodejs'; import * as TE from 'fp-ts/lib/TaskEither'; import { pipe } from 'fp-ts/lib/function'; import fetch from 'node-fetch'; -import { oktaAPIError } from '../../schema'; +import { oktaAPIErrorSchema, urlSchema } from '../../schema'; /** + * Parses a url to check whether it is valid + * @param url - the url to parse + * @returns a TaskEither that resolves to the url if it is valid, otherwise an error. + */ +export const parseUrl = (url: string): TE.TaskEither => { + const parsedURL = urlSchema.safeParse(url); + return parsedURL.success + ? TE.right(parsedURL.data) + : TE.left( + new Error(`Client error. Invalid URL [${url}].`, { + cause: parsedURL.error.issues, + }) + ); +}; + /** * Validates the credentials provided to the tool. * @param client - the Okta client to use to validate the credentials. @@ -28,7 +43,7 @@ export const validateCredentials = ( TE.mapLeft((error) => { const underlyingError = error.cause instanceof Error ? error.cause : error; - const apiError = oktaAPIError.safeParse(underlyingError); + const apiError = oktaAPIErrorSchema.safeParse(underlyingError); const underlyingErrorMessage = underlyingError.message; // eslint-disable-next-line functional/no-conditional-statement switch (true) { @@ -46,7 +61,7 @@ export const validateCredentials = ( 'error:0180006C:bignum routines::no inverse': case underlyingErrorMessage === 'error:1E08010C:DECODER routines::unsupported': { - return new Error('Failed to decode the private key.', { + return new Error('Client error. Please check your private key.', { cause: underlyingError, }); } @@ -75,15 +90,10 @@ export const pingOktaServer = ( `${organisationUrl}/oauth2/default/.well-known/oauth-authorization-server?client_id=${clientId}` ); }, - (error: unknown) => { - // eslint-disable-next-line functional/no-conditional-statement - if (error instanceof Error) { - return error; - } - return new Error('Failed to ping okta server.', { + (error: unknown) => + new Error('Failed to ping okta server.', { cause: error, - }); - } + }) ), // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types TE.chain((response) => { @@ -99,7 +109,7 @@ export const pingOktaServer = ( if (response.status >= 400 && response.status < 500) { return TE.left( new Error( - 'Client error. Please check your client id and the URL of your organisation.', + `Client error. Please check your client id [${clientId}] and the URL of your organisation [${organisationUrl}].`, { cause: response, }