Add endpoints to check account, and fetch projects

This commit is contained in:
Will Hunt 2021-12-01 11:57:34 +00:00
parent dd84f525fc
commit 8919703edc
4 changed files with 145 additions and 13 deletions

View File

@ -1,5 +1,6 @@
import { Router, Request, Response } from "express";
import { BridgeConfigGitHub } from "../Config/Config";
import { ApiError, ErrCode } from "../provisioning/api";
import { UserTokenStore } from "../UserTokenStore";
import { generateGitHubOAuthUrl } from "./AdminCommands";
@ -13,6 +14,9 @@ export class GitHubProvisionerRouter {
}
private onOAuth(req: Request<undefined, undefined, undefined, {userId: string}>, res: Response<{url: string}>) {
if (!this.config.oauth) {
throw new ApiError("GitHub is not configured to support OAuth", ErrCode.UnsupportedOperation);
}
res.send({
url: generateGitHubOAuthUrl(this.config.oauth.client_id, this.config.oauth.redirect_uri, this.tokenStore.createStateForOAuth(req.query.userId))
});

View File

@ -1,5 +1,5 @@
import axios from 'axios';
import axios, { Method } from 'axios';
import JiraApi, { SearchUserOptions } from 'jira-client';
import QuickLRU from "@alloc/quick-lru";
import { JiraAccount, JiraAPIAccessibleResource, JiraIssue, JiraOAuthResult, JiraProject } from './Types';
@ -10,23 +10,53 @@ const log = new LogWrapper("JiraClient");
const ACCESSIBLE_RESOURCE_CACHE_LIMIT = 100;
const ACCESSIBLE_RESOURCE_CACHE_TTL_MS = 60000;
interface JiraProjectSearchResponse {
nextPage: string;
maxResults: number;
startAt: number;
isLast: boolean;
values: JiraProject[];
}
export class HookshotJiraApi extends JiraApi {
constructor(private options: JiraApi.JiraApiOptions) {
constructor(private options: JiraApi.JiraApiOptions, private readonly res: JiraAPIAccessibleResource) {
super(options);
}
public get resource() {
return this.res;
}
private async apiRequest<T>(path: string, method?: Method, data?: undefined): Promise<T>
private async apiRequest<T, R>(path: string, method: Method, data?: R): Promise<T> {
const url = `https://api.atlassian.com/${this.options.base}${path}`;
const res = await axios.request<T>({ url,
method: method || "GET",
data,
headers: {
Authorization: `Bearer ${this.options.bearer}`
},
responseType: 'json',
});
return res.data;
}
async getProject(projectIdOrKey: string): Promise<JiraProject> {
return await super.getProject(projectIdOrKey) as JiraProject;
}
async getIssue(issueIdOrKey: string): Promise<JiraIssue> {
const res = await axios.get<JiraIssue>(`https://api.atlassian.com/${this.options.base}/rest/api/3/issue/${issueIdOrKey}`, {
headers: {
Authorization: `Bearer ${this.options.bearer}`
},
responseType: 'json',
});
return res.data;
return this.apiRequest<JiraIssue>(`/rest/api/3/issue/${issueIdOrKey}`);
}
async * getAllProjects(status = "live"): AsyncIterable<JiraProject> {
let response;
let startAt = 0;
do {
response = await this.apiRequest<JiraProjectSearchResponse>(`/rest/api/3/project/search?startAt=${startAt}&status=${status}`);
yield* response.values;
startAt += response.maxResults;
} while(!response.isLast)
}
async searchUsers(opts: SearchUserOptions): Promise<JiraAccount[]> {
@ -102,6 +132,14 @@ export class JiraClient {
return this.getClientForResource(resource);
}
async getClientForName(name: string) {
const resource = (await this.getAccessibleResources()).find((r) => r.name === name);
if (!resource) {
return null;
}
return this.getClientForResource(resource);
}
async getClientForResource(res: JiraAPIAccessibleResource) {
// Check token age
await this.checkTokenAge();
@ -112,6 +150,6 @@ export class JiraClient {
apiVersion: '3',
strictSSL: true,
bearer: this.bearer,
});
}, res);
}
}

View File

