OAuth2 PKCE With Rails 7, React/Redux and esbuild

Published: Jun 5, 2023

Logging in to a web site is what users do quite a lot. Suppose it is a blog site. Once a user completes a log-in process, the user is allowed to create a new post, update contents and delete a post. The blog site might have a feature to leave comments by logged in users.

The login process is categorized roughly two types: use the login feature implemented in the web site or the social login such as Twitter, GitHub, Google, etc. For a Rails app, implementing a local login feature is not difficult since it can use an established gem called devise. The Rails app has a choice to use a gem called OmniAuth for the social login if the front-end is a legacy, server rendered app.

However, we see a lot of rich clients built by ReactJS, Angular, Vue.js or such. The client app is, sometime, called SPA (Single-Page Application). Such sort of client app initiates the login process itself. The social login becomes complicated. The problem of the social login by SPA is that the app ends up revealing a social login secret parameter.

The Authorization Code Flow, which is a commonly used grant type defined in OAuth2, uses the client id and client secret provided by the authorization endpoint (Twitter, GitHub, Google, etc). From a nature of JavaScript code, the client secret is mixed in to a bundled JavaScript code as is. It might be minified, so very hard to find. Even though, a reverse engineering would identify the client secret.

To solve such potentially risky issue, the Authorization Code Flow had an extension called Proof Key for Code Exchange. In short, it is referred as PKCE (pronounced “pixy”). The PKCE uses a code verifier and code challenge instead of the client secret on the client app side. The code verifier is a randomly generated string, while the code challenge is generated by a specified algorithm using the code verifier as a seed. During multiple interactions with the authorization endpoint, one of code challenge or code verifier is used in a single interaction. The client secret is still used on the server app side. However, the server app can easily protect such secret parameters.

This way, SPA can perform a secure social login.

How Authorization Code Flow with Proof Key for Code Exchange (PKCE) Works

The flow is complicated. Multiple actors are involved in. To make it clear, let’s start off from who are those actors.

Actors

The actors involved in the PKCE are below:

  • User: human, who wants to be authenticated by a social login.
  • SPA: client side app.
  • Rails: server side app.
  • Auth Endpoint: social login such as Twitter, GitHub, Google, etc.

PKCE Flow

With above actors, the PKCE flow in this example works like below:

  1. SPA to User: show Login Button which is a link to social login with code challenge and client id
  2. User to Auth Endpoint: click the button
  3. Auth Endpoint to User: show the login page if the user is not logged in
  4. User to Auth Endpoint: input username (or email, phone number, etc) and password, then click a login button
  5. Auth Endpoint to User: show the Authorize app button with the information about Rails app
  6. User to Auth Endpoint: click the Authorize app button
  7. Auth Endpoint to Rails: redirect to Rails app with a code parameter
  8. Rails to Auth Endpoint: make a request to get an access token with the code parameter, code verifier, client id/secret
  9. Auth Endpoint to Rails: return the access token
  10. Rails to Auth Endpoint: make a request to get a user profile with the access token
  11. Rails to SPA: redirect to SPA’s page with the access token
  12. SPA to Rails: make a request to know who is the user with the access token
  13. Rails to SPA: return the user profile
  14. SPA to User: show the user profile

Example Application

Versions:

  • Ruby: 3.2.2
  • Rails: 7.0.5

Source Code:

Since we learned the basic idea about PKCE, it’s time to implement. For an example application, the client app is created by ReactJS (TypeScript), Redux Toolkit and Chakra UI. The server side is created by Ruby on Rails with esbuild setup.

Although ReactJS and Rails have libraries for PKCE, the example app implements the flow from scratch. It is a good way to understand how PKCE works.

For details about creating Rails and ReactJS apps with esbuild setting, please see the previous post: Rails 7 React/Redux Development with esbuild.

Setup Twitter Social Login

