altair-graphql / altair

✨⚡️ A feature-rich GraphQL Client for all platforms.
https://altairgraphql.dev
MIT License
5.14k stars 322 forks source link

Add ability to add and update environment variables, modify headers, and new lifecycle events #2121

Open ortonomy opened 1 year ago

ortonomy commented 1 year ago

Is your feature request related to a problem? Please describe.

I have an AWS AppSync graphql endpoint. It's secured by Cognito (the auth system for AWS.). It requires an ID token to be able to authenticate. I can't automatically use a valid token, or generate a new one before every request like I can with REST in say, Postmans.

Describe the solution you'd like

I'm trying to write a plugin for Altair that will allow me to manage login and credentials to an AWS Cognito Pool using confidential-client credentials (username,password,client secret, region, poolId) using the aws-sdk lib

I wanted to write my plugin such that it could satisfy:

  1. Pre-request - check local storage for an existing ID/Access token
  2. Check its validity based on expiry
  3. Renew the access/ID token if it was expired
  4. store the token in local storage
  5. use the token in Authorization header to authorize all the graphql requests including fetching the schema

we need:

Describe alternatives you've considered A pre-request script - however, I can't use node packages here and the available modules are too limited (for example decoding a JWT) to do the proper logic needed

Additional context N/A

### Tasks
- [ ] Add ability to `setHeader('key', 'value')` in pre request script
- [ ] Add new "blocking" plugin event that the app waits for before sending requests - essentially pre request script from plugin
- [x] Add ability to `setHeader()` from a plugin
imolorhe commented 1 year ago

Hey! Trying to make sure I understand the issue here

I can't automatically use a valid token, or generate a new one before every request like I can with REST in say, Postmans.

Why can't you automatically use a valid token? Why can't you generate a new one before every request? What is missing in the pre-requests that doesn't allow you do that?

ortonomy commented 1 year ago

Hope I can give a comprehensive answer:

Why can't you automatically use a valid token?

Because I can't set headers from the pre-request script. For some reason I'm given access to the custom headers which have already been set in the array on altair.data.headers but if I push a new value, no extra headers are sent...

So I have to create an empty ENV, and pre-emptively set a the header referencing the empty ENV and then populate it from the script. I don't consider this automated.

Why can't you generate a new one before every request?

Yes, I concede, I can do this. But seems expensive and unnecessary.

What is missing in the pre-requests that doesn't allow you do that?

Here's what I want to do:

I would like to make this a plugin I can share with others including my teammates, so that we have a robust and easy to use GraphQL client. Insomnia just broke their plugin system with the latest updates, and Postman is rubbish for GraphQL.

The pre-requests script at worst needs to be repeated per request, and at best per collection (I split my collections by Query/Mutations and by app)

Top issue:

const CryptoJS = await altair.importModule('crypto-js');
const b64Decode = await altair.importModule('atob');

const collectEnvironmentVariables = () => {
  const clientId = altair.helpers.getEnvironment('AwsCognitoClientId')
  const clientSecret = altair.helpers.getEnvironment('AwsCognitoClientSecret')
  const region = altair.helpers.getEnvironment('AwsCognitoRegion')
  const username = altair.helpers.getEnvironment('AwsCognitoUsername')
  const password = altair.helpers.getEnvironment('AwsCognitoPassword')

  if ( !clientId || !clientSecret || !region || !username || !password) {
    altair.log("AWS Cognito set up is not complete. Missing ENV vars")
    throw new Error("AWS Cognito set up is not complete. Missing ENV vars")
  }

  altair.log("AWS Inputs: " + JSON.stringify({
    clientId,
    clientSecret: Boolean(clientSecret),
    region,
    username,
    password: Boolean(password)
  }))

  return {
    clientId,
    clientSecret,
    region,
    username,
    password
  }
}

const jwtPartLabels = new Map([
  [0, 'algo'],
  [1, 'content']
])

