DEV Community

ali eltaib
ali eltaib

Posted on

OAuth Simplified: A Hands-On Breakdown

introduction:

hey there, in this blog post I'll try to simplify how OAuth works and break down what actually happens behind the scenes.

so I built a small server and a segment of the client app of which would handle the OAuth request.
I decided to take this approach because I couldn't really pinpoint the attack vectors of OAuth with just the theory of how it works, I needed to build it in order to understand how to break it, anyhow enough with the introduction let's get into it.


so before we start anything let's make sure you guys understand the terminology that will be used add to that I will give you a mental model of the context which we will be implementing the OAuth functionality :

Terminology :

  • The Frontchannel (The User's Browser)

The Frontchannel is like a public courier. When the Auth Server wants to send a code to the Client App, it gives it to the browser (the courier) via a URL redirect.

The Risk: Because the data is in the URL, it's visible in browser history, server logs, and can be intercepted by malicious browser extensions.

Analogy: Sending a postcard. Anyone who handles the postcard can read what's written on the back.

  • The Backchannel (Server-to-Server)

The Backchannel is like a private secure line. Once the Client App has the temporary code, it calls the Auth Server directly over a secure HTTPS connection (using a library like axios or fetch).

The Security: This connection is encrypted. The user never sees the data being exchanged (like the code_verifier or the access_token).

Analogy: A private phone call between two offices. No one on the street knows the conversation is even happening.

In this system, the flow moves between the User's Browser (Frontchannel) and Server-to-Server (Backchannel) to ensure security. Here is the breakdown of the requests in order:

Mental model:

To make it clear, the server I built is a Custom OAuth 2.0 Authorization Server using the PKCE extension.

While Google acts as a "Public Identity Provider" for the whole world, this server is currently a "Private Identity Provider." Here is the exact context where this type of server is used:

1. The "Internal Ecosystem" Context

This is the most common real-world use case. Imagine you are building a company called "TechCorp" that has:

  • A Main API (Resource Server) that holds user data.
  • A Mobile App (iOS/Android).
  • A Web Dashboard (React/SPA).
  • A Desktop Tool.

Instead of writing login logic for each app, you build one Authorization Server (the one used here). All your different apps "Sign in with TechCorp" by talking to this single server. It centralizes your security.

2. The "Third-Party Developer" Context

  • Context: You have a platform (like a CRM or E-commerce engine) and you want outside developers to build "Apps" or "Plugins" for it.

  • Role: You give those developers a client_id, and they use the flow we built to let users "Authorize" their third-party apps to access your platform's data.

Why we used PKCE specifically?

this server is specifically designed for Public Clients. These are apps where the source code is visible to the user (like a Mobile App or a React site).

  • Without PKCE: A hacker could intercept the code from the browser and use it.
  • With PKCE: Even if they steal the code, they can't use it because they don't have the code_verifier hidden inside the app's memory.

uml diagram showing the client_app and server interaction

Step 1: The Setup (Client App Internal)

Before any request is made, the Client App prepares a "secret handshake."

  • Functionality: The client generates a code_verifier (a random string) and a code_challenge (a hash of that string).

  • Purpose: To prove later that the app that started the login is the same one that finishes it.

// Step 1: The Setup (Client App Internal)

// Helper: Generate a random string for PKCE
const generateRandomString = () => crypto.randomBytes(32).toString('hex');

// Helper: Hash the string for PKCE (S256)
const generateCodeChallenge = (verifier) => {
    return crypto.createHash("sha256").update(verifier).digest("base64url");
};
Enter fullscreen mode Exit fullscreen mode

Step 2: The Authorization Request (Frontchannel)

Endpoint: GET http://localhost:4000/authorize

  • The Request: The browser is redirected from the Client to the Auth Server with parameters like response_type ,client_id, redirect_uri, and the code_challenge.

  • Functionality: The Auth Server checks if the client_id exists and if the redirect_uri is on the pre-approved "Allowlist."

  • Storage: The Server generates a temporary authorizationCode and saves the code_challenge in its Map, linked to that code.

The Authorization Request shown in burp

client_app:

// Step 2: The Authorization Request (Frontchannel)
app.get("/login", (req, res) => {

    // 1. Create PKCE Verifier and Challenge
    currentVerifier = generateRandomString();
    console.log("verifier :" + currentVerifier)
    const challenge = generateCodeChallenge(currentVerifier);
    console.log("code challenge :" + challenge)

    // 2. Build the Auth Server URL
    const authUrl = `${AUTH_SERVER_URL}/authorize?` +

        `response_type=code&` +  // specifying the grant type
        `client_id=${CLIENT_ID}&` +
        `redirect_uri=${encodeURIComponent(REDIRECT_URI)}&` +
        `code_challenge=${challenge}&` +
        `code_challenge_method=S256`;

    // 3. Send user to the Auth Server
    res.redirect(authUrl);
});
Enter fullscreen mode Exit fullscreen mode

server:

app.get("/authorize", (req, res) => {

const {
response_type,
client_id,
redirect_uri,
code_challenge, //the hashed code challenge
code_challenge_method // specification of the hash used
} = req.query;

// 1. Validate response type
if (response_type !== "code") {
    return res.status(400).send("Unsupported response_type");

};

// 2. Validate client
const client = clients[client_id];

if (!client) {
    return res.status(400).send("Invalid client_id");
};

// 3. Validate redirect URI
if (!client.redirectUris.includes(redirect_uri)) {
    return res.status(400).send("Invalid redirect_uri"); // checking the redirect uri against the allow list
};

// 4. Enforce PKCE
if (!code_challenge || code_challenge_method !== "S256") {
    return res.status(400).send("PKCE required");
};

// ---- Fake login success ----
const authorizationCode = crypto.randomBytes(32).toString("hex"); // Think of this as a "Claim Ticket" a user gives you. It proves that the user just logged in and gave you permission.
console.log("authorization code :" + authorizationCode + " for client : " + client_id);

authorizationCodes.set(authorizationCode, {
client_id,
redirect_uri,
code_challenge
});

// Redirect back to client

const redirectUrl = `${redirect_uri}?code=${authorizationCode}`;
res.redirect(redirectUrl);

});
Enter fullscreen mode Exit fullscreen mode

side note : so here is a fun fact about the request to app.get("/authorize") so at first I thought we should use the post method here but turned out standard APIs usually use POST for creating data, but the OAuth 2.0 specification (RFC 6749 section 3.1) actually requires the /authorize endpoint to support the GET method for multiple reasons (mainly because it's a redirect) .

Step 3: The Code Delivery (Frontchannel)

Endpoint: GET http://localhost:3000/callback

  • The Request: The Auth Server redirects the user’s browser back to the Client’s callback URL, attaching the code in the URL.

  • Functionality: The Client App catches this code from the URL.

  • Security Note: At this point, the Client has the Code, but it doesn't have a Token yet.

as shown in the response section of the The Authorization Request:

The Authorization Request

at this point we finished the front channel section of the uml diagram :

front channel section

Step 4: The Token Exchange (Backchannel)

Endpoint: POST http://localhost:4000/token

  • The Request: The Client App sends a direct "Backchannel" POST request to the Server containing the code and the original code_verifier.

  • Functionality:

    1. The Server retrieves the saved code_challenge from its Map.
    2. It hashes the code_verifier sent by the client.
    3. If Hash(verifier) === challenge, it proves the request is legitimate.
  • Cleanup: The Server deletes the code from its Map (making it single-use).

app.get("/callback", async (req, res) => {
const { code } = req.query;
if (!code) return res.send("No code received from Auth Server.");

try {

// 4. Exchange the Code for a Token
// We send the 'currentVerifier' that we saved earlier
const response = await axios.post(`${AUTH_SERVER_URL}/token`, {
    grant_type: "authorization_code",
    code: code,
    redirect_uri: REDIRECT_URI,
    client_id: CLIENT_ID,
    code_verifier: currentVerifier
}

// uncomment if you want to see the request using a proxy
// ,{
// proxy: {
// protocol: 'http',
// host: '127.0.0.1',
// port: 8080
// }}
);
const { access_token } = response.data;
Enter fullscreen mode Exit fullscreen mode

*side note: some of you might be wondering why are we sending different grant_type parameters (response_type, grant_type) so here is an explanation of the difference :

  1. response_type: Tells the server what to send back to the user's browser (a "code" or a "token").

  2. grant_type: Tells the server what credentials the Client App is presenting to the private API (an "authorization_code", a "password", etc.).

the request won't normally show because it is not supposed to (we don't want the verifier to show ), we used axios as shown in the code to make the call (which creates a direct TCP connection from the terminal process to port 4000.)

The Backchannel request

Step 5: The Response (Backchannel)

Response: 200 OK { "access_token": "..." }

  • Functionality: The Server sends the access_token back to the Client.

  • Result: The Client App now has a valid token to make API requests, and the user is officially "logged in."

as shown in the response section:

Access Token

here is link to the full code : github.com/aligotmelody/Oauth_lab

Top comments (0)