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
3 changes: 2 additions & 1 deletion .gitleaksignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/github/workspace/src/auth.ts:generic-api-key:8
/github/workspace/src/auth.ts:generic-api-key:16
/github/workspace/src/auth.ts:generic-api-key:22
92 changes: 81 additions & 11 deletions __tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
authenticate,
GITHUB_AUTH_API_KEY,
GITHUB_AUTH_SERVICE_URL
} from '../src/auth'
import { authenticate, PRODUCTION_SERVICE, STAGING_SERVICE } from '../src/auth'
import { jest } from '@jest/globals'

describe('authenticate', () => {
Expand All @@ -13,24 +9,54 @@ describe('authenticate', () => {
mockFetch.mockClear()
})

it('should use GitHub App authentication when app is installed', async () => {
it('should use production GitHub App when installed', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => Promise.resolve({ token: 'ghs_app_123' })
json: async () => Promise.resolve({ token: 'ghs_prod_token' })
} as Response)

const result = await authenticate(
{ owner: 'dunder-mifflin', repo: 'website' },
'ghp_default_token'
)

expect(result).toBe('ghs_app_123')
expect(result).toBe('ghs_prod_token')
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith(
`${GITHUB_AUTH_SERVICE_URL}/github/dunder-mifflin/website/installation-token`,
`${PRODUCTION_SERVICE.url}/github/dunder-mifflin/website/installation-token`,
expect.objectContaining({
method: 'POST',
headers: { Authorization: `Bearer ${GITHUB_AUTH_API_KEY}` }
headers: { Authorization: `Bearer ${PRODUCTION_SERVICE.key}` }
})
)
})

it('should use staging GitHub App when production not installed', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({ error: 'Not installed' })
} as Response)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ token: 'ghs_staging_token' })
} as Response)

const result = await authenticate(
{ owner: 'dunder-mifflin', repo: 'website' },
'ghp_default_token'
)

expect(result).toBe('ghs_staging_token')
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenCalledWith(
`${STAGING_SERVICE.url}/github/dunder-mifflin/website/installation-token`,
expect.objectContaining({
method: 'POST',
headers: { Authorization: `Bearer ${STAGING_SERVICE.key}` }
})
)
})
Expand All @@ -48,6 +74,7 @@ describe('authenticate', () => {
)

expect(result).toBe('ghp_default_token')
expect(mockFetch).toHaveBeenCalledTimes(2)
})

it('should fall back to standard authentication when service is unavailable', async () => {
Expand All @@ -65,11 +92,54 @@ describe('authenticate', () => {
const customPAT = 'ghp_custom_pat'

const result = await authenticate(
{ owner: 'owner', repo: 'repo' },
{ owner: 'dunder-mifflin', repo: 'website' },
customPAT
)

expect(result).toBe(customPAT)
expect(mockFetch).not.toHaveBeenCalled()
})

it('should handle all services returning errors', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ error: 'Unauthorized' })
} as Response)
.mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({ error: 'Server error' })
} as Response)

const result = await authenticate(
{ owner: 'dunder-mifflin', repo: 'website' },
'ghp_default_token'
)

expect(result).toBe('ghp_default_token')
expect(mockFetch).toHaveBeenCalledTimes(2)
})

it('should handle unexpected status codes', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 403,
json: async () => ({ error: 'Forbidden' })
} as Response)
.mockResolvedValueOnce({
ok: false,
status: 403,
json: async () => ({ error: 'Forbidden' })
} as Response)

const result = await authenticate(
{ owner: 'dunder-mifflin', repo: 'website' },
'ghp_default_token'
)

expect(result).toBe('ghp_default_token')
})
})
52 changes: 36 additions & 16 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

75 changes: 54 additions & 21 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
import * as core from '@actions/core'
import type { Context } from '@actions/github/lib/context'

// TODO: check production URL?
export const GITHUB_AUTH_SERVICE_URL =
'https://github-auth.staging.code-pushup.dev'

export const GITHUB_AUTH_API_KEY =
'18850f2513adad10662e85f4f085a9714e64cef7793fc2ffe903b5ddcd62de42'

type TokenResponse = {
token: string
}

type AuthService = {
url: string
key: string
name: string
}

export const STAGING_SERVICE: AuthService = {
url: 'https://github-auth.staging.code-pushup.dev',
key: '18850f2513adad10662e85f4f085a9714e64cef7793fc2ffe903b5ddcd62de42',
name: 'Code PushUp (staging)'
}

export const PRODUCTION_SERVICE: AuthService = {
url: 'https://github-auth.code-pushup.dev',
key: 'b2585352366ceead1323a1f3a7cf6b9212387ea6d2d8aeb397e7950aaa3ba776',
name: 'Code PushUp'
}

export async function authenticate(
{ owner, repo }: Context['repo'],
token: string
Expand All @@ -20,29 +31,47 @@ export async function authenticate(
core.info('Using user-provided PAT')
return token
}
const productionResult = await tryService(PRODUCTION_SERVICE, owner, repo)
if (productionResult) {
core.info(`Using ${PRODUCTION_SERVICE.name} GitHub App installation token`)
return productionResult.token
}
const stagingResult = await tryService(STAGING_SERVICE, owner, repo)
if (stagingResult) {
core.info(`Using ${STAGING_SERVICE.name} GitHub App installation token`)
return stagingResult.token
}
core.info('Using default GITHUB_TOKEN')
return token
}

async function tryService(
service: AuthService,
owner: string,
repo: string
): Promise<{ token: string; service: AuthService } | null> {
try {
const response = await fetch(
`${GITHUB_AUTH_SERVICE_URL}/github/${owner}/${repo}/installation-token`,
`${service.url}/github/${owner}/${repo}/installation-token`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${GITHUB_AUTH_API_KEY}`
Authorization: `Bearer ${service.key}`
}
}
)
const data = await response.json()
if (response.ok && isTokenResponse(data)) {
core.info('Using Code PushUp GitHub App installation token')
return data.token
return { token: data.token, service }
}
handleErrorResponse(response.status)
handleErrorResponse(response.status, service.name)
} catch (error) {
core.warning(
`Unable to contact Code PushUp authentication service: ${error}`
core.debug(
`Unable to contact ${service.name} authentication service: ${error}`
)
return null
}
core.info('Using default GITHUB_TOKEN')
return token
return null
}

function isTokenResponse(res: unknown): res is TokenResponse {
Expand All @@ -54,18 +83,22 @@ function isTokenResponse(res: unknown): res is TokenResponse {
)
}

function handleErrorResponse(status: number): void {
function handleErrorResponse(status: number, serviceName: string): void {
switch (status) {
case 404:
core.debug('Code PushUp GitHub App not installed on this repository')
core.debug(`${serviceName} GitHub App not installed on this repository`)
break
case 401:
core.warning('Code PushUp authentication service authorization failed')
core.warning(`${serviceName} authentication service authorization failed`)
break
case 500:
core.warning('Code PushUp authentication service temporarily unavailable')
core.warning(
`${serviceName} authentication service temporarily unavailable`
)
break
default:
core.debug(`Code PushUp authentication service returned status ${status}`)
core.warning(
`${serviceName} authentication service returned unexpected status: ${status}`
)
}
}
Loading