-
Notifications
You must be signed in to change notification settings - Fork 53
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(apps): create credential service server
Create the credential service server. When logged in with an ng-dev token, allows for a user to request via websocket, a temporary Github credential.
- Loading branch information
1 parent
fa61d03
commit 5bb34bd
Showing
6 changed files
with
241 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
load("//tools:defaults.bzl", "esbuild_esm_bundle") | ||
load("@build_bazel_rules_nodejs//:index.bzl", "copy_to_bin", "nodejs_binary") | ||
|
||
nodejs_binary( | ||
name = "serve", | ||
data = [ | ||
":bin_files", | ||
], | ||
entry_point = ":credential-service.js", | ||
) | ||
|
||
copy_to_bin( | ||
name = "bin_files", | ||
srcs = [ | ||
"package.json", | ||
], | ||
) | ||
|
||
esbuild_esm_bundle( | ||
name = "credential-service", | ||
entry_point = "//apps/credential-service/lib:server.ts", | ||
target = "node16", | ||
visibility = [ | ||
"//apps/credential-service:__subpackages__", | ||
], | ||
deps = [ | ||
"//apps/credential-service/lib", | ||
], | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
load("//tools:defaults.bzl", "ts_library") | ||
|
||
exports_files([ | ||
"server.ts", | ||
]) | ||
|
||
ts_library( | ||
name = "lib", | ||
srcs = glob([ | ||
"server.ts", | ||
]), | ||
visibility = [ | ||
"//apps/credential-service:__pkg__", | ||
], | ||
deps = [ | ||
"@npm//@octokit/auth-app", | ||
"@npm//@octokit/rest", | ||
"@npm//@types/node", | ||
"@npm//@types/ws", | ||
"@npm//firebase-admin", | ||
"@npm//ws", | ||
], | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
#!/usr/bin/env node | ||
|
||
import {createServer, IncomingMessage} from 'http'; | ||
import {WebSocketServer, WebSocket} from 'ws'; | ||
import {Octokit} from '@octokit/rest'; | ||
import {createAppAuth} from '@octokit/auth-app'; | ||
import {Duplex} from 'stream'; | ||
import admin, {AppOptions} from 'firebase-admin'; | ||
|
||
/** The temporary access token and a convience method for revoking it. */ | ||
interface AccessTokenAndRevocation { | ||
token: string; | ||
revokeToken: () => void; | ||
} | ||
|
||
/** Regex for matching authorization header uses. */ | ||
const authorizationRegex = new RegExp(/Bearer (.*)/); | ||
/** The length of time in ms between heartbeat checks. */ | ||
const heartBeatIntervalLength = 5000; | ||
/** The port to bind the server to */ | ||
const PORT = process.env.PORT!; | ||
/** The ID of the Github app used to generating tokens. */ | ||
const GITHUB_APP_ID = process.env.GITHUB_APP_ID!; | ||
/** The PEM key of the Github app used to generating tokens. */ | ||
const GITHUB_APP_PEM = process.env.GITHUB_APP_PEM; | ||
/** The firebase confgiuration for the firebase application being used for authentication. */ | ||
const FIREBASE_APP_CONFIG = JSON.parse(process.env.FIREBASE_APP_CONFIG!) as AppOptions; | ||
|
||
// Initialize the Firebase application. | ||
admin.initializeApp(FIREBASE_APP_CONFIG); | ||
|
||
/** Generate a temporary access token with access to the requested repository. */ | ||
export async function generateAccessToken( | ||
owner: string, | ||
repo: string, | ||
): Promise<AccessTokenAndRevocation> { | ||
/** The github client used for generating the token. */ | ||
const github = new Octokit({ | ||
authStrategy: createAppAuth, | ||
auth: {appId: GITHUB_APP_ID, privateKey: GITHUB_APP_PEM}, | ||
}); | ||
/** The specific installation id for the provided repository. */ | ||
const {id: installation_id} = (await github.apps.getRepoInstallation({owner, repo})).data; | ||
/** A temporary github access token. */ | ||
const {token} = (await github.rest.apps.createInstallationAccessToken({installation_id})).data; | ||
|
||
return { | ||
token, | ||
revokeToken: async () => await github.apps.revokeInstallationAccessToken(), | ||
}; | ||
} | ||
|
||
/** | ||
* WebSocket handler to generate temporary Github access token. | ||
* | ||
* The access token is automatically revoked when the websocket is closed. | ||
*/ | ||
async function wsHandler(ws: WebSocket, req: IncomingMessage) { | ||
/** Whether the websocket heartbeat check is still alive. */ | ||
let hasHeartbeat: boolean; | ||
/** The interval instance for checking the heartbeat. */ | ||
let heartbeatInterval = setInterval(checkHeartbeat, heartBeatIntervalLength); | ||
/** | ||
* The repository name and owner for the request. | ||
* Note: We safely case the header type as having these fields because they are checked prior | ||
* to the WebSocket handler function being invoked. | ||
*/ | ||
const {repo, owner} = req.headers as {repo: string; owner: string}; | ||
|
||
/** The temporary Github access token and function to revoke the token.. */ | ||
const {token, revokeToken} = await generateAccessToken(owner, repo); | ||
/** Check to make sure the heartbeat is still alive. */ | ||
function checkHeartbeat() { | ||
if (hasHeartbeat === false) { | ||
ws.close(1008, 'Cannot find socket via heartbeat check'); | ||
ws.terminate(); | ||
} | ||
hasHeartbeat = false; | ||
ws.ping(); | ||
} | ||
|
||
/** | ||
* Cleans up the state of the function when the WebSocket is completed. | ||
*/ | ||
async function complete() { | ||
await revokeToken(); | ||
clearInterval(heartbeatInterval); | ||
} | ||
|
||
// Ensure that cleanup is done when the WebSocket closes. | ||
ws.on('close', complete); | ||
ws.on('error', complete); | ||
|
||
// Handle the pong response from the websocket client, updating the heartbeat as alive. | ||
ws.on('pong', () => (hasHeartbeat = true)); | ||
|
||
// Send the temporary Github token to the websocket client | ||
ws.send(token); | ||
} | ||
|
||
/** | ||
* Handle upgrade requests before the websocket is used. Enforces authentication mechanisms | ||
* and ensuring the required data is present in the request. | ||
*/ | ||
async function upgradeHandler(req: IncomingMessage, socket: Duplex, head: Buffer) { | ||
try { | ||
if (!authorizationRegex.test(req.headers.authorization!)) { | ||
throw Error('Missing or invalid authorization header syntax'); | ||
} | ||
/** The NgDev token from the user to be verified. */ | ||
const [_, ngDevToken] = req.headers.authorization!.match(/Bearer (.*)/)!; | ||
await admin | ||
.auth() | ||
.verifySessionCookie(ngDevToken, /* checkRevoked */ true) | ||
.then((decodedToken: admin.auth.DecodedIdToken) => { | ||
console.log(`Verified login of ${decodedToken.email}`); | ||
}); | ||
} catch (e) { | ||
console.error('Unable to verified authorized user'); | ||
console.error(e); | ||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); | ||
socket.destroy(); | ||
return; | ||
} | ||
|
||
/** The repository name and owner for the request. */ | ||
const {repo, owner} = req.headers as {repo: string; owner: string}; | ||
|
||
if (!repo || !owner) { | ||
console.error('Missing a repo or owner parameter'); | ||
socket.write('HTTP/1.1 400 Bad Reqest\r\n\r\n'); | ||
socket.destroy(); | ||
return; | ||
} | ||
|
||
wss.handleUpgrade(req, socket, head, (ws: WebSocket, req: IncomingMessage) => { | ||
wss.emit('connection', ws, req); | ||
}); | ||
} | ||
|
||
/** The http web server. */ | ||
const server = createServer(); | ||
/** The websocket server to handle websocket requests. */ | ||
const wss = new WebSocketServer({noServer: true}); | ||
|
||
wss.on('connection', wsHandler); | ||
server.on('upgrade', upgradeHandler); | ||
server.on('listening', () => console.log('Credential Service startup complete, listening')); | ||
server.listen(parseInt(PORT, 10)); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"type": "module" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters