Hi Wolfpack! I hope you’re all staying cool during this absolutely mind-boggling summer weekend. This post serves as a recap for the past two sessions, both tracking the story arc of providing fan-out authorization by building our Authorization Gateway.

Fan-Out What?

Even for seasoned developers, fan-out authorization flows are extremely uncommon, mostly because the scopes of an authorized user’s token is usually sufficient to derive access, and if differing tokens between multiple services is needed, a few roundtrips with the authorization server is sufficient to check consents (or prompt for them) and issue relevant tokens. This hits a few limitations when you have specific scenarios that an auth server may not allow:

  • Dynamic, or arbitrarily large numbers of scopes (Cognito, for example, is limited to 50 scopes per client app, 100 per resource server, and dynamic generation is effectively impossible)

  • Dynamic audience targeting within same client app, oftentimes is 1:1 to the client id

  • As an addendum to the previous scenario: differing redirect uris equally requires client id separation

At this point, many would reach for an all-in-one solution like IdentityServer4 and implement authentication, authorization, and federated-server-specific authorization flows all under a series of client apps and scopes. IdentityServer4 is a great solution, but not without its own costs: notably, it does not work with any reasonable degree of performance when hosted as a Lambda (or App Function) due to its long cold start times, and addressing aggressive scale up/down scenarios with ECS and ASGs gets expensive, quickly. Cognito, on the other hand, keeps its costs associated to very simple metrics: Monthly Active Users (MAUs), and the first 50,000 users are free, and an intermediary lightweight web service running on Lambda is equally quite cheap and scalable. Really hard to argue with that.

All that aside, for most authorization scenarios, these limits and flow restrictions are not only reasonable, they are fundamental to ensuring security — allowing arbitrary redirect uris for a client is absolutely a vulnerability, unless there are mutually authenticated flows that establish those redirect uris, and the token is appropriately delegated, such as via audience, which brings us into fan-out authorization.

Fan-Out Authorization

Howler has a unique problem to solve in allowing simple server federation without federated server owners having to set up their own auth server, and also in making the authentication flow seamless for the end users: Join a space on a server – any federated server using our auth services, and the client will negotiate authentication with the gateway service. Logically, the flow looks like this:

Fan-Out Sequence Diagram

From A to AuthZ

This section assumes familiarity with the structure of JSON Web Tokens. An earlier session went over this, and when it is re-added to this site, this note will link to it. In the meantime, the tutorial at JWT.io is a great overview.

From the client-facing side of things, we need only implement a single endpoint. Consume an valid, authorized (via the Cognito auth service) user’s token with Gateway scope, replace the issuer with the gateway service, the audience with the id of the intended federated server, and the scope with the same identifier, adjusting issue/expiration time accordingly, and sign with the gateway service’s private key.

[HttpPost("auth")]
[ProducesResponseType(typeof(string), 200)]
[ProducesResponseType(404)]
public IActionResult Post(string serverId)
{
    var tokens = this.HttpContext.Request.Headers["Authorization"];

    if (tokens.Count != 1)
    {
        // Attempt at pushing multiple tokens, kick em out.
        return this.Forbid();
    }

    var token = tokens.First().Split(' ').Last();
    var jwt = new Microsoft.IdentityModel.JsonWebTokens
        .JsonWebToken(token);
    var validatedServerId = this._federatedDb.Servers
        .Where(s => s.ServerId == serverId)
        .Select(s => s.ServerId)
        .ToList()
        .FirstOrDefault();

    if (validatedServerId == null)
    {
        return this.NotFound();
    }

    // TODO: Whitelist/blacklist

    return this.Ok(
        new GatewayJWT(
            new GatewayJWTHeader(jwt.Kid),
            new GatewayJWTBody(
                jwt.Subject,
                jwt.GetClaim("device_key").Value,
                jwt.GetClaim("event_id").Value,
                serverId,
                long.Parse(jwt.GetClaim("auth_time").Value),
                "https://gateway.howler.chat",
                DateTimeOffset.UtcNow.AddHours(2).ToUnixTimeSeconds(),
                jwt.GetClaim("jti").Value,
                jwt.GetClaim("client_id").Value,
                jwt.GetClaim("username").Value),
            this._signingAlgorithm).ToString());
}

