diff --git a/.contentrain/content/system/error-messages/en.json b/.contentrain/content/system/error-messages/en.json index 2236d9ff..84c2f1b9 100644 --- a/.contentrain/content/system/error-messages/en.json +++ b/.contentrain/content/system/error-messages/en.json @@ -252,6 +252,9 @@ "migrate.grant_bound_elsewhere": "This Studio offer is already tied to another workspace.", "migrate.grant_not_found": "We couldn’t find this Studio offer on your account.", "migrate.grant_used": "This Studio offer has already been used — its included days started on a subscription for this workspace.", + "migrate.grant_not_ready": "Studio is not ready for GitHub yet. Finish the Studio plan step in Migrate first.", + "migrate.install_already": "Studio is already connected to GitHub for this migration.", + "migrate.install_state_invalid": "This GitHub connection link is not valid or has expired. Start again from your Migrate page.", "migrate.unavailable": "Studio offers from Contentrain Migrate are not available on this Studio.", "migration.export_fetch_failed": "Could not fetch the comments export from its URL", "migration.export_too_large": "The comments export is too large to fetch; upload it in chunks instead", diff --git a/nuxt.config.ts b/nuxt.config.ts index 464b6249..0d4af55b 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -126,6 +126,10 @@ export default defineNuxtConfig({ // fetched from (comma-separated, e.g. https://migrate.contentrain.io). // Empty = no fetch; the project's comments settings offer the upload. origins: '', + // NUXT_MIGRATE_INSTALL_STATE_KEY — HS256 secret (min 32 chars) that signs + // the `state` of the GitHub App install URL Migrate hands a customer. Only + // Studio verifies it. Empty = the install-url route is off. + installStateKey: '', }, stripe: { secretKey: '', // NUXT_STRIPE_SECRET_KEY (optional — legacy Stripe plugin) diff --git a/package.json b/package.json index 54fa3706..203dae6b 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@aws-sdk/client-s3": "^3.1076.0", "@contentrain/mcp": "3.9.0", "@contentrain/query": "7.4.0", - "@contentrain/types": "1.40.0", + "@contentrain/types": "1.42.0", "@gitbeaker/rest": "^43.8.0", "@nuxt/eslint": "1.16.0", "@nuxt/image": "2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89b9e83b..39af3a29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,8 +29,8 @@ importers: specifier: 7.4.0 version: 7.4.0 '@contentrain/types': - specifier: 1.40.0 - version: 1.40.0 + specifier: 1.42.0 + version: 1.42.0 '@gitbeaker/rest': specifier: ^43.8.0 version: 43.8.0 @@ -713,8 +713,8 @@ packages: '@contentrain/types@1.30.0': resolution: {integrity: sha512-inJhFqAY25wIw4NvpqDXOn24PRbVEh43s6GGFQSRyBbbmGiWu+xD67D2UEqaEllaFNLSVQ0j2DpOjDj+P6ezQA==} - '@contentrain/types@1.40.0': - resolution: {integrity: sha512-T/oUpkkVvBg6Ad9YcenvPAOh7IkcMmPWsmzoHimAcwUjCB799WNokwH3/kB6JGMPDCrtpXyS7yPZetdGzbIo7g==} + '@contentrain/types@1.42.0': + resolution: {integrity: sha512-/HHbY4vEaqRBBJCzpojziVeJCUsQNLDc1FGaYlSO/0p4xfBsNJPjRsvBxlC+EpIpjdkkj6AYOPO3uA98OGOcYg==} '@conventional-changelog/git-client@3.1.2': resolution: {integrity: sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A==} @@ -7938,7 +7938,7 @@ snapshots: '@contentrain/types@1.30.0': {} - '@contentrain/types@1.40.0': {} + '@contentrain/types@1.42.0': {} '@conventional-changelog/git-client@3.1.2(conventional-commits-parser@7.1.2)': dependencies: diff --git a/server/api/github/setup.get.ts b/server/api/github/setup.get.ts index aea96608..5c6c25ac 100644 --- a/server/api/github/setup.get.ts +++ b/server/api/github/setup.get.ts @@ -1,5 +1,7 @@ import { useDatabaseProvider, useGitAppService } from '../../utils/providers' import { getValidGitHubUserToken } from '../../utils/github-token' +import { looksLikeMigrateInstallState } from '../../utils/migrate-install-state' +import { handleMigrateInstallCallback } from '../../utils/migrate-install-callback' /** * GitHub App installation callback. @@ -16,15 +18,23 @@ import { getValidGitHubUserToken } from '../../utils/github-token' * "trust the redirect" path — this only matters when the user signed * in via Google/magic link AND then somehow ended up at this callback, * which is an unsupported flow but not a hard failure. + * + * A `state` that is a Studio-signed token (not a workspace id) is the install + * a Migrate customer started beside a live move: it carries no session and + * is handled by `handleMigrateInstallCallback`. The auth middleware lets only + * that shape through unauthenticated; everything below is unchanged. */ export default defineEventHandler(async (event) => { - const session = requireAuth(event) - const db = useDatabaseProvider() const query = getQuery(event) as { installation_id?: string setup_action?: string + code?: string state?: string // workspace ID passed during GitHub App install } + if (looksLikeMigrateInstallState(query.state)) return handleMigrateInstallCallback(event, query) + + const session = requireAuth(event) + const db = useDatabaseProvider() if (!query.installation_id) { throw createError({ statusCode: 400, message: errorMessage('github.installation_id_missing') }) diff --git a/server/api/migrate/grants/install-url.post.ts b/server/api/migrate/grants/install-url.post.ts new file mode 100644 index 00000000..8c43a4e2 --- /dev/null +++ b/server/api/migrate/grants/install-url.post.ts @@ -0,0 +1,46 @@ +/** + * POST /api/migrate/grants/install-url + * + * Migrate asks, server to server, for the GitHub App install address the + * customer opens while their move runs (W39). Body `{ token }`: a request + * signed with Migrate's key (`MigrateInstallUrlRequest`), single-use by + * `jti`, keyed by `order_id`. + * + * Offered only once the grant is redeemed (the subscription runs: a project + * could not be opened before, 402) and Studio's App is not yet installed on + * the grant's workspace. The address carries a Studio-signed `state` naming + * the grant and workspace, and no repository: the delivery repo does not + * exist during the move. GitHub returns the customer to the setup callback, + * which binds the installation and signs them in. + */ +import { validateMigrateInstallUrlRequest, validateMigrateInstallUrlResponse } from '@contentrain/types' +import { migrateGrantInstallation, migrateGrantStateOf } from '../../../utils/migrate-grant-status' +import { migrateInstallStateKey, signMigrateInstallState } from '../../../utils/migrate-install-state' +import { readMigrateS2sRequest } from '../../../utils/migrate-s2s-route' + +export default defineEventHandler(async (event) => { + const key = migrateInstallStateKey() + if (!key) throw createError({ statusCode: 404, message: errorMessage('migrate.unavailable') }) + + const request = await readMigrateS2sRequest(event, 'install-url', validateMigrateInstallUrlRequest) + + const grant = await useDatabaseProvider().getMigrateGrantByOrderId(request.order_id) + if (!grant) throw createError({ statusCode: 404, message: errorMessage('migrate.grant_not_found') }) + if (migrateGrantStateOf(grant) !== 'redeemed' || !grant.workspace_id) + throw createError({ statusCode: 409, message: errorMessage('migrate.grant_not_ready') }) + + const { installed } = await migrateGrantInstallation(grant) + if (installed) throw createError({ statusCode: 409, message: errorMessage('migrate.install_already') }) + + const { token, state } = await signMigrateInstallState({ + grantId: grant.id as string, + workspaceId: grant.workspace_id as string, + userId: grant.user_id as string, + }, key) + + const slug = (useRuntimeConfig().public.githubAppSlug as string | undefined) || 'contentrain-studio' + const response = { url: `https://github.com/apps/${slug}/installations/new?state=${token}`, expires_at: state.exp } + if (!validateMigrateInstallUrlResponse(response).ok) + throw createError({ statusCode: 500, message: errorMessage('migrate.s2s_invalid') }) + return response +}) diff --git a/server/api/migrate/grants/status.post.ts b/server/api/migrate/grants/status.post.ts new file mode 100644 index 00000000..c0f02632 --- /dev/null +++ b/server/api/migrate/grants/status.post.ts @@ -0,0 +1,31 @@ +/** + * POST /api/migrate/grants/status + * + * Migrate asks, server to server, where an order's Studio grant stands, so + * its page can show the Studio card (W39). Body `{ token }`: a request signed + * with Migrate's key (`MigrateGrantStatusRequest`, `@contentrain/types`), + * single-use by `jti`, keyed by `order_id`. Not a user surface: no session. + * + * Answers only the state and whether Studio's GitHub App is installed for the + * grant's workspace — never an account, workspace or email. An order Studio + * holds no grant for is a 404. + */ +import { validateMigrateGrantStatusRequest, validateMigrateGrantStatusResponse } from '@contentrain/types' +import { migrateGrantInstallation, migrateGrantStateOf } from '../../../utils/migrate-grant-status' +import { readMigrateS2sRequest } from '../../../utils/migrate-s2s-route' + +export default defineEventHandler(async (event) => { + const request = await readMigrateS2sRequest(event, 'grant-status', validateMigrateGrantStatusRequest) + + const grant = await useDatabaseProvider().getMigrateGrantByOrderId(request.order_id) + if (!grant) throw createError({ statusCode: 404, message: errorMessage('migrate.grant_not_found') }) + + const state = migrateGrantStateOf(grant) + const { installed } = await migrateGrantInstallation(grant) + // An install only counts once the subscription ran (the contract refuses it earlier). + const response = { state, installed: installed && state === 'redeemed' } + // Fail closed on our own answer: Migrate shows it to a customer. + if (!validateMigrateGrantStatusResponse(response).ok) + throw createError({ statusCode: 500, message: errorMessage('migrate.s2s_invalid') }) + return response +}) diff --git a/server/middleware/01.auth.ts b/server/middleware/01.auth.ts index f0f0758d..77158017 100644 --- a/server/middleware/01.auth.ts +++ b/server/middleware/01.auth.ts @@ -50,6 +50,18 @@ const MIGRATE_S2S_PATHS = [ '/api/migrate/grants/install-url', ] +/** + * The GitHub App setup callback of an install Migrate started: GitHub sends + * the customer back with Studio's signed `state` and no Studio session. Only a + * token-shaped `state` passes; an in-app install (workspace-id `state`) is + * still session-protected, and the route verifies the token itself. + */ +function isMigrateInstallCallback(path: string, event: Parameters[0]): boolean { + // `getRequestPath` keeps the query string; the callback always has one. + if (path.split('?', 1)[0] !== '/api/github/setup') return false + return looksLikeMigrateInstallState((getQuery(event) as { state?: unknown }).state) +} + // Refresh tokens 5 minutes before expiry to avoid edge-case failures const REFRESH_BUFFER_SECONDS = 5 * 60 @@ -57,7 +69,8 @@ export default defineEventHandler(async (event) => { const path = getRequestPath(event) // Skip non-API routes and public paths - if (!path.startsWith('/api') || PUBLIC_PATHS.some(p => path.startsWith(p)) || MIGRATE_S2S_PATHS.includes(path)) + if (!path.startsWith('/api') || PUBLIC_PATHS.some(p => path.startsWith(p)) || MIGRATE_S2S_PATHS.includes(path) + || isMigrateInstallCallback(path, event)) return let sessionData diff --git a/server/providers/database.ts b/server/providers/database.ts index 765766d5..960a7d93 100644 --- a/server/providers/database.ts +++ b/server/providers/database.ts @@ -1114,6 +1114,9 @@ export interface DatabaseProvider { origin?: string | null }) => Promise<{ grant: DatabaseRow, created: boolean }> + /** A grant by the Migrate order it belongs to (one per order); null when Studio holds none. For Migrate's server-to-server calls, which name an order and no user. */ + getMigrateGrantByOrderId: (orderId: string) => Promise + /** A grant, only if `userId` owns it. */ getMigrateGrantForUser: (grantId: string, userId: string) => Promise diff --git a/server/providers/postgres-db/migrate-grants.ts b/server/providers/postgres-db/migrate-grants.ts index 8d651e2b..ec3d5707 100644 --- a/server/providers/postgres-db/migrate-grants.ts +++ b/server/providers/postgres-db/migrate-grants.ts @@ -10,6 +10,7 @@ import { getAdmin, throwDbError } from './helpers' type MigrateGrantMethods = Pick< DatabaseProvider, | 'claimMigrateGrant' + | 'getMigrateGrantByOrderId' | 'getMigrateGrantForUser' | 'bindMigrateGrantWorkspace' | 'markMigrateGrantRedeemed' @@ -113,6 +114,20 @@ export function migrateGrantMethods(): MigrateGrantMethods { } }, + async getMigrateGrantByOrderId(orderId) { + try { + const row = await getAdmin() + .selectFrom('migrate_grants') + .selectAll() + .where('order_id', '=', orderId) + .executeTakeFirst() + return (row as DatabaseRow | undefined) ?? null + } + catch (error) { + throwDbError(error) + } + }, + async getMigrateGrantForUser(grantId, userId) { try { const row = await getAdmin() diff --git a/server/providers/supabase-db/migrate-grants.ts b/server/providers/supabase-db/migrate-grants.ts index e6856d1b..371c8c09 100644 --- a/server/providers/supabase-db/migrate-grants.ts +++ b/server/providers/supabase-db/migrate-grants.ts @@ -10,6 +10,7 @@ import { getAdmin } from './helpers' type MigrateGrantMethods = Pick< DatabaseProvider, | 'claimMigrateGrant' + | 'getMigrateGrantByOrderId' | 'getMigrateGrantForUser' | 'bindMigrateGrantWorkspace' | 'markMigrateGrantRedeemed' @@ -125,6 +126,16 @@ export function migrateGrantMethods(): MigrateGrantMethods { return { grant: existing as DatabaseRow, created: false } }, + async getMigrateGrantByOrderId(orderId) { + const { data, error } = await getAdmin() + .from('migrate_grants') + .select('*') + .eq('order_id', orderId) + .maybeSingle() + if (error) fail(error.message) + return (data as DatabaseRow | null) ?? null + }, + async getMigrateGrantForUser(grantId, userId) { const { data, error } = await getAdmin() .from('migrate_grants') diff --git a/server/utils/github-user-code.ts b/server/utils/github-user-code.ts new file mode 100644 index 00000000..1721e706 --- /dev/null +++ b/server/utils/github-user-code.ts @@ -0,0 +1,57 @@ +/** + * Finish GitHub's "authorize during installation" for the setup callback: the + * `code` it sends back is exchanged, with the Studio App's own client + * credentials, for the installing user's tokens, and the user is read with + * them. Same client the sign-in uses (`NUXT_OAUTH_GITHUB_CLIENT_ID/SECRET`). + */ +import type { ProviderTokens } from '../providers/auth' + +export interface GitHubInstallerIdentity { + /** GitHub's numeric user id, as a decimal string. */ + id: string + login: string + tokens: ProviderTokens +} + +const toUnixOrNull = (seconds: unknown): number | null => + typeof seconds === 'number' ? Math.floor(Date.now() / 1000) + seconds : null + +/** The installer, or null when the code is not accepted or GitHub cannot be asked. */ +export async function exchangeGitHubInstallCode(code: string): Promise { + const github = (useRuntimeConfig().oauth as { github?: { clientId?: string, clientSecret?: string } } | undefined)?.github + if (!github?.clientId || !github.clientSecret) return null + + try { + const token = await $fetch<{ + access_token?: string + refresh_token?: string + expires_in?: number + refresh_token_expires_in?: number + error?: string + }>('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { Accept: 'application/json' }, + body: { client_id: github.clientId, client_secret: github.clientSecret, code }, + }) + if (token.error || !token.access_token) return null + + const user = await $fetch<{ id?: number, login?: string }>('https://api.github.com/user', { + headers: { Authorization: `Bearer ${token.access_token}`, Accept: 'application/vnd.github+json' }, + }) + if (typeof user.id !== 'number') return null + + return { + id: String(user.id), + login: user.login ?? '', + tokens: { + accessToken: token.access_token, + refreshToken: token.refresh_token ?? null, + expiresAt: toUnixOrNull(token.expires_in), + refreshTokenExpiresAt: toUnixOrNull(token.refresh_token_expires_in), + }, + } + } + catch { + return null + } +} diff --git a/server/utils/migrate-grant-status.ts b/server/utils/migrate-grant-status.ts new file mode 100644 index 00000000..1e838ec7 --- /dev/null +++ b/server/utils/migrate-grant-status.ts @@ -0,0 +1,20 @@ +/** + * Where a Migrate grant stands, for Migrate's status call and its install-URL + * gate. Lifecycle: migration 031 (claimed → bound → redeemed). `revoked` is + * part of the contract; no stored status maps to it until the revoke column + * exists. + */ +import type { MigrateGrantState } from '@contentrain/types' +import type { DatabaseRow } from '../providers/database' + +export function migrateGrantStateOf(grant: DatabaseRow): MigrateGrantState { + return grant.redeemed_at ? 'redeemed' : grant.bound_at ? 'bound' : 'claimed' +} + +/** The workspace row for a bound grant, with whether Studio's GitHub App is installed on it. */ +export async function migrateGrantInstallation(grant: DatabaseRow): Promise<{ workspace: DatabaseRow | null, installed: boolean }> { + const workspaceId = grant.workspace_id as string | null + if (!workspaceId) return { workspace: null, installed: false } + const workspace = await useDatabaseProvider().getWorkspaceById(workspaceId, 'id, slug, github_installation_id') + return { workspace, installed: workspace?.github_installation_id != null } +} diff --git a/server/utils/migrate-install-callback.ts b/server/utils/migrate-install-callback.ts new file mode 100644 index 00000000..1097bf28 --- /dev/null +++ b/server/utils/migrate-install-callback.ts @@ -0,0 +1,111 @@ +/** + * The GitHub App setup callback's signed-`state` branch (Studio setup beside + * a live Migrate move, W39). + * + * The customer installed Studio's App from the address Migrate handed them. + * With "Request user authorization during installation" on, GitHub returns + * them here with `code` (their authorization), `installation_id` and our + * signed `state`. There is no Studio session yet, so everything rests on: + * + * 1. the `state` — ours, unexpired, single-use (`jti` taken before any write); + * 2. the grant — the one the state names, redeemed, bound to that workspace; + * 3. the `code` — exchanged for the installer's own GitHub identity and token, + * with which GitHub itself confirms they can reach the installation. + * Without a `code` nothing proves who installed, so nothing is bound. + * + * If the installer is the grant's owner (same GitHub account the Migrate + * customer signed in with) they are signed in and sent to the workspace. + * Otherwise (an org admin installed for them) the installation is still bound + * — the installer proved access to it, the grant is the payer's — but no + * session is created; the payer is sent to sign in and open their offer. + * Whoever holds the install link can therefore bind THEIR installation to the + * payer's workspace; the link goes only to Migrate and the customer, so it is + * never logged. + * + * A refused install (GitHub says no, the installation is taken, the workspace + * holds another one) lands the customer on the claim screen with + * `install=failed`, not on a raw error page; a bad or replayed state is an error. + */ +import type { H3Event } from 'h3' +import { exchangeGitHubInstallCode } from './github-user-code' +import { migrateInstallStateKey, verifyMigrateInstallState } from './migrate-install-state' +import { migrateGrantStateOf } from './migrate-grant-status' +import { useAuthProvider, useDatabaseProvider, useGitAppService } from './providers' +import { completeOAuthSignIn } from '../providers/managed-auth' + +interface CallbackQuery { + installation_id?: string + code?: string + state?: string +} + +const claimScreen = (grantId: string, failed = false) => `/migrate/claim?grant=${encodeURIComponent(grantId)}${failed ? '&install=failed' : ''}` +const loginThenClaim = (grantId: string, failed = false) => `/auth/login?redirect=${encodeURIComponent(claimScreen(grantId, failed))}` + +export async function handleMigrateInstallCallback(event: H3Event, query: CallbackQuery) { + const key = migrateInstallStateKey() + const state = key && query.state ? await verifyMigrateInstallState(query.state, key) : null + if (!state) throw createError({ statusCode: 400, message: errorMessage('migrate.install_state_invalid') }) + + const installationId = Number(query.installation_id) + if (!query.installation_id || !Number.isInteger(installationId) || installationId <= 0) + throw createError({ statusCode: 400, message: errorMessage('github.installation_id_invalid') }) + + const db = useDatabaseProvider() + const grant = await db.getMigrateGrantForUser(state.grantId, state.userId) + if (!grant || grant.workspace_id !== state.workspaceId || migrateGrantStateOf(grant) !== 'redeemed') + throw createError({ statusCode: 400, message: errorMessage('migrate.install_state_invalid') }) + + const workspace = await db.getWorkspaceById(state.workspaceId, 'id, slug, github_installation_id') + if (!workspace || typeof workspace.slug !== 'string') + throw createError({ statusCode: 404, message: errorMessage('github.workspace_not_found') }) + + // A workspace holds one installation. If it already holds a different one + // (installed in-app since the link was handed out, or the link opened twice), + // binding would orphan the projects connected through it: bind nothing. + const held = Number(workspace.github_installation_id) + if (held && held !== installationId) return sendRedirect(event, loginThenClaim(grant.id as string, true)) + + // Without the installer's authorization nothing proves who installed: + // bind nothing, send the payer to the manual path. + if (!query.code) return sendRedirect(event, loginThenClaim(grant.id as string)) + + // Single use, taken before any write: a replayed callback finds it gone. + if (!(await db.claimMigrateS2sJti(state.jti, 'install-state', new Date((state.exp + 60) * 1000)))) + throw createError({ statusCode: 409, message: errorMessage('migrate.s2s_replayed') }) + + const installer = await exchangeGitHubInstallCode(query.code) + if (!installer) return sendRedirect(event, loginThenClaim(grant.id as string, true)) + + if (!(await useGitAppService().verifyUserHasAccessToInstallation(installer.tokens.accessToken, installationId))) + return sendRedirect(event, loginThenClaim(grant.id as string, true)) + + if (held !== installationId) { + if (await db.findWorkspaceByGithubInstallation(installationId, state.workspaceId)) + return sendRedirect(event, loginThenClaim(grant.id as string, true)) + await db.updateWorkspaceGithubInstallation(state.workspaceId, installationId) + } + + const owner = await useAuthProvider().getUserById(state.userId) + const sameAccount = !!owner && owner.provider === 'github' && owner.providerAccountId === installer.id + const signInAvailable = useRuntimeConfig().authProvider === 'managed' + if (!owner || !owner.email || !sameAccount || !signInAvailable) + return sendRedirect(event, loginThenClaim(grant.id as string)) + + const session = await completeOAuthSignIn({ + provider: 'github', + providerAccountId: installer.id, + email: owner.email, + name: null, + userName: installer.login || null, + avatarUrl: owner.avatarUrl, + }) + await db.upsertOAuthProviderToken({ userId: session.user.id, provider: 'github', ...installer.tokens }) + await setServerSession(event, { + userId: session.user.id, + accessToken: session.tokens.accessToken, + refreshToken: session.tokens.refreshToken, + expiresAt: session.tokens.expiresAt, + }) + return sendRedirect(event, `/w/${workspace.slug}`) +} diff --git a/server/utils/migrate-install-state.ts b/server/utils/migrate-install-state.ts new file mode 100644 index 00000000..8ef4b276 --- /dev/null +++ b/server/utils/migrate-install-state.ts @@ -0,0 +1,77 @@ +/** + * The `state` Studio puts on the GitHub App install URL it hands Migrate. + * + * A short-lived HS256 JWS only Studio signs and verifies + * (`NUXT_MIGRATE_INSTALL_STATE_KEY`). It names the grant and the workspace the + * installation is for, so the GitHub setup callback needs no Studio session + * to know where to bind. `jti` is single-use: the callback takes it, so a + * replayed callback cannot rebind. A workspace-id `state` (the in-app install) + * is a UUID and never has this shape. + */ +import { jwtVerify, SignJWT } from 'jose' + +export const MIGRATE_INSTALL_STATE_ISSUER = 'contentrain-studio' +export const MIGRATE_INSTALL_STATE_AUDIENCE = 'studio-github-install' +export const MIGRATE_INSTALL_STATE_TTL_SECONDS = 600 +const MIN_KEY_LENGTH = 32 + +export interface MigrateInstallState { + jti: string + /** Seconds since the epoch. */ + exp: number + grantId: string + workspaceId: string + /** The Studio user who owns the grant. */ + userId: string +} + +/** The signing key, or null when install links are off here (not set, or too short to trust). */ +export function migrateInstallStateKey(): Uint8Array | null { + const config = useRuntimeConfig() as unknown as { migrate?: { installStateKey?: string } } + const raw = config.migrate?.installStateKey?.trim() + if (!raw || raw.length < MIN_KEY_LENGTH) return null + return new TextEncoder().encode(raw) +} + +/** Three base64url segments: the shape of our `state`, never of a workspace id. */ +export function looksLikeMigrateInstallState(value: unknown): value is string { + return typeof value === 'string' && value.length <= 2048 && /^[\w-]+\.[\w-]+\.[\w-]+$/.test(value) +} + +export async function signMigrateInstallState( + input: Pick, + key: Uint8Array, + now: Date = new Date(), +): Promise<{ token: string, state: MigrateInstallState }> { + const iat = Math.floor(now.getTime() / 1000) + const state: MigrateInstallState = { ...input, jti: crypto.randomUUID(), exp: iat + MIGRATE_INSTALL_STATE_TTL_SECONDS } + const token = await new SignJWT({ grantId: state.grantId, workspaceId: state.workspaceId, userId: state.userId }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuer(MIGRATE_INSTALL_STATE_ISSUER) + .setAudience(MIGRATE_INSTALL_STATE_AUDIENCE) + .setJti(state.jti) + .setIssuedAt(iat) + .setExpirationTime(state.exp) + .sign(key) + return { token, state } +} + +/** The state a valid, unexpired token carries; null for anything else. */ +export async function verifyMigrateInstallState(token: string, key: Uint8Array, now: Date = new Date()): Promise { + try { + const { payload } = await jwtVerify(token, key, { + algorithms: ['HS256'], + issuer: MIGRATE_INSTALL_STATE_ISSUER, + audience: MIGRATE_INSTALL_STATE_AUDIENCE, + requiredClaims: ['exp', 'jti'], + currentDate: now, + }) + const { grantId, workspaceId, userId, jti, exp } = payload as Record + if (typeof grantId !== 'string' || typeof workspaceId !== 'string' || typeof userId !== 'string' + || typeof jti !== 'string' || typeof exp !== 'number') return null + return { grantId, workspaceId, userId, jti, exp } + } + catch { + return null + } +} diff --git a/server/utils/migrate-s2s-route.ts b/server/utils/migrate-s2s-route.ts new file mode 100644 index 00000000..62fea6f5 --- /dev/null +++ b/server/utils/migrate-s2s-route.ts @@ -0,0 +1,43 @@ +/** + * The shared front of Migrate's server-to-server routes (grant status, + * install URL): the signing key must be configured, the body is `{ token }`, + * and the token must verify (signature, window, contract shape, single-use + * `jti`). Failures map to the same statuses as `account-state`: 404 when off, + * 400 for a bad request, 410 expired, 409 replayed. + */ +import type { H3Event } from 'h3' +import { migrateClaimPublicKey } from './migrate-grant' +import { MigrateS2sError, verifyMigrateS2sRequest } from './migrate-s2s' + +export async function readMigrateS2sRequest( + event: H3Event, + purpose: string, + validate: (payload: unknown, options: { now: number }) => { ok: true, request: T } | { ok: false, errors: string[] }, +): Promise { + const publicKey = migrateClaimPublicKey() + if (!publicKey) throw createError({ statusCode: 404, message: errorMessage('migrate.unavailable') }) + + const body = await readBody<{ token?: unknown }>(event) + if (typeof body?.token !== 'string' || body.token.length === 0 || body.token.length > 4096) + throw createError({ statusCode: 400, message: errorMessage('migrate.s2s_invalid') }) + + try { + return await verifyMigrateS2sRequest( + body.token, + publicKey, + purpose, + (payload, now) => { + const checked = validate(payload, { now }) + return checked.ok ? { ok: true, value: checked.request } : checked + }, + (jti, kind, expiresAt) => useDatabaseProvider().claimMigrateS2sJti(jti, kind, expiresAt), + ) + } + catch (err) { + if (err instanceof MigrateS2sError) { + if (err.reason === 'expired') throw createError({ statusCode: 410, message: errorMessage('migrate.claim_expired') }) + if (err.reason === 'replayed') throw createError({ statusCode: 409, message: errorMessage('migrate.s2s_replayed') }) + } + throw createError({ statusCode: 400, message: errorMessage('migrate.s2s_invalid') }) + } +} diff --git a/tests/contract/migrate-grants.contract.test.ts b/tests/contract/migrate-grants.contract.test.ts index 57b35337..3ad7e53a 100644 --- a/tests/contract/migrate-grants.contract.test.ts +++ b/tests/contract/migrate-grants.contract.test.ts @@ -43,6 +43,12 @@ describe('postgres-db migrate-grants (contract)', () => { expect(second.grant.user_id).toBe(owner.userId) }) + it('finds a grant by its order, and nothing for an order without one', async () => { + await claim(owner.userId) + expect(await methods.getMigrateGrantByOrderId(orderId)).toMatchObject({ order_id: orderId, user_id: owner.userId }) + expect(await methods.getMigrateGrantByOrderId(`${orderId}-none`)).toBeNull() + }) + it('shows a grant only to its owner', async () => { const { grant } = await claim(owner.userId) expect(await methods.getMigrateGrantForUser(grant.id as string, owner.userId)).toMatchObject({ id: grant.id }) diff --git a/tests/integration/migrate-install-callback.integration.test.ts b/tests/integration/migrate-install-callback.integration.test.ts new file mode 100644 index 00000000..01561a85 --- /dev/null +++ b/tests/integration/migrate-install-callback.integration.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { withTestServer } from '../helpers/http' +import { signMigrateInstallState } from '../../server/utils/migrate-install-state' + +const stateKey = 'k'.repeat(40) +const key = new TextEncoder().encode(stateKey) + +const mocks = vi.hoisted(() => ({ + db: { + getWorkspaceForUser: vi.fn(), + getWorkspaceById: vi.fn(), + getMigrateGrantForUser: vi.fn(), + claimMigrateS2sJti: vi.fn(), + findWorkspaceByGithubInstallation: vi.fn(), + updateWorkspaceGithubInstallation: vi.fn(), + upsertOAuthProviderToken: vi.fn(), + getOAuthProviderToken: vi.fn(), + }, + auth: { getUserById: vi.fn(), refreshProviderToken: vi.fn() }, + gitAppService: { verifyUserHasAccessToInstallation: vi.fn() }, + completeOAuthSignIn: vi.fn(), + exchange: vi.fn(), +})) + +vi.mock('../../server/utils/providers', () => ({ + useDatabaseProvider: vi.fn(() => mocks.db), + useAuthProvider: vi.fn(() => mocks.auth), + useGitAppService: vi.fn(() => mocks.gitAppService), +})) +vi.mock('../../server/providers/managed-auth', () => ({ completeOAuthSignIn: mocks.completeOAuthSignIn })) +vi.mock('../../server/utils/github-user-code', () => ({ exchangeGitHubInstallCode: mocks.exchange })) + +const grantRow = { id: 'grant-1', order_id: 'ord_123', user_id: 'user-1', workspace_id: 'ws-1', bound_at: 'x', redeemed_at: 'y' } +const installer = { id: '4242', login: 'octocat', tokens: { accessToken: 'ghu_1', refreshToken: 'ghr_1', expiresAt: 1, refreshTokenExpiresAt: 2 } } + +async function setupHandler() { + return (await import('../../server/api/github/setup.get')).default +} + +describe('GitHub setup callback: Migrate install state', () => { + const setServerSession = vi.fn() + const requireAuth = vi.fn() + let config: Record + + const stateToken = async (over: Partial<{ grantId: string, workspaceId: string, userId: string }> = {}) => + (await signMigrateInstallState({ grantId: 'grant-1', workspaceId: 'ws-1', userId: 'user-1', ...over }, key)).token + + const get = async (query: string) => { + let response!: Response + await withTestServer({ routes: [{ path: '/api/github/setup', handler: await setupHandler() }] }, async ({ request }) => { + response = await request(`/api/github/setup?${query}`, { redirect: 'manual' }) + }) + return response + } + + beforeEach(() => { + config = { sessionSecret: 'test-session-secret-32-characters-min', authProvider: 'managed', migrate: { installStateKey: stateKey }, public: { siteUrl: 'http://localhost:3000' } } + for (const fn of [...Object.values(mocks.db), ...Object.values(mocks.auth), ...Object.values(mocks.gitAppService), mocks.completeOAuthSignIn, mocks.exchange, setServerSession, requireAuth]) fn.mockReset() + mocks.db.getMigrateGrantForUser.mockResolvedValue(grantRow) + mocks.db.getWorkspaceById.mockResolvedValue({ id: 'ws-1', slug: 'acme', github_installation_id: null }) + mocks.db.claimMigrateS2sJti.mockResolvedValue(true) + mocks.db.findWorkspaceByGithubInstallation.mockResolvedValue(null) + mocks.db.updateWorkspaceGithubInstallation.mockResolvedValue(undefined) + mocks.db.upsertOAuthProviderToken.mockResolvedValue(undefined) + mocks.exchange.mockResolvedValue(installer) + mocks.gitAppService.verifyUserHasAccessToInstallation.mockResolvedValue(true) + mocks.auth.getUserById.mockResolvedValue({ id: 'user-1', email: 'owner@example.com', avatarUrl: null, provider: 'github', providerAccountId: '4242' }) + mocks.completeOAuthSignIn.mockResolvedValue({ + user: { id: 'user-1' }, tokens: { accessToken: 'at', refreshToken: 'rt', expiresAt: 99 }, + }) + vi.stubGlobal('useRuntimeConfig', () => config) + vi.stubGlobal('setServerSession', setServerSession) + vi.stubGlobal('requireAuth', requireAuth) + vi.stubGlobal('useDatabaseProvider', () => mocks.db) + vi.stubGlobal('useAuthProvider', () => mocks.auth) + }) + + it('binds the installation to the grant\'s workspace, signs the owner in and opens the workspace', async () => { + const response = await get(`installation_id=555&code=abc&setup_action=install&state=${await stateToken()}`) + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe('/w/acme') + expect(mocks.exchange).toHaveBeenCalledWith('abc') + expect(mocks.gitAppService.verifyUserHasAccessToInstallation).toHaveBeenCalledWith('ghu_1', 555) + expect(mocks.db.updateWorkspaceGithubInstallation).toHaveBeenCalledWith('ws-1', 555) + expect(mocks.db.upsertOAuthProviderToken).toHaveBeenCalledWith({ userId: 'user-1', provider: 'github', ...installer.tokens }) + expect(setServerSession).toHaveBeenCalledWith(expect.anything(), { userId: 'user-1', accessToken: 'at', refreshToken: 'rt', expiresAt: 99 }) + expect(requireAuth).not.toHaveBeenCalled() + }) + + it('takes the state\'s id once: a replayed callback binds nothing', async () => { + mocks.db.claimMigrateS2sJti.mockResolvedValue(false) + const response = await get(`installation_id=555&code=abc&state=${await stateToken()}`) + expect(response.status).toBe(409) + expect(mocks.exchange).not.toHaveBeenCalled() + expect(mocks.db.updateWorkspaceGithubInstallation).not.toHaveBeenCalled() + expect(mocks.db.claimMigrateS2sJti).toHaveBeenCalledWith(expect.any(String), 'install-state', expect.any(Date)) + }) + + it('binds but does not sign in when someone else installed (an org admin): the payer is sent to sign in', async () => { + mocks.exchange.mockResolvedValue({ ...installer, id: '9999' }) + const response = await get(`installation_id=555&code=abc&state=${await stateToken()}`) + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe(`/auth/login?redirect=${encodeURIComponent('/migrate/claim?grant=grant-1')}`) + expect(mocks.db.updateWorkspaceGithubInstallation).toHaveBeenCalledWith('ws-1', 555) + expect(mocks.completeOAuthSignIn).not.toHaveBeenCalled() + expect(setServerSession).not.toHaveBeenCalled() + expect(mocks.db.upsertOAuthProviderToken).not.toHaveBeenCalled() + }) + + it('does not sign in a grant owner who did not sign in with GitHub', async () => { + mocks.auth.getUserById.mockResolvedValue({ id: 'user-1', email: 'owner@example.com', avatarUrl: null, provider: 'google', providerAccountId: '4242' }) + const response = await get(`installation_id=555&code=abc&state=${await stateToken()}`) + expect(response.headers.get('location')).toContain('/auth/login') + expect(setServerSession).not.toHaveBeenCalled() + }) + + it('binds nothing without the installer\'s authorization code', async () => { + const response = await get(`installation_id=555&state=${await stateToken()}`) + expect(response.status).toBe(302) + expect(response.headers.get('location')).toContain('/auth/login') + expect(mocks.db.updateWorkspaceGithubInstallation).not.toHaveBeenCalled() + expect(mocks.db.claimMigrateS2sJti).not.toHaveBeenCalled() + }) + + const failedTo = `/auth/login?redirect=${encodeURIComponent('/migrate/claim?grant=grant-1&install=failed')}` + + it('binds nothing when GitHub rejects the code or the installer cannot reach the installation, and lands the customer on the claim screen', async () => { + mocks.exchange.mockResolvedValueOnce(null) + const rejected = await get(`installation_id=555&code=bad&state=${await stateToken()}`) + expect([rejected.status, rejected.headers.get('location')]).toEqual([302, failedTo]) + + mocks.gitAppService.verifyUserHasAccessToInstallation.mockResolvedValue(false) + const denied = await get(`installation_id=555&code=abc&state=${await stateToken()}`) + expect([denied.status, denied.headers.get('location')]).toEqual([302, failedTo]) + expect(mocks.db.updateWorkspaceGithubInstallation).not.toHaveBeenCalled() + expect(setServerSession).not.toHaveBeenCalled() + }) + + it('refuses an installation another workspace already holds', async () => { + mocks.db.findWorkspaceByGithubInstallation.mockResolvedValue({ id: 'ws-other' }) + const response = await get(`installation_id=555&code=abc&state=${await stateToken()}`) + expect([response.status, response.headers.get('location')]).toEqual([302, failedTo]) + expect(mocks.db.findWorkspaceByGithubInstallation).toHaveBeenCalledWith(555, 'ws-1') + expect(mocks.db.updateWorkspaceGithubInstallation).not.toHaveBeenCalled() + expect(setServerSession).not.toHaveBeenCalled() + }) + + it('never swaps an installation the workspace already holds for a different one', async () => { + mocks.db.getWorkspaceById.mockResolvedValue({ id: 'ws-1', slug: 'acme', github_installation_id: 111 }) + const response = await get(`installation_id=555&code=abc&state=${await stateToken()}`) + expect([response.status, response.headers.get('location')]).toEqual([302, failedTo]) + expect(mocks.db.updateWorkspaceGithubInstallation).not.toHaveBeenCalled() + expect(mocks.db.findWorkspaceByGithubInstallation).not.toHaveBeenCalled() + expect(mocks.exchange).not.toHaveBeenCalled() + expect(setServerSession).not.toHaveBeenCalled() + }) + + it('rewrites nothing when the workspace already holds this installation, and still signs the owner in', async () => { + mocks.db.getWorkspaceById.mockResolvedValue({ id: 'ws-1', slug: 'acme', github_installation_id: 555 }) + const response = await get(`installation_id=555&code=abc&state=${await stateToken()}`) + expect(response.headers.get('location')).toBe('/w/acme') + expect(mocks.db.updateWorkspaceGithubInstallation).not.toHaveBeenCalled() + }) + + it('refuses a bad, foreign, unconfigured or mismatched state before any lookup of the installer', async () => { + expect((await get('installation_id=555&code=abc&state=a.b.c')).status).toBe(400) + expect((await get(`installation_id=abc&code=abc&state=${await stateToken()}`)).status).toBe(400) + + // The state names a workspace that is not the grant's. + expect((await get(`installation_id=555&code=abc&state=${await stateToken({ workspaceId: 'ws-2' })}`)).status).toBe(400) + // The grant is not redeemed. + mocks.db.getMigrateGrantForUser.mockResolvedValue({ ...grantRow, redeemed_at: null }) + expect((await get(`installation_id=555&code=abc&state=${await stateToken()}`)).status).toBe(400) + // Install links are off here. + config.migrate = { installStateKey: '' } + expect((await get(`installation_id=555&code=abc&state=${await stateToken()}`)).status).toBe(400) + + expect(mocks.exchange).not.toHaveBeenCalled() + expect(mocks.db.updateWorkspaceGithubInstallation).not.toHaveBeenCalled() + }) + + it('leaves the in-app install (a workspace id as state) on its session-protected path', async () => { + requireAuth.mockReturnValue({ user: { id: 'user-1' }, accessToken: 'token-1' }) + mocks.db.getWorkspaceForUser.mockResolvedValue({ id: 'workspace-primary', slug: 'studio-team' }) + mocks.db.getOAuthProviderToken.mockResolvedValue(null) + const response = await get('installation_id=123&state=workspace-primary') + expect(response.headers.get('location')).toBe('/w/studio-team') + expect(requireAuth).toHaveBeenCalled() + expect(mocks.db.getWorkspaceForUser).toHaveBeenCalledWith('token-1', 'user-1', 'workspace-primary', ['owner', 'admin']) + expect(mocks.exchange).not.toHaveBeenCalled() + expect(mocks.db.claimMigrateS2sJti).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/auth-middleware-public-paths.test.ts b/tests/unit/auth-middleware-public-paths.test.ts index 750494e3..9b159159 100644 --- a/tests/unit/auth-middleware-public-paths.test.ts +++ b/tests/unit/auth-middleware-public-paths.test.ts @@ -98,3 +98,34 @@ describe('auth middleware public paths', () => { expect(getServerSession).not.toHaveBeenCalled() }) }) + +describe('auth middleware: Migrate install callback', () => { + const getServerSession = vi.fn() + + beforeEach(() => { + vi.resetModules() + getServerSession.mockReset().mockResolvedValue(null) + vi.stubGlobal('defineEventHandler', (fn: unknown) => fn) + vi.stubGlobal('createError', (input: { statusCode: number, message: string }) => Object.assign(new Error(input.message), input)) + vi.stubGlobal('errorMessage', (key: string) => key) + vi.stubGlobal('getServerSession', getServerSession) + vi.stubGlobal('clearServerSession', vi.fn()) + vi.stubGlobal('looksLikeMigrateInstallState', (v: unknown) => typeof v === 'string' && /^[\w-]+\.[\w-]+\.[\w-]+$/.test(v)) + }) + + async function run(path: string, state?: string) { + // Like h3's getRequestPath: the query string stays on the path. + vi.stubGlobal('getRequestPath', () => (state === undefined ? path : `${path}?installation_id=555&code=c&state=${state}`)) + vi.stubGlobal('getQuery', () => (state === undefined ? {} : { state })) + const handler = (await import('../../server/middleware/01.auth')).default as (e: unknown) => Promise + return handler({ context: {} }) + } + + it('lets the GitHub setup callback through unauthenticated only for a signed (token-shaped) state', async () => { + await expect(run('/api/github/setup', 'aaa.bbb.ccc')).resolves.toBeUndefined() + expect(getServerSession).not.toHaveBeenCalled() + await expect(run('/api/github/setup', '3f2b1c9e-6c7a-4e1f-9d1a-2b3c4d5e6f70')).rejects.toMatchObject({ statusCode: 401 }) + await expect(run('/api/github/setup')).rejects.toMatchObject({ statusCode: 401 }) + await expect(run('/api/github/repos', 'aaa.bbb.ccc')).rejects.toMatchObject({ statusCode: 401 }) + }) +}) diff --git a/tests/unit/migrate-grant-status-routes.test.ts b/tests/unit/migrate-grant-status-routes.test.ts new file mode 100644 index 00000000..b85b26aa --- /dev/null +++ b/tests/unit/migrate-grant-status-routes.test.ts @@ -0,0 +1,159 @@ +import { exportSPKI, generateKeyPair, SignJWT } from 'jose' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { MIGRATE_STUDIO_CLAIM_AUDIENCE, MIGRATE_STUDIO_CLAIM_ISSUER } from '@contentrain/types' +import { verifyMigrateInstallState } from '../../server/utils/migrate-install-state' + +vi.mock('../../server/utils/deployment', () => ({ resolveDeployment: () => ({ planSource: 'subscription' }) })) + +let privateKey: CryptoKey +let publicPem: string +const stateKey = 'k'.repeat(40) +const nowSec = () => Math.floor(Date.now() / 1000) +let counter = 0 + +const sign = (body: Record = {}, key = privateKey) => { + const iat = nowSec() + return new SignJWT({ + iss: MIGRATE_STUDIO_CLAIM_ISSUER, aud: MIGRATE_STUDIO_CLAIM_AUDIENCE, jti: `jti-${++counter}`, iat, exp: iat + 300, order_id: 'ord_123', ...body, + }).setProtectedHeader({ alg: 'EdDSA' }).sign(key) +} + +const grant = (over: Record = {}) => ({ + id: 'grant-1', order_id: 'ord_123', user_id: 'user-1', workspace_id: 'ws-1', bound_at: '2026-10-03T10:00:00Z', redeemed_at: '2026-10-03T10:05:00Z', ...over, +}) + +beforeAll(async () => { + const pair = await generateKeyPair('EdDSA', { extractable: true }) + privateKey = pair.privateKey + publicPem = await exportSPKI(pair.publicKey) +}) + +describe('Migrate grant status and install-url routes', () => { + let db: Record> + let body: unknown + const taken = new Set() + const config = { migrate: { claimPublicKey: '', installStateKey: stateKey }, public: { githubAppSlug: 'contentrain-studio' } } + + const call = async (route: 'status' | 'install-url') => { + const handler = (route === 'status' + ? (await import('../../server/api/migrate/grants/status.post')).default + : (await import('../../server/api/migrate/grants/install-url.post')).default) as (e: unknown) => Promise + return handler({}) + } + const status = async (over?: Record) => { + body = { token: await sign(over) } + } + + beforeEach(() => { + vi.resetModules() + taken.clear() + config.migrate.claimPublicKey = publicPem + config.migrate.installStateKey = stateKey + db = { + getMigrateGrantByOrderId: vi.fn().mockResolvedValue(grant()), + getWorkspaceById: vi.fn().mockResolvedValue({ id: 'ws-1', slug: 'acme', github_installation_id: null }), + claimMigrateS2sJti: vi.fn(async (jti: string, purpose: string) => { + if (taken.has(`${purpose}:${jti}`)) return false + taken.add(`${purpose}:${jti}`) + return true + }), + } + vi.stubGlobal('defineEventHandler', (h: unknown) => h) + vi.stubGlobal('readBody', () => Promise.resolve(body)) + vi.stubGlobal('useRuntimeConfig', () => config) + vi.stubGlobal('useDatabaseProvider', () => db) + vi.stubGlobal('errorMessage', (key: string) => key) + }) + + describe('status', () => { + it('answers the state, and installed only once redeemed', async () => { + await status() + expect(await call('status')).toEqual({ state: 'redeemed', installed: false }) + + db.getWorkspaceById.mockResolvedValue({ id: 'ws-1', slug: 'acme', github_installation_id: 4242 }) + await status() + expect(await call('status')).toEqual({ state: 'redeemed', installed: true }) + expect(db.getMigrateGrantByOrderId).toHaveBeenCalledWith('ord_123') + }) + + it.each([ + [{ bound_at: null, redeemed_at: null, workspace_id: null }, 'claimed'], + [{ redeemed_at: null }, 'bound'], + ])('reads %o as %s, never installed before the subscription runs', async (over, state) => { + db.getMigrateGrantByOrderId.mockResolvedValue(grant(over)) + db.getWorkspaceById.mockResolvedValue({ id: 'ws-1', github_installation_id: 4242 }) + await status() + expect(await call('status')).toEqual({ state, installed: false }) + }) + + it('is a 404 for an order Studio holds no grant for', async () => { + db.getMigrateGrantByOrderId.mockResolvedValue(null) + await status() + await expect(call('status')).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('refuses an unsigned, foreign-signed or replayed request, and is off without Migrate\'s key', async () => { + body = { token: 'x.y.z' } + await expect(call('status')).rejects.toMatchObject({ statusCode: 400 }) + body = { token: await sign({}, (await generateKeyPair('EdDSA')).privateKey) } + await expect(call('status')).rejects.toMatchObject({ statusCode: 400 }) + body = { token: await sign({ order_id: '' }) } + await expect(call('status')).rejects.toMatchObject({ statusCode: 400 }) + + await status() + await call('status') + await expect(call('status')).rejects.toMatchObject({ statusCode: 409 }) + + config.migrate.claimPublicKey = '' + await status() + await expect(call('status')).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('does not spend a status token on the install-url route (purposes are separate)', async () => { + await status() + await call('status') + expect([...taken]).toEqual([expect.stringMatching(/^grant-status:/)]) + }) + }) + + describe('install-url', () => { + it('hands out GitHub\'s install page with a signed state naming the grant, workspace and owner', async () => { + await status() + const out = await call('install-url') as { url: string, expires_at: number } + const url = new URL(out.url) + expect(`${url.origin}${url.pathname}`).toBe('https://github.com/apps/contentrain-studio/installations/new') + const state = await verifyMigrateInstallState(url.searchParams.get('state')!, new TextEncoder().encode(stateKey)) + expect(state).toMatchObject({ grantId: 'grant-1', workspaceId: 'ws-1', userId: 'user-1', exp: out.expires_at }) + expect(out.url).not.toMatch(/repo/i) + }) + + it('waits for a running subscription and for a workspace', async () => { + db.getMigrateGrantByOrderId.mockResolvedValue(grant({ redeemed_at: null })) + await status() + await expect(call('install-url')).rejects.toMatchObject({ statusCode: 409, message: 'migrate.grant_not_ready' }) + db.getMigrateGrantByOrderId.mockResolvedValue(grant({ workspace_id: null })) + await status() + await expect(call('install-url')).rejects.toMatchObject({ statusCode: 409, message: 'migrate.grant_not_ready' }) + }) + + it('refuses when the App is already installed, for an unknown order, and when its own key is not set', async () => { + db.getWorkspaceById.mockResolvedValue({ id: 'ws-1', github_installation_id: 4242 }) + await status() + await expect(call('install-url')).rejects.toMatchObject({ statusCode: 409, message: 'migrate.install_already' }) + + db.getMigrateGrantByOrderId.mockResolvedValue(null) + await status() + await expect(call('install-url')).rejects.toMatchObject({ statusCode: 404, message: 'migrate.grant_not_found' }) + + config.migrate.installStateKey = '' + await status() + await expect(call('install-url')).rejects.toMatchObject({ statusCode: 404, message: 'migrate.unavailable' }) + }) + + it('takes each request token once', async () => { + await status() + await call('install-url') + await expect(call('install-url')).rejects.toMatchObject({ statusCode: 409 }) + }) + }) +}) diff --git a/tests/unit/migrate-install-state.test.ts b/tests/unit/migrate-install-state.test.ts new file mode 100644 index 00000000..f646eb69 --- /dev/null +++ b/tests/unit/migrate-install-state.test.ts @@ -0,0 +1,71 @@ +import { SignJWT } from 'jose' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + looksLikeMigrateInstallState, + migrateInstallStateKey, + signMigrateInstallState, + verifyMigrateInstallState, +} from '../../server/utils/migrate-install-state' + +const key = new TextEncoder().encode('k'.repeat(40)) +const otherKey = new TextEncoder().encode('o'.repeat(40)) +const input = { grantId: 'grant-1', workspaceId: 'ws-1', userId: 'user-1' } + +afterEach(() => vi.unstubAllGlobals()) + +describe('migrate install state', () => { + it('round-trips the grant, workspace and user, with a single-use id and a short life', async () => { + const { token, state } = await signMigrateInstallState(input, key) + expect(await verifyMigrateInstallState(token, key)).toEqual(state) + expect(state).toMatchObject(input) + expect(state.exp - Math.floor(Date.now() / 1000)).toBeLessThanOrEqual(600) + expect((await signMigrateInstallState(input, key)).state.jti).not.toBe(state.jti) + }) + + it('refuses a token signed with another key, an expired one, a tampered one and a forged algorithm', async () => { + const { token } = await signMigrateInstallState(input, key) + expect(await verifyMigrateInstallState(token, otherKey)).toBeNull() + + const old = await signMigrateInstallState(input, key, new Date(Date.now() - 3600_000)) + expect(await verifyMigrateInstallState(old.token, key)).toBeNull() + + const [h, , s] = token.split('.') + const forgedBody = Buffer.from(JSON.stringify({ grantId: 'grant-2', workspaceId: 'ws-1', userId: 'user-1' })).toString('base64url') + expect(await verifyMigrateInstallState(`${h}.${forgedBody}.${s}`, key)).toBeNull() + + const none = `${Buffer.from('{"alg":"none"}').toString('base64url')}.${Buffer.from('{}').toString('base64url')}.x` + expect(await verifyMigrateInstallState(none, key)).toBeNull() + expect(await verifyMigrateInstallState('not a token', key)).toBeNull() + }) + + it('refuses a token for another audience or without the grant fields', async () => { + const now = Math.floor(Date.now() / 1000) + const other = await new SignJWT({ grantId: 'g', workspaceId: 'w', userId: 'u' }) + .setProtectedHeader({ alg: 'HS256' }).setIssuer('contentrain-studio').setAudience('someone-else') + .setJti('j').setIssuedAt(now).setExpirationTime(now + 300).sign(key) + expect(await verifyMigrateInstallState(other, key)).toBeNull() + const partial = await new SignJWT({ grantId: 'g' }) + .setProtectedHeader({ alg: 'HS256' }).setIssuer('contentrain-studio').setAudience('studio-github-install') + .setJti('j').setIssuedAt(now).setExpirationTime(now + 300).sign(key) + expect(await verifyMigrateInstallState(partial, key)).toBeNull() + }) + + it('tells a signed state from a workspace id', async () => { + const { token } = await signMigrateInstallState(input, key) + expect(looksLikeMigrateInstallState(token)).toBe(true) + expect(looksLikeMigrateInstallState('3f2b1c9e-6c7a-4e1f-9d1a-2b3c4d5e6f70')).toBe(false) + expect(looksLikeMigrateInstallState('workspace-primary')).toBe(false) + expect(looksLikeMigrateInstallState(undefined)).toBe(false) + expect(looksLikeMigrateInstallState(['a.b.c'])).toBe(false) + }) + + it('is off without a key, and for one too short to trust', () => { + const config = (installStateKey: string) => vi.stubGlobal('useRuntimeConfig', () => ({ migrate: { installStateKey } })) + config('') + expect(migrateInstallStateKey()).toBeNull() + config('short') + expect(migrateInstallStateKey()).toBeNull() + config(' '.repeat(5) + 'k'.repeat(32) + ' ') + expect(migrateInstallStateKey()).toEqual(new TextEncoder().encode('k'.repeat(32))) + }) +})