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(
166    request,
167    public grantType: GrantType
168  ) {
169    super(request);
170    this.clientId = request.clientId;
171    this.clientSecret = request.clientSecret;
172    this.extraParams = request.extraParams;
173    this.scopes = request.scopes;
174  }
175
176  getHeaders(): Headers {
177    const headers: Headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
178    if (typeof this.clientSecret !== 'undefined') {
179      // If client secret exists, it should be converted to base64
180      // https://tools.ietf.org/html/rfc6749#section-2.3.1
181      const encodedClientId = encodeURIComponent(this.clientId);
182      const encodedClientSecret = encodeURIComponent(this.clientSecret);
183      const credentials = `${encodedClientId}:${encodedClientSecret}`;
184      const basicAuth = Base64.encodeNoWrap(credentials);
185      headers.Authorization = `Basic ${basicAuth}`;
186    }
187
188    return headers;
189  }
190
191  async performAsync(discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'>) {
192    // redirect URI must not be nil
193    invariant(
194      discovery.tokenEndpoint,
195      `Cannot invoke \`performAsync()\` without a valid tokenEndpoint`
196    );
197    const response = await requestAsync<ServerTokenResponseConfig | ResponseErrorConfig>(
198      discovery.tokenEndpoint,
199      {
200        dataType: 'json',
201        method: 'POST',
202        headers: this.getHeaders(),
203        body: this.getQueryBody(),
204      }
205    );
206
207    if ('error' in response) {
208      throw new TokenError(response);
209    }
210
211    return new TokenResponse({
212      accessToken: response.access_token,
213      tokenType: response.token_type,
214      expiresIn: response.expires_in,
215      refreshToken: response.refresh_token,
216      scope: response.scope,
217      idToken: response.id_token,
218      issuedAt: response.issued_at,
219    });
220  }
221
222  getQueryBody() {
223    const queryBody: Record<string, string> = {
224      grant_type: this.grantType,
225    };
226
227    if (!this.clientSecret) {
228      // Only add the client ID if client secret is not present, otherwise pass the client id with the secret in the request body.
229      queryBody.client_id = this.clientId;
230    }
231
232    if (this.scopes) {
233      queryBody.scope = this.scopes.join(' ');
234    }
235
236    if (this.extraParams) {
237      for (const extra in this.extraParams) {
238        if (extra in this.extraParams && !(extra in queryBody)) {
239          queryBody[extra] = this.extraParams[extra];
240        }
241      }
242    }
243    return queryBody;
244  }
245}
246
247/**
248 * Access token request. Exchange an authorization code for a user access token.
249 *
250 * [Section 4.1.3](https://tools.ietf.org/html/rfc6749#section-4.1.3)
251 */
252export class AccessTokenRequest
253  extends TokenRequest<AccessTokenRequestConfig>
254  implements AccessTokenRequestConfig
255{
256  readonly code: string;
257  readonly redirectUri: string;
258
259  constructor(options: AccessTokenRequestConfig) {
260    invariant(
261      options.redirectUri,
262      `\`AccessTokenRequest\` requires a valid \`redirectUri\` (it must also match the one used in the auth request). Example: ${Platform.select(
263        {
264          web: 'https://yourwebsite.com/redirect',
265          default: 'myapp://redirect',
266        }
267      )}`
268    );
269
270    invariant(
271      options.code,
272      `\`AccessTokenRequest\` requires a valid authorization \`code\`. This is what's received from the authorization server after an auth request.`
273    );
274    super(options, GrantType.AuthorizationCode);
275    this.code = options.code;
276    this.redirectUri = options.redirectUri;
277  }
278
279  getQueryBody() {
280    const queryBody: Record<string, string> = super.getQueryBody();
281
282    if (this.redirectUri) {
283      queryBody.redirect_uri = this.redirectUri;
284    }
285
286    if (this.code) {
287      queryBody.code = this.code;
288    }
289
290    return queryBody;
291  }
292
293  getRequestConfig() {
294    return {
295      clientId: this.clientId,
296      clientSecret: this.clientSecret,
297      grantType: this.grantType,
298      code: this.code,
299      redirectUri: this.redirectUri,
300      extraParams: this.extraParams,
301      scopes: this.scopes,
302    };
303  }
304}
305
306/**
307 * Refresh request.
308 *
309 * [Section 6](https://tools.ietf.org/html/rfc6749#section-6)
310 */
311export class RefreshTokenRequest
312  extends TokenRequest<RefreshTokenRequestConfig>
313  implements RefreshTokenRequestConfig
314{
315  readonly refreshToken?: string;
316
317  constructor(options: RefreshTokenRequestConfig) {
318    invariant(options.refreshToken, `\`RefreshTokenRequest\` requires a valid \`refreshToken\`.`);
319    super(options, GrantType.RefreshToken);
320    this.refreshToken = options.refreshToken;
321  }
322
323  getQueryBody() {
324    const queryBody = super.getQueryBody();
325
326    if (this.refreshToken) {
327      queryBody.refresh_token = this.refreshToken;
328    }
329
330    return queryBody;
331  }
332
333  getRequestConfig() {
334    return {
335      clientId: this.clientId,
336      clientSecret: this.clientSecret,
337      grantType: this.grantType,
338      refreshToken: this.refreshToken,
339      extraParams: this.extraParams,
340      scopes: this.scopes,
341    };
342  }
343}
344
345/**
346 * Revocation request for a given token.
347 *
348 * [Section 2.1](https://tools.ietf.org/html/rfc7009#section-2.1)
349 */
350export class RevokeTokenRequest
351  extends Request<RevokeTokenRequestConfig, boolean>
352  implements RevokeTokenRequestConfig
353{
354  readonly clientId?: string;
355  readonly clientSecret?: string;
356  readonly token: string;
357  readonly tokenTypeHint?: TokenTypeHint;
358
359  constructor(request: RevokeTokenRequestConfig) {
360    super(request);
361    invariant(request.token, `\`RevokeTokenRequest\` requires a valid \`token\` to revoke.`);
362    this.clientId = request.clientId;
363    this.clientSecret = request.clientSecret;
364    this.token = request.token;
365    this.tokenTypeHint = request.tokenTypeHint;
366  }
367
368  getHeaders(): Headers {
369    const headers: Headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
370    if (typeof this.clientSecret !== 'undefined' && this.clientId) {
371      // If client secret exists, it should be converted to base64
372      // https://tools.ietf.org/html/rfc6749#section-2.3.1
373      const encodedClientId = encodeURIComponent(this.clientId);
374      const encodedClientSecret = encodeURIComponent(this.clientSecret);
375      const credentials = `${encodedClientId}:${encodedClientSecret}`;
376      const basicAuth = Base64.encodeNoWrap(credentials);
377      headers.Authorization = `Basic ${basicAuth}`;
378    }
379
380    return headers;
381  }
382
383  /**
384   * Perform a token revocation request.
385   *
386   * @param discovery The `revocationEndpoint` for a provider.
387   */
388  async performAsync(discovery: Pick<ServiceConfig.DiscoveryDocument, 'revocationEndpoint'>) {
389    invariant(
390      discovery.revocationEndpoint,
391      `Cannot invoke \`performAsync()\` without a valid revocationEndpoint`
392    );
393    await requestAsync<boolean>(discovery.revocationEndpoint, {
394      method: 'POST',
395      headers: this.getHeaders(),
396      body: this.getQueryBody(),
397    });
398
399    return true;
400  }
401
402  getRequestConfig() {
403    return {
404      clientId: this.clientId,
405      clientSecret: this.clientSecret,
406      token: this.token,
407      tokenTypeHint: this.tokenTypeHint,
408    };
409  }
410
411  getQueryBody(): Record<string, string> {
412    const queryBody: Record<string, string> = { token: this.token };
413    if (this.tokenTypeHint) {
414      queryBody.token_type_hint = this.tokenTypeHint;
415    }
416    // Include client creds https://tools.ietf.org/html/rfc6749#section-2.3.1
417    if (this.clientId) {
418      queryBody.client_id = this.clientId;
419    }
420    if (this.clientSecret) {
421      queryBody.client_secret = this.clientSecret;
422    }
423    return queryBody;
424  }
425}
426
427// @needsAudit
428/**
429 * Exchange an authorization code for an access token that can be used to get data from the provider.
430 *
431 * @param config Configuration used to exchange the code for a token.
432 * @param discovery The `tokenEndpoint` for a provider.
433 * @return Returns a discovery document with a valid `tokenEndpoint` URL.
434 */
435export function exchangeCodeAsync(
436  config: AccessTokenRequestConfig,
437  discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'>
438): Promise<TokenResponse> {
439  const request = new AccessTokenRequest(config);
440  return request.performAsync(discovery);
441}
442
443// @needsAudit
444/**
445 * Refresh an access token.
446 * - If the provider didn't return a `refresh_token` then the access token may not be refreshed.
447 * - If the provider didn't return a `expires_in` then it's assumed that the token does not expire.
448 * - Determine if a token needs to be refreshed via `TokenResponse.isTokenFresh()` or `shouldRefresh()` on an instance of `TokenResponse`.
449 *
450 * @see [Section 6](https://tools.ietf.org/html/rfc6749#section-6).
451 *
452 * @param config Configuration used to refresh the given access token.
453 * @param discovery The `tokenEndpoint` for a provider.
454 * @return Returns a discovery document with a valid `tokenEndpoint` URL.
455 */
456export function refreshAsync(
457  config: RefreshTokenRequestConfig,
458  discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'>
459): Promise<TokenResponse> {
460  const request = new RefreshTokenRequest(config);
461  return request.performAsync(discovery);
462}
463
464// @needsAudit
465/**
466 * Revoke a token with a provider. This makes the token unusable, effectively requiring the user to login again.
467 *
468 * @param config Configuration used to revoke a refresh or access token.
469 * @param discovery The `revocationEndpoint` for a provider.
470 * @return Returns a discovery document with a valid `revocationEndpoint` URL. Many providers do not support this feature.
471 */
472export function revokeAsync(
473  config: RevokeTokenRequestConfig,
474  discovery: Pick<ServiceConfig.DiscoveryDocument, 'revocationEndpoint'>
475): Promise<boolean> {
476  const request = new RevokeTokenRequest(config);
477  return request.performAsync(discovery);
478}
479
480/**
481 * Fetch generic user info from the provider's OpenID Connect `userInfoEndpoint` (if supported).
482 *
483 * @see [UserInfo](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo).
484 *
485 * @param config The `accessToken` for a user, returned from a code exchange or auth request.
486 * @param discovery The `userInfoEndpoint` for a provider.
487 */
488export function fetchUserInfoAsync(
489  config: Pick<TokenResponse, 'accessToken'>,
490  discovery: Pick<ServiceConfig.DiscoveryDocument, 'userInfoEndpoint'>
491): Promise<Record<string, any>> {
492  if (!discovery.userInfoEndpoint) {
493    throw new Error('User info endpoint is not defined in the service config discovery document');
494  }
495  return requestAsync<Record<string, any>>(discovery.userInfoEndpoint, {
496    headers: {
497      'Content-Type': 'application/x-www-form-urlencoded',
498      Authorization: `Bearer ${config.accessToken}`,
499    },
500    dataType: 'json',
501    method: 'GET',
502  });
503}
504