Authenticating users via Google OAuth 2.0 on the Edge using Nitro by UnJS and Deno Land
Tags: webdev, javascript, programming, tutorial
Published: 2023-11-14 11:50:15

In this guide, we'll explore a streamlined approach to authenticating users via Google OAuth 2.0 in Nitro by UnJS, powered by Deno Land. By the end, you'll have a solid foundation for implementing Google User Authentication on the Edge.

## Essential Functions for Google OAuth 2.0

### Parsing and Signing Keys

The following functions play a crucial role in parsing and signing keys.

```typescript
// File: utils.ts

import { createHash, createHmac } from 'https://deno.land/std@0.177.0/node/crypto.ts'

// Generates a SHA-256 hash from the provided key using the Node.js crypto library.
export function parseKey(key) {
  return createHash('sha256').update(key).digest()
}

// Creates a SHA-256 HMAC signature for the given data using the provided secret key.
// The resulting signature is then encoded in base64 URL format.
export function sign(data, secret) {
  const key = parseKey(secret)
  const hmac = createHmac('sha256', key)
  hmac.update(data)
  const signature = hmac.digest('base64')
  return base64UrlEncode(signature)
}

// Transforms a base64-encoded string into a URL-safe format.
// It replaces characters to adhere to URL encoding standards,
// converting + to -, / to _, and removing trailing equal signs (=).
export function base64UrlEncode(str) {
  const base64 = btoa(str)
  return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
```

### Generating and Decoding JSON Web Tokens (JWTs)

The following functions play a crucial role in generating and decoding JSON Web Tokens (JWTs).

```typescript
// File: utils.ts

// Reverses the URL-safe encoding process by converting a 
// base64 URL-encoded string back to its original base64 format.
// It replaces URL-safe characters (- to +, _ to /) and 
// handles padding to ensure the correct length before decoding using atob.
export function base64UrlDecode(str) {
  const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
  const padding = base64.length % 4 === 0 ? 0 : 4 - (base64.length % 4)
  const paddedBase64 = base64 + '==='.slice(0, padding)
  return atob(paddedBase64)
}

// Takes a JWT (JSON Web Token) as input, splits it into encoded header 
// and payload segments, and then decodes each segment using base64 URL decoding.
// The resulting decoded header and payload are parsed as JSON objects,
// and the function returns an object containing both.
export function decodeJWT(token) {
  const [encodedHeader, encodedPayload] = token.split('.')
  const header = JSON.parse(base64UrlDecode(encodedHeader))
  const payload = JSON.parse(base64UrlDecode(encodedPayload))
  return { header, payload }
}

// Transforms a base64-encoded string into a URL-safe format.
// It replaces characters to adhere to URL encoding standards,
// converting + to -, / to _, and removing trailing equal signs (=).
export function base64UrlEncode(str) {
  const base64 = btoa(str)
  return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

// Constructs a JSON Web Token (JWT) by encoding the header and payload using base64 URL encoding.
// Then, signs the concatenated header and payload with the provided secret key using the sign function.
// The final JWT is formed by combining the encoded header, payload, and signature.
export function generateJWT(payload, secret, expiresIn) {
  const header = { alg: 'HS256', typ: 'JWT' }
  const encodedHeader = base64UrlEncode(JSON.stringify(header))
  const encodedPayload = base64UrlEncode(payload)
  const signature = sign(`${encodedHeader}.${encodedPayload}`, secret)
  return `${encodedHeader}.${encodedPayload}.${signature}`
}
```

### Handling Cookies for User Authentication

The following function plays a crucial role in parsing `Cookies` and returning the one named `cookieName`.

```typescript
// File: utils.ts

// Extracts the value of a specific cookie from a provided cookie header.
// It splits the cookie header into individual cookies, iterates through them,
// and returns the value of the specified cookie if found.
// If the cookie is not present, it returns null.
export function parseCookie(cookieHeader, cookieName) {
  if (!cookieHeader) return null
  const cookies = cookieHeader.split(';')
  for (const cookie of cookies) {
    const [name, value] = cookie.split('=')
    if (name.trim() === cookieName) {
      return value.trim()
    }
  }
  return null
}
```

## Implementing Google OAuth Authentication in Nitro Routes

For all the routes below, we need to remember that `custom_auth` is the cookie name which contains a signed key with the user data obtained from Google.

### Home Route Logic (`routes/index.ts`)

On the homepage we show the data stored in the cookie (`custom_auth`) by decoding the JSON Web Token. In case no auth is found, we return an empty object.

This comes handy in testing as soon as user is authenticated with the rest of the flow.

