Building on top of OAuth at the edge

Authentication is one of the most obvious uses for edge computing. Understanding who your users are as early and as close as possible to their location yields powerful customizations and speedy responses. But there's more than one way to think about how to apply an authentication scheme at the edge.

In our last post, we released and discussed a reference implementation for performing OAuth at the edge, which gives you access to the current user's security tokens. Now let's think about how you can take advantage of your new authentication gateway in four specific and quite different use cases.

Paywalls and other advanced authorization decisions

Websites sometimes make authorization decisions using complex data that is not available at the edge. Paywalls are a handy example: you might need to check that a user's credit is sufficient to allow them to “buy” a piece of content, but the information in the user's identity tokens doesn't include their current balance.

This is a great use case for passing relevant information from the user's id_token to the origin – as additional HTTP headers. The origin can then use that data to make access decisions.

Here's how we can spread the id_token data over a bunch of HTTP request headers:

// Define a struct that groups together the pieces of data we care about.
#[derive(serde::Serialize, serde::Deserialize)]
struct IdTokenClaims {
    uuid: String,
    email: String,
    country: String,
}

// Validate the ID token, and destructure the claims we defined earlier.
match validate_token_rs256::<IdTokenClaims>(id_token, &settings) {
    Ok(claims) => {
        // Here, claims.custom is an instance of IdTokenClaims.
        req.set_header("Fastly-Auth-Uuid", claims.custom.uuid);
        req.set_header("Fastly-Auth-Email", claims.custom.email);
        req.set_header("Fastly-Auth-Country", claims.custom.country);
   }
    _ => {
        return Ok(responses::unauthorized("ID token invalid."));
    }
}

Now, when the origin uses a specific piece of profile data to make an access decision, it can respond with a Vary header that tells Fastly to cache the response only for users with the same profile state:

Vary: Fastly-Auth-Uuid

We've written a lot before about using the Vary header (our colleague Doc back in 2014 and Andrew more recently): it's a powerful mechanism that, when used well, can provide a huge boost to cache performance at the edge.

Granular access control for static content

Say your content is hosted in a static bucket like Amazon S3 or Google Cloud Storage. If you wanted only some users to access some of that content, it gets a little tricky. We’ve shown previously how Compute@Edge, which is used to build, test, and deploy code in our serverless compute environment, can serve static content from a bucket provider, so why not use information tagged on the content, along with authentication data at the edge, to make access decisions?

  1. Add a Fastly-Require-Country HTTP response header to your static objects

  2. In the edge app, read that header when loading the requested object from origin

  3. Compare the value with data in the user's id_token

  4. If there’s no match, discard the content response and generate a 403 Forbidden response instead

Here's how you could do that, using the same IdTokenClaims struct that we defined for the previous example:

match validate_token_rs256::<IdTokenClaims>(id_token, &settings) {
    Ok(claims) => {
        let beresp = req.send("backend")?;
        if claims.custom.country != beresp.get_header_str("fastly-require-country").unwrap()
{
            return Ok(Response::from_status(StatusCode::FORBIDDEN));
        }
        return Ok(beresp);
    } 
    // ...
}

Upgrading access with incremental authorization

Say you’re running an events ticketing app that uses Google as an identity provider. If the customer just wants to bookmark events, you simply need to know who they are. But if they want to add an event to their Google Calendar, you need an additional scope providing you with write access to the calendar. Later, when the user wants to make a booking, you’ll need access to the user's wallet or payment service, and that might be another scope again.

It might make sense to request a number of scopes during the initial authorization process, especially those that are required for things a large number of users want to do and that are less sensitive. But there may be things (say, payments) that you don't want an origin to be able to do unless specifically authorized, a nice way to enforce the principle of least privilege.

In these cases, the origin can use the current session's access_token to request incremental authorization from the identity provider.

  1. In the edge app, add a Fastly-Access-Token header to the request to allow the origin to see and use the access token

  2. In the origin, use the access token to make requests directly to the identity provider, for example to initiate a payment

  3. If the IdP denies the request due to insufficient scope....

    1. The origin returns a 403 to Fastly with a Fastly-Required-Scopes header

    2. Fastly kicks off a new auth flow to upgrade the user's tokens to allow the new scope, and manages the callback as normal to replace the user's session with a new one

    3. Eventually the user is redirected to the URL that required the upgraded consent and the origin has a new access token to use to make a successful request to the IdP

At the edge then, we only need to recognize a 403 response that has a Fastly-Required-Scopes header, and trigger the new flow:

// First, let’s make the configuration object mutable.
let mut settings = Config::load();

let beresp = req.send("backend")?;
if beresp.get_status() == fastly::http::StatusCode::FORBIDDEN {
    if let Some(incremental_scopes) = beresp.get_header_str("fastly-required-scopes") {
	 // Append the incremental scopes to the original settings.
        settings.config.scope.push(' ');
        settings.config.scope.push_str(incremental_scopes);
    } else {
        return Ok(beresp);
    }
}

Blocking abusive users

For any number of reasons, you may at some point need to block users from your app. There's a tradeoff here: long-lived session tokens are efficient, but no-one wants to block a user and then find their session token is still valid for two weeks with no way to cancel it. Conversely, checking each user’s session before every operation can make things slow, and you may not be able to cache content at the edge at all.

With OAuth at the edge, you can choose to validate the access_token with the identity provider on each request (which is normally very fast if the IdP is optimised for serving such requests globally), and then still use cached content to fulfil their request. This way, if you revoke a user's access at the identity provider, they will be blocked immediately.

In our example app, we've included that real-time verify call to the IdP on each request:

let mut userinfo_res = Request::get(settings.openid_configuration.userinfo_endpoint)
    .with_header(AUTHORIZATION, format!("Bearer {}", access_token))
    .send("idp")?;

if userinfo_res.get_status().is_client_error() {
    return Ok(responses::unauthorized(userinfo_res.take_body()));
}

While this delegates the problem of real-time session validation to an identity provider, it results in that provider adding minimal latency to each request. This is a tradeoff, so if you prefer reduced latency in exchange for a slightly increased delay in revocation of sessions, Fastly can cache the IdP responses at the edge for a short period.

We also offer global purging of cached content in around 150ms, so if you are able to coordinate revoking a session at the IdP with sending a purge request to the Fastly API, you can achieve the best of both worlds, with validation of sessions hitting cache where possible, but revocation being possible within seconds.

Be creative!

When you think about web app security, taking a do-it-yourself approach is often considered bad news, with "rolling your own crypto" perhaps the most terrifying idea that any engineer can come up with.  

But in fact, while there are areas of security best left to robust, battle-tested implementations, how you choose to use authentication and authorization data to control your app can be very much your own decision. Here, we've explored a few ideas, but there are myriad ways that these pieces can fit together as part of your mechanism for making judgements about user rights and permissions.

If you have an interesting one, feel free to tweet @fastly and let us know about it!

Share this post

Ready to get started?

Get in touch or create an account.