@aws-amplify/auth
Version:
Auth category of aws-amplify
273 lines (240 loc) • 6.61 kB
text/typescript
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import {
AMPLIFY_SYMBOL,
AmplifyUrl,
USER_AGENT_HEADER,
urlSafeDecode,
} from '@aws-amplify/core/internals/utils';
import { Hub, decodeJWT } from '@aws-amplify/core';
import { cacheCognitoTokens } from '../../tokenProvider/cacheTokens';
import { dispatchSignedInHubEvent } from '../dispatchSignedInHubEvent';
import { tokenOrchestrator } from '../../tokenProvider';
import { createOAuthError } from './createOAuthError';
import { resolveAndClearInflightPromises } from './inflightPromise';
import { validateState } from './validateState';
import { oAuthStore } from './oAuthStore';
export const completeOAuthFlow = async ({
currentUrl,
userAgentValue,
clientId,
redirectUri,
responseType,
domain,
preferPrivateSession,
}: {
currentUrl: string;
userAgentValue: string;
clientId: string;
redirectUri: string;
responseType: string;
domain: string;
preferPrivateSession?: boolean;
}): Promise<void> => {
const urlParams = new AmplifyUrl(currentUrl);
const error = urlParams.searchParams.get('error');
const errorMessage = urlParams.searchParams.get('error_description');
if (error) {
throw createOAuthError(errorMessage ?? error);
}
if (responseType === 'code') {
return handleCodeFlow({
currentUrl,
userAgentValue,
clientId,
redirectUri,
domain,
preferPrivateSession,
});
}
return handleImplicitFlow({
currentUrl,
redirectUri,
preferPrivateSession,
});
};
const handleCodeFlow = async ({
currentUrl,
userAgentValue,
clientId,
redirectUri,
domain,
preferPrivateSession,
}: {
currentUrl: string;
userAgentValue: string;
clientId: string;
redirectUri: string;
domain: string;
preferPrivateSession?: boolean;
}) => {
/* Convert URL into an object with parameters as keys
{ redirect_uri: 'http://localhost:3000/', response_type: 'code', ...} */
const url = new AmplifyUrl(currentUrl);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
// if `code` or `state` is not presented in the redirect url, most likely
// that the end user cancelled the inflight oauth flow by:
// 1. clicking the back button of browser
// 2. closing the provider hosted UI page and coming back to the app
if (!code || !state) {
throw createOAuthError('User cancelled OAuth flow.');
}
// may throw error is being caught in attemptCompleteOAuthFlow.ts
const validatedState = await validateState(state);
const oAuthTokenEndpoint = 'https://' + domain + '/oauth2/token';
// TODO(v6): check hub events
// dispatchAuthEvent(
// 'codeFlow',
// {},
// `Retrieving tokens from ${oAuthTokenEndpoint}`
// );
const codeVerifier = await oAuthStore.loadPKCE();
const oAuthTokenBody = {
grant_type: 'authorization_code',
code,
client_id: clientId,
redirect_uri: redirectUri,
...(codeVerifier ? { code_verifier: codeVerifier } : {}),
};
const body = Object.entries(oAuthTokenBody)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
const {
access_token,
refresh_token: refreshToken,
id_token,
error,
error_message: errorMessage,
token_type,
expires_in,
} = await (
await fetch(oAuthTokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
[USER_AGENT_HEADER]: userAgentValue,
},
body,
})
).json();
if (error) {
// error is being caught in attemptCompleteOAuthFlow.ts
throw createOAuthError(errorMessage ?? error);
}
const username =
(access_token && decodeJWT(access_token).payload.username) ?? 'username';
await cacheCognitoTokens({
username,
AccessToken: access_token,
IdToken: id_token,
RefreshToken: refreshToken,
TokenType: token_type,
ExpiresIn: expires_in,
});
return completeFlow({
redirectUri,
state: validatedState,
preferPrivateSession,
});
};
const handleImplicitFlow = async ({
currentUrl,
redirectUri,
preferPrivateSession,
}: {
currentUrl: string;
redirectUri: string;
preferPrivateSession?: boolean;
}) => {
// hash is `null` if `#` doesn't exist on URL
const url = new AmplifyUrl(currentUrl);
const {
id_token,
access_token,
state,
token_type,
expires_in,
error_description,
error,
} = (url.hash ?? '#')
.substring(1) // Remove # from returned code
.split('&')
.map(pairings => pairings.split('='))
.reduce((accum, [k, v]) => ({ ...accum, [k]: v }), {
id_token: undefined,
access_token: undefined,
state: undefined,
token_type: undefined,
expires_in: undefined,
error_description: undefined,
error: undefined,
});
if (error) {
throw createOAuthError(error_description ?? error);
}
if (!access_token) {
// error is being caught in attemptCompleteOAuthFlow.ts
throw createOAuthError('No access token returned from OAuth flow.');
}
const validatedState = await validateState(state);
const username =
(access_token && decodeJWT(access_token).payload.username) ?? 'username';
await cacheCognitoTokens({
username,
AccessToken: access_token,
IdToken: id_token,
TokenType: token_type,
ExpiresIn: expires_in,
});
return completeFlow({
redirectUri,
state: validatedState,
preferPrivateSession,
});
};
const completeFlow = async ({
redirectUri,
state,
preferPrivateSession,
}: {
preferPrivateSession?: boolean;
redirectUri: string;
state: string;
}) => {
await tokenOrchestrator.setOAuthMetadata({
oauthSignIn: true,
});
await oAuthStore.clearOAuthData();
await oAuthStore.storeOAuthSignIn(true, preferPrivateSession);
// this should be called before any call that involves `fetchAuthSession`
// e.g. `getCurrentUser()` below, so it allows every inflight async calls to
// `fetchAuthSession` can be resolved
resolveAndClearInflightPromises();
// clear history before sending out final Hub events
clearHistory(redirectUri);
if (isCustomState(state)) {
Hub.dispatch(
'auth',
{
event: 'customOAuthState',
data: urlSafeDecode(getCustomState(state)),
},
'Auth',
AMPLIFY_SYMBOL,
);
}
Hub.dispatch('auth', { event: 'signInWithRedirect' }, 'Auth', AMPLIFY_SYMBOL);
await dispatchSignedInHubEvent();
};
const isCustomState = (state: string): boolean => {
return /-/.test(state);
};
const getCustomState = (state: string): string => {
return state.split('-').splice(1).join('-');
};
const clearHistory = (redirectUri: string) => {
if (typeof window !== 'undefined' && typeof window.history !== 'undefined') {
window.history.replaceState(window.history.state, '', redirectUri);
}
};