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