The example app uses Twitter OAuth2 login. Go to Twitter’s Developer Portal and create an app. The Callback URI / Redirect URL should have “www” in the URL. This example sets “http://www.localhost:3000/oauth/twitter” for the redirect URL.

Server App

The first step is to create Rails app. Then, add the faraday gem to make HTTP request to the authorization endpoint. Also, add the jwt gem to sign the access token with some more additional info.

% rails new [APP NAME] -j esbuild -T
% cd [APP NAME]
% bundle add faraday
% bundle add jwt

Create a user model.

% rails g model User username:string name:string provider:integer pid:string
% rails db:migrate

The provider here means how the user is authenticated. It is an enum field, so the data type is integer. The pid is an id returned from the authorization endpoint. This example app treats username as a unique field, so it needs additional migration.

% rails g migration add_index_to_users_username

Edit, db/migrate/[DATE TIME]_add_index_to_users_username.rb

# db/migrate/[DATE TIME]_add_index_to_users_username.rb

class AddIndexToUsersUsername < ActiveRecord::Migration[7.0]
  def change
    add_index :users, :username, unique: true
  end
end

Then, run

% rails db:migrate

Edit, app/models/user.rb to define the provider enum type and add the username constraint of uniqueness.

# app/models/user.rb

class User < ApplicationRecord
  enum provider: [:local, :twitter]

  validates :username, uniqueness: true
end

Coming to a controller part, the first one is to create an entry point for the client app.

% rails g stimulus client

Create another controller to mount the client app.

% rails g controller pages home

Edit app/views/pages/home.html.erb to add the div tag with id=root as the mount point.

<%# app/views/pages/home.html.erb %>

<h1>Pages#home</h1>
<p>Find me in app/views/pages/home.html.erb</p>
<%= content_tag(:div, "", id:"root", data:{ controller: "client" }) %>

Edit config/routes.rb to make the pages#home to the root route.

# config/routes.rb

Rails.application.routes.draw do
  root 'pages#home'
end

Create a controller for the redirect URL. The URL should be “http://www.localhost:3000/oauth/twitter” as it is set at Twitter Developer Portal.

% rails g controller oauth twitter --skip-template-engine

The oauth controller will have a core logic explained in the PKCE Flow section. Among the steps, the oauth controller does step from 7 to 11. Let’s get back for the PKCE implementation later.

Lastly, create a controller for the me query. The me query is used to fetch the profile of the currently authenticated user.

% rails g controller me --skip-template-engine

The controller implementation will be discussed later, so for now, add profile method to create a route.

# app/controllers/me_controller.rb

class MeController < ApplicationController
  def profile
  end
end

Edit config/routes.rb to create a path to the me query.

# config/routes.rb

Rails.application.routes.draw do
  get 'oauth/twitter'
  root 'pages#home'
  get 'me', action: :profile, controller: 'me'
end

Client App

The client app takes a typical Redux Toolkit directory structure described below:

app/javascript
├── App.tsx
├── app
│   ├── hooks.ts
│   └── store.ts
├── application.js
├── controllers
│   ├── application.js
│   ├── client_controller.js
│   └── index.js
├── features
│   ├── auth
│   │   └── authSlice.ts
│   └── twitter
│       ├── TwitterLogin.tsx
│       ├── TwitterProfile.tsx
│       └── twitterSlice.ts
└── utils
    ├── hashUtils.ts
    └── twitterOauthConfig.ts

7 directories, 13 files
Environment Variable Setup

This is a setup for environment variables for SPA. In general, the dotenv is used for such purpose. However, for SPA, the dotenv is quite hard stuff to setup also not meaningful. Since the files for the app are bundled in a single JavaScript (and stylesheets), it depends on the build tool how the dotenv is looked up. Additionally, the content in dotenv will be hardcoded in the built JavaScript file.

For an alternative way, this example app uses settings.js file which is put in the directory where the Rails asset pipeline sees.

Create app/assets/javascripts/settings.js and write your twitter app’s client id:

// app/assets/javascripts/settings.js

