Dejan Lukić

Creating Authorized Routes using OAuth2 in Deno

Originally written for Deno.

Concepts

Overview

oauth2_client is an OAuth2 client for Deno. OAuth2 itself is a standard allowing an application to access resources hosted by other web apps on behalf of a user. It is the golden standard for online authorization.

OAuth2 Client Setup

Start by importing the dependencies. Import MemoryStore and Session from oak_sessions.

main.ts

import {
	MemoryStore,
	Session,
} from 'https://deno.land/x/oak_sessions@v4.0.5/mod.ts';

Then, import OAuth2Client from oauth2_client.

import { OAuth2Client } from 'https://deno.land/x/oauth2_client/mod.ts';

To handle environment variables, you will use dotenv from the std library.

import { load } from 'https://deno.land/std@0.177.1/dotenv/mod.ts';

Load the environment variables by providing the envPath to the load() method.

const env = await load({
	envPath: '.env',
});

Next, instantiate a new OAuth2Client with the following configuration. In this exmaple, you will use GitHub’s OAuth2 provider.

export const oauth2Client = new OAuth2Client({
	clientId: env['CLIENT_ID'],
	clientSecret: env['CLIENT_SECRET'],
	authorizationEndpointUri: 'https://github.com/login/oauth/authorize',
	tokenUri: 'https://github.com/login/oauth/access_token',
	redirectUri: 'http://localhost:8000/oauth2/callback',
	defaults: {
		scope: 'read:user',
	},
});

To set up the OAuth2 client, you will need the client ID, client secret, authorization endpoint, token URL and redirect URL.

See GitHub’s documentation for OAuth2 on how to create the OAuth2 app.

Create a new type AppState that will be passed in the application as a generic type, containing the session object.

export type AppState = {
	session: Session;
};

Update the app constructor with the newly defined type.

const app = new Application<AppState>();

Create a new session store with the MemoryStore class. MemoryStore is a class from used to store sessions in memory

const sessionStore = new MemoryStore();

Storing session data in-memory shouldn’t be necessarily used in production. It’s good for testing. You can use other storage layers in production, like cookies, SQLite and others.

Next, add a secret key to the app.keys array, which will be used to compute the session hash.

app.keys = ['super-secret-key'];

Now, provide the session middleware to the application by using Session.initMiddleware() method.

app.use(
	Session.initMiddleware(sessionStore, {
		cookieSetOptions: {
			httpOnly: true,
			sameSite: 'lax',
			// Enable for when running outside of localhost
			// secure: true,
			signed: true,
		},
		cookieGetOptions: {
			signed: true,
		},
		expireAfterSeconds: 60 * 10,
	})
);

The initMiddleware() method takes the above defined sessionStore which provides the middleware with current session data. The following provided object contains the session options.

Authentication Middleware

Now, you will create an authentication middleware that will be passed to every protected route (for example, a route that requires the user to be logged in).

Start by creating a new folder utils and inside of it create a new file auth.ts.

utils/auth.ts

Import the Context to provide type safety to the function.

import { Context } from "https://deno.land/x/oak@v11.1.0/context.ts";

Create a new asynchronous function authenticate.

export const authenticate = async (ctx: Context, next: Function) => {
	if (!ctx.state.session.get('user')) {
		ctx.response.redirect('/login');
		return;
	}
	await next();
};

The function will check if the user isn’t provided within the session, and if so, they will be redirected to the login page, otherwise they are allowed to access the route.

Routes

Now, you will implement the route logic for the authentication with the OAuth2 client.

Login route

Start by importing the dependencies you defined previously in the main file.

routes/index.ts

import { AppState, oauth2Client } from '../main.ts';
import { create } from "https://deno.land/x/djwt@v2.9/mod.ts"

Continue to create the /login route. The route will be unprotected, as it doesn’t allow the user to view the sensitive data.

router.get('/login', async (ctx: Context) => {
		// Construct the URL for the authorization redirect and get a PKCE codeVerifier
		const { uri, codeVerifier } = await oauth2Client.code.getAuthorizationUri();

		// Store both the state and codeVerifier in the user session
		ctx.state.session.flash('codeVerifier', codeVerifier);

		// Redirect the user to the authorization endpoint
		ctx.response.redirect(uri);
	})

oauth2client.code.getAuthorizationUri() method constructs the URL for the authorization redirect from the provided config in the oauth2client and generates a Proof Key for Code Exchange (PKCE) codeVerifier.

PKCE is an extension to the OAuth 2.0 protocol that provides a method to secure authorization codes in public clients, typically single-page apps or mobile apps, where the client secret can't be safely stored. It mitigates the risk of an authorization code interception attack by introducing a one-time secret created by the calling application and sent in the authorization request, which is verified by the authorization server when the authorization code is exchanged for an access token.

The constructed URL will point to the OAuth2 provider's authorization endpoint, and it will include parameters like the client ID, requested scopes, and a generated state value for CSRF protection.

Then, codeVerifier is stored in the user’s session using the ctx.state.session.flash() method. This is done so that it can be retrieved later when the authorization code is exchanged for an access token.

Finally, the user is redirected to the authorization endpoint using ctx.response.redirect() method

Callback route

In OAuth2, a callback (also known as a redirect URI or callback URL) is an important component of the authorization process. When a user wants to grant access to their protected resources (such as personal data or services) to a third-party application, the callback URL is used to redirect the user back to the application after they have authorized the access.

router.get('/oauth2/callback', async (ctx: Context) => {
		// Make sure the codeVerifier is present for the user's session
		const codeVerifier = ctx.state.session.get('codeVerifier');
		if (typeof codeVerifier !== 'string') {
			throw new Error('invalid codeVerifier');
		}

		// Exchange the authorization code for an access token
		const tokens = await oauth2Client.code.getToken(ctx.request.url, {
			codeVerifier,
		});

		// Use the access token to make an authenticated API request
		const userResponse = await fetch('https://api.github.com/user', {
			headers: {
				Authorization: `Bearer ${tokens.accessToken}`,
			},
		});
		const key: CryptoKey = await crypto.subtle.generateKey(
				{
					name: "HMAC",
					hash: "SHA-256",
				},
				true,
				["sign", "verify"],
		);
		const { login } = await userResponse.json();
		const jwt = await create(
			{
				alg: 'HS512',
				typ: 'JWT',
			},
			{
				user: login,
				token: tokens.accessToken,
			},
			key
		);
		ctx.response.redirect(`http://localhost:8000?code=${jwt}`);
	})

The function retireves the codeVerifer from the user's session. If the codeVerifier is not a string, an error is thrown.

The function then exchanges the authorization code (which is included in the redirect URL) for an access token by calling oauth2Client.code.getToken(). The codeVerifier is included in this request to prove that this is the same application that initiated the authorization request.

The returned tokens object includes the access token and possibly a refresh token, depending on the OAuth2 provider's implementation.

The function then makes an authenticated API request to 'https://api.github.com/user' using the access token. This could be a request to any API that the access token has been authorized to access.

The response from the API request is parsed as JSON, and the login property (the user's GitHub username) is extracted.

A JSON Web Token (JWT) is then created using the login and the access token. This JWT can be used to authenticate the user in the application.

Finally, the user is redirected to 'http://localhost:5173' with the JWT included as a query parameter. This could be a redirection to any page in the application where the JWT can be extracted and used for authentication.

Test the demo

To test the login, navigate to http://localhost:8000/login in your browser. You will be redirected to the OAuth2 provider, and upon successful authorization, you’ll be redirected to the http://localhost:5173 with the authorization code.

#api #deno #oauth2