Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/gitlab

src/auth.ts

204 lines · 6.4 KB · TypeScript
import type { GitlabAuthInput, GitlabAuthLaneInfo, GitlabResolvedAuth } from './types.ts'

export const DEFAULT_GITLAB_API_BASE_URL = 'https://gitlab.com/api/v4'
export const DEFAULT_GITLAB_INSTANCE_URL = 'https://gitlab.com'
export const DEFAULT_GITLAB_API_HOST = 'gitlab.com'
export const DEFAULT_GITLAB_INTEGRATION_NAME = 'gitlab'
export const DEFAULT_GITLAB_SECRET_NAME = 'gitlabAccessToken'

export const GITLAB_OAUTH_AUTHORIZE_URL = 'https://gitlab.com/oauth/authorize'
export const GITLAB_OAUTH_TOKEN_URL = 'https://gitlab.com/oauth/token'
export const GITLAB_APPLICATIONS_URL = 'https://gitlab.com/-/profile/applications'
export const GITLAB_PAT_CREATE_URL =
	'https://gitlab.com/-/user_settings/personal_access_tokens?scopes=api'

export const SUGGESTED_GITLAB_OAUTH_SCOPES = ['api'] as const
export const READ_ONLY_GITLAB_OAUTH_SCOPES = ['read_api', 'read_user'] as const

export const GITLAB_OAUTH_CONNECT_URL = buildOauthConnectUrl({
	provider: DEFAULT_GITLAB_INTEGRATION_NAME,
})

export const GITLAB_PAT_SETUP_URL = buildPatSetupUrl(DEFAULT_GITLAB_SECRET_NAME)

const gitlabAuthLanes = [
	{
		lane: 'oauth',
		default: true,
		integrationName: DEFAULT_GITLAB_INTEGRATION_NAME,
		connectUrl: GITLAB_OAUTH_CONNECT_URL,
		useWhen:
			'Bring-your-own GitLab OAuth application. Pass a distinct integrationName (gitlab-work, …) for each extra connected identity.',
		avoidWhen: 'You only have a personal access token and have not connected OAuth.',
		mutationGuidance:
			'Mutating helpers require dryRun: true to preview, then confirm: true to apply.',
	},
	{
		lane: 'pat',
		default: false,
		secretName: DEFAULT_GITLAB_SECRET_NAME,
		secretSetupUrl: GITLAB_PAT_SETUP_URL,
		useWhen:
			'Personal access tokens. Pass secretName (default gitlabAccessToken) and do not pass integrationName.',
		avoidWhen: 'A saved GitLab OAuth integration already covers the work.',
		mutationGuidance:
			'Mutating helpers require dryRun: true to preview, then confirm: true to apply. Grant only the PAT scopes the mutation needs.',
	},
] as const satisfies readonly GitlabAuthLaneInfo[]

/**
 * Return the supported GitLab auth lanes and selection guidance.
 */
export function accounts(): readonly GitlabAuthLaneInfo[] {
	return gitlabAuthLanes
}

/**
 * Resolve OAuth vs PAT credentials and the GitLab API base URL.
 */
export function resolveGitlabAuth(input: GitlabAuthInput = {}): GitlabResolvedAuth {
	const apiBaseUrl = resolveApiBaseUrl(input)
	const apiHost = hostFromUrl(apiBaseUrl)
	const secretName = trimToUndefined(input.secretName)
	if (secretName) {
		return {
			mode: 'pat',
			integrationName: null,
			secretName,
			label: secretName,
			apiBaseUrl,
			apiHost,
		}
	}

	const integrationName =
		trimToUndefined(input.integrationName) ??
		trimToUndefined(input.account) ??
		DEFAULT_GITLAB_INTEGRATION_NAME

	return {
		mode: 'oauth',
		integrationName,
		secretName: null,
		label: integrationName,
		apiBaseUrl,
		apiHost,
	}
}

