Scopes

A critical concept to understand when working with the Stellate Edge Cache is Scopes. Think of scopes as a way to split your cache into various buckets for different users or queries. Each bucket is secured by a specific key that needs to be present on a request for a particular cached document to be served from that bucket.

Each service has a unique scope called PUBLIC that is available without any keys. So if your service only serves publicly available information, that's all you need.

You can define custom scopes based on HTTP headers, cookies, or a combination of those. For example, a common use case for scopes is creating a scope for authenticated users based on the Authorization header or a session cookie.

For example, let's say we have a scoped configured called AUTHENTICATED, which is based on a header called Authorization. We're also going to use our SpaceX API for the following examples, which is configured to cache the query we're using with that specific scope. (How to configure your cache to cache specific types with specific scopes is part of the next chapter.)

We're going to send the following query multiple times, and we're going to use different values for the Authorization header.

{
  roadster {
    name
    earth_distance_km
  }
}

To make it easier to send multiple requests, we've saved the commands to a shell script called test-scopes.sh in our current directory. That script also allows us to pass in the value for the Authorization header via an environment variable.

#!/bin/sh
curl -g -D - -X POST https://spacex-api.stellate.sh \
  -H "Content-Type: application/json" \
  -H "Authorization: ${AUTHORIZATION}" \
  -d '{ "query": "{ roadster { name earth_distance_km } }" }'

Amanda, our first user, sees a cache miss the first time she runs that query. This is indicated by the MISS in the gcdn-cache response header. However, if she sends the same query again, she will see a cache HIT instead.

# First request, produces a cache miss and adds the document to the cache for Amanda
$ AUTHORIZATION="amanda" ./test-scopes.sh
HTTP/2 200
#... (some headers removed for brevity)
gcdn-cache: MISS
{"data":{"roadster":{"name":"Elon Musk's Tesla Roadster","earth_distance_km":57928338.62299857}}}

# Second request, a cache hit, because Amanda already requested that document and it is stored in her cache
$ AUTHORIZATION="amanda" ./test-scopes.sh
HTTP/2 200
#... (some headers removed for brevity)
gcdn-cache: HIT
{"data":{"roadster":{"name":"Elon Musk's Tesla Roadster","earth_distance_km":57928338.62299857}}}

Mike, a different user, wants to know more about that Roadster. So he sends the same GraphQL query. However, he uses mike as the value for the AUTHORIZATION header.

# Mike didn't request that document yet, and doesn't have access to Amandas cache, so he gets a cach miss initially
$ AUTHORIZATION="mike" ./test-scopes.sh
HTTP/2 200
#... (some headers removed for brevity)
gcdn-cache: MISS
{"data":{"roadster":{"name":"Elon Musk's Tesla Roadster","earth_distance_km":57928338.62299857}}}

Because Mike didn't send that query previously and doesn't have access to Amanda's cache, he will see a cache miss on his first request. However, if he sends the same query again, he would get a cache hit, as Amanda did on her second request.

Scopes allow you to cache documents that are different for different users and improve your application's performance even in use cases where data is not shared between users.

Some types and fields can contain data that is specific to a certain user. Cached query results that contain those types (or fields) should not be returned to any other user.

In order to handle this scenario, you can define "scopes" in your service which let you scope (hence the name) cached query results to specific headers and/or cookies.

Every service has a special PUBLIC scope it uses by default. This scope returns the same cached results for all users.


Defining Scopes

You can define scopes for your service based on the header and/or cookie your users use to authenticate. In our TypeScript configuration file you can add the following to do so.

import { Config } from 'stellate'

const config: Config = {
  config: {
    scopes: {
      AUTHORIZATION_HEADER: 'header:authorization',
      SESSION_COOKIE: 'cookie:session',
    },
  },
}
export default config

You can also combine multiple headers (or cookies, or a combination of both), into a single scope. This makes it easier to configure your cache rules. However, for a request to be served from the cache, subsequent requests need to match all defined headers and/or cookies. This includes the absence of specific headers or cookies as well.

import { Config } from 'stellate'

const config: Config = {
  config: {
    scopes: {
      AUTHENTICATED: 'header:Authorization|cookie:session',
    },
  },
}
export default config

With the above scope configuration and assuming the Authorization header for a user would be set to abcd, and the value of the session cookie would be 1234, you could have the following buckets for the AUTHENTICATED scope:

  • Authorization header present and set to abcd, and session cookie present and set to 1234
  • Authorization header present and set to abcd, session cookie not set
  • Authorization header not set, session cookie present and set to 1234
  • Authorizationheader not set and session cookie not set

Different values for either the Authorization header, or the session cookie would add additional cache buckets.

Using Scopes