As you can see, it’s pretty straightforward – we abstract away the signing algorithm and structural interpretation of the JWT to relevant classes, check that the server is in our roster (and as the TODO notes, the user or server is not on our blacklist or bypass if the user is on the whitelist). Ultimately this gives us a perfectly-valid access token that a federated server should be configured to accept:

// ... in the Startup.cs ConfigureServices method:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(x =>
    {
        x.Authority = "https://gateway.howler.chat";
        x.Audience = "id-of-federated-server";
        x.RequireHttpsMetadata = true;
        x.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = true,
            ValidateIssuer = true,
            ValidateIssuerSigningKey = true,
        };
    });

Can it really be that easy? There’s a little bit of magic being glossed over here: OpenID Discovery.

Self-discovery of Auth Servers

ASP.NET authorization support lets you specify an authority, and the service will request the well-known configuration info, which is where some blatant standards-breaking is happening:

GET https://gateway.howler.chat/.well-known/openid-configuration

{
    "authorization_endpoint": "https://auth.howler.chat/oauth2/authorize",
    "id_token_signing_alg_values_supported": [
        "RS256"
    ],
    "issuer": "https://gateway.howler.chat",
    "jwks_uri": "https://gateway.howler.chat/.well-known/jwks.json",
    "response_types_supported": [
        "code",
        "token"
    ],
    "scopes_supported": [
        "openid",
        "email",
        "phone",
        "profile"
    ],
    "subject_types_supported": [
        "public"
    ],
    "token_endpoint": "https://auth.howler.chat/oauth2/token",
    "token_endpoint_auth_methods_supported": [
        "client_secret_basic",
        "client_secret_post"
    ],
    "userinfo_endpoint": "https://auth.howler.chat/oauth2/userInfo"
}

What’s so unusual about this response? For starters, all the actual OAuth2 endpoints are still the main auth server, but the two key differences are important for self-discovery: the issuer and jwks_uri. Both are used by the remaining part of the self-discovery process of ASP.NET:

GET https://gateway.howler.chat/.well-known/jwks.json

{
    "keys": [
        {
            "alg": "RS256",
            "e": "AQAB",
            "kid": "M9U+QO1DuXQHdNgt3BfZmVa04+q4PMF4raal0/Kc6sA=",
            "kty": "RSA",
            "n": "0Mf8RSwPcMfsH2Xvu7MAqTFSOLV....nqjIceBnkOQ6_5ec4DRovow",
            "use": "sig"
        }
    ]
}

These are not the actual values as this service is not yet deployed. Stay tuned for the next session!

This endpoint returns the public keys, that ASP.NET uses for validation of the signature component of the JWT.

Therefore to complete the loop, we must provide two actions to surface the .well-known paths:

/// <summary>
/// Retrieves openid-configuration info.
/// </summary>
/// <returns>
/// Returns openid-configuration info.
/// </returns>
[HttpGet("openid-configuration")]
[ProducesResponseType(typeof(OpenIDConfigurationInfo), 200)]
public OpenIDConfigurationInfo Config()
{
    return new OpenIDConfigurationInfo();
}

/// <summary>
/// Retrieves JWKS info.
/// </summary>
/// <returns>
/// Returns JWKS info.
/// </returns>
[HttpGet("jwks.json")]
[ProducesResponseType(typeof(JWKSInfo), 200)]
public JWKSInfo JWKS()
{
    return new JWKSInfo();
}

The two types OpenIDConfigurationInfo and JWKSInfo simply format the responses in the expected formats above. And like that: you now have a basic fan-out authorization gateway!

Cassandra Heart

Cassie Heart is the creator of Code Wolfpack, BDFL of Howler, The Bit with a Byte, Resident Insomniac.