Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ jobs:
run: pnpm test

launch-gate:
name: First-launch approval gate
name: Connections and Action Consent launch gate
runs-on: ubuntu-latest
services:
postgres:
Expand Down Expand Up @@ -195,8 +195,8 @@ jobs:
- name: Run database migrations
run: pnpm db:migrate

- name: Run first-launch approval gate
run: pnpm test:launch
- name: Run Connections and Action Consent launch gate
run: pnpm test:connections-launch

build:
name: Build
Expand Down
246 changes: 246 additions & 0 deletions apps/platform-api/test/connections-launch-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { createHash, randomUUID } from 'node:crypto'
import { googleAuthorizationScopes } from '@linea/connectors'
import { db, repositories, schema } from '@linea/db'
import {
calculateJwkThumbprint,
exportJWK,
generateKeyPair,
SignJWT,
type JWK,
type KeyLike,
} from 'jose-v5'
import { generateApplicationKey } from '../src/auth/api-key.util'

type ProofKey = { privateKey: KeyLike; publicJwk: JWK }

export type LaunchSession = {
externalSubjectId: string
accessToken: string
nonce: string
key: ProofKey
}

export type LaunchFixture = {
workspaceId: string
applicationId: string
applicationKey: string
googleWorkflowId: string
githubWorkflowId: string
primary: LaunchSession
secondDevice: LaunchSession
otherSubject: LaunchSession
}

function hash(value: string, encoding: 'hex' | 'base64url'): string {
return createHash('sha256').update(value).digest(encoding)
}

async function session(input: {
workspaceId: string
applicationId: string
externalSubjectId: string
}): Promise<LaunchSession> {
const { privateKey, publicKey } = await generateKeyPair('ES256')
const key = { privateKey, publicJwk: await exportJWK(publicKey) }
const accessToken = `lnu_${randomUUID().replaceAll('-', '')}`
const nonce = randomUUID()
await db.insert(schema.endUserSessions).values({
...input,
tokenHash: hash(accessToken, 'hex'),
proofJkt: await calculateJwkThumbprint(key.publicJwk, 'sha256'),
nonceHash: hash(nonce, 'hex'),
expiresAt: new Date(Date.now() + 10 * 60_000),
})
return { externalSubjectId: input.externalSubjectId, accessToken, nonce, key }
}

async function subject(input: { workspaceId: string; applicationId: string }) {
const [created] = await db
.insert(schema.externalSubjects)
.values({
workspaceId: input.workspaceId,
issuer: 'https://identity.example.com',
issuerSubject: randomUUID(),
status: 'verified',
verifiedAt: new Date(),
})
.returning()
await db.insert(schema.externalSubjectApplications).values({
...input,
externalSubjectId: created.id,
})
return created.id
}

export async function createConnectionsLaunchFixture(): Promise<LaunchFixture> {
const suffix = randomUUID()
const [workspace] = await db
.insert(schema.organizations)
.values({
name: 'Connections launch gate',
slug: `connections-launch-${suffix}`,
createdAt: new Date(),
})
.returning()
const [application] = await db
.insert(schema.applications)
.values({
workspaceId: workspace.id,
environment: 'dev',
displayName: 'Connections launch application',
allowedBrowserOrigins: ['http://127.0.0.1:4173'],
allowedRedirectOrigins: ['http://127.0.0.1:4173'],
oidcIssuer: 'https://identity.example.com',
oidcClientId: `connections-launch-${suffix}`,
oidcAudience: `connections-launch-${suffix}`,
oidcJwksUrl: 'https://identity.example.com/jwks',
connectorAccessPolicy: {
providers: [
{
provider: 'google',
actionFamilies: ['gmail_read'],
maxScopes: [...googleAuthorizationScopes(['gmail_read'])],
},
{
provider: 'github',
actionFamilies: ['issues'],
maxScopes: ['read:user', 'repo'],
},
],
},
})
.returning()
const workflows = new Map<string, string>()
for (const [provider, operation] of [
['google', 'google.gmail.list_messages'],
['github', 'github.issues.create'],
]) {
const workflow = await repositories.workflow.createWorkflow(db, {
workspaceId: workspace.id,
name: `${provider} launch workflow`,
slug: `${provider}-launch-${suffix}`,
})
const contract =
await repositories.workflowContract.createWorkflowContractRevision(
db,
workspace.id,
workflow.id,
{
inputSchema: {
type: 'object',
properties: {
connectionId: { type: 'string' },
input: { type: 'object' },
},
required: ['connectionId', 'input'],
additionalProperties: false,
},
outputSchema: { type: 'object' },
},
)
if (contract.outcome !== 'created')
throw new Error('Contract creation failed')
const version = await repositories.workflow.createWorkflowVersion(db, {
workflowId: workflow.id,
graph: {
version: 1,
trigger: { type: 'api' },
entryNodeId: 'action',
nodes: [
{ id: 'action', type: 'connector', config: { operation } },
{ id: 'end', type: 'end', config: {} },
],
edges: [{ from: 'action', to: 'end' }],
},
contentHash: `${provider}-${suffix}`,
workflowContractRevisionId: contract.revision.id,
})
await repositories.workflow.publishWorkflowVersion(
db,
workflow.id,
version.id,
)
const binding =
await repositories.applicationWorkflowBinding.putApplicationWorkflowBinding(
db,
workspace.id,
application.id,
workflow.id,
{
workflowContractRevisionId: contract.revision.id,
allowBackendStart: false,
allowEndUserStart: true,
enabled: true,
},
)
if (binding.outcome !== 'updated')
throw new Error('Workflow binding failed')
workflows.set(provider, workflow.id)
}
const primarySubjectId = await subject({
workspaceId: workspace.id,
applicationId: application.id,
})
const otherSubjectId = await subject({
workspaceId: workspace.id,
applicationId: application.id,
})
const generatedKey = generateApplicationKey()
await db.insert(schema.applicationKeys).values({
workspaceId: workspace.id,
applicationId: application.id,
name: 'Connections launch gate',
scopes: ['audit:read', 'executions:read', 'executions:cancel'],
hashedKey: generatedKey.hashedKey,
keyPrefix: generatedKey.keyPrefix,
})
const googleWorkflowId = workflows.get('google')
const githubWorkflowId = workflows.get('github')
if (!googleWorkflowId || !githubWorkflowId)
throw new Error('Workflows missing')
return {
workspaceId: workspace.id,
applicationId: application.id,
applicationKey: generatedKey.rawKey,
googleWorkflowId,
githubWorkflowId,
primary: await session({
workspaceId: workspace.id,
applicationId: application.id,
externalSubjectId: primarySubjectId,
}),
secondDevice: await session({
workspaceId: workspace.id,
applicationId: application.id,
externalSubjectId: primarySubjectId,
}),
otherSubject: await session({
workspaceId: workspace.id,
applicationId: application.id,
externalSubjectId: otherSubjectId,
}),
}
}

