Scopes

A critical concept to understand when working with the Stellate Edge Cache is Scopes. The use of scopes allows the web application to have different authentication mechanisms and settings for different parts or functionalities of the application. This provides a way to manage and enforce access control and security policies based on the specific requirements of each scope.

About 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, by default, without any keys. This scope returns the same cached results for all users. So if your service only serves publicly available information, that's all you need.

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.

Benefits of Using Scopes

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

Define Scopes

You can define scopes for your service based on the header and/or cookie your users employ to authenticate:

  • Header names are case-insensitive
  • Cookie names are case-sensitive.

In our TypeScript configuration file you can add the following to define scopes.

stellate.ts →

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.

Example: Combined scope configuration

The following example shows the use of a combined headers and cookies scope configuration to create different buckets for the Authenticated scope:

stellate.ts →

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, you need to cache a certain query result. To do this you need to create a cache rule for the types you want to cache separately, per-user (see Cache Rules) for more information. The following example shows how to set the scope by creating a cache rule.

Example: Create cache rules for types

stellate.ts →

import { Config } from 'stellate';

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

export default config;

With this cache rule, 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.

Define Dynamic Scopes

You can 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.

Example: Use a function as the scope definition

The following example illustrates how to use a function as the scope definition to get the scope value:

stellate.ts →

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;

Define Custom Scopes

You can define custom scopes based on:

  • HTTP headers
  • Cookies
  • A combination of both HTTP headers and cookies.

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.

Example: Custom scope for the Space X API

We are going to use the SpaceX API for the following examples, which are configured to cache the query we're using with that specific scope.

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": "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": "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 Amanda's cache,
# so he gets a cache miss initially
$ AUTHORIZATION="mike" ./test-scopes.sh
HTTP/2 200
#... (some headers removed for brevity)
gcdn-cache: MISS
{
  "data": {
    "roadster": {
      "name": "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.

JWT-Based Scopes

You can define scopes that use JWT-based authentication for the "AUTHENTICATED" scope and cookie-based authentication.

Example: Configure JWT-based authentication and cookies for scopes

The following example code defines a configuration object for a web application using the Stellate library. The configuration includes the following key elements: The following code sets up a configuration for a web application that uses JWT-based authentication for the "AUTHENTICATED" scope and cookie-based authentication for the "ANOTHER_SCOPE". The JWT configuration includes details about the claim, algorithm, and secret used for signing the tokens.

stellate.ts →

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-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo
4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u
+qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh
kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ
0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg
cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc
mwIDAQAB
-----END PUBLIC KEY-----`,
        },
      },
      // Sidenote: The shorthand for passing just a string here still works!
      ANOTHER_SCOPE: 'cookie:session',
    },
  },
};

export default config;

For JWT-based scopes, note the following considerations:

  • We support nested claims using lodash-like dotpath-notation (for example, by passing user.id to claim).
  • Supported algorithms are:
    • HS256, HS384, and HS512 (symmetric)
    • RS256, RS384, and RS512 (asymmetric)
    • ES256, ES256k, ES384 (asymmetric)
    • RSA-PSS algorithms (PS256, PS384, and PS512, all of them asymmetric)
    • EdDSA
  • When using an asymmetric signature, the public key shall be passed as value for secret as shown in the prior example.

Example: Manually validate a JWT token

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

stellate.ts →

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;

Limitations with JWT-baased scopes

  • The authorization header needs to be in the format Bearer {token}, as required by the spec.
  • The combined size of all secrets / public keys used in JWT scopes must not exceed 6,000 characters.
  • 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.
  • 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 (such as Hasura does with their default configuration), you will need to escape those dots with a double backslash \\., (for example, to cache based on the user ID for the example configuration shared at https://hasura.io/docs/latest/auth/authentication/jwt).

The following example illustrates the use of backslashes to escape dots.

stellate.ts →

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;

Discover more