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);
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      console.warn(
167        'The useProxy option is deprecated and will be removed in a future release, for more information check https://expo.fyi/auth-proxy-migration.'
168      );
169      returnUrl = sessionUrlProvider.getDefaultReturnUrl(proxyOptions?.path, proxyOptions);
170      startUrl = sessionUrlProvider.getStartUrl(url, returnUrl, options.projectNameForProxy);
171    }
172    // Prevent multiple sessions from running at the same time, WebBrowser doesn't
173    // support it this makes the behavior predictable.
174    if (_authLock) {
175      if (__DEV__) {
176        console.warn(
177          'Attempted to call AuthSession.startAsync multiple times while already active. Only one AuthSession can be active at any given time.'
178        );
179      }
180
181      return { type: 'locked' };
182    }
183
184    // About to start session, set lock
185    _authLock = true;
186
187    let result: WebBrowser.WebBrowserAuthSessionResult;
188    try {
189      const { useProxy, ...openOptions } = options;
190      result = await WebBrowser.openAuthSessionAsync(startUrl, returnUrl, openOptions);
191    } finally {
192      _authLock = false;
193    }
194
195    if (result.type === 'opened') {
196      // This should never happen
197      throw new Error('An unexpected error occurred');
198    }
199    if (result.type !== 'success') {
200      return { type: result.type };
201    }
202
203    return this.parseReturnUrl(result.url);
204  }
205
206  parseReturnUrl(url: string): AuthSessionResult {
207    const { params, errorCode } = QueryParams.getQueryParams(url);
208    const { state, error = errorCode } = params;
209
210    let parsedError: AuthError | null = null;
211    let authentication: TokenResponse | null = null;
212    if (state !== this.state) {
213      // This is a non-standard error
214      parsedError = new AuthError({
215        error: 'state_mismatch',
216        error_description:
217          'Cross-Site request verification failed. Cached state and returned state do not match.',
218      });
219    } else if (error) {
220      parsedError = new AuthError({ error, ...params });
221    }
222    if (params.access_token) {
223      authentication = TokenResponse.fromQueryParams(params);
224    }
225
226    return {
227      type: parsedError ? 'error' : 'success',
228      error: parsedError,
229      url,
230      params,
231      authentication,
232
233      // Return errorCode for legacy
234      errorCode,
235    };
236  }
237
238  /**
239   * Create the URL for authorization.
240   *
241   * @param discovery
242   */
243  async makeAuthUrlAsync(discovery: AuthDiscoveryDocument): Promise<string> {
244    const request = await this.getAuthRequestConfigAsync();
245    if (!request.state) throw new Error('Cannot make request URL without a valid `state` loaded');
246
247    // Create a query string
248    const params: Record<string, string> = {};
249
250    if (request.codeChallenge) {
251      params.code_challenge = request.codeChallenge;
252    }
253
254    // copy over extra params
255    for (const extra in request.extraParams) {
256      if (extra in request.extraParams) {
257        params[extra] = request.extraParams[extra];
258      }
259    }
260
261    if (request.usePKCE && request.codeChallengeMethod) {
262      params.code_challenge_method = request.codeChallengeMethod;
263    }
264
265    if (request.clientSecret) {
266      params.client_secret = request.clientSecret;
267    }
268
269    if (request.prompt) {
270      params.prompt = request.prompt;
271    }
272
273    // These overwrite any extra params
274    params.redirect_uri = request.redirectUri;
275    params.client_id = request.clientId;
276    params.response_type = request.responseType!;
277    params.state = request.state;
278
279    if (request.scopes?.length) {
280      params.scope = request.scopes.join(' ');
281    }
282
283    const query = QueryParams.buildQueryString(params);
284    // Store the URL for later
285    this.url = `${discovery.authorizationEndpoint}?${query}`;
286    return this.url;
287  }
288
289  private async ensureCodeIsSetupAsync(): Promise<void> {
290    if (this.codeVerifier) {
291      return;
292    }
293
294    // This method needs to be resolved like all other native methods.
295    const { codeVerifier, codeChallenge } = await PKCE.buildCodeAsync();
296
297    this.codeVerifier = codeVerifier;
298    this.codeChallenge = codeChallenge;
299  }
300}
301