const decodeAWSCognitoJWTToken = jwtString => {
  return jwtString.split('.').reduce((parts, next, i) => {

    if (jwtPartLabels.has(i)) {
      parts.set(jwtPartLabels.get(i), JSON.parse(atob(next)))
    }

    return parts
  }, new Map())
}

const unixTimestampAtNow = () => {
  return Math.round(Date.now() / 1000)
}

const isAWSCognitoJWTokenValid  = jwtContent => {
  const { exp } = jwtContent
  return unixTimestampAtNow() < exp
}

const getRequestSecretHash = (requestSettings) => {
  return CryptoJS.enc.Base64.stringify(
    CryptoJS.HmacSHA256(
      [requestSettings.username, requestSettings.clientId].join(''),
      requestSettings.clientSecret
    )
  )
}

const getAWSCognitoAuthURI = (region) => {
  return `https://cognito-idp.${region}.amazonaws.com`
}

const generateRequestBody = requestSettings => {
  return {
    AuthFlow: "USER_PASSWORD_AUTH",
    ClientId: requestSettings.clientId,
    AuthParameters: {
      USERNAME: requestSettings.username,
      PASSWORD: requestSettings.password,
      SECRET_HASH: getRequestSecretHash(requestSettings)
    }
  }
}

const getNewAWSCognitoIdToken = async requestSettings => {
  const { AuthenticationResult: { IdToken } } = await altair.helpers.request(
    'POST', 
    getAWSCognitoAuthURI(requestSettings.region),
    {
      headers: {
        "content-type": "application/x-amz-json-1.1",
        "x-amz-target": "AWSCognitoIdentityProviderService.InitiateAuth",
      },
      body: JSON.stringify(generateRequestBody(requestSettings))
    }
  );

  altair.log("Id token retrieved: " + IdToken)
  return IdToken
}

const getExistingAWSCognitoSession = () => {
  return localStorage.getItem("AWSCognitoIdToken") || null
}

const getSessionAWSCognitoToken = () => {
  let token = getExistingAWSCognitoSession()

  if ( !token || ( token && !isAWSCognitoJWTokenValid(token) ) ) {
    // no existing session or it's expired, get a new one
    token = getNewAWSCognitoIdToken(collectEnvironmentVariables())
  } 

  return token 
}

const addAuthenticationToRequest = async () => {
  const token = await getSessionAWSCognitoToken()

  altair.helpers.setEnvironment('AwsCognitoIdToken', token)
}

await addAuthenticationToRequest()
imolorhe commented 1 year ago

@ortonomy Trying to split this up into multiple separate tasks.

I can't set headers from the pre-request script

The headers provided to the pre request scripts are read only, and any changes need to use the environment variable like you described. I think it's a valid point to be able to modify the headers directly.

the pre-request scripts are manual, and don't lend themselves to sharing

I'm not sure I understand if there's something to be fixed here, or if you're just stating a fact about pre-request scripts generally.

Re the plugin..

save these values to the selected environment

I am not sold on the idea of allowing the plugin interact with the environment variables just yet. In general, the environment variables tend to be the more sensitive part of the data.

before every request (including schema fetching) run a script ...

This seems to be the main part that is missing from the current plugin architecture that will enable you do what you want using a plugin. So basically, we need a new event triggered with a "blocking" callback (i.e. it is not just a regular event listener, but it should wait for the event listener to return before proceeding)

automatically set the Auth headers

We should also be able to set headers from plugins


Do these summarize the action items or did I miss anything?

ortonomy commented 1 year ago

Hey @imolorhe - thanks for being so responsive and trying to address this. Sorry haven't responded. will make effort to edit this comment and respond today or tomorrow.

ortonomy commented 1 year ago

@imolorhe

Do these summarize the action items or did I miss anything?

Really really appreciate you feedback and analysis here. I agree with them all, especially:

