Skip to content

Commit

Permalink
feat(tunnel): support cli deploy custom ui assets to cloud (#6530)
Browse files Browse the repository at this point in the history
  • Loading branch information
charIeszhao authored Sep 3, 2024
1 parent 31296f0 commit ff4cd67
Show file tree
Hide file tree
Showing 13 changed files with 472 additions and 143 deletions.
33 changes: 33 additions & 0 deletions .changeset/modern-ghosts-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@logto/tunnel": minor
---

add deploy command and env support

#### Add new `deploy` command to deploy your local custom UI assets to your Logto Cloud tenant

1. Create a machine-to-machine app with Management API permissions in your Logto tenant
2. Run the following command

```bash
npx @logto/tunnel deploy --auth <your-m2m-app-id>:<your-m2m-app-secret> --endpoint https://<tenant-id>.logto.app --management-api-resource https://<tenant-id>.logto.app/api --experience-path /path/to/your/custom/ui
```

Note: The `--management-api-resource` (or `--resource`) can be omitted when using the default Logto domain, since the CLI can infer the value automatically. If you are using custom domain for your Logto endpoint, this option must be provided.

#### Add environment variable support

1. Create a `.env` file in the CLI root directory, or any parent directory where the CLI is located.
2. Alternatively, specify environment variables directly when running CLI commands:

```bash
ENDPOINT=https://<tenant-id>.logto.app npx @logto/tunnel ...
```

Supported environment variables:

- LOGTO_AUTH
- LOGTO_ENDPOINT
- LOGTO_EXPERIENCE_PATH
- LOGTO_EXPERIENCE_URI
- LOGTO_MANAGEMENT_API_RESOURCE
4 changes: 4 additions & 0 deletions packages/tunnel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,20 @@
"@logto/core-kit": "workspace:^",
"@logto/shared": "workspace:^",
"@silverhand/essentials": "^2.9.1",
"adm-zip": "^0.5.14",
"chalk": "^5.3.0",
"dotenv": "^16.4.5",
"find-up": "^7.0.0",
"http-proxy-middleware": "^3.0.0",
"mime": "^4.0.4",
"ora": "^8.0.1",
"yargs": "^17.6.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@silverhand/eslint-config": "6.0.1",
"@silverhand/ts-config": "6.0.0",
"@types/adm-zip": "^0.5.5",
"@types/node": "^20.9.5",
"@types/yargs": "^17.0.13",
"@vitest/coverage-v8": "^2.0.0",
Expand Down
108 changes: 108 additions & 0 deletions packages/tunnel/src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { existsSync } from 'node:fs';
import path from 'node:path';

import { isValidUrl } from '@logto/core-kit';
import chalk from 'chalk';
import ora from 'ora';
import type { CommandModule } from 'yargs';

import { consoleLog } from '../../utils.js';

import { type DeployCommandArgs } from './types.js';
import { deployToLogtoCloud } from './utils.js';

const tunnel: CommandModule<unknown, DeployCommandArgs> = {
command: ['deploy'],
describe: 'Deploy your custom UI assets to Logto Cloud',
builder: (yargs) =>
yargs
.options({
auth: {
describe:
'Auth credentials of your Logto M2M application. E.g.: <app-id>:<app-secret> (Docs: https://docs.logto.io/docs/recipes/interact-with-management-api/#create-an-m2m-app)',
type: 'string',
},
endpoint: {
describe:
'Logto endpoint URI that points to your Logto Cloud instance. E.g.: https://<tenant-id>.logto.app/',
type: 'string',
},
path: {
alias: ['experience-path'],
describe: 'The local folder path of your custom sign-in experience assets.',
type: 'string',
},
resource: {
alias: ['management-api-resource'],
describe: 'Logto Management API resource indicator. Required if using custom domain.',
type: 'string',
},
verbose: {
describe: 'Show verbose output.',
type: 'boolean',
default: false,
},
})
.epilog(
`Refer to our documentation for more details:\n${chalk.blue(
'https://docs.logto.io/docs/references/tunnel-cli/deploy'
)}`
),
handler: async (options) => {
const {
auth,
endpoint,
path: experiencePath,
resource: managementApiResource,
verbose,
} = options;
if (!auth) {
consoleLog.fatal(
'Must provide valid Machine-to-Machine (M2M) authentication credentials. E.g. `--auth <app-id>:<app-secret>` or add `LOGTO_AUTH` to your environment variables.'
);
}
if (!endpoint || !isValidUrl(endpoint)) {
consoleLog.fatal(
'A valid Logto endpoint URI must be provided. E.g. `--endpoint https://<tenant-id>.logto.app/` or add `LOGTO_ENDPOINT` to your environment variables.'
);
}
if (!experiencePath) {
consoleLog.fatal(
'A valid experience path must be provided. E.g. `--experience-path /path/to/experience` or add `LOGTO_EXPERIENCE_PATH` to your environment variables.'
);
}
if (!existsSync(path.join(experiencePath, 'index.html'))) {
consoleLog.fatal(`The provided experience path must contain an "index.html" file.`);
}

const spinner = ora();

if (verbose) {
consoleLog.plain(
`${chalk.bold('Starting deployment...')} ${chalk.gray('(with verbose output)')}`
);
} else {
spinner.start('Deploying your custom UI assets to Logto Cloud...');
}

await deployToLogtoCloud({ auth, endpoint, experiencePath, managementApiResource, verbose });

if (!verbose) {
spinner.succeed('Deploying your custom UI assets to Logto Cloud... Done.');
}

const endpointUrl = new URL(endpoint);
spinner.succeed(`🎉 ${chalk.bold(chalk.green('Deployment successful!'))}`);
consoleLog.plain(`${chalk.green('➜')} You can try your own sign-in UI on Logto Cloud now.`);
consoleLog.plain(`${chalk.green('➜')} Make sure the Logto endpoint URI in your app is set to:`);
consoleLog.plain(` ${chalk.blue(chalk.bold(endpointUrl.href))}`);
consoleLog.plain(
`${chalk.green(
'➜'
)} If you are using social sign-in, make sure the social redirect URI is set to:`
);
consoleLog.plain(` ${chalk.blue(chalk.bold(`${endpointUrl.href}callback/<connector-id>`))}`);
},
};

export default tunnel;
7 changes: 7 additions & 0 deletions packages/tunnel/src/commands/deploy/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type DeployCommandArgs = {
auth?: string;
endpoint?: string;
path?: string;
resource?: string;
verbose: boolean;
};
161 changes: 161 additions & 0 deletions packages/tunnel/src/commands/deploy/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { appendPath } from '@silverhand/essentials';
import AdmZip from 'adm-zip';
import chalk from 'chalk';
import ora from 'ora';

import { consoleLog } from '../../utils.js';

type TokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
scope: string;
};

