Skip to content

Commit

Permalink
🛂 Port authentication related code from v3 API
Browse files Browse the repository at this point in the history
The code is ported from hpc_service repo
  • Loading branch information
Pl217 committed Aug 18, 2021
1 parent 4bf56f2 commit 23593c9
Show file tree
Hide file tree
Showing 12 changed files with 1,683 additions and 0 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@
"io-ts": "2.2.9",
"knex": "0.21.1",
"lodash": "^4.17.21",
"node-fetch": "~2.6.1",
"pg": "^8.5.1"
},
"devDependencies": {
"@types/hapi__hapi": "20.0.8",
"@types/lodash": "^4.14.170",
"@types/node": "^14.0.5",
"@types/node-fetch": "^2.5.7",
"@types/restify": "8.4.2",
"@unocha/hpc-repo-tools": "^0.1.3",
"eslint": "^7.29.0",
Expand Down
79 changes: 79 additions & 0 deletions src/auth/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Lightweight in-memory cache to speed-up authorization processes.
*
* TODO: extend this with some cross-container cache such as redis or memcached
*/

import { createHash } from 'crypto';

const sha256 = (str: string) => createHash('sha256').update(str).digest('hex');

export type HIDInfo = {
userId: string;
given_name: string;
family_name: string;
email: string;
};

export type HIDResponse =
| {
type: 'success';
info: HIDInfo;
}
| {
type: 'forbidden';
message: string;
};

type CachedValue<T> = {
value: T;
time: Date;
};

export class HashTableCache<V> {
/**
* The number of milliseconds a value should remain valid for
*/
private readonly cacheItemLifetimeMs: number;

private map = new Map<string, CachedValue<V>>();

constructor(opts: { cacheItemLifetimeMs: number }) {
this.cacheItemLifetimeMs = opts.cacheItemLifetimeMs;
}

public store = (key: string, value: V, cacheTime?: Date): void => {
this.map.set(sha256(key), {
value,
time: cacheTime || new Date(),
});
this.clearExpiredValues();
};

public get = (key: string): V | null => {
const item = this.map.get(sha256(key));

if (item && item.time.getTime() + this.cacheItemLifetimeMs > Date.now()) {
return item.value;
}

return null;
};

public clearExpiredValues = (): void => {
const now = Date.now();
for (const [key, item] of this.map.entries()) {
if (item.time.getTime() + this.cacheItemLifetimeMs < now) {
this.map.delete(key);
}
}
};

public clear = (): void => this.map.clear();

public size = (): number => this.map.size;
}

export const HID_CACHE = new HashTableCache<HIDResponse>({
cacheItemLifetimeMs: 5 * 60 * 1000, // 5 minutes
});
92 changes: 92 additions & 0 deletions src/auth/hid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as t from 'io-ts';
import * as fetch from 'node-fetch';

import { URL } from 'url';
import Context from '../Context';
import { Request } from '../Request';
import { ForbiddenError } from '../util/error';
import { HIDInfo, HID_CACHE } from './cache';

// Export for mocking purposes inside test cases
export { fetch };

const HID_ACCOUNT_INFO = t.type({
given_name: t.string,
family_name: t.string,
user_id: t.string,
email: t.string,
});

/**
* Use the Authentication headers to get the HID token for the request,
* and use that to determine what HID user the user is logged-in as by
* requesting account information from HID using the token.
*
* If this has already been done for the current request, the previous result
* will be returned and no requests will be made. Therefore it is safe and
* efficient to use this function multiple times.
*/
export const getHidInfo = async (
context: Pick<Context, 'token' | 'request' | 'config'>
): Promise<(HIDInfo & Request['apiAuth']) | undefined> => {
const { token, request, config } = context;
if (!token || !request || !config) {
return undefined;
}
const existing = HID_CACHE.get(token);

if (existing) {
if (existing.type === 'success') {
request.apiAuth = {
...(request.apiAuth || {}),
userId: existing.info.userId,
};

return existing.info;
}

throw new ForbiddenError(existing.message);
} else {
const accountUrl = new URL('/account.json', config.authBaseUrl);

const res = await fetch.default(accountUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});

if (!res.ok) {
if (res.status === 401) {
const r = await res.json();
const message = r.message || 'Invalid Token';
HID_CACHE.store(token, { type: 'forbidden', message });
throw new ForbiddenError(message);
} else {
throw new ForbiddenError(
`Unexpected error from HID: ${res.statusText}`
);
}
}

const data = await res.json();

if (!HID_ACCOUNT_INFO.is(data)) {
throw new ForbiddenError('Got invalid data from HID');
}

const info: HIDInfo = {
userId: data.user_id,
family_name: data.family_name,
given_name: data.given_name,
email: data.email,
};
HID_CACHE.store(token, { type: 'success', info });

request.apiAuth = {
...(request.apiAuth || {}),
userId: info.userId,
};

return info;
}
};
Loading

0 comments on commit 23593c9

Please sign in to comment.