@guardian/pan-domain-node
Version:
NodeJs implementation of Guardian pan-domain auth verification
156 lines (120 loc) • 4.28 kB
text/typescript
import * as crypto from 'crypto';
import * as https from 'https';
import {User} from './api';
import {URLSearchParams} from 'url';
export function decodeBase64(data: string): string {
return Buffer.from(data, 'base64').toString('utf8');
}
export type ParsedCookie = { data: string, signature: string };
/**
* Check if a string is valid base64
*/
function isBase64(str: string): boolean {
try {
return Buffer.from(str, 'base64').toString('base64') === str;
} catch (err) {
return false;
}
}
/**
* Parse a pan-domain user cookie in to data and signature
* Validates that the cookie is properly formatted (two base64 strings separated by '.')
*/
export function parseCookie(cookie: string): ParsedCookie | undefined {
const cookieRegex = /^([\w\W]*)\.([\w\W]*)$/;
const match = cookie.match(cookieRegex);
if (!match) {
return undefined;
}
const [, data, signature] = match;
if (!isBase64(data) || !isBase64(signature)) {
return undefined;
}
return {
data: decodeBase64(data),
signature: signature
};
}
/**
* Verify signed data using nodeJs crypto library
*/
export function verifySignature(message: string, signature: string, pandaPublicKey: string): boolean {
return crypto.createVerify('sha256WithRSAEncryption')
.update(message, 'utf8')
.verify(pandaPublicKey, signature, 'base64');
}
export function sign(message: string, privateKey: string): string {
const sign = crypto.createSign("sha256WithRSAEncryption");
sign.write(message);
sign.end();
return sign.sign(privateKey, 'base64');
}
const ASCII_NEW_LINE = String.fromCharCode(10);
export function base64ToPEM (key: string, headerFooter: string): string {
const PEM_HEADER = `-----BEGIN ${headerFooter} KEY-----`;
const PEM_FOOTER = `-----END ${headerFooter} KEY-----`;
let tmp = [];
const ret = [Buffer.from(PEM_HEADER).toString('ascii')];
for (let i = 0, len = key.length; i < len; i++) {
if (i>0 && i%64 === 0) {
ret.push(tmp.join(''));
tmp = [];
}
tmp.push(key[i]);
}
ret.push(tmp.join(''));
ret.push(Buffer.from(PEM_FOOTER).toString('ascii'));
return ret.join(ASCII_NEW_LINE);
}
export function httpGet(path: string): Promise<string> {
return new Promise((resolve, reject) => {
const data: string[] = [];
https.get(path, res => {
res.on('data', chunk => data.push(chunk.toString('utf8')));
res.on('error', err => reject(err));
res.on('end', () => {
const body = data.join('');
if(res.statusCode == 200) {
resolve(body);
} else {
// Response might be XML
const match = body.match(/<message>(.*)<\/message>/i);
const error = new Error(match ? match[1] : 'Invalid public key response');
reject(error);
}
});
});
});
}
export function parseUser(data: string): User {
const params = new URLSearchParams(data);
function stringField(name: string): string {
const value = params.get(name);
if(!value ) { throw new Error(`Missing ${name}`) }
return value;
}
function numberField(name: string): number {
const value = params.get(name);
if(!value) { throw new Error(`Missing ${name}`) }
return parseInt(value);
}
function booleanField(name: string): boolean {
return params.get(name) === 'true';
}
function stringListField(name: string): string[] {
const value = params.get(name);
if(!value) { throw new Error(`Missing ${name}`) }
return value.split(",");
}
const avatarUrl = params.get("avatarUrl");
return {
firstName: stringField("firstName"),
lastName: stringField("lastName"),
email: stringField("email"),
avatarUrl: avatarUrl ? avatarUrl : undefined,
authenticatingSystem: stringField("system"),
authenticatedIn: stringListField("authedIn"),
expires: numberField("expires"),
multifactor: booleanField("multifactor")
};
}