var environment = {
  "REACT_APP_TWITTER_CLIENT_ID": "YOUR-CLIENT-ID-HERE",
}

Add javascripts directory in app/assets/config/manifest.js as in the last line below:

//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_tree ../builds
//= link_directory ../javascripts .js

Lastly, add javascript tag in app/views/layouts/application.html.erb. The tag should come before application.js since the variables defined in settings.js will be referenced in application.js.

<%# app/views/layouts/application.html.erb %>

...
<head>
    <title>TwitterOauthPkceExample</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_include_tag "settings", "data-turbo-track": "reload", defer: true %>
    <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
  </head>
...
Install Packages

At Rails app’s root directory, run yarn add command to install packages.

% yarn add react react-dom
% yarn add typescript @types/node @types/react @types/react-dom
% yarn add react-redux @reduxjs/toolkit
% yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion
Login Button

The first step of the PKCE is to show the login button which has a link to the authorization endpoint with necessary info.

// app/javascript/features/twitter/TwitterLogin.tsx

...
return (
  <VStack spacing="4">
    {!isLoggedIn &&
      <Button
        colorScheme='blue'
        aria-label="Log in using OAuth 2.0"
        onClick={() => window.open(getTwitterOauthUrl(), '_self')}
      >
        Log in with Twitter
      </Button>
    }
    {isLoggedIn &&
      <Text>
        Token expiry date: {tokenExpiryDate}
      </Text>
    }
  </VStack>
);
...

The link url is created in the function defined in twitterOauthConfig.ts. The code challenge is hardcoded in this example app. In the real world, it should be generated by a specified algorithm.

// app/javascript/utils/twitterOauthConfig.ts

const authEndpoint = 'https://twitter.com/i/oauth2/authorize';

const client_id = environment.REACT_APP_TWITTER_CLIENT_ID;
const redirect_uri = "http://www.localhost:3000/oauth/twitter";

export const getTwitterOauthUrl = (): string => {
  // temporarily uses static code verifier and challenge
  let code_challenge = "WeI7ul0uzUr0Zv89EPknzv4iNqmQuEysEtkWan7P3FA";
  let state = "state";
  let scope = ["users.read", "tweet.read", "follows.read", "follows.write"].join(" ");
  // @ts-ignore
  let args = new URLSearchParams({
    redirect_uri,
    client_id,
    state,
    response_type: 'code',
    code_challenge,
    code_challenge_method: "S256",
    scope,
  }).toString();
  return `${authEndpoint}?${args}`;
}

The button looks like below:

img: oauth pkce login button

When a user (human) clicks the button, Twitter navigates to the login page if the user has not yet logged in. If the user is already logged in, this step is skipped.

img: oauth pkce twitter login page

After the successful login, Twitter shows the authorize app page.

img: oauth pkce twitter authorize app page

When the user clicks the Authorize app button, Twitter redirects to the given redirect URI with the code as the URL parameter.

Up here, PKCE flow’s 1 to 6 steps are completed.

User Profile

Let’s assume the server side app successfully gets the access token and the user profile from Twitter. What the server app does at this point is to redirect back to the client app. The example here sends back access_token and token expiry as URL parameters. Those parameters are handled by TwitterLogin.tsx and hashUtils.ts.

// app/javascript/features/twitter/TwitterLogin.tsx
...
const { access_token, expires_in } = getOAuthParams();
removeHashParamsFromUrl();
...
useEffect(() => {
  if (access_token) {
    dispatch(setLoggedIn(true));
    dispatch(setAccessToken(access_token));
    dispatch(setTokenExpiryDate(Number(expires_in)));
    dispatch(setUserProfileAsync(access_token));
  }
}, [])
...
// app/javascript/utils/hashUtils.ts
export const getOAuthParams = () => {
  const urlParams = new URLSearchParams(window.location.search);
  return {
    access_token: urlParams.get('access_token'),
    expires_in: urlParams.get('expires_in'),
  };
}

