Skip to content

Commit

Permalink
feat: add IAP and additional claims support (#268)
Browse files Browse the repository at this point in the history
  • Loading branch information
JustinBeckwith authored Jan 30, 2018
1 parent 730d4b7 commit 0803242
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 60 deletions.
41 changes: 41 additions & 0 deletions examples/iap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2017, Google, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

'use strict';

const { JWT } = require('google-auth-library');

/**
* The JWT authorization is ideal for performing server-to-server
* communication without asking for user consent.
*
* Suggested reading for Admin SDK users using service accounts:
* https://developers.google.com/admin-sdk/directory/v1/guides/delegation
**/

const keys = require('./jwt.keys.json');
const oauth2Keys = require('./iap.keys.json');

async function main() {
const clientId = oauth2Keys.web.client_id;
const client = new JWT({
email: keys.client_email,
key: keys.private_key,
additionalClaims: { target_audience: clientId }
});
const url = `https://iap-demo-dot-el-gato.appspot.com`;
const res = await client.request({ url });
console.log(res.data);
}

main().catch(console.error);
31 changes: 22 additions & 9 deletions src/auth/jwtaccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import * as jws from 'jws';
import * as LRU from 'lru-cache';
import * as stream from 'stream';
import {JWTInput} from './credentials';
import {RequestMetadataCallback, RequestMetadataResponse} from './oauth2client';
import {RequestMetadataResponse} from './oauth2client';

export class JWTAccess {
email?: string|null;
Expand Down Expand Up @@ -57,10 +57,14 @@ export class JWTAccess {
/**
* Get a non-expired access token, after refreshing if necessary.
*
* @param {string} authURI the URI being authorized
* @param authURI 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.
*/
getRequestMetadata(authURI: string): RequestMetadataResponse {
getRequestMetadata(
authURI: string,
additionalClaims?: {[index: string]: string}): RequestMetadataResponse {
const cachedToken = this.cache.get(authURI);
if (cachedToken) {
return cachedToken;
Expand All @@ -71,12 +75,21 @@ export class JWTAccess {
// The payload used for signed JWT headers has:
// iss == sub == <client email>
// aud == <the authorization uri>
const payload = {iss: this.email, sub: this.email, aud: authURI, exp, iat};
const assertion = {
header: {alg: 'RS256'} as jws.Header,
payload,
secret: this.key
};
const defaultClaims =
{iss: this.email, sub: this.email, aud: authURI, 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 payload = Object.assign(defaultClaims, additionalClaims);

// Sign the jwt and add it to the cache
const signedJWT =
Expand Down
67 changes: 36 additions & 31 deletions src/auth/jwtclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@
* limitations under the License.
*/

import {GoogleToken, TokenOptions} from 'gtoken';
import {GoogleToken} from 'gtoken';
import * as stream from 'stream';

import {Credentials, JWTInput} from './credentials';
import {JWTAccess} from './jwtaccess';
import {GetTokenResponse, OAuth2Client, RefreshOptions, RequestMetadataResponse} from './oauth2client';
Expand All @@ -29,6 +28,7 @@ export interface JWTOptions extends RefreshOptions {
key?: string;
scopes?: string|string[];
subject?: string;
additionalClaims?: {};
}

export class JWT extends OAuth2Client {
Expand All @@ -39,6 +39,9 @@ export class JWT extends OAuth2Client {
scope?: string;
subject?: string;
gtoken: GoogleToken;
additionalClaims?: {};

private access: JWTAccess;

/**
* JWT service account credentials.
Expand Down Expand Up @@ -68,6 +71,7 @@ export class JWT extends OAuth2Client {
this.key = opts.key;
this.scopes = opts.scopes;
this.subject = opts.subject;
this.additionalClaims = opts.additionalClaims;
this.credentials = {refresh_token: 'jwt-placeholder', expiry_date: 1};
}

Expand All @@ -82,22 +86,32 @@ export class JWT extends OAuth2Client {
keyFile: this.keyFile,
key: this.key,
scopes,
subject: this.subject
subject: this.subject,
additionalClaims: this.additionalClaims
});
}

/**
* Obtains the metadata to be sent with the request.
*
* @param {string} optUri the URI being authorized.
* @param optUri the URI being authorized.
*/
protected async getRequestMetadataAsync(url?: string|null):
Promise<RequestMetadataResponse> {
if (this.createScopedRequired() && url) {
// no scopes have been set, but a uri has been provided. Use JWTAccess
// credentials.
const alt = new JWTAccess(this.email, this.key);
return alt.getRequestMetadata(url);
if (!this.apiKey && this.createScopedRequired() && url) {
if (this.additionalClaims && (this.additionalClaims as {
target_audience: string
}).target_audience) {
const {tokens} = await this.refreshToken();
return {headers: {Authorization: `Bearer ${tokens.access_token}`}};
} else {
// no scopes have been set, but a uri has been provided. Use JWTAccess
// credentials.
if (!this.access) {
this.access = new JWTAccess(this.email, this.key);
}
return this.access.getRequestMetadata(url, this.additionalClaims);
}
} else {
return super.getRequestMetadataAsync(url);
}
Expand Down Expand Up @@ -151,16 +165,25 @@ export class JWT extends OAuth2Client {

/**
* Refreshes the access token.
* @param {object=} ignored
* @param refreshToken ignored
* @private
*/
async refreshToken(refreshToken?: string|null): Promise<GetTokenResponse> {
const newGToken = this.createGToken();
const token = await newGToken.getToken();
if (!this.gtoken) {
this.gtoken = new GoogleToken({
iss: this.email,
sub: this.subject,
scope: this.scopes,
keyFile: this.keyFile,
key: this.key,
additionalClaims: this.additionalClaims
});
}
const token = await this.gtoken.getToken();
const tokens = {
access_token: token,
token_type: 'Bearer',
expiry_date: newGToken.expiresAt
expiry_date: this.gtoken.expiresAt
};
return {res: null, tokens};
}
Expand Down Expand Up @@ -239,22 +262,4 @@ export class JWT extends OAuth2Client {
}
this.apiKey = apiKey;
}

/**
* Creates the gToken instance if it has not been created already.
* @param {function=} callback Callback.
* @private
*/
private createGToken() {
if (!this.gtoken) {
this.gtoken = new GoogleToken({
iss: this.email,
sub: this.subject,
scope: this.scopes,
keyFile: this.keyFile,
key: this.key
} as TokenOptions);
}
return this.gtoken;
}
}
9 changes: 4 additions & 5 deletions src/auth/oauth2client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,10 +649,9 @@ export class OAuth2Client extends AuthClient {
}

/**
* Provides a request implementation with OAuth 2.0 flow.
* If credentials have a refresh_token, in cases of HTTP
* 401 and 403 responses, it automatically asks for a new
* access token and replays the unsuccessful request.
* Provides a request implementation with OAuth 2.0 flow. If credentials have
* a refresh_token, in cases of HTTP 401 and 403 responses, it automatically
* asks for a new access token and replays the unsuccessful request.
* @param {object} opts Request options.
* @param {function} callback callback.
* @return {Request} Request object
Expand All @@ -676,7 +675,7 @@ export class OAuth2Client extends AuthClient {
Promise<AxiosResponse<T>> {
let r2: AxiosResponse;
try {
const r = await this.getRequestMetadataAsync(null);
const r = await this.getRequestMetadataAsync(opts.url);
if (r.headers && r.headers.Authorization) {
opts.headers = opts.headers || {};
opts.headers.Authorization = r.headers.Authorization;
Expand Down
63 changes: 50 additions & 13 deletions test/test.jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,11 @@ import * as assert from 'assert';
import * as fs from 'fs';
import * as jws from 'jws';
import * as nock from 'nock';
import {JWTInput} from '../src/auth/credentials';

import {CredentialRequest, JWTInput} from '../src/auth/credentials';
import {GoogleAuth, JWT} from '../src/index';

const keypair = require('keypair');
const noop = Function.prototype;

interface TokenCallback {
(err: Error|null, result: string): void;
}

const PEM_PATH = './test/fixtures/private.pem';
const PEM_CONTENTS = fs.readFileSync(PEM_PATH, 'utf8');

Expand All @@ -44,12 +39,7 @@ function createJSON() {
};
}

interface GTokenResult {
access_token?: string;
expires_in?: number;
}

function createGTokenMock(body: GTokenResult) {
function createGTokenMock(body: CredentialRequest) {
return nock('https://www.googleapis.com')
.post('/oauth2/v4/token')
.reply(200, body);
Expand Down Expand Up @@ -185,7 +175,54 @@ describe('JWT auth client', () => {
done();
});
});

it('should accept additionalClaims', async () => {
const keys = keypair(1024 /* bitsize of private key */);
const email = 'foo@serviceaccount.com';
const someClaim = 'cat-on-my-desk';
const jwt = new JWT({
email: 'foo@serviceaccount.com',
key: keys.private,
subject: 'ignored@subjectaccount.com',
additionalClaims: {someClaim}
});
jwt.credentials = {refresh_token: 'jwt-placeholder'};

const testUri = 'http:/example.com/my_test_service';
const {headers} = await jwt.getRequestMetadata(testUri);
const got = headers as {
Authorization: string;
};
assert.notStrictEqual(null, got, 'the creds should be present');
const decoded = jws.decode(got.Authorization.replace('Bearer ', ''));
const payload = JSON.parse(decoded.payload);
assert.strictEqual(testUri, payload.aud);
assert.strictEqual(someClaim, payload.someClaim);
});
});

it('should accept additionalClaims that include a target_audience',
async () => {
const keys = keypair(1024 /* bitsize of private key */);
const email = 'foo@serviceaccount.com';
const jwt = new JWT({
email: 'foo@serviceaccount.com',
key: keys.private,
subject: 'ignored@subjectaccount.com',
additionalClaims: {target_audience: 'applause'}
});
jwt.credentials = {refresh_token: 'jwt-placeholder'};

const testUri = 'http:/example.com/my_test_service';
createGTokenMock({access_token: 'abc123'});
const {headers} = await jwt.getRequestMetadata(testUri);
const got = headers as {
Authorization: string;
};
assert.notStrictEqual(null, got, 'the creds should be present');
const decoded = got.Authorization.replace('Bearer ', '');
assert.strictEqual(decoded, 'abc123');
});
});

describe('.request', () => {
Expand Down
12 changes: 10 additions & 2 deletions test/test.jwtaccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,15 @@ describe('.getRequestMetadata', () => {
let testUri: string;
let email: string;
let client: JWTAccess;
let res: RequestMetadataResponse;
beforeEach(() => {
keys = keypair(1024 /* bitsize of private key */);
testUri = 'http:/example.com/my_test_service';
email = 'foo@serviceaccount.com';
client = new JWTAccess(email, keys.private);
res = client.getRequestMetadata(testUri);
});

it('create a signed JWT token as the access token', () => {
const res = client.getRequestMetadata(testUri);
assert.notStrictEqual(
null, res.headers, 'an creds object should be present');
const decoded = jws.decode(
Expand All @@ -61,12 +60,21 @@ describe('.getRequestMetadata', () => {
assert.strictEqual(testUri, payload.aud);
});

it('should not allow overriding with additionalClaims', () => {
const additionalClaims = {iss: 'not-the-email'};
assert.throws(() => {
client.getRequestMetadata(testUri, additionalClaims);
}, `The 'iss' property is not allowed when passing additionalClaims. This claim is included in the JWT by default.`);
});

it('should return a cached token on the second request', () => {
const res = client.getRequestMetadata(testUri);
const res2 = client.getRequestMetadata(testUri);
assert.strictEqual(res, res2);
});

it('should not return cached tokens older than an hour', () => {
const res = client.getRequestMetadata(testUri);
const realDateNow = Date.now;
try {
// go forward in time one hour (plus a little)
Expand Down

0 comments on commit 0803242

Please sign in to comment.