diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 44a5aad..f6c91bf 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,6 +4,9 @@ on: push: pull_request: workflow_dispatch: + pull_request: + branches: + - main jobs: test: diff --git a/README.md b/README.md index 20cbbf6..0dda0c6 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ fusionauth --help; ``` Currently, the CLI supports the following commands: +- Application Update + - `fusionauth application:update ` - Updates an application with provided data + - `--host` - Required. Provide a FusionAuth host URL or add it via an environment variable (`FUSIONAUTH_HOST`) + - `--key` - Required. Provide an API key with permissions for updating the given application or add via an environment variable( `FUSIONAUTH_API_KEY`) + - `-d, --data ` - Provide a data file containing all the properties you wish to update constructed like the body of an application update + - `-p, --prop ` - Update a single property in the application + - `--redirect-url ` - Update the Authorized redirect URL for your applicatoin + - `--example` - Create an example file with editable properties to use in conjunction with the `--data` flag - Common config check - `fusionauth check:common-config` - Checks to make sure common configuration settings are set. - Emails diff --git a/src/commands/application-update/index.ts b/src/commands/application-update/index.ts new file mode 100644 index 0000000..fb7048c --- /dev/null +++ b/src/commands/application-update/index.ts @@ -0,0 +1 @@ +export * from "./update.js"; diff --git a/src/commands/application-update/update.ts b/src/commands/application-update/update.ts new file mode 100644 index 0000000..5b2c4ab --- /dev/null +++ b/src/commands/application-update/update.ts @@ -0,0 +1,125 @@ +import { Command } from "@commander-js/extra-typings"; +import { __dirname, logEvent } from '../../utils.js' +import { + ApplyOptions, + ExecutionMetrics, + StepResult, + StepStatus, + ErrorCategory, +} from '../../utilities/apply/types.js'; +import { HTTPClient } from '../../utilities/apply/http-client.js'; +import { apiKeyOption, hostOption } from '../../options.js'; +import path from "node:path"; +import { readFileSync, writeFileSync } from "node:fs"; +import chalk from "chalk"; +import { exampleApplicationBody } from "../../utils.js"; + +function getData(file: string) { + const fileLoc = path.resolve(file) + + const contentBuffer = readFileSync(fileLoc).toString('utf-8') + const contents = JSON.parse(contentBuffer) + + return contents + +} + +function setNestedProps(obj: any, path: string, value: any) { + /* Takes object and dynamically applies a property at any depth + myprop.somedepth.key = "value" coverts to {myprop: {somedepth: {key: value}}} + */ + let schema = obj; + const pList = path.split('.'); + const len = pList.length; + for(var i = 0; i < len-1; i++) { + var elem = pList[i]; + if( !schema[elem] ) schema[elem] = {} + schema = schema[elem]; + } + + schema[pList[len-1]] = value; + + return schema +} + + +function displaySuccess() { + + console.log(chalk.green("Successfully submitted Application update")) + +} + + + + +export function convertOptionsToApiBody(options: any) { + let body: Record = { + application: {} + } + console.log({ options }) + if (options.redirectUrl) { + if (!body?.application?.oauthConfiguration) body.application.oauthConfiguration = {} + body.application.oauthConfiguration.authorizedRedirectURLs = [options.redirectUrl] + } + + console.log(body) + return body +} + +function splitProp(prop: string) { + const [key,value] = prop.split("=") + return {key, value} +} + +const action = async function (options: Record): Promise { + const { + host = 'http://localhost:9011', + key, + id + } = options + const httpClient = new HTTPClient(host, key); + + try { + + if (options?.example) { + console.log(chalk.yellow("Generating example file in current directory")) + writeFileSync('./application.example.json', JSON.stringify(exampleApplicationBody, null, 2)) + console.log(chalk.green(`File created at ${path.resolve('./application.example.json')}`)) + return + } + + if (options?.prop) { + let data = { application: {}} + const splitprops = options.prop.map((prop:string) => splitProp(prop)) + splitprops.forEach((prop:any) => setNestedProps(data.application, prop.key, prop.value)) + const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) + return + } + + if (options?.data) { + const data = await getData(options.data) + await httpClient.executeRequest('PATCH', `/api/application/${id}`, { application: data }) + displaySuccess() + return + } + + const apiBody = convertOptionsToApiBody(options) + await httpClient.executeRequest('PATCH', `/api/application/${id}`, apiBody) + return + + } catch (e) { + console.log(e) + } + +} +export const appUpdate = new Command() + .command('application:update') + .requiredOption('-i, --id ', "The FusionAuth Application ID to update") + .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") + .option('--redirect-url ', 'Oauth2.0 Authorized URL') + .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') + .option('--example', "Generate an example JSON document showing much of what can be updated via application:update") + .addOption(hostOption) + .addOption(apiKeyOption) + .description('Updates an application with data provided via a file, a property, or a command flag.') + .action(action) diff --git a/src/commands/index.ts b/src/commands/index.ts index 07f0f21..16789d9 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,3 +1,4 @@ +export * from './application-update/index.js' export * from './check-common-config.js'; export * from './email-create.js'; export * from './email-download.js'; diff --git a/src/utils.ts b/src/utils.ts index ec2da2b..d5250b5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -324,4 +324,199 @@ async function updateGlobalConfig(propertiesToAdd: PropertyToAdd | PropertyToAdd } fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2)) -} \ No newline at end of file +} + +export const exampleApplicationBody = { + "accessControlConfiguration": {}, + "active": true, + "authenticationTokenConfiguration": { + "enabled": false + }, + "data": {}, + "emailConfiguration": {}, + "externalIdentifierConfiguration": {}, + "formConfiguration": { + "adminRegistrationFormId": "UUID", + "selfServiceFormConfiguration": { + "requireCurrentPasswordOnPasswordChange": false + } + }, + "id": "UUID", + "insertInstant": 1234, + "jwtConfiguration": { + "accessTokenKeyId": "UUID", + "enabled": true, + "idTokenKeyId": "e73fe48a-1527-43cf-9b66-9eaa4c44d909", + "refreshTokenExpirationPolicy": "Fixed", + "refreshTokenOneTimeUseConfiguration": { + "gracePeriodInSeconds": 0 + }, + "refreshTokenSlidingWindowConfiguration": { + "maximumTimeToLiveInMinutes": 43200 + }, + "refreshTokenTimeToLiveInMinutes": 43200, + "refreshTokenUsagePolicy": "Reusable", + "timeToLiveInSeconds": 3600 + }, + "lambdaConfiguration": {}, + "lastUpdateInstant": 1789396293195, + "loginConfiguration": { + "allowTokenRefresh": false, + "generateRefreshTokens": false, + "requireAuthentication": true + }, + "multiFactorConfiguration": { + "email": {}, + "sms": {}, + "voice": {} + }, + "name": "Name string", + "oauthConfiguration": { + "authorizedOriginURLs": [ + "http://localhost:3000" + ], + "authorizedRedirectURLs": [ + "http://localhost:1002" + ], + "authorizedResourceUris": [ + "http://localhost:3000" + ], + "authorizedURLValidationPolicy": "ExactMatch", + "clientAuthenticationPolicy": "NotRequiredWhenUsingPKCE", + "clientId": "UUID", + "clientSecret": "super-secret-secret-that-should-be-regenerated-for-production", + "consentMode": "AlwaysPrompt", + "debug": true, + "enabledGrants": [ + "authorization_code", + "refresh_token" + ], + "generateRefreshTokens": true, + "logoutBehavior": "AllApplications", + "logoutURL": "http://localhost:3000", + "proofKeyForCodeExchangePolicy": "Required", + "providedScopePolicy": { + "address": { + "enabled": true, + "required": false + }, + "email": { + "enabled": true, + "required": false + }, + "phone": { + "enabled": true, + "required": false + }, + "profile": { + "enabled": true, + "required": false + } + }, + "relationship": "FirstParty", + "requireClientAuthentication": true, + "requireRegistration": true, + "scopeHandlingPolicy": "Strict", + "unknownScopePolicy": "Reject" + }, + "passwordlessConfiguration": { + "emailLoginStrategy": "ClickableLink", + "enabled": false, + "phoneLoginStrategy": "FormField" + }, + "phoneConfiguration": {}, + "registrationConfiguration": { + "birthDate": { + "enabled": false, + "required": false + }, + "completeRegistration": false, + "confirmPassword": false, + "enabled": true, + "firstName": { + "enabled": false, + "required": false + }, + "fullName": { + "enabled": false, + "required": false + }, + "lastName": { + "enabled": false, + "required": false + }, + "loginIdType": "email", + "middleName": { + "enabled": false, + "required": false + }, + "mobilePhone": { + "enabled": false, + "required": false + }, + "preferredLanguages": { + "enabled": false, + "required": false + }, + "type": "basic" + }, + "registrationDeletePolicy": { + "unverified": { + "enabled": false, + "numberOfDaysToRetain": 120 + } + }, + "roles": [], + "samlv2Configuration": { + "assertionEncryptionConfiguration": { + "digestAlgorithm": "SHA256", + "enabled": false, + "encryptionAlgorithm": "AES256GCM", + "keyLocation": "Child", + "keyTransportAlgorithm": "RSA_OAEP", + "maskGenerationFunction": "MGF1_SHA1" + }, + "authorizedRedirectURLs": [], + "debug": false, + "enabled": false, + "initiatedLogin": { + "enabled": false, + "nameIdFormat": "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + }, + "loginHintConfiguration": { + "enabled": true, + "parameterName": "login_hint" + }, + "logout": { + "behavior": "AllParticipants", + "requireSignedRequests": false, + "singleLogout": { + "enabled": false, + "xmlSignatureC14nMethod": "exclusive_with_comments" + }, + "xmlSignatureC14nMethod": "exclusive_with_comments" + }, + "requireSignedRequests": false, + "xmlSignatureC14nMethod": "exclusive_with_comments", + "xmlSignatureLocation": "Assertion" + }, + "scopes": [], + "state": "Active", + "tenantId": "d7d09513-a3f5-401c-9685-34ab6c552453", + "universalConfiguration": { + "universal": false + }, + "unverified": { + "behavior": "Allow" + }, + "verifyRegistration": false, + "webAuthnConfiguration": { + "bootstrapWorkflow": { + "enabled": false + }, + "enabled": false, + "reauthenticationWorkflow": { + "enabled": false + } + } +} \ No newline at end of file