1import * as WebBrowser from 'expo-web-browser';
2import invariant from 'invariant';
3import { Platform } from 'react-native';
4
5import {
6  AuthRequestConfig,
7  AuthRequestPromptOptions,
8  CodeChallengeMethod,
9  ResponseType,
10  Prompt,
11} from './AuthRequest.types';
12import { AuthSessionResult } from './AuthSession.types';
13import { DiscoveryDocument } from './Discovery';
14import { AuthError } from './Errors';
15import * as PKCE from './PKCE';
16import * as QueryParams from './QueryParams';
17import sessionUrlProvider from './SessionUrlProvider';
18import { TokenResponse } from './TokenRequest';
19
20let _authLock: boolean = false;
21
22type AuthDiscoveryDocument = Pick<DiscoveryDocument, 'authorizationEndpoint'>;
23
24// @needsAudit @docsMissing
25/**
26 * Used to manage an authorization request according to the OAuth spec: [Section 4.1.1][https://tools.ietf.org/html/rfc6749#section-4.1.1].
27 * You can use this class directly for more info around the authorization.
28 *
29 * **Common use-cases:**
30 *
31 * - Parse a URL returned from the authorization server with `parseReturnUrlAsync()`.
32 * - Get the built authorization URL with `makeAuthUrlAsync()`.
33 * - Get a loaded JSON representation of the auth request with crypto state loaded with `getAuthRequestConfigAsync()`.
34 *
35 * @example
36 * ```ts
37 * // Create a request.
38 * const request = new AuthRequest({ ... });
39 *
40 * // Prompt for an auth code
41 * const result = await request.promptAsync(discovery, { useProxy: true });
42 *
43 * // Get the URL to invoke
44 * const url = await request.makeAuthUrlAsync(discovery);
45 *
46 * // Get the URL to invoke
47 * const parsed = await request.parseReturnUrlAsync("<URL From Server>");
48 * ```
49 */
50export class AuthRequest implements Omit<AuthRequestConfig, 'state'> {
51  /**
52   * Used for protection against [Cross-Site Request Forgery](https://tools.ietf.org/html/rfc6749#section-10.12).
53   */
54  public state: string;
55  public url: string | null = null;
56  public codeVerifier?: string;
57  public codeChallenge?: string;
58
59  readonly responseType: ResponseType | string;
60  readonly clientId: string;
61  readonly extraParams: Record<string, string>;
62  readonly usePKCE?: boolean;
63  readonly codeChallengeMethod: CodeChallengeMethod;
64  readonly redirectUri: string;
65  readonly scopes?: string[];
66  readonly clientSecret?: string;
67  readonly prompt?: Prompt;
68
69  constructor(request: AuthRequestConfig) {
70    this.responseType = request.responseType ?? ResponseType.Code;
71    this.clientId = request.clientId;
72    this.redirectUri = request.redirectUri;
73    this.scopes = request.scopes;
74    this.clientSecret = request.clientSecret;
75    this.prompt = request.prompt;
76    this.state = request.state ?? PKCE.generateRandom(10);
77    this.extraParams = request.extraParams ?? {};
78    this.codeChallengeMethod = request.codeChallengeMethod ?? CodeChallengeMethod.S256;
79    // PKCE defaults to true
80    this.usePKCE = request.usePKCE ?? true;
81
82    // Some warnings in development about potential confusing application code
83    if (__DEV__) {
84      if (this.prompt && this.extraParams.prompt) {
85        console.warn(`\`AuthRequest\` \`extraParams.prompt\` will be overwritten by \`prompt\`.`);
86      }
87      if (this.clientSecret && this.extraParams.client_secret) {
88        console.warn(
89          `\`AuthRequest\` \`extraParams.client_secret\` will be overwritten by \`clientSecret\`.`
90        );
91      }
92      if (this.codeChallengeMethod && this.extraParams.code_challenge_method) {
93        console.warn(
94          `\`AuthRequest\` \`extraParams.code_challenge_method\` will be overwritten by \`codeChallengeMethod\`.`
95        );
96      }
97    }
98
99    invariant(
100      this.codeChallengeMethod !== CodeChallengeMethod.Plain,
101      `\`AuthRequest\` does not support \`CodeChallengeMethod.Plain\` as it's not secure.`
102    );
103    invariant(
104      this.redirectUri,
105      `\`AuthRequest\` requires a valid \`redirectUri\`. Ex: ${Platform.select({
106        web: 'https://yourwebsite.com/',
107        default: 'com.your.app:/oauthredirect',
108      })}`
109    );
110  }
111
112  /**
113   * Load and return a valid auth request based on the input config.
114   */
115  async getAuthRequestConfigAsync(): Promise<AuthRequestConfig> {
116    if (this.usePKCE) {
117      await this.ensureCodeIsSetupAsync();
118    }
119
120    return {
121      responseType: this.responseType,
122      clientId: this.clientId,
123      redirectUri: this.redirectUri,
124      scopes: this.scopes,
125      clientSecret: this.clientSecret,
126      codeChallenge: this.codeChallenge,
127      codeChallengeMethod: this.codeChallengeMethod,
128      prompt: this.prompt,
129      state: this.state,
130      extraParams: this.extraParams,
131      usePKCE: this.usePKCE,
132    };
133  }
134
135  /**
136   * Prompt a user to authorize for a code.
137   *
138   * @param discovery
139   * @param promptOptions
140   */
141  async promptAsync(
142    discovery: AuthDiscoveryDocument,
143    { url, proxyOptions, ...options }: AuthRequestPromptOptions = {}
144  ): Promise<AuthSessionResult> {
145    if (!url) {
146      if (!this.url) {
147        // Generate a new url
148        return this.promptAsync(discovery, {
149          ...options,
150          url: await this.makeAuthUrlAsync(discovery),
151        });
152      }
153      // Reuse the preloaded url
154      url = this.url;
155    }
156
157    // Prevent accidentally starting to an empty url
158    invariant(
159      url,
160      'No authUrl provided to AuthSession.startAsync. An authUrl is required -- it points to the page where the user will be able to sign in.'
161    );
162
163    let startUrl: string = url!;
164    let returnUrl: string = this.redirectUri;
165    if (options.useProxy) {
166      returnUrl = sessionUrlProvider.getDefaultReturnUrl(proxyOptions?.path, proxyOptions);
167      startUrl = sessionUrlProvider.getStartUrl(url, returnUrl, options.projectNameForProxy);
168    }
169    // Prevent multiple sessions from running at the same time, WebBrowser doesn't
170    // support it this makes the behavior predictable.
171    if (_authLock) {
172      if (__DEV__) {
173        console.warn(
174          'Attempted to call AuthSession.startAsync multiple times while already active. Only one AuthSession can be active at any given time.'
175        );
176      }
177
178      return { type: 'locked' };
179    }
180
181    // About to start session, set lock
182    _authLock = true;
183
184    let result: WebBrowser.WebBrowserAuthSessionResult;
185    try {
186      const { useProxy, ...openOptions } = options;
187      result = await WebBrowser.openAuthSessionAsync(startUrl, returnUrl, openOptions);
188    } finally {
189      _authLock = false;
190    }
191
192    if (result.type === 'opened') {
193      // This should never happen
194      throw new Error('An unexpected error occurred');
195    }
196    if (result.type !== 'success') {
197      return { type: result.type };
198    }
199
200    return this.parseReturnUrl(result.url);
201  }
202
203  parseReturnUrl(url: string): AuthSessionResult {
204    const { params, errorCode } = QueryParams.getQueryParams(url);
205    const { state, error = errorCode } = params;
206
207    let parsedError: AuthError | null = null;
208    let authentication: TokenResponse | null = null;
209    if (state !== this.state) {
210      // This is a non-standard error
211      parsedError = new AuthError({
212        error: 'state_mismatch',
213        error_description:
214          'Cross-Site request verification failed. Cached state and returned state do not match.',
215      });
216    } else if (error) {
217      parsedError = new AuthError({ error, ...params });
218    }
219    if (params.access_token) {
220      authentication = TokenResponse.fromQueryParams(params);
221    }
222
223    return {
224      type: parsedError ? 'error' : 'success',
225      error: parsedError,
226      url,
227      params,
228      authentication,
229
230      // Return errorCode for legacy
231      errorCode,
232    };
233  }
234
235  /**
236   * Create the URL for authorization.
237   *
238   * @param discovery
239   */
240  async makeAuthUrlAsync(discovery: AuthDiscoveryDocument): Promise<string> {
241    const request = await this.getAuthRequestConfigAsync();
242    if (!request.state) throw new Error('Cannot make request URL without a valid `state` loaded');
243
244    // Create a query string
245    const params: Record<string, string> = {};
246
247    if (request.codeChallenge) {
248      params.code_challenge = request.codeChallenge;
249    }
250
251    // copy over extra params
252    for (const extra in request.extraParams) {
253      if (extra in request.extraParams) {
254        params[extra] = request.extraParams[extra];
255      }
256    }
257
258    if (request.usePKCE && request.codeChallengeMethod) {
259      params.code_challenge_method = request.codeChallengeMethod;
260    }
261
262    if (request.clientSecret) {
263      params.client_secret = request.clientSecret;
264    }
265
266    if (request.prompt) {
267      params.prompt = request.prompt;
268    }
269
270    // These overwrite any extra params
271    params.redirect_uri = request.redirectUri;
272    params.client_id = request.clientId;
273    params.response_type = request.responseType!;
274    params.state = request.state;
275
276    if (request.scopes?.length) {
277      params.scope = request.scopes.join(' ');
278    }
279
280    const query = QueryParams.buildQueryString(params);
281    // Store the URL for later
282    this.url = `${discovery.authorizationEndpoint}?${query}`;
283    return this.url;
284  }
285
286  private async ensureCodeIsSetupAsync(): Promise<void> {
287    if (this.codeVerifier) {
288      return;
289    }
290
291    // This method needs to be resolved like all other native methods.
292    const { codeVerifier, codeChallenge } = await PKCE.buildCodeAsync();
293
294    this.codeVerifier = codeVerifier;
295    this.codeChallenge = codeChallenge;
296  }
297}
298