Skip to content

Commit

Permalink
fix: Make GitHub app flow edge compatible
Browse files Browse the repository at this point in the history
  • Loading branch information
HugoRCD committed Feb 1, 2025
1 parent 19e326f commit 1f03719
Show file tree
Hide file tree
Showing 3 changed files with 184 additions and 86 deletions.
2 changes: 2 additions & 0 deletions apps/shelve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@vue-email/render": "0.0.9",
"@vueuse/core": "12.5.0",
"@vueuse/nuxt": "12.5.0",
"blakejs": "^1.2.1",
"drizzle-kit": "0.30.2",
"drizzle-orm": "0.38.4",
"jsonwebtoken": "^9.0.2",
Expand All @@ -39,6 +40,7 @@
"nuxt-auth-utils": "0.5.9",
"nuxt-build-cache": "0.1.1",
"resend": "4.1.1",
"tweetnacl": "^1.0.3",
"vue": "3.5.13",
"vue-router": "4.5.0",
"vue-sonner": "1.3.0",
Expand Down
196 changes: 139 additions & 57 deletions apps/shelve/server/services/github.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,85 @@
import jwt from 'jsonwebtoken'
import sodium from 'libsodium-wrappers'
import type { H3Event } from 'h3'
import type { GithubApp, GitHubAppResponse, GitHubRepo } from '@types'

import nacl from 'tweetnacl'
import { blake2b } from 'blakejs'

function deriveNonce(
ephemeralPublicKey: Uint8Array,
recipientPublicKey: Uint8Array
): Uint8Array {
const input = new Uint8Array(
ephemeralPublicKey.length + recipientPublicKey.length
)
input.set(ephemeralPublicKey, 0)
input.set(recipientPublicKey, ephemeralPublicKey.length)
return blake2b(input, undefined, nacl.box.nonceLength)
}

function cryptoBoxSeal(
message: Uint8Array,
recipientPublicKey: Uint8Array
): Uint8Array {
const ephemeralKeyPair = nacl.box.keyPair()
const nonce = deriveNonce(ephemeralKeyPair.publicKey, recipientPublicKey)
const encryptedMessage = nacl.box(
message,
nonce,
recipientPublicKey,
ephemeralKeyPair.secretKey
)
const sealedBox = new Uint8Array(
ephemeralKeyPair.publicKey.length + encryptedMessage.length
)
sealedBox.set(ephemeralKeyPair.publicKey, 0)
sealedBox.set(encryptedMessage, ephemeralKeyPair.publicKey.length)
return sealedBox
}