type DeployArgs = {
auth: string;
endpoint: string;
experiencePath: string;
managementApiResource?: string;
verbose: boolean;
};

export const deployToLogtoCloud = async ({
auth,
endpoint,
experiencePath,
managementApiResource,
verbose,
}: DeployArgs) => {
const spinner = ora();
if (verbose) {
spinner.start('[1/4] Zipping files...');
}
const zipBuffer = await zipFiles(experiencePath);
if (verbose) {
spinner.succeed('[1/4] Zipping files... Done.');
}

try {
if (verbose) {
spinner.start('[2/4] Exchanging access token...');
}
const endpointUrl = new URL(endpoint);
const tokenResponse = await getAccessToken(auth, endpointUrl, managementApiResource);
if (verbose) {
spinner.succeed('[2/4] Exchanging access token... Done.');
spinner.succeed(
`Token exchange response:\n${chalk.gray(JSON.stringify(tokenResponse, undefined, 2))}`
);
spinner.start('[3/4] Uploading zip...');
}
const accessToken = tokenResponse.access_token;
const uploadResult = await uploadCustomUiAssets(accessToken, endpointUrl, zipBuffer);

if (verbose) {
spinner.succeed('[3/4] Uploading zip... Done.');
spinner.succeed(
`Received response:\n${chalk.gray(JSON.stringify(uploadResult, undefined, 2))}`
);
spinner.start('[4/4] Saving changes to your tenant...');
}

await saveChangesToSie(accessToken, endpointUrl, uploadResult.customUiAssetId);

if (verbose) {
spinner.succeed('[4/4] Saving changes to your tenant... Done.');
}
} catch (error: unknown) {
spinner.fail();
const errorMessage = error instanceof Error ? error.message : String(error);
consoleLog.fatal(chalk.red(errorMessage));
}
};

const zipFiles = async (path: string): Promise<Uint8Array> => {
const zip = new AdmZip();
await zip.addLocalFolderPromise(path, {});
return zip.toBuffer();
};

const getAccessToken = async (auth: string, endpoint: URL, managementApiResource?: string) => {
const tokenEndpoint = appendPath(endpoint, '/oidc/token').href;
const resource = managementApiResource ?? getManagementApiResourceFromEndpointUri(endpoint);

const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(auth).toString('base64')}`,
},
body: new URLSearchParams({
grant_type: 'client_credentials',
resource,
scope: 'all',
}).toString(),
});

if (!response.ok) {
throw new Error(`Failed to fetch access token: ${response.statusText}`);
}

return response.json<TokenResponse>();
};

const uploadCustomUiAssets = async (accessToken: string, endpoint: URL, zipBuffer: Uint8Array) => {
const form = new FormData();
const blob = new Blob([zipBuffer], { type: 'application/zip' });
const timestamp = Math.floor(Date.now() / 1000);
form.append('file', blob, `custom-ui-${timestamp}.zip`);

const uploadResponse = await fetch(
appendPath(endpoint, '/api/sign-in-exp/default/custom-ui-assets'),
{
method: 'POST',
body: form,
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
}
);

if (!uploadResponse.ok) {
throw new Error(`Request error: [${uploadResponse.status}] ${uploadResponse.status}`);
}

return uploadResponse.json<{ customUiAssetId: string }>();
};

const saveChangesToSie = async (accessToken: string, endpointUrl: URL, customUiAssetId: string) => {
const timestamp = Math.floor(Date.now() / 1000);
const patchResponse = await fetch(appendPath(endpointUrl, '/api/sign-in-exp'), {
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
customUiAssets: { id: customUiAssetId, createdAt: timestamp },
}),
});

if (!patchResponse.ok) {
throw new Error(`Request error: [${patchResponse.status}] ${patchResponse.statusText}`);
}

return patchResponse.json();
};

const getTenantIdFromEndpointUri = (endpoint: URL) => {
const splitted = endpoint.hostname.split('.');
return splitted.length > 2 ? splitted[0] : 'default';
};

const getManagementApiResourceFromEndpointUri = (endpoint: URL) => {
const tenantId = getTenantIdFromEndpointUri(endpoint);

// This resource domain is fixed to `logto.app` for all environments (prod, staging, and dev)
return `https://${tenantId}.logto.app/api`;
};
Loading

0 comments on commit ff4cd67

Please sign in to comment.