```javascript
// File: routes/index.ts

import { decodeJWT, parseCookie } from '../utils'

export default defineEventHandler((event) => {
  // Get the cookie header in Nitro
  const cookieHeader = event.headers.get('Cookie')
  // Parse the custom_auth cookie to get the user auth values (if logged in)
  const cookie = parseCookie(cookieHeader, 'custom_auth')
  if (cookie) {
    const decodedToken = decodeJWT(cookie)
    // Just for demonstration purposes
    if (decodedToken) return event.respondWith(new Response(JSON.stringify(decodedToken), { headers: { 'Content-Type': 'application/json' } }))
  }
  return event.respondWith(new Response(JSON.stringify({}), { headers: { 'Content-Type': 'application/json' } }))
})
```

### Google OAuth Initiation Logic (`routes/auth/google.ts`)

On the page, `/auth/google`, we want to redirect users to Google login screen. To do that, we generate the authorization url which will in turn will call the `CALLBACK_URL` once user authenticates.

```javascript
// File: routes/auth/google.ts

export default defineEventHandler((event) => {
  const authorizationUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth')
  // Get the Google Client ID from the env
  authorizationUrl.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID)
  // Add your own callback URL
  authorizationUrl.searchParams.set('redirect_uri', process.env.GOOGLE_CLIENT_CALLBACK_URL)
  authorizationUrl.searchParams.set('prompt', 'consent')
  authorizationUrl.searchParams.set('response_type', 'code')
  authorizationUrl.searchParams.set('scope', 'openid email profile')
  authorizationUrl.searchParams.set('access_type', 'offline')
  // Redirect the user to Google Login
  return event.respondWith(
    new Response(null, {
      status: 302,
      headers: {
        Location: authorizationUrl.toString(),
      },
    }),
  )
})
```

### Google Callback After Google Authentication (`routes/auth/callback/google.ts`)

On the page, `/auth/callback/google`, we want to fetch the authenticated user information and secure it in the cookie `custom_auth`. To do that, in this `callback` handler, we obtain a token from google to fetch user info, generate a JWT, and finally set the cookie, `custom_auth`.

Once all this is done, we redirect the user back to the home page.

```javascript
// File: routes/auth/callback/google.ts

import { generateJWT } from '../../../utils'

export default defineEventHandler(async (event) => {
  const code = new URL(event.node.req.url, 'https://a.b').searchParams.get('code')
  if (!code) return event.respondWith(new Response())
  try {
    const tokenEndpoint = new URL('https://accounts.google.com/o/oauth2/token')
    tokenEndpoint.searchParams.set('code', code)
    tokenEndpoint.searchParams.set('grant_type', 'authorization_code')
    // Get the Google Client ID from the env
    tokenEndpoint.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID)
    // Get the Google Secret from the env
    tokenEndpoint.searchParams.set('client_secret', process.env.GOOGLE_CLIENT_SECRET)
    // Add your own callback URL
    tokenEndpoint.searchParams.set('redirect_uri', process.env.GOOGLE_CLIENT_CALLBACK_URL)
    const tokenResponse = await fetch(tokenEndpoint.origin + tokenEndpoint.pathname, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: tokenEndpoint.searchParams.toString(),
    })
    const tokenData = await tokenResponse.json()
    // Get the access_token from the Token fetch response
    const accessToken = tokenData.access_token
    const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    })
    // Get user info via that fetched access_token
    const userInfo = await userInfoResponse.json()
    // Destructure email, name, picture from the users' Google Account Info
    const { email, name, picture } = userInfo
    const tokenPayload = JSON.stringify({ email, name, picture })
    // Create a Cookie for the payload, i.e. user info as above
    // Set the expiration to say 1 hour
    const cookie = generateJWT(tokenPayload, 'My Secret Variable', '1h')
    return event.respondWith(
      new Response(null, {
        status: 302,
        headers: {
          Location: '/',
          // This is the key here, place the cookie in the browser
          'Set-Cookie': `custom_auth=${cookie}; Path=/; HttpOnly`,
        },
      }),
    )
  } catch (error) {
    console.error('Error fetching user info:', error)
  }
})
```

## Deployment and Live Demo

All done, let's deploy our app now!

### Deploying to Deno Land

To deploy your Nitro project to Deno Land, follow these steps:

```bash
# Build the Nitro project for Deno
NITRO_PRESET=deno_deploy npm run build

# Change the CLI to the output by Nitro
cd .output

# Deploy to Deno Land
deployctl deploy --project=auth2 server/index.ts --prod
```

## Live Demo

Explore the live example at [auth2.deno.dev](https://auth2.deno.dev), and witness the Google OAuth 2.0 authentication in action.