export function resolveApiBaseUrl(input: GitlabAuthInput = {}): string {
	const apiBaseUrl = trimToUndefined(input.apiBaseUrl)
	if (apiBaseUrl) return normalizeApiBaseUrl(apiBaseUrl)

	const instanceUrl = trimToUndefined(input.instanceUrl)
	if (instanceUrl) return normalizeApiBaseUrl(new URL('/api/v4', instanceUrl).href)

	return DEFAULT_GITLAB_API_BASE_URL
}

export function normalizeApiBaseUrl(value: string): string {
	let parsed: URL
	try {
		parsed = new URL(value)
	} catch {
		throw new Error(
			`apiBaseUrl / instanceUrl must be an absolute https URL (received ${JSON.stringify(value)}).`,
		)
	}
	if (parsed.protocol !== 'https:') {
		throw new Error('GitLab helpers only accept https API bases.')
	}
	parsed.hash = ''
	parsed.search = ''
	const path = parsed.pathname.replace(/\/+$/, '')
	if (path === '' || path === '/') {
		parsed.pathname = '/api/v4'
	} else if (path === '/api') {
		parsed.pathname = '/api/v4'
	} else if (!path.endsWith('/api/v4')) {
		throw new Error(
			'apiBaseUrl must be a GitLab origin or end with /api or /api/v4 (for example https://gitlab.example.com/api/v4).',
		)
	} else {
		parsed.pathname = path
	}
	return parsed.href.replace(/\/+$/, '')
}

export function hostFromUrl(value: string): string {
	return new URL(value).host
}

export function buildOauthConnectUrl(options: {
	provider?: string
	instanceUrl?: string
	scopes?: readonly string[]
}): string {
	const provider = trimToUndefined(options.provider) ?? DEFAULT_GITLAB_INTEGRATION_NAME
	const instanceUrl = (trimToUndefined(options.instanceUrl) ?? DEFAULT_GITLAB_INSTANCE_URL).replace(
		/\/+$/,
		'',
	)
	const scopes = options.scopes ?? SUGGESTED_GITLAB_OAUTH_SCOPES
	const authorizeUrl = `${instanceUrl}/oauth/authorize`
	const tokenUrl = `${instanceUrl}/oauth/token`
	const apiBaseUrl = `${instanceUrl}/api/v4`
	const host = hostFromUrl(instanceUrl)
	const params = new URLSearchParams({
		provider,
		authorizeUrl,
		tokenUrl,
		flow: 'confidential',
		scopes: scopes.join(' '),
		allowedHosts: host,
		apiBaseUrl,
		dashboardUrl: `${instanceUrl}/-/profile/applications`,
	})
	return `https://kody.codes/connect/oauth?${params.toString()}`
}

export function buildPatSetupUrl(
	secretName = DEFAULT_GITLAB_SECRET_NAME,
	apiHost = DEFAULT_GITLAB_API_HOST,
): string {
	const params = new URLSearchParams({
		name: secretName,
		description: 'GitLab personal access token',
		allowedHosts: apiHost,
		scope: 'user',
	})
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

export function pickAuthInput(params: Record<string, unknown>): GitlabAuthInput {
	return {
		integrationName: readOptionalString(params.integrationName),
		secretName: readOptionalString(params.secretName),
		account: readOptionalString(params.account),
		apiBaseUrl: readOptionalString(params.apiBaseUrl),
		instanceUrl: readOptionalString(params.instanceUrl),
	}
}

export function isDryRun(params: Record<string, unknown>): boolean {
	return params.dryRun === true
}

export function isConfirmed(params: Record<string, unknown>): boolean {
	return params.confirm === true
}

export function trimToUndefined(value: string | undefined): string | undefined {
	if (typeof value !== 'string') return undefined
	const trimmed = value.trim()
	return trimmed.length > 0 ? trimmed : undefined
}

export function readOptionalString(value: unknown): string | undefined {
	return typeof value === 'string' ? value : undefined
}

export function stringifyError(error: unknown): string {
	if (error instanceof Error) return error.message
	return String(error)
}