Implementing the most secure OAuth flow possible

Whilst the documentation is being worked on for OAuth, I would like to share my implementation of the most secure authorization flow (as far as I’m aware). This will not work out of the box - it requires you to implement it how you need it, but it should be enough for you to work out how to implement it in your own app correctly. I’ll also use TypeScript here just because it’s the language I wrote this demo in.

We’re going to use two libraries here: axios and jose. Axios is a web request library, and Jose is a JSON Web Token handling library. We will be using the latter for decoding the returned id_token that you get from the token redemption endpoint, and also to skip using the token introspection and user information endpoints.

To begin with, let’s just set up like so:

import crypto from "node:crypto";

import axios from "axios";
import * as jose from "jose";

const AUTHORIZATION_URL = "https://apis.roblox.com/oauth/v1/authorize";
const CLIENT_ID = "...";
const CLIENT_SECRET = "...";
const REDIRECT_URI = "...";
const SCOPES = "profile openid";

const JWKS = jose.createRemoteJWKSet(new URL("https://apis.roblox.com/oauth/v1/certs"));

const userRequest = new Map<string, { codeVerifier: string; nonce: string; }>();

This is quite the opener, so let me explain what each of these bits are doing.

The constants in SCREAMING_SNAKE_CASE are the values that will not change at all throughout this flow. These are just your app’s information and scopes, plus the authorization URL for convenience.

The JWKS constant is for verifying and decoding the ID token later. Roblox publishes the public keys used to sign this token at this URL to be consumed by clients when verifying the signatures on the tokens to make sure they are legitimately from Roblox. Jose will use these keys as part of its verification and decoding step.

Finally, userRequest is a store for request states. This can be any form of store (e.g., Redis, memcached, …), but a basic in-memory one works here.

Now, let’s generate the authorization URL. This is a surprisingly complex procedure for what seems like a mundane task.

function getAuthorizationURL() {
    const state = crypto.randomBytes(32).toString("hex");
    const nonce = crypto.randomBytes(32).toString("hex");

    const codeVerifier = crypto.randomBytes(32).toString("hex");

    const codeChallenge = crypto
        .createHash("sha256")
        .update(codeVerifier)
        .digest("base64url");

    const params = new URLSearchParams({
        response_type: "code",
        client_id: CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        scope: SCOPES,
        prompt: "login consent select_account",
        state,
        nonce,
        code_challenge: codeChallenge,
        code_challenge_method: "S256"
    });

    userRequest.set(state, { codeVerifier, nonce });
    setTimeout(() => {
        userRequest.delete(state);
    }, 120000);

    return `${AUTHORIZATION_URL}?${params}`;
}

Once again, let’s break this bit down.

const state = crypto.randomBytes(32).toString("hex");
const nonce = crypto.randomBytes(32).toString("hex");

const codeVerifier = crypto.randomBytes(32).toString("hex");

Here, we are generating 3 sets of unique data:

  • state will be put in the URL which will identify this authorization flow.
  • nonce will also be included in the URL, but will be used to identify the final token rather than the request as a whole, since this is passed back when we get the token.
  • codeVerifier is not included in the request raw.

Next up, we generate a “challenge”:

const codeChallenge = crypto
    .createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");

This challenge is generated from the verifier by hashing and encoding it. It is then included with the URL. This is for when we redeem our token later - it’s the piece of data that allows Roblox to know we are the legitimate requester of this authorization since we are the only ones who know what the verifier that generated the challenge is.

After that, we build the URL parameters:

const params = new URLSearchParams({
    response_type: "code",
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: SCOPES,
    prompt: "login consent select_account",
    state,
    nonce,
    code_challenge: codeChallenge,
    code_challenge_method: "S256"
});

Anyone familiar with OAuth should know what these parameters do. The only real unique thing in this set is the code_challenge parameters, which are just telling Roblox what our challenge is and which hashing algorithm has been used.

We then store the verifier and nonce with a timeout:

userRequest.set(state, { codeVerifier, nonce });
setTimeout(() => {
    userRequest.delete(state);
}, 120000);

The timeout prevents people from creating tons of states and abandoning them.

Then, we assemble the URL:

return `${AUTHORIZATION_URL}?${params}`;

And we’re done!

Phew. That was a lot to generate a URL. But we’re only half way there. The real challenge comes from processing the data that Roblox sends back to us, since this requires a lot of validation.

Without further ado, processCallback:

