1import invariant from 'invariant';
2import { Platform } from 'react-native';
3
4import * as Base64 from './Base64';
5import * as ServiceConfig from './Discovery';
6import { ResponseErrorConfig, TokenError } from './Errors';
7import { Headers, requestAsync } from './Fetch';
8import {
9  AccessTokenRequestConfig,
10  GrantType,
11  RefreshTokenRequestConfig,
12  RevokeTokenRequestConfig,
13  ServerTokenResponseConfig,
14  TokenRequestConfig,
15  TokenResponseConfig,
16  TokenType,
17  TokenTypeHint,
18} from './TokenRequest.types';
19
20/**
21 * Returns the current time in seconds.
22 */
23export function getCurrentTimeInSeconds(): number {
24  return Math.floor(Date.now() / 1000);
25}
26
27/**
28 * Token Response.
29 *
30 * [Section 5.1](https://tools.ietf.org/html/rfc6749#section-5.1)
31 */
32export class TokenResponse implements TokenResponseConfig {
33  /**
34   * Determines whether a token refresh request must be made to refresh the tokens
35   *
36   * @param token
37   * @param secondsMargin
38   */
39  static isTokenFresh(
40    token: Pick<TokenResponse, 'expiresIn' | 'issuedAt'>,
41    /**
42     * -10 minutes in seconds
43     */
44    secondsMargin: number = 60 * 10 * -1
45  ): boolean {
46    if (!token) {
47      return false;
48    }
49    if (token.expiresIn) {
50      const now = getCurrentTimeInSeconds();
51      return now < token.issuedAt + token.expiresIn + secondsMargin;
52    }
53    // if there is no expiration time but we have an access token, it is assumed to never expire
54    return true;
55  }
56  /**
57   * Creates a `TokenResponse` from query parameters returned from an `AuthRequest`.
58   *
59   * @param params
60   */
61  static fromQueryParams(params: Record<string, any>): TokenResponse {
62    return new TokenResponse({
63      accessToken: params.access_token,
64      refreshToken: params.refresh_token,
65      scope: params.scope,
66      state: params.state,
67      idToken: params.id_token,
68      tokenType: params.token_type,
69      expiresIn: params.expires_in,
70      issuedAt: params.issued_at,
71    });
72  }
73
74  accessToken: string;
75  tokenType: TokenType;
76  expiresIn?: number;
77  refreshToken?: string;
78  scope?: string;
79  state?: string;
80  idToken?: string;
81  issuedAt: number;
82
83  constructor(response: TokenResponseConfig) {
84    this.accessToken = response.accessToken;
85    this.tokenType = response.tokenType ?? 'bearer';
86    this.expiresIn = response.expiresIn;
87    this.refreshToken = response.refreshToken;
88    this.scope = response.scope;
89    this.state = response.state;
90    this.idToken = response.idToken;
91    this.issuedAt = response.issuedAt ?? getCurrentTimeInSeconds();
92  }
93
94  private applyResponseConfig(response: TokenResponseConfig) {
95    this.accessToken = response.accessToken ?? this.accessToken;
96    this.tokenType = response.tokenType ?? this.tokenType ?? 'bearer';
97    this.expiresIn = response.expiresIn ?? this.expiresIn;
98    this.refreshToken = response.refreshToken ?? this.refreshToken;
99    this.scope = response.scope ?? this.scope;
100    this.state = response.state ?? this.state;
101    this.idToken = response.idToken ?? this.idToken;
102    this.issuedAt = response.issuedAt ?? this.issuedAt ?? getCurrentTimeInSeconds();
103  }
104
105  getRequestConfig(): TokenResponseConfig {
106    return {
107      accessToken: this.accessToken,
108      idToken: this.idToken,
109      refreshToken: this.refreshToken,
110      scope: this.scope,
111      state: this.state,
112      tokenType: this.tokenType,
113      issuedAt: this.issuedAt,
114      expiresIn: this.expiresIn,
115    };
116  }
117
118  async refreshAsync(
119    config: Omit<TokenRequestConfig, 'grantType' | 'refreshToken'>,
120    discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'>
121  ): Promise<TokenResponse> {
122    const request = new RefreshTokenRequest({
123      ...config,
124      refreshToken: this.refreshToken,
125    });
126    const response = await request.performAsync(discovery);
127    // Custom: reuse the refresh token if one wasn't returned
128    response.refreshToken = response.refreshToken ?? this.refreshToken;
129    const json = response.getRequestConfig();
130    this.applyResponseConfig(json);
131    return this;
132  }
133
134  shouldRefresh(): boolean {
135    // no refresh token available and token has expired
136    return !(TokenResponse.isTokenFresh(this) || !this.refreshToken);
137  }
138}
139
140class Request<T, B> {
141  constructor(protected request: T) {}
142
143  async performAsync(discovery: ServiceConfig.DiscoveryDocument): Promise<B> {
144    throw new Error('performAsync must be extended');
145  }
146
147  getRequestConfig(): T {
148    throw new Error('getRequestConfig must be extended');
149  }
150
151  getQueryBody(): Record<string, string> {
152    throw new Error('getQueryBody must be extended');
153  }
154}
155
156/**
157 * A generic token request.
158 */
159class TokenRequest<T extends TokenRequestConfig> extends Request<T, TokenResponse> {
160  readonly clientId: string;
161  readonly clientSecret?: string;
162  readonly scopes?: string[];
163  readonly extraParams?: Record<string, string>;
164
165  constructor(request, public grantType: GrantType) {
166    super(request);
167    this.clientId = request.clientId;
168    this.clientSecret = request.clientSecret;
169    this.extraParams = request.extraParams;
170    this.scopes = request.scopes;
171  }
172
173  getHeaders(): Headers {
174    const headers: Headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
175    if (typeof this.clientSecret !== 'undefined') {
176      // If client secret exists, it should be converted to base64
177      // https://tools.ietf.org/html/rfc6749#section-2.3.1
178      const encodedClientId = encodeURIComponent(this.clientId);
179      const encodedClientSecret = encodeURIComponent(this.clientSecret);
180      const credentials = `${encodedClientId}:${encodedClientSecret}`;
181      const basicAuth = Base64.encodeNoWrap(credentials);
182      headers.Authorization = `Basic ${basicAuth}`;
183    }
184
185    return headers;
186  }
187
188  async performAsync(discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'>) {
189    // redirect URI must not be nil
190    invariant(
191      discovery.tokenEndpoint,
192      `Cannot invoke \`performAsync()\` without a valid tokenEndpoint`
193    );
194    const response = await requestAsync<ServerTokenResponseConfig | ResponseErrorConfig>(
195      discovery.tokenEndpoint,
196      {
197        dataType: 'json',
198        method: 'POST',
199        headers: this.getHeaders(),
200        body: this.getQueryBody(),
201      }
202    );
203
204    if ('error' in response) {
205      throw new TokenError(response);
206    }
207
208    return new TokenResponse({
209      accessToken: response.access_token,
210      tokenType: response.token_type,
211      expiresIn: response.expires_in,
212      refreshToken: response.refresh_token,
213      scope: response.scope,
214      idToken: response.id_token,
215      issuedAt: response.issued_at,
216    });
217  }
218
219  getQueryBody() {
220    const queryBody: Record<string, string> = {
221      grant_type: this.grantType,
222    };
223
224    if (!this.clientSecret) {
225      // Only add the client ID if client secret is not present, otherwise pass the client id with the secret in the request body.
226      queryBody.client_id = this.clientId;
227    }
228
229    if (this.scopes) {
230      queryBody.scope = this.scopes.join(' ');
231    }
232
233    if (this.extraParams) {
234      for (const extra in this.extraParams) {
235        if (extra in this.extraParams && !(extra in queryBody)) {
236          queryBody[extra] = this.extraParams[extra];
237        }
238      }
239    }
240    return queryBody;
241  }
242}
243
244/**
245 * Access token request. Exchange an authorization code for a user access token.
246 *
247 * [Section 4.1.3](https://tools.ietf.org/html/rfc6749#section-4.1.3)
248 */
249export class AccessTokenRequest
250  extends TokenRequest<AccessTokenRequestConfig>
251  implements AccessTokenRequestConfig
252{
253  readonly code: string;
254  readonly redirectUri: string;
255
256  constructor(options: AccessTokenRequestConfig) {
257    invariant(
258      options.redirectUri,
259      `\`AccessTokenRequest\` requires a valid \`redirectUri\` (it must also match the one used in the auth request). Example: ${Platform.select(
260        {
261          web: 'https://yourwebsite.com/redirect',
262          default: 'myapp://redirect',
263        }
264      )}`
265    );
266
267    invariant(
268      options.code,
269      `\`AccessTokenRequest\` requires a valid authorization \`code\`. This is what's received from the authorization server after an auth request.`
270    );
271    super(options, GrantType.AuthorizationCode);
272    this.code = options.code;
273    this.redirectUri = options.redirectUri;
274  }
275
276  getQueryBody() {
277    const queryBody: Record<string, string> = super.getQueryBody();
278
279    if (this.redirectUri) {
280      queryBody.redirect_uri = this.redirectUri;
281    }
282
283    if (this.code) {
284      queryBody.code = this.code;
285    }
286
287    return queryBody;
288  }
289
290  getRequestConfig() {
291    return {
292      clientId: this.clientId,
293      clientSecret: this.clientSecret,
294      grantType: this.grantType,
295      code: this.code,
296      redirectUri: this.redirectUri,
297      extraParams: this.extraParams,
298      scopes: this.scopes,
299    };
300  }
301}
302
303/**
304 * Refresh request.
305 *
306 * [Section 6](https://tools.ietf.org/html/rfc6749#section-6)
307 */
308export class RefreshTokenRequest
309  extends TokenRequest<RefreshTokenRequestConfig>
310  implements RefreshTokenRequestConfig
311{
312  readonly refreshToken?: string;
313
314  constructor(options: RefreshTokenRequestConfig) {
315    invariant(options.refreshToken, `\`RefreshTokenRequest\` requires a valid \`refreshToken\`.`);
316    super(options, GrantType.RefreshToken);
317    this.refreshToken = options.refreshToken;
318  }
319
320  getQueryBody() {
321    const queryBody = super.getQueryBody();
322
323    if (this.refreshToken) {
324      queryBody.refresh_token = this.refreshToken;
325    }
326
327    return queryBody;
328  }
329
330  getRequestConfig() {
331    return {
332      clientId: this.clientId,
333      clientSecret: this.clientSecret,
334      grantType: this.grantType,
335      refreshToken: this.refreshToken,
336      extraParams: this.extraParams,
337      scopes: this.scopes,
338    };
339  }
340}
341
342/**
343 * Revocation request for a given token.
344 *
345 * [Section 2.1](https://tools.ietf.org/html/rfc7009#section-2.1)
346 */
347export class RevokeTokenRequest
348  extends Request<RevokeTokenRequestConfig, boolean>
349  implements RevokeTokenRequestConfig
350{
351  readonly clientId?: string;
352  readonly clientSecret?: string;
353  readonly token: string;
354  readonly tokenTypeHint?: TokenTypeHint;
355
356  constructor(request: RevokeTokenRequestConfig) {
357    super(request);
358    invariant(request.token, `\`RevokeTokenRequest\` requires a valid \`token\` to revoke.`);
359    this.clientId = request.clientId;
360    this.clientSecret = request.clientSecret;
361    this.token = request.token;
362    this.tokenTypeHint = request.tokenTypeHint;
363  }
364
365  getHeaders(): Headers {
366    const headers: Headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
367    if (typeof this.clientSecret !== 'undefined' && this.clientId) {
368      // If client secret exists, it should be converted to base64
369      // https://tools.ietf.org/html/rfc6749#section-2.3.1
370      const encodedClientId = encodeURIComponent(this.clientId);
371      const encodedClientSecret = encodeURIComponent(this.clientSecret);
372      const credentials = `${encodedClientId}:${encodedClientSecret}`;
373      const basicAuth = Base64.encodeNoWrap(credentials);
374      headers.Authorization = `Basic ${basicAuth}`;
375    }
376
377    return headers;
378  }
379
380  /**
381   * Perform a token revocation request.
382   *
383   * @param discovery The `revocationEndpoint` for a provider.
384   */
385  async performAsync(discovery: Pick<ServiceConfig.DiscoveryDocument, 'revocationEndpoint'>) {
386    invariant(
387      discovery.revocationEndpoint,
388      `Cannot invoke \`performAsync()\` without a valid revocationEndpoint`
389    );
390    await requestAsync<boolean>(discovery.revocationEndpoint, {
391      method: 'POST',
392      headers: this.getHeaders(),
393      body: this.getQueryBody(),
394    });
395
396    return true;
397  }
398
399  getRequestConfig() {
400    return {
401      clientId: this.clientId,
402      clientSecret: this.clientSecret,
403      token: this.token,
404      tokenTypeHint: this.tokenTypeHint,
405    };
406  }
407
408  getQueryBody(): Record<string, string> {
409    const queryBody: Record<string, string> = { token: this.token };
410    if (this.tokenTypeHint) {
411      queryBody.token_type_hint = this.tokenTypeHint;
412    }
413    // Include client creds https://tools.ietf.org/html/rfc6749#section-2.3.1
414    if (this.clientId) {
415      queryBody.client_id = this.clientId;
416    }
417    if (this.clientSecret) {
418      queryBody.client_secret = this.clientSecret;
419    }
420    return queryBody;
421  }
422}
423
424// @needsAudit
425/**
426 * Exchange an authorization code for an access token that can be used to get data from the provider.
427 *
428 * @param config Configuration used to exchange the code for a token.
429 * @param discovery The `tokenEndpoint` for a provider.
430 * @return Returns a discovery document with a valid `tokenEndpoint` URL.
431 */
432export function exchangeCodeAsync(
433  config: AccessTokenRequestConfig,
434  discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'>
435): Promise<TokenResponse> {
436  const request = new AccessTokenRequest(config);
437  return request.performAsync(discovery);
438}
439
440// @needsAudit
441/**
442 * Refresh an access token.
443 * - If the provider didn't return a `refresh_token` then the access token may not be refreshed.
444 * - If the provider didn't return a `expires_in` then it's assumed that the token does not expire.
445 * - Determine if a token needs to be refreshed via `TokenResponse.isTokenFresh()` or `shouldRefresh()` on an instance of `TokenResponse`.
446 *
447 * @see [Section 6](https://tools.ietf.org/html/rfc6749#section-6).
448 *
449 * @param config Configuration used to refresh the given access token.
450 * @param discovery The `tokenEndpoint` for a provider.
451 * @return Returns a discovery document with a valid `tokenEndpoint` URL.
452 */
453export function refreshAsync(
454  config: RefreshTokenRequestConfig,
455  discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'>
456): Promise<TokenResponse> {
457  const request = new RefreshTokenRequest(config);
458  return request.performAsync(discovery);
459}
460
461// @needsAudit
462/**
463 * Revoke a token with a provider. This makes the token unusable, effectively requiring the user to login again.
464 *
465 * @param config Configuration used to revoke a refresh or access token.
466 * @param discovery The `revocationEndpoint` for a provider.
467 * @return Returns a discovery document with a valid `revocationEndpoint` URL. Many providers do not support this feature.
468 */
469export function revokeAsync(
470  config: RevokeTokenRequestConfig,
471  discovery: Pick<ServiceConfig.DiscoveryDocument, 'revocationEndpoint'>
472): Promise<boolean> {
473  const request = new RevokeTokenRequest(config);
474  return request.performAsync(discovery);
475}
476
477/**
478 * Fetch generic user info from the provider's OpenID Connect `userInfoEndpoint` (if supported).
479 *
480 * @see [UserInfo](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo).
481 *
482 * @param config The `accessToken` for a user, returned from a code exchange or auth request.
483 * @param discovery The `userInfoEndpoint` for a provider.
484 */
485export function fetchUserInfoAsync(
486  config: Pick<TokenResponse, 'accessToken'>,
487  discovery: Pick<ServiceConfig.DiscoveryDocument, 'userInfoEndpoint'>
488): Promise<Record<string, any>> {
489  if (!discovery.userInfoEndpoint) {
490    throw new Error('User info endpoint is not defined in the service config discovery document');
491  }
492  return requestAsync<Record<string, any>>(discovery.userInfoEndpoint, {
493    headers: {
494      'Content-Type': 'application/x-www-form-urlencoded',
495      Authorization: `Bearer ${config.accessToken}`,
496    },
497    dataType: 'json',
498    method: 'GET',
499  });
500}
501