Scopes
Concept
Another 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 toabcd
, andsession
cookie present and set to1234
Authorization
header present and set toabcd
,session
cookie not setAuthorization
header not set,session
cookie present and set to1234
Authorization
header not set andsession
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
toclaim
. - 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 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 (Beta)
This feature is not available on the free plan.
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