async function processCallback(request: Request) {
    const query = new URLSearchParams(new URL(request.url).searchParams);

    if (query.has("error")) {
        userRequest.delete(query.get("state"));
        return;
    }

    if (!query.has("code") || !query.has("state")) {
        userRequest.delete(query.get("state"));
        return;
    }

    const code = query.get("code");
    const state = query.get("state");

    const requestInfo = userRequest.get(state);
    userRequest.delete(state);

    if (!requestInfo) {
        return;
    }

    const { codeVerifier, nonce } = requestInfo;

    const { data, status } = await axios.post("https://apis.roblox.com/oauth/v1/token", {
        grant_type: "authorization_code",
        client_id: CLIENT_ID
        client_secret: CLIENT_SECRET,
        code,
        code_verifier: codeVerifier
    }, {
        headers: {
            "Content-Type": "application/x-www-form-urlencoded"
        },
        validateStatus: () => false
    });

    if (data.error || status !== 200) {
        return;
    }

    const { userInfo: payload } = await jose.jwtVerify(data.id_token, JWKS, {
        issuer: "https://apis.roblox.com/oauth/",
        audience: CLIENT_ID
    });

    if (userInfo.nonce !== nonce) {
        return;
    }

    return {
        id: userInfo.sub,
        displayName: userInfo.name,
        username: userInfo.preferred_username
    };
}

This function is quite long, but hopefully I can make it a lot less scary.

const query = new URLSearchParams(new URL(request.url).searchParams);

Here, we are just getting the query parameters from the callback URL. If you’re using a router such as Express or Fastify then this will be handled for you and should be in request.query.

We then do some basic error handling:

if (query.has("error")) {
    userRequest.delete(query.get("state"));
    return;
}

If an OAuth request fails, Roblox will send back to us an error string telling us what happened, which can be the user cancelling the request or some other error. You can handle this if you want, but for this example (and the future error handling) we’ll just be returning. Also, we clean up the state (if any is provided) to prevent this specific request from being used again.

if (!query.has("code") || !query.has("state")) {
    userRequest.delete(query.get("state"));
    return;
}

Here, we’re just checking if the code and state are provided. If one or the other isn’t provided, then something is wrong. Once again, we clean up the state if applicable to prevent reuse.

const code = query.get("code");
const state = query.get("code");

const requestInfo = userRequest.get(state);
userRequest.delete(state);

if (!requestInfo) {
    return;
}

const { codeVerifier, nonce } = requestInfo;

Here, once we confirm all the data is available, we pull them into separate variables and then check if the request is actually one that was made by us (from when we generated the URL earlier). If not, we throw the request out. Once everything’s validated, we can get the code verifier and nonce from the request to use later.

That’s the end of the initial error handling! Let’s move on to actually doing something with our data.

const { data, status } = await axios.post("https://apis.roblox.com/oauth/v1/token", {
    grant_type: "authorization_code",
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    code,
    code_verifier: codeVerifier
}, {
    headers: {
        "Content-Type": "application/x-www-form-urlencoded"
    },
    validateStatus: () => false
});

We send off a web request to redeem our token. Notice we have code_verifier here - this is the same verifier that we generated earlier and passed as a challenge. As aforementioned, we send this off to verify that we are the ones who got this code generated and made this authorization start in the first place.

if (data.error || status !== 200) {
    return;
}

If something goes wrong with this process, then we just back out. There’s nothing we can do at this point to recover.

const { payload: userInfo } = await jose.jwtVerify(data.id_token, JWKS, {
    issuer: "https://apis.roblox.com/oauth/",
    audience: CLIENT_ID
});

Now, we verify the returned identifier token using Jose. We use those keys we got from earlier to validate that Roblox signed this token, and we add some options stating that this token should have been issued by Roblox and it should be for us only. This should always pass and if it fails then something is dreadfully wrong.

if (userInfo.nonce !== nonce) {
    return;
}

One last bit of sanity: if the nonce in the token doesn’t match the nonce we specified at the very start, then this token was never meant for this request. This should never happen, but it’s best to implement regardless for security reasons.

return {
    id: userInfo.sub,
    displayName: userInfo.name,
    username: userInfo.preferred_username
};

Finally, we have all the user information we could desire! Huzzah!

The ID token contains the following:

  • sub (the user ID)
  • name and nickname (the user’s display name)
  • preferred_username (the user’s username)
  • created_at (when the user was created)
  • profile (the user’s profile URL)

Which we can now safely conclude belongs to the person that filed this request, because we implemented the most secure OAuth possible. :tada:

12 Likes

This is incredibly useful, thank you so much! I’ve never worked with “challenges” before so being able to see how it works out this way is really helpful.

2 Likes

No clue what this is and what it’s doing on this forum but it looks really good.