export async function sessionHeaders(
baseUrl: string,
session: LaunchSession,
method: string,
path: string,
): Promise<Record<string, string>> {
const proof = await new SignJWT({
jti: randomUUID(),
htm: method,
htu: `${baseUrl}${path}`,
iat: Math.floor(Date.now() / 1_000),
nonce: session.nonce,
ath: hash(session.accessToken, 'base64url'),
})
.setProtectedHeader({
typ: 'dpop+jwt',
alg: 'ES256',
jwk: session.key.publicJwk,
})
.sign(session.key.privateKey)
return { Authorization: `DPoP ${session.accessToken}`, DPoP: proof }
}
88 changes: 88 additions & 0 deletions apps/platform-api/test/connections-launch-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { createServer, type IncomingMessage } from 'node:http'

type ProviderRequest = {
authorization: string | undefined
method: string | undefined
path: string
body: string
}

async function body(request: IncomingMessage): Promise<string> {
const chunks: Uint8Array[] = []
for await (const chunk of request) {
if (!(chunk instanceof Uint8Array)) throw new Error('Invalid provider body')
chunks.push(chunk)
}
return Buffer.concat(chunks).toString('utf8')
}

export async function startConnectorApiProvider() {
const requests: ProviderRequest[] = []
const server = createServer((request, response) => {
void (async () => {
const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
const requestBody = await body(request)
requests.push({
authorization: request.headers.authorization,
method: request.method,
path,
body: requestBody,
})
if (
path === '/gmail/v1/users/me/messages' &&
request.method === 'GET' &&
request.headers.authorization?.startsWith('Bearer google-access-')
) {
response.writeHead(200, { 'content-type': 'application/json' }).end(
JSON.stringify({
messages: [{ id: 'message-one', threadId: 'thread-one' }],
resultSizeEstimate: 1,
rawProviderSecret: request.headers.authorization,
}),
)
return
}
if (
path === '/repos/octo/demo/issues' &&
request.method === 'POST' &&
request.headers.authorization?.startsWith('Bearer gho_')
) {
const input: unknown = JSON.parse(requestBody)
if (!input || typeof input !== 'object' || !('title' in input)) {
response.writeHead(400).end()
return
}
response.writeHead(201, { 'content-type': 'application/json' }).end(
JSON.stringify({
id: 42,
number: 7,
title: input.title,
state: 'open',
html_url: 'https://github.com/octo/demo/issues/7',
rawProviderSecret: request.headers.authorization,
}),
)
return
}
response.writeHead(404).end()
})().catch((error: unknown) => {
response.destroy(
error instanceof Error ? error : new Error(String(error)),
)
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('No API address')
return {
baseUrl: `http://127.0.0.1:${address.port}`,
requests,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()))
}),
}
}
Loading
Loading