import * as jws from 'jws';
import * as LRU from 'lru-cache';
import * as stream from 'stream';
import {JWTInput} from './credentials';
import {Headers} from './oauth2client';
const DEFAULT_HEADER: jws.Header = {
alg: 'RS256',
typ: 'JWT',
};
export interface Claims {
[index: string]: string;
}
export class JWTAccess {
email?: string | null;
key?: string | null;
keyId?: string | null;
projectId?: string;
eagerRefreshThresholdMillis: number;
private cache = new LRU<string, {expiration: number; headers: Headers}>({
max: 500,
maxAge: 60 * 60 * 1000,
});
/**
* JWTAccess service account credentials.
*
* Create a new access token by using the credential to create a new JWT token
* that's recognized as the access token.
*
* @param email the service account email address.
* @param key the private key that will be used to sign the token.
* @param keyId the ID of the private key used to sign the token.
*/
constructor(
email?: string | null,
key?: string | null,
keyId?: string | null,
eagerRefreshThresholdMillis?: number
) {
this.email = email;
this.key = key;
this.keyId = keyId;
this.eagerRefreshThresholdMillis =
eagerRefreshThresholdMillis ?? 5 * 60 * 1000;
}
/**
* Get a non-expired access token, after refreshing if necessary.
*
* @param url The URI being authorized.
* @param additionalClaims An object with a set of additional claims to
* include in the payload.
* @returns An object that includes the authorization header.
*/
getRequestHeaders(url: string, additionalClaims?: Claims): Headers {
// Return cached authorization headers, unless we are within
// eagerRefreshThresholdMillis ms of them expiring:
const cachedToken = this.cache.get(url);
const now = Date.now();
if (
cachedToken &&
cachedToken.expiration - now > this.eagerRefreshThresholdMillis
) {
return cachedToken.headers;
}
const iat = Math.floor(Date.now() / 1000);
const exp = JWTAccess.getExpirationTime(iat);
// The payload used for signed JWT headers has:
// iss == sub == <client email>
// aud == <the authorization uri>
const defaultClaims = {
iss: this.email,
sub: this.email,
aud: url,
exp,
iat,
};
// if additionalClaims are provided, ensure they do not collide with
// other required claims.
if (additionalClaims) {
for (const claim in defaultClaims) {
if (additionalClaims[claim]) {
throw new Error(
`The '${claim}' property is not allowed when passing additionalClaims. This claim is included in the JWT by default.`
);
}
}
}
const header = this.keyId
? {...DEFAULT_HEADER, kid: this.keyId}
: DEFAULT_HEADER;
const payload = Object.assign(defaultClaims, additionalClaims);
// Sign the jwt and add it to the cache
const signedJWT = jws.sign({header, payload, secret: this.key});
const headers = {Authorization: `Bearer ${signedJWT}`};
this.cache.set(url, {
expiration: exp * 1000,
headers,
});
return headers;
}
/**
* Returns an expiration time for the JWT token.
*
* @param iat The issued at time for the JWT.
* @returns An expiration time for the JWT.
*/
private static getExpirationTime(iat: number): number {
const exp = iat + 3600; // 3600 seconds = 1 hour
return exp;
}
/**
* Create a JWTAccess credentials instance using the given input options.
* @param json The input object.
*/
fromJSON(json: JWTInput): void {
if (!json) {
throw new Error(
'Must pass in a JSON object containing the service account auth settings.'
);
}
if (!json.client_email) {
throw new Error(
'The incoming JSON object does not contain a client_email field'
);
}
if (!json.private_key) {
throw new Error(
'The incoming JSON object does not contain a private_key field'
);
}
// Extract the relevant information from the json key file.
this.email = json.client_email;
this.key = json.private_key;
this.keyId = json.private_key_id;
this.projectId = json.project_id;
}
/**
* Create a JWTAccess credentials instance using the given input stream.
* @param inputStream The input stream.
* @param callback Optional callback.
*/
fromStream(inputStream: stream.Readable): Promise<void>;
fromStream(
inputStream: stream.Readable,
callback: (err?: Error) => void
): void;
fromStream(
inputStream: stream.Readable,
callback?: (err?: Error) => void
): void | Promise<void> {
if (callback) {
this.fromStreamAsync(inputStream).then(() => callback(), callback);
} else {
return this.fromStreamAsync(inputStream);
}
}
private fromStreamAsync(inputStream: stream.Readable): Promise<void> {
return new Promise((resolve, reject) => {
if (!inputStream) {
reject(
new Error(
'Must pass in a stream containing the service account auth settings.'
)
);
}
let s = '';
inputStream
.setEncoding('utf8')
.on('data', chunk => (s += chunk))
.on('error', reject)
.on('end', () => {
try {
const data = JSON.parse(s);
this.fromJSON(data);
resolve();
} catch (err) {
reject(err);
}
});
});
}
}