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