expo-auth-session
Version:
Expo module for browser-based authentication
291 lines (255 loc) • 8.98 kB
text/typescript
import * as WebBrowser from 'expo-web-browser';
import invariant from 'invariant';
import { Platform } from 'react-native';
import {
AuthRequestConfig,
AuthRequestPromptOptions,
CodeChallengeMethod,
ResponseType,
Prompt,
AuthDiscoveryDocument,
} from './AuthRequest.types';
import { AuthSessionResult } from './AuthSession.types';
import { AuthError } from './Errors';
import * as PKCE from './PKCE';
import * as QueryParams from './QueryParams';
import { TokenResponse } from './TokenRequest';
let _authLock: boolean = false;
// @needsAudit @docsMissing
/**
* 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).
* You can use this class directly for more info around the authorization.
*
* **Common use-cases:**
*
* - Parse a URL returned from the authorization server with `parseReturnUrlAsync()`.
* - Get the built authorization URL with `makeAuthUrlAsync()`.
* - Get a loaded JSON representation of the auth request with crypto state loaded with `getAuthRequestConfigAsync()`.
*
* @example
* ```ts
* // Create a request.
* const request = new AuthRequest({ ... });
*
* // Prompt for an auth code
* const result = await request.promptAsync(discovery);
*
* // Get the URL to invoke
* const url = await request.makeAuthUrlAsync(discovery);
*
* // Get the URL to invoke
* const parsed = await request.parseReturnUrlAsync("<URL From Server>");
* ```
*/
export class AuthRequest implements Omit<AuthRequestConfig, 'state'> {
/**
* Used for protection against [Cross-Site Request Forgery](https://tools.ietf.org/html/rfc6749#section-10.12).
*/
public state: string;
public url: string | null = null;
public codeVerifier?: string;
public codeChallenge?: string;
readonly responseType: ResponseType | string;
readonly clientId: string;
readonly extraParams: Record<string, string>;
readonly usePKCE?: boolean;
readonly codeChallengeMethod: CodeChallengeMethod;
readonly redirectUri: string;
readonly scopes?: string[];
readonly clientSecret?: string;
readonly prompt?: Prompt | Prompt[];
constructor(request: AuthRequestConfig) {
this.responseType = request.responseType ?? ResponseType.Code;
this.clientId = request.clientId;
this.redirectUri = request.redirectUri;
this.scopes = request.scopes;
this.clientSecret = request.clientSecret;
this.prompt = request.prompt;
this.state = request.state ?? PKCE.generateRandom(10);
this.extraParams = request.extraParams ?? {};
this.codeChallengeMethod = request.codeChallengeMethod ?? CodeChallengeMethod.S256;
// PKCE defaults to true
this.usePKCE = request.usePKCE ?? true;
// Some warnings in development about potential confusing application code
if (__DEV__) {
if (this.prompt && this.extraParams.prompt) {
console.warn(`\`AuthRequest\` \`extraParams.prompt\` will be overwritten by \`prompt\`.`);
}
if (this.clientSecret && this.extraParams.client_secret) {
console.warn(
`\`AuthRequest\` \`extraParams.client_secret\` will be overwritten by \`clientSecret\`.`
);
}
if (this.codeChallengeMethod && this.extraParams.code_challenge_method) {
console.warn(
`\`AuthRequest\` \`extraParams.code_challenge_method\` will be overwritten by \`codeChallengeMethod\`.`
);
}
}
invariant(
this.codeChallengeMethod !== CodeChallengeMethod.Plain,
`\`AuthRequest\` does not support \`CodeChallengeMethod.Plain\` as it's not secure.`
);
invariant(
this.redirectUri,
`\`AuthRequest\` requires a valid \`redirectUri\`. Ex: ${Platform.select({
web: 'https://yourwebsite.com/',
default: 'com.your.app:/oauthredirect',
})}`
);
}
/**
* Load and return a valid auth request based on the input config.
*/
async getAuthRequestConfigAsync(): Promise<AuthRequestConfig> {
if (this.usePKCE) {
await this.ensureCodeIsSetupAsync();
}
return {
responseType: this.responseType,
clientId: this.clientId,
redirectUri: this.redirectUri,
scopes: this.scopes,
clientSecret: this.clientSecret,
codeChallenge: this.codeChallenge,
codeChallengeMethod: this.codeChallengeMethod,
prompt: this.prompt,
state: this.state,
extraParams: this.extraParams,
usePKCE: this.usePKCE,
};
}
/**
* Prompt a user to authorize for a code.
*
* @param discovery
* @param promptOptions
*/
async promptAsync(
discovery: AuthDiscoveryDocument,
{ url, ...options }: AuthRequestPromptOptions = {}
): Promise<AuthSessionResult> {
if (!url) {
if (!this.url) {
// Generate a new url
return this.promptAsync(discovery, {
...options,
url: await this.makeAuthUrlAsync(discovery),
});
}
// Reuse the preloaded url
url = this.url;
}
// Prevent accidentally starting to an empty url
invariant(
url,
'No authUrl provided to AuthSession.startAsync. An authUrl is required -- it points to the page where the user will be able to sign in.'
);
const startUrl: string = url!;
const returnUrl: string = this.redirectUri;
// Prevent multiple sessions from running at the same time, WebBrowser doesn't
// support it this makes the behavior predictable.
if (_authLock) {
if (__DEV__) {
console.warn(
'Attempted to call AuthSession.startAsync multiple times while already active. Only one AuthSession can be active at any given time.'
);
}
return { type: 'locked' };
}
// About to start session, set lock
_authLock = true;
let result: WebBrowser.WebBrowserAuthSessionResult;
try {
result = await WebBrowser.openAuthSessionAsync(startUrl, returnUrl, options);
} finally {
_authLock = false;
}
if (result.type === 'opened') {
// This should never happen
throw new Error('An unexpected error occurred');
}
if (result.type !== 'success') {
return { type: result.type };
}
return this.parseReturnUrl(result.url);
}
parseReturnUrl(url: string): AuthSessionResult {
const { params, errorCode } = QueryParams.getQueryParams(url);
const { state, error = errorCode } = params;
let parsedError: AuthError | null = null;
let authentication: TokenResponse | null = null;
if (state !== this.state) {
// This is a non-standard error
parsedError = new AuthError({
error: 'state_mismatch',
error_description:
'Cross-Site request verification failed. Cached state and returned state do not match.',
});
} else if (error) {
parsedError = new AuthError({ error, ...params });
}
if (params.access_token) {
authentication = TokenResponse.fromQueryParams(params);
}
return {
type: parsedError ? 'error' : 'success',
error: parsedError,
url,
params,
authentication,
// Return errorCode for legacy
errorCode,
};
}
/**
* Create the URL for authorization.
*
* @param discovery
*/
async makeAuthUrlAsync(discovery: AuthDiscoveryDocument): Promise<string> {
const request = await this.getAuthRequestConfigAsync();
if (!request.state) throw new Error('Cannot make request URL without a valid `state` loaded');
// Create a query string
const params: Record<string, string> = {};
if (request.codeChallenge) {
params.code_challenge = request.codeChallenge;
}
// copy over extra params
for (const extra in request.extraParams) {
if (extra in request.extraParams) {
params[extra] = request.extraParams[extra];
}
}
if (request.usePKCE && request.codeChallengeMethod) {
params.code_challenge_method = request.codeChallengeMethod;
}
if (request.clientSecret) {
params.client_secret = request.clientSecret;
}
if (request.prompt) {
params.prompt =
typeof request.prompt === 'string' ? request.prompt : request.prompt.join(' ');
}
// These overwrite any extra params
params.redirect_uri = request.redirectUri;
params.client_id = request.clientId;
params.response_type = request.responseType!;
params.state = request.state;
if (request.scopes?.length) {
params.scope = request.scopes.join(' ');
}
// Store the URL for later
this.url = `${discovery.authorizationEndpoint}?${new URLSearchParams(params)}`;
return this.url;
}
private async ensureCodeIsSetupAsync(): Promise<void> {
if (this.codeVerifier) {
return;
}
// This method needs to be resolved like all other native methods.
const { codeVerifier, codeChallenge } = await PKCE.buildCodeAsync();
this.codeVerifier = codeVerifier;
this.codeChallenge = codeChallenge;
}
}