So basically, we need a new event triggered with a "blocking" callback (i.e. it is not just a regular event listener, but it should wait for the event listener to return before proceeding)


Allow me to clarify your question

I said:

the pre-request scripts are manual, and don't lend themselves to sharing

You said:

I'm not sure I understand if there's something to be fixed here, or if you're just stating a fact about pre-request scripts generally.

It is stating a fact, but cannot act alone as a sentence withou context (which was split apart in my request)

I would like to make this a plugin I can share with others including my teammates, .... the pre-request scripts are manual, and don't lend themselves to sharing.

To emphasise: if I could write a plugin, I could offer a smooth way for teammates (existing or new) to get started with our AWS appsync grpahql without messy setting up of the pre-request script.


I am not sold on the idea of allowing the plugin interact with the environment variables just yet. In general, the environment variables tend to be the more sensitive part of the data.

I only suggested this because:

is that clear?

if you allow us to set headers in the plugin with a blocking request, yea, then the ENV setting it not needed.


as an aside one UX improvement I saw with altair which was somewhat confusing is that despite enabling the "collection" level pre-request script, it doesn't actually get enabled per request, unless you enable the pre-request script in the request. Which may be empty...

Suggest removing this limuitation. If you have a pre-request script at collection level and it's enabled, it should be executed regardless of if the request script itself has been enabled.

Thanks for working on a great app!

imolorhe commented 1 year ago

So for the environment variables, you only want to be able to set them and not read them?

ortonomy commented 1 year ago

I think reading and setting them would give ultimately flexibility to plugin creators - you could chain plugins and pass data?

If you are making this update, please make the available plugin events available on docs pages. I had to dive into the code to find what was available.

imolorhe commented 1 year ago

I try to keep the docs (available docs) updated with relevant changes. I didn't add the events at all though 😅 so nothing to update (since I was concerned they will get stale quickly)

Anyway would appreciate any help on the documentation front.

ortonomy commented 1 year ago

Sure @imolorhe - are docs for site in this repo? Add a PR from a fork?

Let me know the list of events and I'll update the plugin pages. I also couldn't find a full spec on the current versions plugin specification in the manifest.

I also want to update the description about a) using file:// as a URI for the local plugin testing rather than a URL - using a URL results in errors because of insecure resources

imolorhe commented 1 year ago

@ortonomy Yes! Docs are here.

Most of the context around plugins are here. Specifically the events and their payload types are here (these may change a bit since I will be adding a "blocking" listener, which we don't have at the moment).

All the actions from the plugin context are defined here.

yuniers commented 1 year ago

I need transform my altair.data.query with jose npm package, using "RSA-OAEP-256" algorithm and "A256GCM" encoder. How I can do that from pre-request script?

imolorhe commented 1 year ago

@yuniers I am not sure how you can do that easily. You'll need to pretty much copy and paste the entire code into the pre request script.

yuniers commented 1 year ago

@yuniers I am not sure how you can do that easily. You'll need to pretty much copy and paste the entire code into the pre request script.

I'm trying with next code.

await dynamicallyLoadScript('https://cdnjs.cloudflare.com/ajax/libs/jose/4.14.0/index.umd.min.js', 'module');
async function dynamicallyLoadScript(url, type = 'text/javascript') {
  try {
    let script = document.createElement('script');
    let content = await fetch(url);
    script.textContent = await content.text();
    script.type = type;
    document.head.appendChild(script);
  } catch (e) {
    console.log(e);
    throw e;
  }
}

But a CSP error is triggered. I can't do document.head.appendChild(script);

Content Security Policy: Las opciones para esta página han bloqueado la carga de un recurso en inline (script-src).

imolorhe commented 1 year ago

Yes you can't execute inline scripts on the page

yuniers commented 1 year ago

Yes you can't execute inline scripts on the page

Could be added another default module?

imolorhe commented 1 year ago

Yes that's the only other alternative