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