UNPKG

@backstage/integration

Version:

Helpers for managing integrations towards external systems

212 lines (206 loc) • 6.81 kB
'use strict'; var parseGitUrl = require('git-url-parse'); var authApp = require('@octokit/auth-app'); var rest = require('@octokit/rest'); var luxon = require('luxon'); function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; } var parseGitUrl__default = /*#__PURE__*/_interopDefaultCompat(parseGitUrl); class Cache { tokenCache = /* @__PURE__ */ new Map(); async getOrCreateToken(owner, repo, supplier) { let existingInstallationData = this.tokenCache.get(owner); if (!existingInstallationData || this.isExpired(existingInstallationData.expiresAt)) { existingInstallationData = await supplier(); existingInstallationData.expiresAt = existingInstallationData.expiresAt.minus({ minutes: 10 }); this.tokenCache.set(owner, existingInstallationData); } if (!this.appliesToRepo(existingInstallationData, repo)) { throw new Error( `The Backstage GitHub application used in the ${owner} organization does not have access to a repository with the name ${repo}` ); } return { accessToken: existingInstallationData.token }; } isExpired = (date) => luxon.DateTime.local() > date; appliesToRepo(tokenData, repo) { if (repo === void 0) { return true; } if (tokenData.repositories !== void 0) { return tokenData.repositories.includes(repo); } return true; } } const HEADERS = { Accept: "application/vnd.github.machine-man-preview+json" }; class GithubAppManager { appClient; baseUrl; baseAuthConfig; cache = new Cache(); allowedInstallationOwners; // undefined allows all installations constructor(config, baseUrl) { this.allowedInstallationOwners = config.allowedInstallationOwners; this.baseUrl = baseUrl; this.baseAuthConfig = { appId: config.appId, privateKey: config.privateKey.replace(/\\n/gm, "\n") }; this.appClient = new rest.Octokit({ baseUrl, headers: HEADERS, authStrategy: authApp.createAppAuth, auth: this.baseAuthConfig }); } async getInstallationCredentials(owner, repo) { if (this.allowedInstallationOwners) { if (!this.allowedInstallationOwners?.includes(owner)) { return { accessToken: void 0 }; } } return this.cache.getOrCreateToken(owner, repo, async () => { const { installationId, suspended } = await this.getInstallationData( owner ); if (suspended) { throw new Error(`The GitHub application for ${owner} is suspended`); } const result = await this.appClient.apps.createInstallationAccessToken({ installation_id: installationId, headers: HEADERS }); let repositoryNames; if (result.data.repository_selection === "selected") { const installationClient = new rest.Octokit({ baseUrl: this.baseUrl, auth: result.data.token }); const repos = await installationClient.paginate( installationClient.apps.listReposAccessibleToInstallation ); const repositories = repos.repositories ?? repos; repositoryNames = repositories.map((repository) => repository.name); } return { token: result.data.token, expiresAt: luxon.DateTime.fromISO(result.data.expires_at), repositories: repositoryNames }; }); } getInstallations() { return this.appClient.paginate(this.appClient.apps.listInstallations); } async getInstallationData(owner) { const allInstallations = await this.getInstallations(); const installation = allInstallations.find( (inst) => inst.account && "login" in inst.account && inst.account.login?.toLocaleLowerCase("en-US") === owner.toLocaleLowerCase("en-US") ); if (installation) { return { installationId: installation.id, suspended: Boolean(installation.suspended_by) }; } const notFoundError = new Error( `No app installation found for ${owner} in ${this.baseAuthConfig.appId}` ); notFoundError.name = "NotFoundError"; throw notFoundError; } } class GithubAppCredentialsMux { apps; constructor(config) { this.apps = config.apps?.map((ac) => new GithubAppManager(ac, config.apiBaseUrl)) ?? []; } async getAllInstallations() { if (!this.apps.length) { return []; } const installs = await Promise.all( this.apps.map((app) => app.getInstallations()) ); return installs.flat(); } async getAppToken(owner, repo) { if (this.apps.length === 0) { return void 0; } const results = await Promise.all( this.apps.map( (app) => app.getInstallationCredentials(owner, repo).then( (credentials) => ({ credentials, error: void 0 }), (error) => ({ credentials: void 0, error }) ) ) ); const result = results.find( (resultItem) => resultItem.credentials?.accessToken ); if (result) { return result.credentials.accessToken; } const errors = results.map((r) => r.error); const notNotFoundError = errors.find((err) => err?.name !== "NotFoundError"); if (notNotFoundError) { throw notNotFoundError; } return void 0; } } class SingleInstanceGithubCredentialsProvider { constructor(githubAppCredentialsMux, token) { this.githubAppCredentialsMux = githubAppCredentialsMux; this.token = token; } static create = (config) => { return new SingleInstanceGithubCredentialsProvider( new GithubAppCredentialsMux(config), config.token ); }; /** * Returns {@link GithubCredentials} for a given URL. * * @remarks * * Consecutive calls to this method with the same URL will return cached * credentials. * * The shortest lifetime for a token returned is 10 minutes. * * @example * ```ts * const { token, headers } = await getCredentials({ * url: 'github.com/backstage/foobar' * }) * ``` * * @param opts - The organization or repository URL * @returns A promise of {@link GithubCredentials}. */ async getCredentials(opts) { const parsed = parseGitUrl__default.default(opts.url); const owner = parsed.owner || parsed.name; const repo = parsed.owner ? parsed.name : void 0; let type = "app"; let token = await this.githubAppCredentialsMux.getAppToken(owner, repo); if (!token) { type = "token"; token = this.token; } return { headers: token ? { Authorization: `Bearer ${token}` } : void 0, token, type }; } } exports.GithubAppCredentialsMux = GithubAppCredentialsMux; exports.SingleInstanceGithubCredentialsProvider = SingleInstanceGithubCredentialsProvider; //# sourceMappingURL=SingleInstanceGithubCredentialsProvider.cjs.js.map