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