To set the scope a certain query result should be cached by, create a cache rule for the types you want to cache separately per-user (see Cache Rules for more information):

import { Config } from 'stellate'

const config: Config = {
  config: {
    rules: [
      {
        types: ['User'],
        maxAge: 900,
        swr: 900,
        scope: 'AUTHENTICATED',
        description: 'Cache Users',
      },
    ],
  },
}
export default config

Now any query result that contains any User will be cached with the corresponding cookie and/or header of the AUTHENTICATED scope and will only be returned for the same requester. Users that aren't authenticated will still get the same cached results.

JWT Based Scopes

import { Config } from 'stellate'

const config: Config = {
  config: {
    scopes: {
      // To support JWT scopes we modified our config format so that you may
      // pass `definition` and the optional `jwt` option separately.
      AUTHENTICATED: {
        definition: 'header:authorization',

        // Setting this marks the scope as "the value of the definition contains
        // a JWT". If you pass multiple headers and/or cookies in the definition,
        // we'll take the first existing value we can find.
        jwt: {
          // Pass the claim by which the cache should be scoped
          claim: 'sub',

          // Pass the algorithm you use to sign your JWTs
          algorithm: 'HS256',

          // Pass the secret you use for signing (or the public key when using
          // an asymmetric algorithm)
          secret: ':a_very_secret_passphrase',
          // or
          secret:
            '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo\n4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u\n+qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh\nkd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ\n0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg\ncKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc\nmwIDAQAB\n-----END PUBLIC KEY-----\n',
        },
      },
      // Sidenote: The shorthand for passing just a string here still works!
      ANOTHER_SCOPE: 'cookie:session',
    },
  },
}
export default config
  • We support nested claims using lodash-like dotpath-notation, e.g. by passing user.id to claim.
  • Supported algorithms are: HS256, HS384, and HS512 (symmetric), RS256, RS384, and RS512 (asymmetric), ES256, ES256k, ES384 (asymmetric), the RSA-PSS algorithms (PS256, PS384, and PS512, all of them asymmetric) as well as EdDSA.
  • When using an asymmetric signature, the public key shall be passed as value for secret as shown in the example above.

Limitations with JWT based scopes

  • The authorization header needs to be in the format Bearer {token}, as required by the spec.
  • After pushing the config it takes some time for the changes to actually come into effect globally. This can vary between a couple of seconds and 1-2 minutes. We’re looking into how we can make this faster and/or predictably wait when pushing until the propagation of these changes is complete.
  • The combined size of all secrets / public keys used in JWT scopes must not exceed 6,000 characters.
  • We do not support JSON Web Key Sets (JWKS) at this time. Your configuration will need to refer to the actual public key and not a JWKS URL at this time.
  • If your claim includes dots like e.g. Hasura does with their default configuration, you will need to escape those dots with a double backslash \\., e.g. to cache based on the user ID for the example configuration shared at https://hasura.io/docs/latest/auth/authentication/jwt/
import { Config } from 'stellate'

const config: Config = {
  config: {
    scopes: {
      AUTHENTICATED: {
        definition: 'header:authorization',
        jwt: {
          claim: 'https://hasura\\.io/jwt/claims.x-hasura-user-id',
        },
      },
    },
  },
}
export default config

Dynamic Scopes

You can also define scopes dynamically based on the value of a header or cookie, with custom logic. This is useful when you want to process the header or cookie in some way to get the scope value.

To use dynamic scopes, you can provide a function as the scope definition, and we will call this function on every request to compute the scope value.

import { Config } from 'stellate'

const config: Config = {
  config: {
    scopes: {
      // ctx is an object with the following properties:
      // - headers: an object with all headers of the request
      // - cookies: an object with all cookies of the request
      // { headers: Record<string | string[]>, cookies: Record<string | string[]> }
      AUTHENTICATED: (ctx) => {
        const userId = ctx.cookies.uid
        if (!userId || userId.startsWith('lo_')) {
          return 'UNAUTHENTICATED'
        }
        return userId
      },
    },
  },
}

export default config

If you are using a JWT token from a header, you need to validate it manually inside your function:

import { Config } from 'stellate'

const config: Config = {
  config: {
    scopes: {
      // ctx is an object with the following properties:
      // - headers: an object with all headers of the request
      // - cookies: an object with all cookies of the request
      // { headers: Record<string | string[]>, cookies: Record<string | string[]> }
      AUTHENTICATED: (ctx) => {
        const token = ctx.headers.authorization.replace(/^bearer /i, '')
        const payload = validateToken(token, {
          algorithm: 'HS256',
          secret: 'shhh',
        })
        return payload ? payload.sub : null
      },
    },
  },
}

export default config