Skip to content

Commit

Permalink
prepares REST-API for Jira adapter (closes #50)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonasfroeller committed Sep 15, 2024
1 parent 874d2b9 commit 58294fa
Show file tree
Hide file tree
Showing 21 changed files with 509 additions and 83 deletions.
Binary file modified bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"main:studio": "drizzle-kit studio --config drizzle.config.main.ts --host 127.0.0.1"
},
"dependencies": {
"@apollo/client": "^3.11.8",
"@bogeychan/elysia-logger": "^0.0.20",
"@elysiajs/bearer": "^1.0.2",
"@elysiajs/cors": "^1.0.2",
Expand All @@ -24,6 +25,7 @@
"@octokit/graphql-schema": "^14.47.1",
"drizzle-orm": "^0.30.9",
"elysia": "^1.0.9",
"graphql": "^16.9.0",
"octokit": "^3.1.2",
"pg": "^8.11.5"
},
Expand Down
6 changes: 4 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ export const HOME_URLS = {
} as const;

// CORS settings for development and production servers
export const CORS_ORIGINS = [
export const CORS_ORIGINS = DEV_MODE ? [
"http://localhost:5000"
] : [
HOME_URLS.website.url,
"https://propromo-d08144c627d3.herokuapp.com",
DEV_MODE ? "http://localhost:5000" : "https://propromo-ts.vercel.app",
"https://propromo-ts.vercel.app",
];

// Home Page
Expand Down
28 changes: 28 additions & 0 deletions src/v1/adapters/authenticate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Context } from "elysia";
import { MicroserviceError } from "./error";

/**
* Check for the presence of a token and throw an error if it is missing.
*
* @param {string | undefined} token - The token to be checked
* @param {Context["set"]} set - The set context
* @param {string} errorMessage - The error message to be thrown if the token is missing
* @return {string} The token as a string if it is present
*/
export function checkForTokenPresence(
token: string | undefined,
set: Context["set"],
jwtRealm: "propromoRestAdaptersGithub" | "propromoRestAdaptersJira" = "propromoRestAdaptersGithub",
errorMessage = "Token is missing. Create one at https://github.com/settings/tokens.",
): string {
if (!token || token.trim().length === 0) {
// Authorization: Bearer <token>
set.status = 400;
set.headers["WWW-Authenticate"] =
`Bearer realm='${jwtRealm}', error="bearer_token_missing"`;

throw new MicroserviceError({ error: errorMessage, code: 400 });
}

return token;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PAT_SALT as SALT } from "../../../../environment";
import { PAT_SALT as SALT } from "../../environment";

export async function encryptString(plaintext: string) {
const plaintextBuffer = new TextEncoder().encode(plaintext);
Expand Down
File renamed without changes.
58 changes: 58 additions & 0 deletions src/v1/adapters/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ApolloClient, InMemoryCache, gql, HttpLink } from '@apollo/client/core'; // https://github.com/apollographql/apollo-client/issues/11351
import { setContext } from '@apollo/client/link/context';
import { Context } from 'elysia';
import { MicroserviceError } from './error';
import { DEV_MODE } from '../../environment';

/**
* Fetches data from a GraphQL endpoint using Basic Authentication.
*
* @param document The GraphQL query document.
* @param endpoint The URL of the GraphQL endpoint.
* @param user The email address to use for Basic Authentication.
* @param secret The API token to use for Basic Authentication.
* @param set The context set function.
* @returns A promise that resolves to a GraphQL response.
*/
export async function fetchGraphqlEndpointUsingBasicAuth<T>(
document: string,
endpoint: string,
{ user, secret }: { user: string; secret: string },
set: Context["set"]
) {
if (DEV_MODE) console.log(`Fetching data from the GraphQL endpoint '${endpoint}' using Basic Authentication {${user}:${secret}}...`);

const authLink = setContext((_, { headers }) => {
const base64Token = Buffer.from(`${user}:${secret}`).toString('base64');

return {
headers: {
...headers,
Authorization: `Basic ${base64Token}`,
},
};
});

// Creates a link that combines the auth and HTTP link
const host = `https://${endpoint}/gateway/api/graphql`;
const httpLink = new HttpLink({ uri: host });
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
});

// Fetches data from the GraphQL endpoint
return await client.query<T>({
query: gql`${document}`,
}).catch((error) => {
const code = error.message.includes('401') ? 401 : 500;

set.status = code;
throw new MicroserviceError({
error: `Failed to fetch data from the GraphQL endpoint '${endpoint}'.`,
code: code,
});
}).finally(() => {
client.stop();
});
}
34 changes: 5 additions & 29 deletions src/v1/adapters/github/functions/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import bearer from "@elysiajs/bearer";
import { fetchGithubDataUsingGraphql } from "./fetch";
import type { RateLimit } from "@octokit/graphql-schema";
import { GITHUB_QUOTA } from "../graphql";
import { maybeStringToNumber } from "./parse";
import { maybeStringToNumber } from "../../parse";
import { DEV_MODE, JWT_SECRET } from "../../../../environment";
import { MicroserviceError } from "../error";
import { decryptString, encryptString } from "./crypto";
import { MicroserviceError } from "../../error";
import { decryptString, encryptString } from "../../crypto";
import { checkForTokenPresence } from "../../authenticate";

