Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft: Authentication in Console Walkthrough #244

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions src/authentication/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,39 +389,59 @@ export const logInWithPopUp = async (reset = false) => {
return jso_getToken(authConfig.provider);
};

// If you read the Authentication in Console, you already have an idea of what it's going on here
const logInWithWebMessageAndPKCE = async (reset: boolean) => {
// The first thing we do is to get the config
const auth = getConfig();

// Then we return a pending promise that will handle the authentication
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
try {
// Some guards. If we don't have the client_id specified there is no use trying to run authentication
if (!auth.client_id) {
reject(new Error("Client_id in AUTH_CONFIG is mandatory"));
}

// ensure that there is an access token, redirecting to auth if needed
// If the redirect_uri isn't specified, we will take the current origin (we specify it on console,
// but if we didn't, this would be set to console.platform.sh)
if (!auth.redirect_uri) {
// Some targets just need some dynamism
auth.redirect_uri = window.location.origin;
}

// Then we "configure" the config. We are not going to review the implementation for simplicity sake, but we:
// 1. Check that the provided configure is not in popUp mode, and that it has the response_type set to token (our case)
// 2. Check if the hash contains an access token. And if it do, extract the state, compare with config, and store the access token for later use.
jso_configure({ [auth.provider]: auth });

// Then we access the token here
const storedToken = jso_getToken(auth.provider);

// If we found a token and we don't want to refres, we will resolve with this token
if (storedToken && !reset) {
resolve(storedToken);
}

// If not, we will delete all tokens and remove any existing iframes
jso_wipe();
removeIFrame();

// If we come from a redirect
// Remember that after authenticating in the auth ui, we are redirected to console with code and
// state in the query params. Here we check the query params an get the code and the state.
const oauthResp = jso_checkforcode();
// If we found them, it means that we just got redirected from the auth ui. We are going to request
// our first token!
if (oauthResp) {
// We now get the code verifier from localStorage (remember that console always has it on the localStorage)
const codeVerifier = jso_getCodeVerifier(auth.provider);

if (codeVerifier && oauthResp.code && oauthResp.state) {
// Now we resolve
resolve(
// In this function, we get the token by making a request to get it, sending the code and the code_verifier. We also send
// the string "platform-cli:" encoded on base64 in the Authorization header. This is so that Auth Server
// knows that we are a trusted client. If we get the token, we will set it's expiration, save it, and
// then remove the state and the code_verifier from the localStorage.
await authorizationCodeCallback(
auth,
codeVerifier,
Expand All @@ -432,47 +452,76 @@ const logInWithWebMessageAndPKCE = async (reset: boolean) => {
}
}

// If we are here it means 1. We don't have the code on the query params or 2. We don't have the code_verifier in the local storage
// either ways, it means it's not our first token request, so we need to prepare the iframe silent token refresh. In order to do this
// cross domain iframe trick, we need the Storage Access API. So if we don't have access, we will request it. This will create an
// iframe, and set it's src to <authOrigin>/request-storage-access.html. It will give us an HTML as a response, that contains an
// script granting us access to make this operation.
if (document.hasStorageAccess !== null) {
await checkForStorageAccess(auth);
}

// Then, we will wrab the request from localStorage
const req = jso_getAuthRequest(auth.provider, auth.scope);

// We will generate a new code_challenge and a new code_verifier
const pkce = await generatePKCE();

// And we will set the request code_challenge to the one we just generated
req.code_challenge = pkce.codeChallenge;
req.code_challenge_method = "S256";

// Here we define a fallback so that if the silent token refresh fails we have a way to reauthenticate the user. It's worst beacuse
// it stops our users and make them reauthenticate.
const timeout = setTimeout(() => {
// If we have the popupMode enabled (meaning we will interrupt our users and prompt them with a popUp to reauthenticate),
// we will resolve with it.
if (auth.popupMode) {
resolve(logInWithPopUp());
return;
}

// If we don't, then we will redirect them to auth ui so that they authenticate
resolve(logInWithRedirect());
}, 5000);

// This function is later used as the handler of an event listener that listen to messages. This is the function that will
// run if we receive a message from an iframe
const receiveMessage = async (event: MessageEvent) => {
// If the message is not from an auth iframe, we don't care about it
if (event.origin !== auth.authentication_url) {
return false;
}
// We grab the data from the message
const { data } = event;

// If there is an error, or que don't have a payload, or the message isn't the one we expect, we do the same as with the fallback
// "if the silent token refresh fails we have a way to reauthenticate the user. It's worst beacuse
// it stops our users and make them reauthenticate."
if (data.error || !data.payload || data.state !== req.state) {
// If we have the popupMode enabled (meaning we will interrupt our users and prompt them with a popUp to reauthenticate),
// we will resolve with it.
if (auth.popupMode) {
return logInWithPopUp();
}

// If we don't, then we will redirect them to auth ui so that they authenticate
return logInWithRedirect();
}

// If we are here, it means the message is what we expected, so we remove the request form the localStorage
localStorage.removeItem(`state-${req.providerID}-${req.state}`);
// And also the event listener, because we already have what we wanted
window.removeEventListener("message", receiveMessage, false);

// We also clear the timeout of 5 seconds that runs the fallback, because if we are here it means the silent refresh
// is going well
clearTimeout(timeout);

// We get the code from the payload of the data of the message
const code = data.payload;

// We resolve with the code and the code_verifier we generated before
resolve(
await authorizationCodeCallback(
auth,
Expand All @@ -484,42 +533,55 @@ const logInWithWebMessageAndPKCE = async (reset: boolean) => {
return;
};

// Here we add the event listener that listen to messages of iframes
window.addEventListener("message", receiveMessage, false);

// And here we create the auth iframe!
const authUrl = encodeURL(auth.authorization, req);
createIFrame(authUrl);
} catch (err) {
// If anything happen, we catch it and run the fallback
console.log("Error Silent refresh");
console.log(err);
if (auth.popupMode) {
void logInWithPopUp();
}
console.log("Error In web message mode, trying redirect...");
void logInWithRedirect();

// That is it! Authentication in Console
}
});
};

// This function takes a token, a reset value and a config (already described in previous steps)
export default async (
token?: string,
reset = false,
config?: Partial<ClientConfiguration>
) => {
// If we are running the client through NodeJS, we use the token to authenticate
if (isNode && token) {
return logInWithToken(token).catch(e => new Error(e));
}

// If we have extra_params, we use the only login function that works with them
if (config?.extra_params && Object.entries(config.extra_params).length) {
return logInWithRedirect(reset, config.extra_params);
}

// Console usecase!
if (config?.response_mode === "web_message" && config.prompt === "none") {
return logInWithWebMessageAndPKCE(reset);
}

// If we run with the popUp mode enabled
if (config?.popupMode) {
return logInWithPopUp(reset);
}

// If not, we fallback to logIn with redirection
return logInWithRedirect(reset, config?.extra_params);
};

// Now let's review the console usecase! Go to line 392
28 changes: 27 additions & 1 deletion src/authentication/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type JWTToken = {
scope: string;
};

// This is the function. It receives a config, and runs the authentication.
export default async (
{
api_token,
Expand All @@ -29,35 +30,60 @@ export default async (
}: ClientConfiguration,
reset = false
): Promise<JWTToken> => {
// As you can see, we declare a let variable on line 10 called authenticationInProgress.
// We will use it to know if there is an ongoing authentication, and if so, we will return
// the promise that handles it.
if (authenticationInProgress) {
return getAuthenticationPromise();
}

// If we don't have an outgoing authentication, we will start one.
authenticationInProgress = true;

// If we received an access_token in the config, we can resolve the promise with it.
// Note that it will we are setting this token with expires: -1. We use this so that
// when we call the authenticatedRequests function, it doesn't fail even if the token
// is expired
const promise = access_token
? Promise.resolve({ access_token, expires: -1 })
: connector(api_token, reset, {
: // If not, we will try to get an access token. API tokens are used if you are running
// the client in NodeJS, which is not our case. Reset is set to false by default. We
// set it to true if the function is called through reAuthenticate(), defined in line
// 50 in /src/index.ts
connector(api_token, reset, {
provider,
// This defines if we should open a popup to handle authentication, we don't use it on Console
popupMode,
// This is set to web_message on Console, triggering the iframe creation
response_mode,
// This is set to none, so that we don't promp the user to authenticate in Console,
// doing a silent token refresh with the iframes
prompt,
// This is in case you want to use extra parameters, we don't use it on Console
extra_params
});

// Now that we have defined the promise, if we have any, we will:
if (promise) {
// Set the authenticationPromise to the promise, this will allow us to get the
// promise anywhere in the application
setAuthenticationPromise(promise);

// Make sure to set the authenticationInProgress to false once the promise is resolved
void promise.then(() => {
authenticationInProgress = false;
});

// Return the promise
return promise;
}

// If we don't have a promise, something went wrong :(
return Promise.reject(new Error());
};

// Now lets go to read the connector function, go to line 557 in /src/authentication/connector.ts

export const authenticatedRequest = api;

export const wipeToken = jso_wipe;
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ export default class Client {
getAccountInfoPromise: Promise<Me> | undefined;

constructor(authenticationConfig: ClientConfiguration) {
// When the client is initialiced, we set the config with this function
// this will allow us to acces the config from anywhere in the client.
setConfig(authenticationConfig);

// Then we call the connector function passing it the config, and we
// set the authenticationPromise to the return of it. Go to line 20
// of /src/authentication/index.ts
this.authenticationPromise = connector(authenticationConfig);
}

Expand Down