Learn OpenID Connect (OIDC) by Building a Client

Alex Yakubovsky

In our previous guide, you learned about the OAuth protocol by implementing a minimal Login with Google flow. Although OAuth facilitates access control, it isn't designed for authentication, as it doesn't provide a standard method to retrieve user information. Usually, each OAuth provider (like Google or GitHub) exposes a unique endpoint that your server can use to fetch user details (after the user granted permission). This requires you to go through each provider's documentation and build a custom implementation.

OIDC standardizes the way for your website to retrieve information about users from OAuth providers. In this guide you'll learn about OIDC by:

  • Updating the previous code, using Typescript, Node.js and Express, to use OIDC for user authentication

  • Learning the minimal changes required to support any OIDC complaint provider

OAuth Refresher

Before diving into OIDC, here's a quick recap on the OAuth flow using Google as the example provider. Some details are simplified for brevity. Initially, you need to register your application with Google to obtain a client ID and a secret key.

  1. User Initiation: A user clicks the Login with Google button on your website. Your website redirects the user to Google's authorization page, where it passes:

    • client_id: to identify your application

    • scope: specifies the information your application will requests about the user

  2. User Consent: After the user agrees to share their information, Google redirects them back to your site via a specified callback URL. Google appends an authorization code to the query parameters of this URL.

  3. Code Exchange: Your server takes the authorization code and sends it back to Google, along with your secret key. In response, Google sends your server an access token.

  4. Information Request: Your server uses this access token to request user details, such as their email address. If a user with that email exists in your database, your server creates a new user session.

  5. Session Creation: Your server sets the user's session (typically using a Set-Cookie header) and redirects the user to a page that requires authentication.

Introduction to OIDC

OIDC builds on the OAuth 2.0 protocol by introducing two additions:

  1. ID Token: In the process of exchanging an authorization code for an access token, if "openid" is included in the scope parameter, Google also returns an ID token. This ID token is a JSON Web Token (JWT) that contains authenticated user information. You will explore JWTs in more detail shortly.

  2. Standardized User Information Endpoint: Google and other OIDC-compliant providers expose a /userinfo endpoint, allowing your application to read fresh user information. This standardization removes the need for custom implementations to gather user information for each OAuth provider.

JWT Overview

A JSON Web Token (JWT) is a compact, URL-safe string that, when decoded, converts into JSON. It carries a couple of additional, useful properties:

  1. Issuer Verification: JWTs allow your application to cryptographically verify the issuer of the token. This means you can confirm that the JWT was indeed issued by Google and not fabricated by a potential attacker.

  2. Integrity Verification: It is possible to ensure that the JWT has not been tampered with. This cryptographic verification confirms that the content within the decoded JSON remains unchanged by any potential attackers.

While the detailed implementation of JWTs is beyond the scope of this guide, those interested in a deeper dive into how JWTs work can check out this excellent article on JWTs.

With a review of OAuth and an introduction to OIDC and JWTs under your belt, you're now ready to dive into the code! You'll follow the user journey, beginning at a page where the user is presented with a Login with Google button.

User Chooses to Log in with Google

The OIDC authentication flow starts when the user clicks a Login with Google link styled as a simple <a> element. This directs the user's browser to your server's /oauth/google endpoint.

Redirecting the User to Google for User Consent

The Express application attaches a handler to the GET /oauth/google endpoint. Here, your server constructs the URL to redirect the user to Google’s consent screen. While the previous guide detailed the OAuth-specific parameters, this guide will focus on the new OIDC specific bits.

The first change is setting the scope parameter to include "openid". This tells Google that your application expects to receive an ID token along with the access token during the token exchange step. Additionally, you can append "email" and "profile" to the scope. This ensures that the JSON inside the ID token includes details such as the user's email and name.

The next step is to safeguard the integrity of the ID token generated by Google, ensuring it corresponds to the current user's session. Why? Consider this scenario:

  1. Server Hiccup: After Google redirects back to your server with an authorization code, your server experiences downtime and exchange the authorization code is not exchanged.

  2. URL Exposure: An attacker obtains the full URL to which Google redirected to (possibly from leaked HTTP logs) and enters this URL into their own browser.

  3. Unauthorized Access: The authentication process resumes, allowing the attacker to log in as the legitimate user.

To prevent such attacks, your application generates a unique, unguessable token known as a nonce and include a hash of this nonce as a query parameter when redirecting the user to Google's auth screen. According to OIDC specifications, if a nonce is sent to the provider's consent screen, it must be included in the JSON within the ID token issued by Google. Validating that the returned nonce matches the original ensures the request was not manipulated by an attacker. More on the validation later on.

Store the unhashed nonce in the browser's cookies. Later, your server will retrieve this nonce from the cookie and compare it against the nonce returned in the ID token.

Finally, just as in the basic OAuth flow, redirect the user to Google's consent screen. Here, the user will confirm to share their name and email with your application.

Google Asks User for Consent

At the consent screen, Google displays the user's available accounts (if they have multiple) and prompts them to select one. The screen also confirms with the user that they're willing to share their email and name with your application. After the user consents, they are redirected to your specified callback URL, in this case being http://localhost:3000/oauth/google/callback.