/* JWT */

Expand All @@ -29,31 +30,6 @@ export const GITHUB_JWT = new Elysia().use(
}),
);

/**
* Check for the presence of a token and throw an error if it is missing.
*
* @param {string | undefined} token - The token to be checked
* @param {Context["set"]} set - The set context
* @param {string} errorMessage - The error message to be thrown if the token is missing
* @return {string} The token as a string if it is present
*/
export function checkForTokenPresence(
token: string | undefined,
set: Context["set"],
errorMessage = "Token is missing. Create one at https://github.com/settings/tokens.",
): string {
if (!token || token.trim().length === 0) {
// Authorization: Bearer <token>
set.status = 400;
set.headers["WWW-Authenticate"] =
`Bearer realm='${GITHUB_JWT_REALM}', error="bearer_token_missing"`;

throw new MicroserviceError({ error: errorMessage, code: 400 });
}

return token as string;
}

/**
* Check if the provided token is valid by fetching Github data using GraphQL.
*
Expand Down Expand Up @@ -124,7 +100,7 @@ export const RESOLVE_JWT = new Elysia()

/* APP AND TOKEN AUTHENTICATION */

export const GITHUB_APP_AUTHENTICATION = new Elysia({ prefix: "/auth" })
export const GITHUB_AUTHENTICATION = new Elysia({ prefix: "/auth" })
.use(bearer())
.use(GITHUB_JWT)
.post(
Expand Down
2 changes: 1 addition & 1 deletion src/v1/adapters/github/functions/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { GITHUB_API_HEADERS } from "../globals";
import { getOctokitObject } from "./authenticate";
import type { OctokitResponse } from "@octokit/types";
import { MicroserviceError } from "../error";
import { MicroserviceError } from "../../error";
import { DEV_MODE, OPEN_SOURCE_PROGRAM_PATS } from "../../../../environment";

/**
Expand Down
41 changes: 2 additions & 39 deletions src/v1/adapters/github/functions/parse.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Context } from "elysia";
import { MicroserviceError } from "../error";
import { MicroserviceError } from "../../error";
import { isValidEnumArray } from "../../parse";

/**
* Parses the input scopes and returns an array of valid enum values.
Expand Down Expand Up @@ -36,41 +37,3 @@ export function parseScopes<T>(

return scope_values as T[];
}

/**
* Checks if all elements in the array are valid enum values.
*
* @param {string[]} array - the array to be checked
* @param {string[]} enumValues - the valid enum values
* @return {boolean} true if all elements in the array are valid enum values, false otherwise
*/
export function isValidEnumArray(
array: string[],
enumValues: string[],
): boolean {
for (let i = 0; i < array.length; i++) {
if (!enumValues.includes(array[i])) {
return false;
}
}
return true;
}

/**
* Converts a string or number input to a string or number output.
* If the input is a string representation of a number, it is converted to a number.
* If the input is already a number, it is returned as is.
* If the input is not a valid number, the input is returned as is.
*
* @param {string | number} input - The input value to be converted.
* @return {string | number} - The converted value.
*/
export function maybeStringToNumber(
input: string | number | undefined,
): string | number {
if (!input) return -1;
const maybeNumber = +input; // like Number() - if it is a number, give me a number, if it is not, give me NaN, parseInt() stops at the first non numeric value and returns the number => weird :)

if (!Number.isNaN(maybeNumber)) return maybeNumber;
return input;
}
5 changes: 3 additions & 2 deletions src/v1/adapters/github/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import { fetchGithubDataUsingGraphql, fetchRateLimit } from "./functions/fetch";
import { createPinoLogger } from "@bogeychan/elysia-logger";
import { RESOLVE_JWT } from "./functions/authenticate";
import { guardEndpoints } from "./plugins";
import { guardEndpoints } from "../plugins";
import {
GITHUB_ACCOUNT_SCOPES,
GITHUB_MILESTONE_ISSUE_STATES,
Expand All @@ -29,7 +29,8 @@ import {
GITHUB_REPOSITORY_PARAMS,
} from "./params";
import { OrganizationFetcher, Repository, UserFetcher } from "./scopes";
import { parseScopes, maybeStringToNumber } from "./functions/parse";
import { parseScopes } from "./functions/parse";
import { maybeStringToNumber } from "../parse";

const log = createPinoLogger();
// TODO: write tests for all endpoints
Expand Down
139 changes: 139 additions & 0 deletions src/v1/adapters/jira/functions/authenticate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { type Context, Elysia } from "elysia";
import jwt from "@elysiajs/jwt";
import bearer from "@elysiajs/bearer";
import { DEV_MODE, JWT_SECRET } from "../../../../environment";
import { MicroserviceError } from "../../error";
import { decryptString, encryptString } from "../../crypto";
import { checkForTokenPresence } from "../../authenticate";
import { JIRA_CLOUD_ID } from "../graphql";
import { fetchGraphqlEndpointUsingBasicAuth } from "../../fetch";
import { JIRA_AUTHENTICATION_STRATEGY_OPTIONS, tenantContexts } from "../types";

/* JWT */

export const JIRA_JWT_REALM = "propromoRestAdaptersJira";

export const JIRA_JWT = new Elysia().use(
jwt({
name: JIRA_JWT_REALM,
secret: JWT_SECRET,
alg: "HS256",
iss: "propromo",
}),
);

/**
* Checks if the provided token is valid by querying the Jira Cloud API.
*
* @param host - The Jira Cloud instance to query.
* @param {user, secret} - The credentials to use for authentication.
* @param set - The context set function.
* @return {Promise<boolean>} A promise that resolves to true if the token is valid, or throws an error if it is not.
* @throws {MicroserviceError} If the token is invalid or has expired.
*/
export async function checkIfTokenIsValid(
host: string,
auth: { user: string; secret: string },
set: Context["set"],
): Promise<boolean> {
const response = await fetchGraphqlEndpointUsingBasicAuth<{ tenantContexts?: { cloudId?: string } | null }>(JIRA_CLOUD_ID([host]), host, auth, set);

if (response?.data?.tenantContexts === null) {
set.status = 401;
set.headers["WWW-Authenticate"] =
`Bearer realm='${JIRA_JWT_REALM}', error="invalid_bearer_token"`;

throw new MicroserviceError({
error:
"The provided token is invalid or has expired. Please try another token. Perhaps you chose the wrong provider?",
code: 401,
info: response?.error,
});
}

return true;
}

export const RESOLVE_JWT = new Elysia()
.use(JIRA_JWT)
.resolve(
{ as: "scoped" },
async ({ propromoRestAdaptersJira, headers: { authorization }, set }) => {
const bearer = authorization?.split(" ")[1];
const token = checkForTokenPresence(bearer, set, JIRA_JWT_REALM, "Token is missing. Create one at https://id.atlassian.com/manage-profile/security/api-tokens.");

const jwt = await propromoRestAdaptersJira.verify(token);
if (DEV_MODE) console.log(jwt);

if (!jwt) {
set.status = 401;
set.headers["WWW-Authenticate"] =
`Bearer realm='${JIRA_JWT_REALM}', error="bearer_token_invalid"`;

throw new MicroserviceError({
error:
"Unauthorized. Authentication token is missing or invalid. Please provide a valid token. Tokens can be obtained from the `/auth/basic` endpoint.",
code: 401,
});
}

const patToken = await decryptString(String(jwt.auth));
if (DEV_MODE) console.log("decryptedToken:", patToken);

return {
fetchParams: {
auth_type: jwt.auth_type as JIRA_AUTHENTICATION_STRATEGY_OPTIONS,
auth: patToken,
},
};
},
);

/* APP AND TOKEN AUTHENTICATION */

export const JIRA_AUTHENTICATION = new Elysia({ prefix: "/auth" })
.use(bearer())
.use(JIRA_JWT)
.post(
"/basic",
async ({ propromoRestAdaptersJira, bearer, set }) => {
const auth = bearer; // bearer is checked beforeHandle

const token = await encryptString(auth as string);
if (DEV_MODE) console.log("encryptedToken", token);

const bearerToken = await propromoRestAdaptersJira.sign({
auth_type: JIRA_AUTHENTICATION_STRATEGY_OPTIONS.BASIC,
auth: token,
iat: Math.floor(Date.now() / 1000) - 60,
/* exp: Math.floor(Date.now() / 1000) + (10 * 60) */
});

const tokenParts = auth.split(" ");
const tokenAuth = tokenParts[1].split(":");
const host = tokenParts[0];
const user = tokenAuth[0];
const secret = tokenAuth[1];
const jiraCloudContext = await fetchGraphqlEndpointUsingBasicAuth<tenantContexts>(JIRA_CLOUD_ID([host]), host, { user: user, secret: secret }, set);

return {
bearer: bearerToken,
context: {
cloudId: jiraCloudContext?.data?.tenantContexts ? jiraCloudContext?.data?.tenantContexts[0]?.cloudId : -1,
}
};
},
{
async beforeHandle({ bearer, set }) {
const token = checkForTokenPresence(bearer, set, JIRA_JWT_REALM, "Token is missing. Create one at https://id.atlassian.com/manage-profile/security/api-tokens.");
const tokenParts = token.split(" ");
const tokenAuth = tokenParts[1].split(":");
const valid = await checkIfTokenIsValid(tokenParts[0], { user: tokenAuth[0], secret: tokenAuth[1] }, set);
if (DEV_MODE) console.log("decryptedToken:", token, "| valid:", valid);
},
detail: {
description: "Authenticate using a Jira Email:API-Token (basic authentication).",
tags: ["jira", "authentication"],
},
},
);
Loading

0 comments on commit 58294fa

Please sign in to comment.