export class GithubService {

private readonly GITHUB_API = 'https://api.github.com'
private readonly tokenCache = new Map<string, { token: string, expiresAt: Date }>()
private readonly tokenCache = new Map<string, { token: string; expiresAt: Date }>()
private readonly encryptionKey: string

constructor(event: H3Event) {
this.encryptionKey = useRuntimeConfig(event).private.encryptionKey
if (!this.encryptionKey) {
console.error('Encryption key is not defined in runtime config!')
} else {
console.log('Encryption key loaded successfully.')
}
}

private async encryptValue(value: string): Promise<string> {
return await seal(value, this.encryptionKey)
}

private async decryptValue(value: string): Promise<string> {
return await unseal(value, this.encryptionKey) as string
return (await unseal(value, this.encryptionKey)) as string
}

async handleAppCallback(userId: number, code: string) {
const appConfig = await $fetch<GitHubAppResponse>(`${this.GITHUB_API}/app-manifests/${code}/conversions`, {
method: 'POST',
headers: {
'Accept': 'application/vnd.github.v3+json',
const appConfig = await $fetch<GitHubAppResponse>(
`${this.GITHUB_API}/app-manifests/${code}/conversions`,
{
method: 'POST',
headers: {
Accept: 'application/vnd.github.v3+json'
}
}
})
)

await useDrizzle().insert(tables.githubApp)
.values({
slug: appConfig.slug,
appId: appConfig.id,
privateKey: await this.encryptValue(appConfig.pem),
webhookSecret: await this.encryptValue(appConfig.webhook_secret),
clientId: appConfig.client_id,
clientSecret: await this.encryptValue(appConfig.client_secret),
userId: userId,
})
await useDrizzle().insert(tables.githubApp).values({
slug: appConfig.slug,
appId: appConfig.id,
privateKey: await this.encryptValue(appConfig.pem),
webhookSecret: await this.encryptValue(appConfig.webhook_secret),
clientId: appConfig.client_id,
clientSecret: await this.encryptValue(appConfig.client_secret),
userId: userId
})

return `https://github.com/apps/${appConfig.slug}/installations/new`
}
Expand All @@ -52,25 +93,44 @@ export class GithubService {
const app = await useDrizzle().query.githubApp.findFirst({
where: eq(tables.githubApp.userId, userId)
})
if (!app) throw createError({ statusCode: 404, statusMessage: 'GitHub App not found' })
if (!app)
throw createError({
statusCode: 404,
statusMessage: 'GitHub App not found'
})
const privateKey = await this.decryptValue(app.privateKey)

const now = Math.floor(Date.now() / 1000)
const appJWT = jwt.sign({
iat: now - 60,
exp: now + (10 * 60),
iss: app.appId
}, privateKey, { algorithm: 'RS256' })
const appJWT = jwt.sign(
{
iat: now - 60,
exp: now + 10 * 60,
iss: app.appId
},
privateKey,
{ algorithm: 'RS256' }
)

const installations = await $fetch<{ id: number }[]>(`${this.GITHUB_API}/app/installations`, {
headers: {
Authorization: `Bearer ${appJWT}`,
Accept: 'application/vnd.github.v3+json'
const installations = await $fetch<{ id: number }[]>(
`${this.GITHUB_API}/app/installations`,
{
headers: {
Authorization: `Bearer ${appJWT}`,
Accept: 'application/vnd.github.v3+json'
}
}
})
if (!installations.length) throw createError({ statusCode: 404, statusMessage: 'GitHub App not installed in any repositories' })
)
if (!installations.length)
throw createError({
statusCode: 404,
statusMessage:
'GitHub App not installed in any repositories'
})

const { token } = await $fetch<{ token: string, expires_at: string }>(`${this.GITHUB_API}/app/installations/${installations[0].id}/access_tokens`, {
const { token } = await $fetch<{
token: string
expires_at: string
}>(`${this.GITHUB_API}/app/installations/${installations[0].id}/access_tokens`, {
method: 'POST',
headers: {
Authorization: `Bearer ${appJWT}`,
Expand All @@ -86,7 +146,10 @@ export class GithubService {
return token
}

async getUserRepos(userId: number, query?: string): Promise<GitHubRepo[]> {
async getUserRepos(
userId: number,
query?: string
): Promise<GitHubRepo[]> {
const token = await this.getAuthToken(userId)

try {
Expand All @@ -102,7 +165,9 @@ export class GithubService {

if (!query) return repos

return repos.filter((repo: GitHubRepo) => repo.name.toLowerCase().includes(query.toLowerCase()))
return repos.filter((repo: GitHubRepo) =>
repo.name.toLowerCase().includes(query.toLowerCase())
)
} catch (error: any) {
throw createError({
statusCode: error.status || 500,
Expand All @@ -111,27 +176,39 @@ export class GithubService {
}
}

async sendSecrets(userId: number, repository: string, variables: { key: string, value: string }[]) {
async sendSecrets(
userId: number,
repository: string,
variables: { key: string; value: string }[]
) {
try {
const token = await this.getAuthToken(userId)

// eslint-disable-next-line @typescript-eslint/naming-convention
const { key_id, key } = await $fetch<{ key_id: string, key: string }>(
`${this.GITHUB_API}/repos/${repository}/actions/secrets/public-key`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
})

await sodium.ready
const { key_id, key } = await $fetch<{
key_id: string
key: string
}>(`${this.GITHUB_API}/repos/${repository}/actions/secrets/public-key`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
})

const binaryPublicKey = Uint8Array.from(
Buffer.from(key, 'base64')
)

for (const { key: secretKey, value: secretValue } of variables) {
try {
const binkey = sodium.from_base64(key, sodium.base64_variants.ORIGINAL)
const binsec = sodium.from_string(secretValue)
const encBytes = sodium.crypto_box_seal(binsec, binkey)
const encryptedValue = sodium.to_base64(encBytes, sodium.base64_variants.ORIGINAL)
const binarySecretValue = new TextEncoder().encode(secretValue)
const encryptedBytes = cryptoBoxSeal(
binarySecretValue,
binaryPublicKey
)
const encryptedValue = Buffer.from(encryptedBytes).toString(
'base64'
)

await $fetch(`${this.GITHUB_API}/repos/${repository}/actions/secrets/${secretKey}`, {
method: 'PUT',
Expand All @@ -154,7 +231,8 @@ export class GithubService {

return {
statusCode: 201,
message: 'Secrets successfully encrypted and sent to GitHub repository'
message:
'Secrets successfully encrypted and sent to GitHub repository'
}
} catch (error: any) {
throw createError({
Expand Down Expand Up @@ -182,22 +260,26 @@ export class GithubService {
}
}

getUserApps(userId: number): Promise<GithubApp[]> {
return useDrizzle().query.githubApp.findMany({
async getUserApps(userId: number): Promise<GithubApp[]> {
return await useDrizzle().query.githubApp.findMany({
where: eq(tables.githubApp.userId, userId)
})
}

async deleteApp(userId: number, slug: string) {
await useDrizzle().delete(tables.githubApp)
.where(and(
eq(tables.githubApp.userId, userId),
eq(tables.githubApp.slug, slug)
))
await useDrizzle()
.delete(tables.githubApp)
.where(
and(
eq(tables.githubApp.userId, userId),
eq(tables.githubApp.slug, slug)
)
)

return {
statusCode: 200,
message: 'App removed from Shelve. Dont forget to delete it from GitHub',
message:
'App removed from Shelve. Dont forget to delete it from GitHub',
link: `https://github.com/settings/apps/${slug}/advanced`
}
}
Expand Down
Loading

0 comments on commit 1f03719

Please sign in to comment.