Redeeming the Access Token and Id Token

The user's browser is redirected to http://localhost:3000/oauth/google/callback . Here, you exchange the authorization code returned for an access token and an ID token. The guide will focus on just new parts introduced by OIDC.

Retrieve the code (the authorization code) and state from the query parameters. For a detailed explanation of the state parameter, refer to the previous guide.

You'll implement the exchangeCodeForToken function which lets you obtain both an accessToken and an idToken.

The exchangeCodeForToken function exchanges the authorization code for both an access token and an ID token.

To exchange the authorization code, your server makes a POST request to the provider's exchange endpoint. For Google, this endpoint is https://oauth2.googleapis.com/token. This step is the same as in the standard OAuth flow.

Because you included the "openid" scope earlier, Google's response will contain an ID token. However, before your server can trust this ID token, it must perform validation, which you'll implement next.

Finally, return the the accessToken and idToken to the caller.

Validating the ID Token

It's a best practice to validate that a JWT token came from the expected source and has not been tampered with by an attacker.

To cryptographically confirm that a token originated from the expected provider, the provider must host their public keys in a JSON Web Key Set (JWKS) on a domain they control. For example, Google hosts their public keys at https://www.googleapis.com/oauth2/v3/certs.

The high-level idea is that Google uses a secret key to sign its JWTs. Only Google has access to this secret key; if others had access, they could forge JWTs to appear as though they came from Google. Using their secret key, Google generates public keys. Your application can cryptographically verify that the entity possessing the secret key (Google) used to generate the public key also used the same secret key to sign the JWT.

You'll use the popular jose library for JWT validation. First, use createRemoteJWKSet to prepare the keyset. Note that the keyset is not actually downloaded until the first JWT validation attempt is made. Afterwards, the keys are cached and periodically re-fetched in case Google issues new public keys.

Returning to the handler for /oauth/google/callback, your server retrieves the previously set nonce from the cookie. If the nonce is missing, the request is aborted. Next, you'll implement the verifyIdToken function to verify the authenticity of the ID token.

The verifyIdToken function accepts two parameters: the idToken and the non-hashed nonce string.

Your application uses the jwtVerify function from the jose to cryptographically verify the authenticity of the ID token. The function requires three parameters:

  • idToken: The ID token to be verified

  • JWKS: The JSON Web Key Set used for verification

  • The third parameter allows your application to make additional assertions about the ID token, specifically:

    • issuer: Asserts who is the issuer of the JWT. Here you assert that the issuer is https://accounts.google.com

    • audience: Asserts that the JWT was intended for your application. This helps prevent misuse of an ID token that might have been leaked from another service but also issued by Google

    • maxTokenAge: Specifies that the token should not be older than 5 minutes, adhering to best practices of only accepting fresh tokens

Once the ID token is verified a payload is returned. The payload is JSON with fields that describe the user and information about the token itself. It might looks something like

{
  "iss": "https://accounts.google.com",
  "aud": "<your client id>",
  "iat": <timestamp when the token was issued at>,
  "email": "<the user's email>",
  "name": "<the user's name">,
  "nonce": "<the nonce value you passed earlier>"
  ...
}

The OIDC standard requires that the nonce used during the initial redirect to Google's authentication screen be included in the JSON payload when decoding the ID token. Recall that your server stores the plaintext nonce securely as an httpOnly cookie in the user's browser while sending the hash of this nonce to Google.

Your application compares the nonce retrieved from the user's cookie, hashes it, and then compares this hash to the nonce hash stored in the JWT. If the hashes match, it confirms that the authentication flow was indeed initiated by the current user session.

Lastly, return the payload to the caller

verifyIdToken returns null when the nonce values did not align. If that's the case, your server aborts the authentication flow and returns an HTTP error code.

After verification, your server instructs the user's browser to discard the stored nonce by setting Max-Age=0 on the cookie. In this demo, your server simply returns the ID token's payload to the user.

In a real-world application, you might check your database to see if there is a user with an email address that matches jwtPayload.email. If a match is found, create a session token for that user and return it to the frontend application.

Voila, you've Login with Google for your application! Below are some additional details about OIDC, and how it compares to OAuth.

Flow Types

Your server implements what is known as the authorization code flow. This flow is named so because after the consent screen, Google redirects the user to your website with an authorization code, which your server then exchanges for an access token and an ID token. This is the most common and secure type of flow, suitable for applications that include a server.

For those interested in exploring other types of OAuth flows, here's a great article with diagrams illustrating the different flows.

Comparing with OAuth

In this demo, you integrated with Google as the provider. The same flow can be applied to other providers, such as Microsoft, by simply changing the JWKS URL (public keys), CLIENT_ID, CLIENT_SECRET, and the issuer assertion in verifyIdToken.

By contrast, implementing an authentication flow using just OAuth would require you to sift through each provider's documentation to determine how to retrieve user information like an email, identify the necessary scopes for access, and ultimately develop a separate implementation for each provider.

The general rule of thumb is to use OIDC for authentication (i.e., determining who the user is) and to use OAuth for delegated authorization (e.g., sending an email on the user's behalf).