Skip to content

Commit c27f023

Browse files
build(auth): added full implementation of GoogleToken (#814)
1 parent 8f8b2a5 commit c27f023

File tree

2 files changed

+298
-8
lines changed

2 files changed

+298
-8
lines changed

packages/google-auth-library-nodejs/src/gtoken/googleToken.ts

Lines changed: 135 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,34 @@
1414

1515
import {request} from 'gaxios';
1616
import {TokenOptions, Transporter} from './tokenOptions';
17+
import {TokenHandler} from './tokenHandler';
18+
import {revokeToken} from './revokeToken';
19+
import {TokenData} from './getToken';
1720

21+
/**
22+
* Options for fetching an access token.
23+
*/
24+
export interface GetTokenOptions {
25+
/**
26+
* If true, a new token will be fetched, ignoring any cached token.
27+
*/
28+
forceRefresh?: boolean;
29+
}
30+
31+
/**
32+
* Callback type for the `getToken` method.
33+
*/
34+
export type GetTokenCallback = (err: Error | null, token?: TokenData) => void;
35+
36+
/**
37+
* The GoogleToken class is used to manage authentication with Google's OAuth 2.0 authorization server.
38+
* It handles fetching, caching, and refreshing of access tokens.
39+
*/
1840
class GoogleToken {
41+
/** The configuration options for this token instance. */
1942
private tokenOptions: TokenOptions;
43+
/** The handler for token fetching and caching logic. */
44+
private tokenHandler: TokenHandler;
2045

2146
/**
2247
* Create a GoogleToken.
@@ -25,17 +50,121 @@ class GoogleToken {
2550
*/
2651
constructor(options?: TokenOptions) {
2752
this.tokenOptions = options || {};
28-
// If transporter is not set, by default set Gaxios.request.
29-
if (!this.tokenOptions.transporter) {
30-
this.tokenOptions.transporter = {
31-
request: opts => request(opts),
32-
};
53+
// If a transporter is not set, by default set it to use gaxios.
54+
this.tokenOptions.transporter = this.tokenOptions.transporter || {
55+
request: opts => request(opts),
56+
};
57+
if (!this.tokenOptions.iss) {
58+
this.tokenOptions.iss = this.tokenOptions.email;
59+
}
60+
if (typeof this.tokenOptions.scope === 'object') {
61+
this.tokenOptions.scope = this.tokenOptions.scope.join(' ');
62+
}
63+
this.tokenHandler = new TokenHandler(this.tokenOptions);
64+
}
65+
66+
get expiresAt(): number | undefined {
67+
return this.tokenHandler.tokenExpiresAt;
68+
}
69+
70+
/**
71+
* The most recent access token obtained by this client.
72+
*/
73+
get accessToken(): string | undefined {
74+
return this.tokenHandler.token?.access_token;
75+
}
76+
77+
/**
78+
* The most recent ID token obtained by this client.
79+
*/
80+
get idToken(): string | undefined {
81+
return this.tokenHandler.token?.id_token;
82+
}
83+
84+
/**
85+
* The token type of the most recent access token.
86+
*/
87+
get tokenType(): string | undefined {
88+
return this.tokenHandler.token?.token_type;
89+
}
90+
91+
/**
92+
* The refresh token for the current credentials.
93+
*/
94+
get refreshToken(): string | undefined {
95+
return this.tokenHandler.token?.refresh_token;
96+
}
97+
98+
/**
99+
* A boolean indicating if the current token has expired.
100+
*/
101+
hasExpired(): boolean {
102+
return this.tokenHandler.hasExpired();
103+
}
104+
105+
/**
106+
* A boolean indicating if the current token is expiring soon,
107+
* based on the `eagerRefreshThresholdMillis` option.
108+
*/
109+
isTokenExpiring(): boolean {
110+
return this.tokenHandler.isTokenExpiring();
111+
}
112+
113+
/**
114+
* Fetches a new access token and returns it.
115+
* @param opts Options for fetching the token.
116+
*/
117+
getToken(opts?: GetTokenOptions): Promise<TokenData>;
118+
getToken(callback: GetTokenCallback, opts?: GetTokenOptions): void;
119+
getToken(
120+
callbackOrOptions?: GetTokenCallback | GetTokenOptions,
121+
opts: GetTokenOptions = {forceRefresh: false},
122+
): void | Promise<TokenData> {
123+
// Handle the various method overloads.
124+
let callback: GetTokenCallback | undefined;
125+
if (typeof callbackOrOptions === 'function') {
126+
callback = callbackOrOptions;
127+
} else if (typeof callbackOrOptions === 'object') {
128+
opts = callbackOrOptions;
129+
}
130+
131+
// Delegate the token fetching to the token handler.
132+
const promise = this.tokenHandler.getToken(opts.forceRefresh ?? false);
133+
134+
// If a callback is provided, use it, otherwise return the promise.
135+
if (callback) {
136+
promise.then(token => callback(null, token), callback);
33137
}
138+
return promise;
34139
}
35140

141+
/**
142+
* Revokes the current access token and resets the token handler.
143+
*/
144+
revokeToken(): Promise<void>;
145+
revokeToken(callback: (err?: Error) => void): void;
146+
revokeToken(callback?: (err?: Error) => void): void | Promise<void> {
147+
if (!this.accessToken) {
148+
return Promise.reject(new Error('No token to revoke.'));
149+
}
150+
const promise = revokeToken(
151+
this.accessToken,
152+
this.tokenOptions.transporter as Transporter,
153+
);
154+
155+
// If a callback is provided, use it.
156+
if (callback) {
157+
promise.then(() => callback(), callback);
158+
}
159+
// After revoking, reset the token handler to clear the cached token.
160+
this.tokenHandler = new TokenHandler(this.tokenOptions);
161+
}
162+
/**
163+
* Returns the configuration options for this token instance.
164+
*/
36165
get googleTokenOptions(): TokenOptions {
37166
return this.tokenOptions;
38167
}
39168
}
40169

41-
export {GoogleToken, Transporter, TokenOptions};
170+
export {GoogleToken, Transporter, TokenOptions, TokenData};

packages/google-auth-library-nodejs/test/gtoken/test.googleToken.ts

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,34 @@
1313
// limitations under the License.
1414

1515
import * as assert from 'assert';
16-
import {describe, it} from 'mocha';
16+
import {describe, it, beforeEach, afterEach} from 'mocha';
17+
import * as sinon from 'sinon';
1718
import {
1819
GoogleToken,
1920
TokenOptions,
2021
Transporter,
22+
TokenData,
2123
} from '../../src/gtoken/googleToken';
22-
import {GaxiosOptions, GaxiosResponse, request} from 'gaxios';
24+
import {GaxiosOptions, GaxiosResponse} from 'gaxios';
25+
import * as tokenHandler from '../../src/gtoken/tokenHandler';
26+
import * as revokeTokenModule from '../../src/gtoken/revokeToken';
2327

2428
describe('GoogleToken', () => {
29+
const sandbox = sinon.createSandbox();
30+
let tokenHandlerStub: sinon.SinonStubbedInstance<tokenHandler.TokenHandler>;
31+
let revokeTokenStub: sinon.SinonStub;
32+
33+
beforeEach(() => {
34+
// Stub the TokenHandler constructor to control its behavior
35+
tokenHandlerStub = sandbox.createStubInstance(tokenHandler.TokenHandler);
36+
sandbox.stub(tokenHandler, 'TokenHandler').returns(tokenHandlerStub);
37+
revokeTokenStub = sandbox.stub(revokeTokenModule, 'revokeToken');
38+
});
39+
40+
afterEach(() => {
41+
sandbox.restore();
42+
});
43+
2544
it('should initialize with default options if none are provided', () => {
2645
const token: GoogleToken = new GoogleToken();
2746
const options: TokenOptions = token.googleTokenOptions;
@@ -60,6 +79,30 @@ describe('GoogleToken', () => {
6079
assert.ok(typeof options.transporter.request === 'function');
6180
});
6281

82+
it('should set iss from email if provided', () => {
83+
const providedOptions: TokenOptions = {
84+
85+
};
86+
const token: GoogleToken = new GoogleToken(providedOptions);
87+
const options: TokenOptions = token.googleTokenOptions;
88+
assert.strictEqual(options.iss, '[email protected]');
89+
});
90+
91+
it('should not override iss with email if both are provided', () => {
92+
const providedOptions: TokenOptions = {
93+
94+
95+
};
96+
const token: GoogleToken = new GoogleToken(providedOptions);
97+
const options: TokenOptions = token.googleTokenOptions;
98+
assert.strictEqual(options.iss, '[email protected]');
99+
});
100+
101+
it('should convert array of scopes to a space-delimited string', () => {
102+
const token = new GoogleToken({scope: ['scope1', 'scope2']});
103+
assert.strictEqual(token.googleTokenOptions.scope, 'scope1 scope2');
104+
});
105+
63106
it('should use a custom transporter if provided in options', () => {
64107
const customTransporter: Transporter = {
65108
request: async <T>(opts: GaxiosOptions) => {
@@ -89,4 +132,122 @@ describe('GoogleToken', () => {
89132
providedOptions.email,
90133
);
91134
});
135+
136+
describe('Getters', () => {
137+
it('should return undefined for token properties if no token is set', () => {
138+
const token = new GoogleToken();
139+
assert.strictEqual(token.accessToken, undefined);
140+
assert.strictEqual(token.idToken, undefined);
141+
assert.strictEqual(token.tokenType, undefined);
142+
assert.strictEqual(token.refreshToken, undefined);
143+
});
144+
145+
it('should return correct values from the cached token', () => {
146+
const tokenData: TokenData = {
147+
access_token: 'access',
148+
id_token: 'id',
149+
token_type: 'Bearer',
150+
refresh_token: 'refresh',
151+
};
152+
tokenHandlerStub.token = tokenData;
153+
const token = new GoogleToken();
154+
assert.strictEqual(token.accessToken, 'access');
155+
assert.strictEqual(token.idToken, 'id');
156+
assert.strictEqual(token.tokenType, 'Bearer');
157+
assert.strictEqual(token.refreshToken, 'refresh');
158+
});
159+
});
160+
161+
describe('Expiration methods', () => {
162+
it('should delegate hasExpired to the token handler', () => {
163+
tokenHandlerStub.hasExpired.returns(true);
164+
const token = new GoogleToken();
165+
assert.strictEqual(token.hasExpired(), true);
166+
assert.ok(tokenHandlerStub.hasExpired.calledOnce);
167+
});
168+
169+
it('should delegate isTokenExpiring to the token handler', () => {
170+
tokenHandlerStub.isTokenExpiring.returns(false);
171+
const token = new GoogleToken();
172+
assert.strictEqual(token.isTokenExpiring(), false);
173+
assert.ok(tokenHandlerStub.isTokenExpiring.calledOnce);
174+
});
175+
});
176+
177+
describe('getToken', () => {
178+
it('should call tokenHandler.getToken and return a promise', async () => {
179+
const tokenData: TokenData = {access_token: 'new-token'};
180+
tokenHandlerStub.getToken.resolves(tokenData);
181+
const token = new GoogleToken();
182+
const result = await token.getToken({forceRefresh: true});
183+
assert.strictEqual(result, tokenData);
184+
assert.ok(tokenHandlerStub.getToken.calledOnceWith(true));
185+
});
186+
187+
it('should work with a callback on success', done => {
188+
const tokenData: TokenData = {access_token: 'new-token'};
189+
tokenHandlerStub.getToken.resolves(tokenData);
190+
const token = new GoogleToken();
191+
token.getToken((err, result) => {
192+
assert.ifError(err);
193+
assert.strictEqual(result, tokenData);
194+
assert.ok(tokenHandlerStub.getToken.calledOnceWith(false));
195+
done();
196+
});
197+
});
198+
199+
it('should work with a callback on error', done => {
200+
const error = new Error('getToken failed');
201+
tokenHandlerStub.getToken.rejects(error);
202+
const token = new GoogleToken();
203+
token.getToken((err, result) => {
204+
assert.strictEqual(err, error);
205+
assert.strictEqual(result, undefined);
206+
done();
207+
});
208+
});
209+
});
210+
211+
describe('revokeToken', () => {
212+
it('should call revokeToken and reset the handler', async () => {
213+
const tokenData: TokenData = {access_token: 'token-to-revoke'};
214+
tokenHandlerStub.token = tokenData;
215+
revokeTokenStub.resolves();
216+
const token = new GoogleToken();
217+
await token.revokeToken();
218+
assert.ok(revokeTokenStub.calledOnceWith('token-to-revoke'));
219+
// Check that a new handler was created (initial creation + reset)
220+
assert.ok(
221+
(tokenHandler.TokenHandler as unknown as sinon.SinonStub).calledTwice,
222+
);
223+
});
224+
225+
it('should reject if there is no token to revoke', async () => {
226+
const token = new GoogleToken();
227+
await assert.rejects(() => token.revokeToken(), /No token to revoke/);
228+
});
229+
230+
it('should work with a callback on success', done => {
231+
const tokenData: TokenData = {access_token: 'token-to-revoke'};
232+
tokenHandlerStub.token = tokenData;
233+
revokeTokenStub.resolves();
234+
const token = new GoogleToken();
235+
token.revokeToken(err => {
236+
assert.ifError(err);
237+
assert.ok(revokeTokenStub.calledOnce);
238+
done();
239+
});
240+
});
241+
242+
it('should work with a callback on error', done => {
243+
const error = new Error('Revoke failed');
244+
revokeTokenStub.rejects(error);
245+
tokenHandlerStub.token = {access_token: 'token'};
246+
const token = new GoogleToken();
247+
token.revokeToken(err => {
248+
assert.strictEqual(err, error);
249+
done();
250+
});
251+
});
252+
});
92253
});

0 commit comments

Comments
 (0)