export const removeHashParamsFromUrl = () => {
  window.history.pushState("", document.title, window.location.pathname);
}

The last line in useEffect function, dispatch(setUserProfileAsync(access_token));, triggers the me query. The function is defined in twitterSlice.ts.

// app/javascript/features/twitter/twitterSlice.ts
...
export const setUserProfileAsync = (accessToken: string): AppThunk => dispatch => {
  fetch('http://www.localhost:3000/me', {
    method: 'GET',
    headers: {
      Authorization: 'Bearer ' + accessToken,
    },
  }).then(response => response.json())
    .then((data) => {
      dispatch(setLoggedIn(true));
      dispatch(setName(data.name ? data.name : data.id));
      dispatch(setUsername(data.username));
    }).catch((error) => {
    console.log(error);
    if (error instanceof XMLHttpRequest) {
      if (error.status === 401) {
        dispatch(setLoggedIn(false));
      }
    }
  });
};
...

The client app returns the access token as is in the Authorization header. This is the step 12 of the PKCE flow. When the server app returns the user profile, that will be rendered in TwitterProfile.tsx. By that, the last step, 14, of the PKCE flow completes.

// app/javascript/features/twitter/TwitterProfile.tsx

...
export function TwitterProfile() {
  const name = useSelector(selectName);
  const username = useSelector(selectUsername);

  return (
    <VStack>
      {name &&
        <Box>
          <Text>Logged in as: {name}</Text>
        </Box>}
      {username &&
        <Box>
          <Text>Username: {username}</Text>
        </Box>
      }
      </VStack>
  );
}
...

img: oauth pkce user profile

Core Logic on the Server Side App

Setup Secrets

The controllers needs some secret parameters. Before going to the controller details, setup those parameters. Choose your favorite editor, for example, EDITOR=”code –wait”. The vim is the choice here.

% EDITOR=vim rails credentials:edit

Write your JWT secret, Twitter app’s client id and client secret in the format below:

jwt_secret: YOUR-JWT-SECRET-HERE
twitter:
  client_id: YOUR-TWITTER-APP-CLIENT-ID-HERE
  client_secret: YOUR-TWITTER-APP-CLIENT-SECRET-HERE
OauthController Details

The OauthController performs the PKCE flow’s step from 7 to 11. The authentication request made by the client app ends up coming to the redirect uri, http://www.localhost:3000/oauth/twitter with the code as a URL parameter. The controller gets the code, then makes a request to the Twitter access token endpoint with the code and other parameters.

// app/controllers/oauth_controller.rb

...
    def twitter
        client_id = Rails.application.credentials.twitter.client_id.strip
        client_secret = Rails.application.credentials.twitter.client_secret.strip
        oauthTokenParams = {
          redirect_uri: 'http://www.localhost:3000/oauth/twitter',
          code: params[:code]
        }
        access_token, expires_in = getOAuthToken("https://api.twitter.com", "/2/oauth2/token",
                                                 client_id, client_secret,
                                                 oauthTokenParams) 
        ...
    end
...
    def getBasicAuthToken(client_id, client_secret)
      Base64.strict_encode64("#{client_id}:#{client_secret}")
    end

    def getOAuthToken(url, path, client_id, client_secret, oauth_token_params)
      oauth_token_params = oauth_token_params.merge({
                                                      grant_type: 'authorization_code',
                                                      client_id: client_id,
                                                      code_verifier: CODE_VERIFIER
                                                    }))
      conn = Faraday.new(
        url: url,
        headers: {
          'Content-Type' => 'application/x-www-form-urlencoded',
          'Authorization' => "Basic #{getBasicAuthToken(client_id, client_secret)}"
        }
      )
      response = conn.post(path, URI.encode_www_form(oauth_token_params))
      body_obj = JSON.parse(response.body)
      [body_obj['access_token'], body_obj['expires_in']]
    end
...

