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