Skip to content

Commit c6dac08

Browse files
committed
Using new http API for token generation
1 parent 1063d4a commit c6dac08

File tree

3 files changed

+117
-108
lines changed

3 files changed

+117
-108
lines changed

src/auth/token-generator.ts

Lines changed: 43 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import { FirebaseApp } from '../firebase-app';
1818
import {Certificate} from './credential';
1919
import {AuthClientErrorCode, FirebaseAuthError, FirebaseError} from '../utils/error';
20-
import { SignedApiRequestHandler } from '../utils/api-request';
20+
import { AuthorizedHttpClient, HttpError, HttpRequestConfig, HttpClient } from '../utils/api-request';
2121

2222
import * as validator from '../utils/validator';
2323
import { toWebSafeBase64 } from '../utils';
@@ -80,79 +80,68 @@ export class ServiceAccountSigner implements CryptoSigner {
8080
}
8181

8282
export class IAMSigner implements CryptoSigner {
83-
private readonly requestHandler_: SignedApiRequestHandler;
84-
private serviceAccountId_: string;
83+
private readonly httpClient: AuthorizedHttpClient;
84+
private serviceAccountId: string;
8585

86-
constructor(requestHandler: SignedApiRequestHandler, serviceAccountId?: string) {
87-
if (!requestHandler) {
86+
constructor(httpClient: AuthorizedHttpClient, serviceAccountId?: string) {
87+
if (!httpClient) {
8888
throw new FirebaseAuthError(
89-
AuthClientErrorCode.INVALID_CREDENTIAL,
90-
'INTERNAL ASSERT: Must provide a request handler to initialize IAMSigner.',
89+
AuthClientErrorCode.INVALID_ARGUMENT,
90+
'INTERNAL ASSERT: Must provide a HTTP client to initialize IAMSigner.',
9191
);
9292
}
93-
this.requestHandler_ = requestHandler;
94-
this.serviceAccountId_ = serviceAccountId;
93+
this.httpClient = httpClient;
94+
this.serviceAccountId = serviceAccountId;
9595
}
9696

9797
public sign(buffer: Buffer): Promise<Buffer> {
9898
return this.getAccount().then((serviceAccount) => {
99-
const request = {bytesToSign: buffer.toString('base64')};
100-
return this.requestHandler_.sendRequest(
101-
'iam.googleapis.com',
102-
443,
103-
`/v1/projects/-/serviceAccounts/${serviceAccount}:signBlob`,
104-
'POST',
105-
request);
99+
const request: HttpRequestConfig = {
100+
method: 'POST',
101+
url: `https://iam.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:signBlob`,
102+
data: {bytesToSign: buffer.toString('base64')},
103+
};
104+
return this.httpClient.send(request);
106105
}).then((response: any) => {
107106
// Response from IAM is base64 encoded. Decode it into a buffer and return.
108-
return new Buffer(response.signature, 'base64');
109-
}).catch((response) => {
110-
const error = (typeof response === 'object' && 'statusCode' in response) ?
111-
response.error : response;
112-
if (error instanceof FirebaseError) {
113-
throw error;
114-
}
115-
let errorCode: string;
116-
let errorMsg: string;
117-
if (validator.isNonNullObject(error) && error.error) {
118-
errorCode = error.error.status || null;
119-
errorMsg = error.error.message || null;
107+
return new Buffer(response.data.signature, 'base64');
108+
}).catch((err) => {
109+
if (err instanceof HttpError) {
110+
const error = err.response.data;
111+
let errorCode: string;
112+
let errorMsg: string;
113+
if (validator.isNonNullObject(error) && error.error) {
114+
errorCode = error.error.status || null;
115+
errorMsg = error.error.message || null;
116+
}
117+
throw FirebaseAuthError.fromServerError(errorCode, errorMsg, error);
120118
}
121-
throw FirebaseAuthError.fromServerError(errorCode, errorMsg, error);
119+
throw err;
122120
});
123121
}
124122

125123
public getAccount(): Promise<string> {
126-
if (validator.isNonEmptyString(this.serviceAccountId_)) {
127-
return Promise.resolve(this.serviceAccountId_);
124+
if (validator.isNonEmptyString(this.serviceAccountId)) {
125+
return Promise.resolve(this.serviceAccountId);
128126
}
129-
const options = {
127+
const request: HttpRequestConfig = {
130128
method: 'GET',
131-
host: 'metadata',
132-
path: '/computeMetadata/v1/instance/service-accounts/default/email',
129+
url: 'http://metadata/computeMetadata/v1/instance/service-accounts/default/email',
133130
headers: {
134131
'Metadata-Flavor': 'Google',
135132
},
136133
};
137-
const http = require('http');
138-
return new Promise((resolve, reject) => {
139-
const req = http.request(options, (res) => {
140-
const buffers: Buffer[] = [];
141-
res.on('data', (buffer) => buffers.push(buffer));
142-
res.on('end', () => {
143-
this.serviceAccountId_ = Buffer.concat(buffers).toString();
144-
resolve(this.serviceAccountId_);
145-
});
146-
});
147-
req.on('error', (err) => {
148-
reject(new FirebaseAuthError(
149-
AuthClientErrorCode.INVALID_CREDENTIAL,
150-
`Failed to determine service account: ${err.toString()}. Make sure to initialize ` +
151-
`the SDK with a service account credential. Alternatively specify a service ` +
152-
`account with iam.serviceAccounts.signBlob permission.`,
153-
));
154-
});
155-
req.end();
134+
const client = new HttpClient();
135+
return client.send(request).then((response) => {
136+
this.serviceAccountId = response.text;
137+
return this.serviceAccountId;
138+
}).catch((err) => {
139+
throw new FirebaseAuthError(
140+
AuthClientErrorCode.INVALID_CREDENTIAL,
141+
`Failed to determine service account: ${err.toString()}. Make sure to initialize ` +
142+
`the SDK with a service account credential. Alternatively specify a service ` +
143+
`account with iam.serviceAccounts.signBlob permission.`,
144+
);
156145
});
157146
}
158147
}
@@ -162,7 +151,7 @@ export function signerFromApp(app: FirebaseApp): CryptoSigner {
162151
if (cert != null && validator.isNonEmptyString(cert.privateKey) && validator.isNonEmptyString(cert.clientEmail)) {
163152
return new ServiceAccountSigner(cert);
164153
}
165-
return new IAMSigner(new SignedApiRequestHandler(app), app.options.serviceAccountId);
154+
return new IAMSigner(new AuthorizedHttpClient(app), app.options.serviceAccountId);
166155
}
167156

168157
/**

test/unit/auth/token-generator.spec.ts

Lines changed: 57 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,9 @@ import * as nock from 'nock';
2626

2727
import * as mocks from '../../resources/mocks';
2828
import {FirebaseTokenGenerator, ServiceAccountSigner, IAMSigner} from '../../../src/auth/token-generator';
29-
import {FirebaseAuthError, AuthClientErrorCode} from '../../../src/utils/error';
3029

3130
import {Certificate} from '../../../src/auth/credential';
32-
import { SignedApiRequestHandler, HttpRequestHandler } from '../../../src/utils/api-request';
31+
import { AuthorizedHttpClient, HttpClient } from '../../../src/utils/api-request';
3332
import { FirebaseApp } from '../../../src/firebase-app';
3433
import * as utils from '../utils';
3534

@@ -111,11 +110,6 @@ describe('CryptoSigner', () => {
111110

112111
before(() => {
113112
utils.mockFetchAccessTokenRequests(mockAccessToken);
114-
nock('http://metadata')
115-
.matchHeader('Metadata-Flavor', 'Google')
116-
.persist()
117-
.get('/computeMetadata/v1/instance/service-accounts/default/email')
118-
.reply(200, 'discovered-service-account');
119113
});
120114

121115
after(() => nock.cleanAll());
@@ -132,102 +126,115 @@ describe('CryptoSigner', () => {
132126
expect(() => {
133127
const anyIAMSigner: any = IAMSigner;
134128
return new anyIAMSigner();
135-
}).to.throw('Must provide a request handler to initialize IAMSigner');
129+
}).to.throw('Must provide a HTTP client to initialize IAMSigner');
136130
});
137131

138132
describe('explicit service account', () => {
139133
const response = {signature: Buffer.from('testsignature').toString('base64')};
134+
const input = Buffer.from('base64');
135+
const signRequest = {
136+
method: 'POST',
137+
url: `https://iam.googleapis.com/v1/projects/-/serviceAccounts/test-service-account:signBlob`,
138+
headers: {Authorization: `Bearer ${mockAccessToken}`},
139+
data: {bytesToSign: input.toString('base64')},
140+
};
140141
let stub: sinon.SinonStub;
141142

142143
afterEach(() => {
143144
stub.restore();
144145
});
145146

146147
it('should sign using the IAM service', () => {
147-
stub = sinon.stub(HttpRequestHandler.prototype, 'sendRequest')
148-
.returns(Promise.resolve(response));
149-
const requestHandler = new SignedApiRequestHandler(mockApp);
148+
const expectedResult = utils.responseFrom(response);
149+
stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult);
150+
const requestHandler = new AuthorizedHttpClient(mockApp);
150151
const signer = new IAMSigner(requestHandler, 'test-service-account');
151-
const input = Buffer.from('base64');
152152
return signer.sign(input).then((signature) => {
153153
expect(signature.toString('base64')).to.equal(response.signature);
154-
expect(stub).to.have.been.calledOnce.and.calledWith(
155-
'iam.googleapis.com', 443,
156-
'/v1/projects/-/serviceAccounts/test-service-account:signBlob',
157-
'POST', {bytesToSign: input.toString('base64')},
158-
{Authorization: `Bearer ${mockAccessToken}`}, undefined);
154+
expect(stub).to.have.been.calledOnce.and.calledWith(signRequest);
159155
});
160156
});
161157

162158
it('should fail if the IAM service responds with an error', () => {
163-
stub = sinon.stub(HttpRequestHandler.prototype, 'sendRequest')
164-
.throws({
165-
statusCode: 500,
166-
error: {error: {status: 'PROJECT_NOT_FOUND', message: 'test reason'}},
167-
});
168-
const requestHandler = new SignedApiRequestHandler(mockApp);
159+
const expectedResult = utils.errorFrom({
160+
error: {
161+
status: 'PROJECT_NOT_FOUND',
162+
message: 'test reason',
163+
},
164+
});
165+
stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult);
166+
const requestHandler = new AuthorizedHttpClient(mockApp);
169167
const signer = new IAMSigner(requestHandler, 'test-service-account');
170-
const input = Buffer.from('base64');
171168
return signer.sign(input).catch((err) => {
172169
expect(err.message).to.equal('test reason');
173-
expect(stub).to.have.been.calledOnce.and.calledWith(
174-
'iam.googleapis.com', 443,
175-
'/v1/projects/-/serviceAccounts/test-service-account:signBlob',
176-
'POST', {bytesToSign: input.toString('base64')},
177-
{Authorization: `Bearer ${mockAccessToken}`}, undefined);
170+
expect(stub).to.have.been.calledOnce.and.calledWith(signRequest);
178171
});
179172
});
180173

181174
it('should return the explicitly specified service account', () => {
182-
const signer = new IAMSigner(new SignedApiRequestHandler(mockApp), 'test-service-account');
175+
const signer = new IAMSigner(new AuthorizedHttpClient(mockApp), 'test-service-account');
183176
return signer.getAccount().should.eventually.equal('test-service-account');
184177
});
185178
});
186179

187180
describe('auto discovered service account', () => {
188181
const input = Buffer.from('base64');
189182
const response = {signature: Buffer.from('testsignature').toString('base64')};
183+
const metadataRequest = {
184+
method: 'GET',
185+
url: `http://metadata/computeMetadata/v1/instance/service-accounts/default/email`,
186+
headers: {'Metadata-Flavor': 'Google'},
187+
};
188+
const signRequest = {
189+
method: 'POST',
190+
url: `https://iam.googleapis.com/v1/projects/-/serviceAccounts/discovered-service-account:signBlob`,
191+
headers: {Authorization: `Bearer ${mockAccessToken}`},
192+
data: {bytesToSign: input.toString('base64')},
193+
};
190194
let stub: sinon.SinonStub;
191195

192196
afterEach(() => {
193197
stub.restore();
194198
});
195199

196200
it('should sign using the IAM service', () => {
197-
stub = sinon.stub(HttpRequestHandler.prototype, 'sendRequest')
198-
.returns(Promise.resolve(response));
199-
const requestHandler = new SignedApiRequestHandler(mockApp);
201+
stub = sinon.stub(HttpClient.prototype, 'send');
202+
stub.onCall(0).resolves(utils.responseFrom('discovered-service-account'));
203+
stub.onCall(1).resolves(utils.responseFrom(response));
204+
const requestHandler = new AuthorizedHttpClient(mockApp);
200205
const signer = new IAMSigner(requestHandler);
201206
return signer.sign(input).then((signature) => {
202207
expect(signature.toString('base64')).to.equal(response.signature);
203-
expect(stub).to.have.been.calledOnce.and.calledWith(
204-
'iam.googleapis.com', 443,
205-
'/v1/projects/-/serviceAccounts/discovered-service-account:signBlob',
206-
'POST', {bytesToSign: input.toString('base64')},
207-
{Authorization: `Bearer ${mockAccessToken}`}, undefined);
208+
expect(stub).to.have.been.calledTwice;
209+
expect(stub.getCall(0).args[0]).to.deep.equal(metadataRequest);
210+
expect(stub.getCall(1).args[0]).to.deep.equal(signRequest);
208211
});
209212
});
210213

211214
it('should fail if the IAM service responds with an error', () => {
212-
stub = sinon.stub(HttpRequestHandler.prototype, 'sendRequest')
213-
.throws({
214-
statusCode: 500,
215-
error: {error: {status: 'PROJECT_NOT_FOUND', message: 'test reason'}},
216-
});
217-
const requestHandler = new SignedApiRequestHandler(mockApp);
215+
const expectedResult = {
216+
error: {
217+
status: 'PROJECT_NOT_FOUND',
218+
message: 'test reason',
219+
},
220+
};
221+
stub = sinon.stub(HttpClient.prototype, 'send');
222+
stub.onCall(0).resolves(utils.responseFrom('discovered-service-account'));
223+
stub.onCall(1).rejects(utils.errorFrom(expectedResult));
224+
const requestHandler = new AuthorizedHttpClient(mockApp);
218225
const signer = new IAMSigner(requestHandler);
219226
return signer.sign(input).catch((err) => {
220227
expect(err.message).to.equal('test reason');
221-
expect(stub).to.have.been.calledOnce.and.calledWith(
222-
'iam.googleapis.com', 443,
223-
'/v1/projects/-/serviceAccounts/discovered-service-account:signBlob',
224-
'POST', {bytesToSign: input.toString('base64')},
225-
{Authorization: `Bearer ${mockAccessToken}`}, undefined);
228+
expect(stub).to.have.been.calledTwice;
229+
expect(stub.getCall(0).args[0]).to.deep.equal(metadataRequest);
230+
expect(stub.getCall(1).args[0]).to.deep.equal(signRequest);
226231
});
227232
});
228233

229234
it('should return the discovered service account', () => {
230-
const signer = new IAMSigner(new SignedApiRequestHandler(mockApp));
235+
stub = sinon.stub(HttpClient.prototype, 'send');
236+
stub.onCall(0).resolves(utils.responseFrom('discovered-service-account'));
237+
const signer = new IAMSigner(new AuthorizedHttpClient(mockApp));
231238
return signer.getAccount().should.eventually.equal('discovered-service-account');
232239
});
233240
});

test/unit/utils.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,30 @@ export function generateRandomAccessToken(): string {
7171
/**
7272
* Creates a mock HTTP response from the given data and parameters.
7373
*
74-
* @param {*} data Data to be included in the response body.
74+
* @param {object | string} data Data to be included in the response body.
7575
* @param {number=} status HTTP status code (defaults to 200).
7676
* @param {*=} headers HTTP headers to be included in the ersponse.
7777
* @returns {HttpResponse} An HTTP response object.
7878
*/
79-
export function responseFrom(data: any, status: number = 200, headers: any = {}): HttpResponse {
79+
export function responseFrom(data: object | string, status: number = 200, headers: any = {}): HttpResponse {
80+
let responseData: any;
81+
let responseText: string;
82+
if (typeof data === 'object') {
83+
responseData = data;
84+
responseText = JSON.stringify(data);
85+
} else {
86+
try {
87+
responseData = JSON.parse(data);
88+
} catch (error) {
89+
responseData = null;
90+
}
91+
responseText = data as string;
92+
}
8093
return {
8194
status,
8295
headers,
83-
data,
84-
text: JSON.stringify(data),
96+
data: responseData,
97+
text: responseText,
8598
};
8699
}
87100

0 commit comments

Comments
 (0)