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