secure-keys

How to implement OIDC in a Nextjs (SSR) app

OIDC (or open ID client) is a simple identity layer on top of the OAuth 2.0 protocol. It allows clients to verify the identity of the user based on the authentication performed by an authorization server, as well as to obtain basic profile information about the user in an interoperable and REST-like manner.

Routes and setup

For a small login/logout system, the very necessary APIs we're gonna need are:

RoutePathWhy
Login/api/loginWhere client starts communicating with authorization server (Initial handshake)
Callback/api/callbackA known place by the authorization server for responding the first hand-shake
Logout/api/logoutWhere to tell the authorization server to log out the user

Then we're going to use this amazing NPM package called openid-client which is a server side OpenID Relying Party (RP), implementation for Node.js.

And of course we'll need an authorization server, for this purpose you can use Google developer service described in here for generating necessary keys, secrets and URLs.

All-inclusive route

First thing first, let's create an action.ts file for our three services, something like:

 -pages
    - api
	    - auth
		    - [action].ts

In place of the client_secret, the client app creates a unique string value, code_verifier, which it hashes and encodes as a code_challenge. When the client app initiates the first part of the Authorization Code flow, it sends a hashed code_challenge.

[action].ts

import { Issuer, generators } from 'openid-client';

/* generate code challenge with code verifier */
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);

Note that you can store the code_challenge in session cookies. After generating the code_challenge it's time to initialize the client with the configured Google OAuth 2.0 app secrets, so: [action].ts

import { Issuer, generators } from 'openid-client';
/* generate code challenge with code verifier */
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);

export default async (req, res) => {
  /* check the realm configuration and start a client */
  const issuer = await Issuer.discover('https://accounts.google.com');

  const client = new issuer.Client({
    client_id: 'GET_IT_FROM_GOOGLE',
    client_secret: 'GET_IT_FROM_GOOGLE',
    redirect_uris: ['http://localhost:3000/api/auth/callback'],
    response_types: ['code']
  });

  /* get the main api */
  const {
	  query: { action }
  } = req;
}

Now let's jump onto the login service, here we need to prepare the authorizationUrl with passing through resource, code_challenge, scope and code_challenge_method.

After that we'll redirect user to the prepared auth link, the auth server gets the secrets and tries to redirect user to the provided callback URL including secrets (not accessToken yet).

Not that the default hashing format is SHA-256, but it can be any method you want.

...
/* api route for /api/auth/login */
if (action === 'login') {
  try {
    const googleRedirectLink = client.authorizationUrl({
      resource: 'https://my.api.example.com/resource/32178',
      code_challenge,  // generated in previous steps
      scope: 'DEEFINED_SCOPE',
      code_challenge_method: 'S256',
    });

    res.writeHead(302, {
       Location: googleRedirectLink,
    });
    res.end();

  } catch (e) {
    res.status(500).send('Something broke in login api!');
    res.end();
  }
}
...

After user redirected back to callback service, we need to make another reuqet to get the accessToken, refreshToken, .etc.

/* api route for /api/auth/callback */
if (action === 'callback') {
     const params = client.callbackParams(req);
     const body = `grant_type=${oAuthValues.grant_type}&code=${params.code}&client_id=${oAuthValues.client_id}&code_verifier=${code_verifier}&redirect_uri=${redirect_uri}`;

   /* regular http post request */
   if (params.code) {
		try {
		  /* post method must be in application/x-www-form-urlencoded format */
          await fetch(tokenUrl, {
              method: 'POST',
	          body,
	          headers: {
	              'Content-Type': 'application/x-www-form-urlencoded',
	          },
          })
          .then(response => response.json())
          .then(result => {
            if (result.access_token) {
              /* authorized user with received tokens */
	            }
              redirect(res, '/');
      }
      /* unauthorized user */
      redirect(res, '/api/auth/login');
      });
    } catch (error) {
      res.status(500).send('Something broke in callback api!');
      res.end();
    }
   }
}

Cool, now that the user is logged in they might want to logged out at some point, let's see how we can provide the service for them.

/* api route for /api/auth/logout */
if (action === 'logout') {
  const { cookies } = new Cookies(req.headers.cookie);
  const body = `client_id=${oAuthValues.client_id}&refresh_token=${cookies.refreshToken}`;

 try {
    await fetch(logoutUrl, {
      method: 'POST',
      body,
      headers: {
        Authorization: `Bearer ${cookies.accessToken}`,
       'Content-Type': 'application/x-www-form-urlencoded',
      },
    }).then(response => {
      if (response.status === 204) {
        /* remove tokens here from header and redirect user back to login service*/

        res.writeHead(302, { Location: '/api/auth/login' });
        res.end();
       }
      /* if the logout failed */
      return redirect(res, '/');
    });
  } catch (error) {
    res.status(500).send('Something broke in logout!');
    res.end();
  }
}
Was the article helpful ?
Yess!No
Afsaneh@2022