@ -1,19 +1,26 @@
import { BridgeConfigJira } from "../Config/Config";
import { generateJiraURL } from "./AdminCommands";
import { JiraOAuthResult } from "./Types";
import { JiraOAuthResult, JiraProject } from "./Types";
import { MessageQueue } from "../MessageQueue";
import { OAuthRequest } from "../WebhookTypes";
import { Router, Request, Response } from "express";
import { Router, Request, Response, NextFunction } from "express";
import { UserTokenStore } from "../UserTokenStore";
import axios from "axios";
import LogWrapper from "../LogWrapper";
import { ApiError, ErrCode } from "../provisioning/api";
import { HookshotJiraApi } from "./Client";
const log = new LogWrapper("JiraRouter");
interface OAuthQuery {
state: string;
code: string;
}
export class JiraWebhooksRouter {
constructor(private readonly config: BridgeConfigJira, private readonly queue: MessageQueue) { }
private async onOAuth(req: Request, res: Response) {
private async onOAuth(req: Request<unknown, unknown, unknown, OAuthQuery>, res: Response<string|{error: string}>) {
if (typeof req.query.state !== "string") {
return res.status(400).send({error: "Missing 'state' parameter"});
}
@ -64,12 +71,28 @@ export class JiraWebhooksRouter {
}
interface JiraAccountStatus {
loggedIn: boolean;
instances?: {
name: string;
url: string;
id: string;
}[]
}
interface JiraProjectsListing {
name: string;
key: string;
url: string;
}
export class JiraProvisionerRouter {
constructor(private readonly config: BridgeConfigJira, private readonly tokenStore: UserTokenStore) { }
public getRouter() {
const router = Router();
router.get("/oauth", this.onOAuth.bind(this));
router.get("/account", this.onGetAccount.bind(this));
router.get("/instances/:instanceName/projects", this.onGetInstanceProjects.bind(this));
return router;
}
@ -78,4 +101,66 @@ export class JiraProvisionerRouter {
url: generateJiraURL(this.config.oauth.client_id, this.config.oauth.redirect_uri, this.tokenStore.createStateForOAuth(req.query.userId))
});
}
private async onGetAccount(req: Request<undefined, undefined, undefined, {userId: string}>, res: Response<JiraAccountStatus>, next: NextFunction) {
const jiraUser = await this.tokenStore.getJiraForUser(req.query.userId);
if (!jiraUser) {
return res.send({
loggedIn: false,
});
}
const instances = [];
try {
for (const resource of await jiraUser.getAccessibleResources()) {
instances.push({
url: resource.url,
name: resource.name,
id: resource.id,
});
}
} catch (ex) {
log.warn(`Failed to fetch accessible resources for ${req.query.userId}`, ex);
return next( new ApiError("Could not fetch accessible resources for JIRA user", ErrCode.Unknown));
}
return res.send({
loggedIn: true,
instances: instances
})
}
private async onGetInstanceProjects(req: Request<{instanceName: string}, undefined, undefined, {userId: string}>, res: Response<JiraProjectsListing[]>, next: NextFunction) {
const jiraUser = await this.tokenStore.getJiraForUser(req.query.userId);
if (!jiraUser) {
// TODO: Better error?
return next( new ApiError("Not logged in", ErrCode.ForbiddenUser));
}
let resClient: HookshotJiraApi|null;
try {
resClient = await jiraUser.getClientForName(req.params.instanceName);
} catch (ex) {
log.warn(`Failed to fetch client for ${req.params.instanceName} for ${req.query.userId}`, ex);
return next( new ApiError("Could not fetch accessible resources for JIRA user", ErrCode.Unknown));
}
if (!resClient) {
return next( new ApiError("Instance not known or not accessible to this user", ErrCode.ForbiddenUser));
}
const projects = [];
try {
for await (const project of resClient.getAllProjects()) {
projects.push({
key: project.key,
name: project.name,
// Technically not the real URL, but good enough for hookshot!
url: `${resClient.resource.url}/projects/${project.key}`,
});
}
} catch (ex) {
log.warn(`Failed to fetch accessible projects for ${req.params.instanceName} / ${req.query.userId}`, ex);
return next( new ApiError("Could not fetch accessible projects for JIRA user", ErrCode.Unknown));
}
return res.send(projects);
}
}

View File

@ -47,6 +47,10 @@ export enum ErrCode {
* The secret token provided to the API was invalid or not given.
*/
BadToken = "HS_BAD_TOKEN",
/**
* The requested feature is not enabled in the bridge.
*/
DisabledFeature = "HS_DISABLED_FEATURE",
}
const ErrCodeToStatusCode: Record<ErrCode, number> = {
@ -58,6 +62,7 @@ const ErrCodeToStatusCode: Record<ErrCode, number> = {
HS_NOT_IN_ROOM: 403,
HS_BAD_VALUE: 400,
HS_BAD_TOKEN: 401,
HS_DISABLED_FEATURE: 500,
}
export class ApiError extends Error {