Once the access token is successfully returned from Twitter, the next step is to get a user profile. The me query to Twitter does the job. The access token is set in the Authorization header to make the me query.

// app/controllers/oauth_controller.rb

def twitter
    ...
    data = getUser("https://api.twitter.com", "/2/users/me", access_token)
    ...
end
...
def getUser(url, path, access_token)
    conn = Faraday.new(
      url: url,
      headers: {
        'Content-Type' => 'application/x-www-form-urlencoded',
        'Authorization' => "Bearer #{access_token}"
      }
    )
    response = conn.get(path)
    body_obj = JSON.parse(response.body)
    body_obj['data']
end
...

After the server side app gets the user profile, the user is either created on or retrieved from a local database.

// app/controllers/oauth_controller.rb

def twitter
  ...
  user = getUserFromDb(
    {
      username: data['username'],
      name: data['name'],
      provider: User.providers[:twitter],
      pid: data['id']
    }
  )
  ...
end

Almost there. The following step is to sign access token and user info. This example app uses user’s username and name with the access token.

// app/controllers/oauth_controller.rb

def twitter
  ...
  signedToken = getSignedToken(access_token, user)
  ...
end
...
def getSignedToken(access_token, user)
  JWT.encode(
    {
      username: user.username,
      provider: user.provider,
      accessToken: access_token
    },
    Rails.application.credentials.jwt_secret,
    'HS256'
  )
end

Finally, the controller redirects back to the client app with the signed access token and token’s expiry.

// app/controllers/oauth_controller.rb

def twitter
  ...
  redirect_to(root_path(access_token: signedToken, expires_in: expires_in))
end
MeController Details

The MeController gets signed access token from the Authorization header which is sent back by the client app. Then, the token is decoded using the same JWT secret. The decoded token has the user info which can be verified to make a query to the local database. The result of the database query is returned to the client app.

The example app skips the access token validity check since it focuses on the PKCE flow only. However, in a real situation, the access token should be checked.

// app/controllers/me_controller.rb

...
class MeController < ApplicationController
  def profile
    payload = decodeSignedJwtToken(getBearerToken()).first
    user = User.where(provider: payload["provider"], username: payload["username"]).first
    render json: user
  end

  def getBearerToken
    pattern = /^Bearer /
    request.authorization.gsub(pattern, '') if request.authorization
  end

  def decodeSignedJwtToken(token)
    JWT.decode(
      token,
      Rails.application.credentials.jwt_secret,
      true,
      { algorithm: 'HS256' }
    )
  end
end

Conclusion

The example app’s purpose is to show how PKCE flow works. So, some parts are missing. For example, the code verifier and code challenge should be generated in each authentication process, but not. The access token should be saved in both client and server apps and re-requested when the token is expired. The example app skips that as well.

Even though, the example app would help to understand how the PKCE flow works between React app, Rails app and Authorization endpoint.

References

Proof Key for Code Exchange

Implementation

Latest Posts

Vite + Vue + Bun on Rails

Vue.js is one of frontend frameworks gaining popularity among rapidly emerging JavaScript technologies. The combination of Vue.js and Rails is becoming more popular as well, however, Vue.js development on Rails is not so straightforward. The reason would be that Vue.js relies on Vite for a development environment such as HMR (Hot Module Replacement) and bundling.

Bun + React on Rails

In the frontend world, new technologies keep emerging rapidly these years. Still, React is a well-established and very popular frontend framework, Vue.js, Svelte, Astro and more frameworks are gaining popularity. Not just the frameworks, tools for a transpiler/bundler or sort are also under a rapid development. In JavaScript domain, esbuild, rollup, vite and some more are out.

Ruby on Rails Secrets Management

A web application needs various kinds of values, params and etc which should not be revealed to the public, say GitHub repo. For example, API keys, tokens, passwords, and endpoints, all those should be kept secret. Another important factor is that such secrets should be shared among the team members. Additionally, all those secrets should come along with the deployment.