Practical: Authenticating with Google

Initial Due Date: 2024-11-14 11:59PM Final Due Date: 2024-12-09 4:00PM

Github Classroom Gradescope
In this practical you will adapt your practical 8 solution (“RDBMS”) to implement authentication.
Learning Goals:
  • Learn how to use Google to allow users to sign in to your application
  • Learn about tokens for maintaining authentication across multiple communications with the server
  • Implement required authentication of specific API routes
  • Practice extending an existing RDBMS-backed server
  • Implement additional associations using an ORM
Submission: Successfully submitting your assignment is an ordered two-step process:
  1. Commit and push your changes to GitHub
  2. Submit your repository to the Gradescope assignment

Prerequisites

Instead of starting with a skeleton repository, you are going to clone your practical solution (thereby creating a copy) and refactor it. Be careful as you are copying and pasting the code snippets below! These are derived from the solution which may not match you approach exactly. Often you will need to add, modify or otherwise incorporate code from your implementation.

  1. Clone the repository of your practical 8 solution. You can do so locally on your computer by executing 💻 git clone <practical 8 directory> <practical directory> replacing <practical 8 directory> with the path to your practical 8 and providing a meaningful name for the copy. For example for me it would be 💻 git clone practical08-rdbms-lbiester practical10-auth-lbiester (where lbiester is my GitHub username). Alternately you can clone your practical 8 solution from GitHub. If you do so, append the desired practical directory to the clone command to create a new copy locally, e.g., 💻 git clone git... practical10-auth-lbiester.
  2. Copy over the .env.development.local file from your practical 8 solution.
  3. Open up the package.json file in your newly created copy and change your package name to be “practical-auth”
  4. Install the module dependencies by executing 💻 npm install in the terminal.
  5. Start the development server (💻 npm run dev) so that the PostgreSQL Docker container launches, and then
  6. Initialize the database by executing 💻 npx knex migrate:latest in your practical directory. Note that any “knex” commands will require the database container to be running.
  7. Shut down the development server

Background

In this practical you will add authentication to Simplepedia’s server using Google’s Oauth2 implementation. OAuth is designed to allow applications to request tokens on behalf of resource owners (users) so they can access those resources. Imagine you had written an application that made use of users’ Google data (like their calendar, for example). Users shouldn’t trust your app with their account passwords, but if they want to allow the application to have access to their data, they could log into Google and allow your application to access their Google data, and then Google would then give your application a token that could be used to request the authorized data.

We can use OAuth to perform authentication. We ask the user to log into the Google (or another provider), and we use the token we receive as proof that the person is the same Google user that originally created the account with us. There are a couple of reasons for us to do this:

  • Getting security right is hard. This is a situation where DIY is not the way to go. Use something tested and trustworthy.
  • Middlebury has a “G Suite” domain. In other words, the school subscribes to Google’s cloud collection. The advantage for us is that we can restrict the authentication to only allow users with Middlebury credentials.

Client-side authentication

We will start by implementing the login on the client side. We will use the NextAuth package to implement the client-side authentication workflow. NextAuth supports multiple providers, but here we will only use Google.

Start by install the NextAuth package: 💻 npm install --save next-auth. Then we will add an API route for use by NextAuth in the authentication workflow. Create a file src/pages/api/auth/[…nextauth].js (sometimes the three periods get copied as a single “ellipse” character, make sure the filename contains three actual period characters). Recall that this creates a dynamic route handler at the /api/auth root. Create your handler as shown below. This specifies we are using Google as a (the) authentication provider and further adding a callback that executes on sign-in to check if the user is signing in from a Middlebury account. To allow users outside Middlebury you would delete that callback and setup your associated Google project to allow users outside the organization (in practice, we might need to use a different provider due to Google’s verification requirements). Notice that this code references environment variables, e.g., process.env.GOOGLE_CLIENT_ID, instead of hard-coding those secrets into our application. As we did before we will create a separate file that is excluded from version control to manage those secrets.

import NextAuth from "next-auth"
import GoogleProvider from "next-auth/providers/google";

export const authOptions = {
  // Configure one or more authentication providers
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET
    }),
  ],
  callbacks: {
    async signIn({ account, profile }) {
      if (account.provider === "google") {
        return profile.email_verified && profile.email.endsWith("@middlebury.edu")
      }
      return true // Do different verification for other providers that don't have `email_verified`
    },
  }
}

export default NextAuth(authOptions)

To use Google as a provider in your application you will need to create a client ID for your application (you can do so at https://console.developers.google.com/apis/credentials). For simplicity we will use the one I created. For more details, read Google’s primer on adding Google sign-in to your web app. You can download the .env.local file from Ed (save that file in the root directory of your application and note that filename, including the leading period, matters). In addition to the Google client ID and secret, the file contains NEXTAUTH_SECRET, a random string used by NextAuth to encrypt tokens.

Note that these Google credentials are configured for a project called ‘CS312 authn example’ and expects to be run locally (i.e., the origin and callbacks are configured for localhost:3000). For a deployed project you would need to add your application domain to the authorized Javascript origins and authorized redirect URIs.

For reference, to obtain these credentials, I logged into the Google API console and navigated to the credentials tab. There I created an “OAuth client ID” with the following settings:

  • Application Type: Web Application
  • Name: A relevant name for your application
  • Authorized JavaScript origins: The URL of your app homepage. In development mode it is http://localhost:3000. In production it would be the deployed domain for your application.
  • Authorized redirect URIs: The URL users will be redirected to after authenticating with Google. In development mode it is http://localhost:3000/api/auth/callback/google. In production it would likely be the same path, but with your deployed domain.
  • On the “Oauth consent screen”, I only filled in the required fields (if requires an authorized domains, you can use “csci312.dev”).

In production mode NextAuth also requires you to specify the NEXTAUTH_URL environment variable with the canonical URL of your site, e.g., NEXTAUTH_URL=https://simplepedia.csci312.dev You only want to specify that variable in a production setting, e.g., as a secret in your deployed application, not locally during development. It used by NextAuth to determine the correct redirect URL when authenticating (so if you are seeing an incorrect redirect in production, that is a possible cause).

Logging in (and out)

We first add a SessionProvider to _app.js. This makes the session data available to all the pages in the application (via the useSession hook) and keeps the session in sync across browser windows and tabs. Update src/pages/_app.js with:

/* eslint-disable react/jsx-props-no-spreading,react/prop-types */
import { SessionProvider } from "next-auth/react"

export default function App({ Component, pageProps: { session, ...pageProps } }) {
  
  return (
    <SessionProvider session={session}>
      <Component {...pageProps} />
    </SessionProvider>
  );
}

The sessions itself looks something like:

{
  expires: '2055-12-07T09:56:01.450Z',
  user: {
    email: "panther@middlebury.edu",
    image: "https://...",
    name: "Middlebury Panther',
  }
}

Create a new component LoginWidget in src/components/LoginWidget.js, as shown below, which will encapsulate the login and logout buttons provided by NextAuth. Here we use the useSession hook to determine if there is a current, valid, session and if not, render a button for initiating the sign-in process.

import { signIn, signOut, useSession } from "next-auth/react"

export default function LoginWidget() {
  const { data: session } = useSession();

  if (session) {
    return (<div>
      <p>Signed in as {session.user.email} <button type="button" onClick={signOut}>Sign out</button></p>
    </div>);
  }
  return (<div>
    <button type="button" onClick={() => signIn("google")}>Sign in</button>
  </div>);
}

Add your new component to src/pages/index.js, after the h1. Start the development server. You should see a “Sign In” button. When you click that button you should be redirected to Google to authenticate and give our application permission to access your basic account information (name, e-mail, a picture).

If this step doesn’t work, try changing to another browser and/or disabling extensions.

Using the session on the client

We can use the NextAuth session on both the client and server to restrict access to specific resources. We already saw an example in LoginWidget of how we can use the session to change what we render client side. The useSession hook has optional argument to require authentication. If a user navigates to that page without logging in, they will automatically be redirected to sign in. To test this out create a src/pages/secure.js with the implementation below. Then “Sign Out” and try to navigate to http://localhost:3000/secure; you should be redirected to login. If you are already logged in, you should be able to navigate to the restricted page.

import { useSession } from "next-auth/react"

export default function SecurePage() {
  const { data: session, status } = useSession({ required: true });

  if (status === "loading") {
    return <div>Loading...</div>
  }
  return (
    <div>
      <h2>Secure Page</h2>
      <pre>{JSON.stringify(session, null, 2)}</pre>
    </div>
  );
}

We can test (some of) this behavior by mocking the useSession hook in our tests to control whether the user is logged in or not. Create a file src/_tests_/secure.test.js and paste in the following. Notice the tests with user logged in and with the user not logged in. There are some additional steps when rendering components contain the SessionProvider. In thinking about these tests, we aren’t trying to test if NextAuth works correctly, e.g., redirecting the user to authenticate when specified, (we assume it works), but that we are using it correctly, i.e., requiring logins on pages that need it and not showing content when not authenticated. For example, with expect(useSession).toBeCalledWith({ required: true }); we test that the Secure component is requiring the user to be authenticated.

import { render, screen } from "@testing-library/react";
import { useSession, SessionProvider } from "next-auth/react";
import App from "../pages/_app";
import Secure from "../pages/secure";

// Mock the NextAuth package
jest.mock("next-auth/react");

describe("Client-side testing of secure pages", () => {
  afterEach(() => {
    // Clear all mocks between tests
    jest.resetAllMocks();
  });

  test("Renders secure portions of page when logged in", async () => {
    // When rendering an individual page we can just mock useSession (in this case to
    // simulate an authenticated user)
    useSession.mockReturnValue({
      data: {
        user: { id: 1 },
        expires: new Date(Date.now() + 2 * 86400).toISOString(),
      },
      status: "authenticated",
    });
    render(<Secure />);
    expect(useSession).toBeCalledWith({ required: true });
    expect(screen.getByText(/\{ "user": \{ "id": 1 \}/i)).toBeInTheDocument();
  });

  test("Doesn't render secure portions when not logged in", async () => {
    useSession.mockReturnValue({ data: null, status: "unauthenticated" });
    render(<Secure />);
    expect(
      screen.queryByText(/\{ "user": \{ "id": 1 \}/i)
    ).not.toBeInTheDocument();
  });

  test("Render app with session provider", () => {
    // When rendering _app, (or any component containing the SessionProvider component)
    // we need to mock the provider to prevent NextAuth from attempting to make API requests
    // for the session.
    SessionProvider.mockImplementation(({ children }) => (
      <mock-provider>{children}</mock-provider>
    ));

    useSession.mockReturnValue({
      data: {
        user: { id: 1 },
        expires: new Date(Date.now() + 2 * 86400).toISOString(),
      },
      status: "authenticated",
    });

    // Set the session prop expected by our _app component
    render(<App Component={Secure} pageProps={{ session: undefined }} />);
    expect(screen.getByText(/\{ "user": \{ "id": 1 \}/i)).toBeInTheDocument();
  });
});

Using the session on the server

Recall that restricting access to client-side functionality does not make our application secure. This is all JavaScript running in the user’s browser, under the user’s control. (Almost) anyone could tweak the code a little so it skips the authentication. For that matter, they could just make requests to the server’s endpoints directly. Any sensitive operation needs to be authenticated and authorized on the server (where as the application developer we are in control).

NextAuth provide similar tools for accessing the session server-side. Let’s start by restricting new article creation to logged in users. In src/api/articles/index.js import the server side session access and the authOptions

import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]"

Then modify your POST /api/articles endpoint to get the session and restrict inserting new articles to logged in users, e.g.,

const session = await getServerSession(req, res, authOptions)
if (session) {
  // Perform insert and send article
} else {
  res.status(403).end("You must be signed in to access this endpoint.");
} 

To test this out, add a button in src/pages/index.js to initiate a POST request to create a new article and display the resulting message on the page. For example, your MainApp component could look like:

import { useState } from "react";
import Head from "next/head";
import LoginWidget from "../components/LoginWidget";

export default function MainApp() {
  const [serverResponse, setServerResponse] = useState("");
  
  const sendPost = async () => {
    const newArticle = {
      title: "A new article",
      contents: "Article body",
      edited: "2016-11-19T22:57:32.639Z",
    };
    const resp = await fetch("/api/articles", {
      method: "POST",
      headers: new Headers({
        Accept: "application/json",
        "Content-Type": "application/json",
      }),
      body: JSON.stringify(newArticle),
    });
    if (resp.ok) {
      setServerResponse(JSON.stringify(await resp.json(), null, 2));
    } else {
      setServerResponse(await resp.text());
    }
  }
  
  return (
    <>
      <Head>
        <title>Authentication Practical</title>
        <meta name="description" content="Generated by create next app" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <main>
        <h1>Authentication Practical</h1>
        <LoginWidget />
        <button type="button" onClick={sendPost}>Create New Article</button>
        <div><pre>{serverResponse}</pre></div>
      </main>
    </>
  );
}

Sign out and then try to create a new article. Hopefully you see the expected error message! Now sign in again, you should be able to successfully add a new article! Note that you will need to migrate and seed your database before can you do so.

💻 npx knex migrate:latest
💻 npx knex seed:run

Making users our own

At this point users have authenticated with Google, but they are still just Google’s users not ours. We don’t have any record of who they are and so cannot connect them to other data in our application. Our next step, then, is to create, or find, user entries in our database for individuals when they sign in and then make that information available in the session. There are multiple approaches we could use to do so. For example, NextAuth can create and manage a user’s table in our database. For simplicity, however, we are going to continue to use the default NextAuth approach with JSON Web Token-based sessions, but add our user ID to that token.

To start, we need to create the associated database table. Make a new migration 💻 npx knex migrate:make users and implement the simple User model shown below with attributes for the id, name and e-mail provided by Google. We will use the googleId attribute to identify users from the information Google sends back when they authenticate. Then run the migrations to bring the database schema up -to-date: 💻 npx knex migrate:latest.

/* eslint-disable func-names */
exports.up = function (knex) {
  return knex.schema.createTable("User", (table) => {
    table.increments("id").primary();
    table.string("googleId");
    table.string("name");
    table.text("email");
  });
};

exports.down = function (knex) {
  return knex.schema.dropTableIfExists("User");
};

Next create a corresponding objection.js User model in models/User.js. Notice that we are overriding the $formatJson method provided by Objection.js. We do so to strip out (in an aspect-oriented way) the google ID so it doesn’t appear in objects sent to the client (e.g., as part of Article, etc). where it is not needed (and would leak information about our users).

import BaseModel from "./BaseModel";

export default class User extends BaseModel {
  static get tableName() {
    return "User";
  }

  static get jsonSchema() {
    return {
      type: "object",
      required: ["googleId"],

      properties: {
        id: { type: "integer" },
        googleId: { type: "string" },
        name: { type: "string" },
        email: { type: "string" },
      },
    };
  }

  // Override this method to exclude googleId
  $formatJson(json) {
    const formattedJson = super.$formatJson(json);
    delete formattedJson.googleId;
    return formattedJson;
  }
}

To create users, we use the jwt and session callbacks provided by NextAuth. In src/pages/api/auth/[…nextauth].js, in the callbacks section of authOptions implement the two following callbacks (note you will need to import your newly created User model). The jwt callback adds the user id into the token, creating the user entry if it doesn’t already exist. The jwt callback is invoked anytime a JWT token is created or updated. The former occurs at sign-in. In that invocation the user property is defined (but not in subsequent calls). NextAuth sets user.id to be the unique ID maintained by Google. We use that to find the corresponding user, or create one if needed. We then stick the id generated by the database into the token. The second callback copies that id from the token to the session. NextAuth only includes a subset of the token data in the session, any data that we want to make available in the session has to be explicitly forwarded in the session callback.

async jwt({token, user}) {
  if (user) {
    let localUser = await User.query().findOne("googleId", user.id);
    if (!localUser) {
      // Create new user record in the database
      localUser = await User.query().insertAndFetch({
        googleId: user.id,
        name: user.name,
        email: user.email,
      });
    }
    // Add user id to the token
    // eslint-disable-next-line no-param-reassign
    token.id = localUser.id;
  }
  return token;
},
async session({ session, token }) {
  // Add user id to the session
  // eslint-disable-next-line no-param-reassign
  session.user.id = token.id;
  return session;
},

At this point you should be able to create a new user. Sign in again (first signing out if needed). That should trigger the user creation. Recall from the RDBMS practical that you can use the PostgreSQL extension in VSCode to view the data stored in your database, e.g., your newly created user entry.

Aspect-oriented authentication

We want to be able to use that session information in our handlers (for authentication and other purposes). Add the following function to your src/lib/middleware.js file. This provides a piece of middleware that extracts the user id from the session, finds the corresponding User and attaches that instance to the request for subsequent handlers. If the user it is not authenticated it sends an error response and terminates the handler chain.

import { getServerSession } from "next-auth/next";
import { authOptions } from "../pages/api/auth/[...nextauth]";
import User from "../../models/User";

// Previous code...

export async function authenticated(request, response, next) {
  const session = await getServerSession(request, response, authOptions);
  if (session) {
    request.user = await User.query()
      .findById(session.user.id)
      .throwIfNotFound();
    await next(); // Authenticated, proceed to the next handler
  } else {
    response.status(403).end("You must be signed in to access this endpoint.");
  }
}

We can insert this middleware into any request handler we want to restrict to authenticated users. For example, instead of the manual checking as we did before in the POST /api/articles handler, we can import this middleware and add it to the post handler like shown below. Do this for both your POST /api/articles and PUT /api/articles/:id handlers (removing the manual checking, i.e., the if statement you added earlier). This restricts “editing” routes to logged in users. Note that our post, get, etc. methods can take multiple response functions that are executed in series. We use that selectively incorporate the authentication middleware into specific endpoints.

.post(authenticated, async (req, res) => {...})

Testing

If we run our tests again they will - should - fail (since we added the authentication component). To test our authenticated API we will need to add some additional testing infrastructure. If you get timeout errors when running the tests, remember to “pre-pull” the Docker images needed to run our tests against the PostgreSQL database.

💻 docker pull postgres:16
💻 docker pull testcontainers/ryuk:0.5.1
  1. In knex/seeds/test/load-test-articles.js modify the seed function to reset both the Article and User tables and insert a test user. Note that we reset the auto-increment id to ensure ids are consistent and tests are repeatable. Recall that this code is a little more complex than might be generally needed as we want our implementation to work for both SQLite and PostgreSQL.
    const { client } = knex.client.config;
      if (client === "sqlite3") {
     return knex("sqlite_sequence")
       .whereIn("name", ["Article", "User"])
       .update({ seq: 0 })
       .then(() => knex("Article").del())
       .then(() => knex.batchInsert("Article", data, 100))
       .then(() => knex("User").del())
       .then(() => knex("User").insert({ googleId: "1234567890", name: "Middlebury Panther", email: "panther@middlebury.edu" }));
      }
      if (client === "pg") {
     return knex
       .raw('ALTER SEQUENCE "Article_id_seq" RESTART WITH 1')
       .then(() => knex.raw('ALTER SEQUENCE "User_id_seq" RESTART WITH 1'))
       .then(() => knex("Article").del())
       .then(() => knex.batchInsert("Article", data, 100))
       .then(() => knex("User").del())
       .then(() => knex("User").insert({ googleId: "1234567890", name: "Middlebury Panther", email: "panther@middlebury.edu" }));
      }
      return knex("Article")
     .del()
     .then(() => knex.batchInsert("Article", data, 100))
     .then(() => knex("User").del())
     .then(() => knex("User").insert({ googleId: "1234567890", name: "Middlebury Panther", email: "panther@middlebury.edu" }));
    
  2. In src/__tests__/api.test.js we will need to mock the getServerSession function to ensure the tests are fast, independent and repeatable. At the very top level, import getServerSession from next-auth/next and use jest to mock that that module:
     import { getServerSession } from "next-auth/next"
     jest.mock("next-auth/next");
    
  3. In src/__tests__/api.test.js update the “before” and “after” methods to initialize the mock (return a user with id of 1) and reset it. The four methods should look as follows.
     beforeAll(() =>
       // Ensure test database is initialized before an tests
       knex.migrate.rollback().then(() => knex.migrate.latest()),
     );
    
     afterAll(() =>
       // Ensure database connection is cleaned up after all tests
       knex.destroy(),
     );
    
     beforeEach(() => {
       // Mock nex-auth getServerSession with id of test user
       getServerSession.mockResolvedValue({
         user: {
           id: 1, 
         }
       }); 
       // Reset contents of the test database
       return knex.seed.run();
     });
    
     afterEach(() => {
       getServerSession.mockReset();
     });
    

With the additions in place, it should now appear that there is an authenticated user for all requests. Rerun the tests. They should all pass! However the existing tests only verify that authenticated requests are accepted, not that unauthenticated requests are rejected. Let’s add tests of unauthenticated edit actions. Add a new describe block within the outer describe block (so it is uses the same setup functions) to test unauthenticated requests. Here we set the mock getServerSession to return undefined, i.e., the requestor is not authenticated, and expect the requests to fail with status code 403. These new tests should also pass!

describe("Unauthenticated edits are rejected", () => {
  beforeEach(() => {
    getServerSession.mockResolvedValue(undefined);
  });

  test("Unauthenticated POST", async () => {
    const newArticle = {
      title: "A new article",
      contents: "Article body",
      edited: "2016-11-19T22:57:32.639Z",
    };
    await testApiHandler({
      rejectOnHandlerError: false, // We want to assert on the error
      pagesHandler: articlesEndpoint,
      test: async ({ fetch }) => {
        const res = await fetch({
          method: "POST",
          headers: {
            "content-type": "application/json",
          },
          body: JSON.stringify(newArticle),
        });
        expect(res.ok).toBe(false);
        expect(res.status).toBe(403);
      },
    });
  });

  test("Unauthenticated PUT", async () => {
    const newArticle = { id: 1, ...data[0], title: "New title" }; // Article at index 0 has id 1
    await testApiHandler({
      rejectOnHandlerError: false, // We want to assert on the error
      pagesHandler: articleEndpoint,
      params: { id: newArticle.id },
      test: async ({ fetch }) => {
        const res = await fetch({
          method: "PUT",
          headers: {
            "content-type": "application/json",
          },
          body: JSON.stringify(newArticle),
        });
        expect(res.ok).toBe(false);
        expect(res.status).toBe(403);
      },
    });
  });
});

Update lastEditedBy

To briefly show you how we can use the session data beyond authentication, we will add a lastEditedBy field to our Articles to track the last editor.

To practice TDD, first create tests that verify edits (creating a new article or updating an existing article) set the lastEditedBy attribute to an article. In the case of the update we want to make sure the previous value for lastEditedBy is different and the update sets it to the authenticated user’s id. Add a new describe block within the outer describe block (so it is uses the same setup functions) to test the lastEditedBy field.

describe("Edits updated lastEditedBy", () => {
  test("Should create a new article", async () => {
    const newArticle = {
      title: "A new article",
      contents: "Article body",
      edited: "2016-11-19T22:57:32.639Z",
    };

    await testApiHandler({
      rejectOnHandlerError: true,
      pagesHandler: articlesEndpoint,
      test: async ({ fetch }) => {
        const res = await fetch({
          method: "POST",
          headers: {
            "content-type": "application/json", // Must use correct content type
          },
          body: JSON.stringify(newArticle),
        });
        const resArticle = await res.json();
        expect(resArticle).toMatchObject({
          ...newArticle,
          id: expect.any(Number),
          lastEditedBy: 1,
        });
      },
    });
  });

  test("PUT updates lastEditedBy", async () => {
    const oldArticle = await Article.query().findById(1);
    expect(oldArticle.lastEditedBy).not.toBe(1);
    
    const newArticle = { ...oldArticle, title: "New title" }; 
    await testApiHandler({
      rejectOnHandlerError: true,
      pagesHandler: articleEndpoint,
      params: { id: newArticle.id },
      test: async ({ fetch }) => {
        const res = await fetch({
          method: "PUT",
          headers: {
            "content-type": "application/json",
          },
          body: JSON.stringify(newArticle),
        });
        await expect(res.json()).resolves.toMatchObject({... newArticle, lastEditedBy: 1});
      },
    });
  });
});

The tests should fail. Now let’s add the feature and ensure the tests pass. First create a new migration to add the relevant column to the Article table via 💻 npx knex migrate:make add_editedby. The migration below adds a column to Article to implement the “has many/belongs to” association for “last edited by” (a User has edited many Articles, an Article was last edited by one User). The foreign key goes in the “has many” side, e.g., in Article. Then run the migration via 💻 npx knex migrate:latest.

/* eslint-disable func-names */
exports.up = function (knex) {
  return knex.schema.table("Article", (table) => {
    table.integer("lastEditedBy");
    table.foreign("lastEditedBy").references("User.id").onDelete("SET NULL");
  });
};

exports.down = function (knex) {
  return knex.schema.table("Article", (table) => {
    table.dropColumn("lastEditedBy");
  });
};

Add the corresponding association to the relationMappings in the Article model. We are specifying the “belongs to one” side of the association in Article. This will enable us to query for the associated user via an Article. Note that you will need ot import User.

editedBy: {
  relation: Model.BelongsToOneRelation,
  modelClass: User,
  join: {
    from: "Article.lastEditedBy",
    to: "User.id"
  }
},

In our API handlers, the user data is present in req.user (with the id available via req.user.id). To add this field to our article data we could do something like the following in handlers that create or edit articles. Instead of inserting or updating with just req.body (what was sent in the request), incorporate the lastEditedBy field in the new/updated object.

{ ...req.body, lastEditedBy: req.user.id }

To obtain this information when querying for a specific article modify your GET /api/articles/id handler to fetch both the related Articles and the User who last edited this article via .withGraphFetched("[related, editedBy]") (be careful about the square brackets, they are inside the string!). Try creating an article via the button your created earlier and then performing a fetch in the browser to the URL to get your newly created article. It should show you as the last editor! Note that you may need to re-run the seeding, 💻 npx knex seed:run, to clear out the previously created article with the same name (recall the titles must be unique!).

Not working? Some common issues…

The development server seems out of sync with the database when using SQLite

If the database file, i.e., simplepedia.db is deleted while the development server is running, the server can and will continue accessing the previous file and this will not observe as changes you make through migrations, etc. If it seems like the development server has a stale version of the database, try restarting the development server.

Secure at last?

An obvious question would be to ask if the application is now fully locked down. Sadly, the answer is no. It is better than it was, but all of our communications between the client and the server is done using HTTP instead of HTTPS. As a result, some bad actor could sniff the traffic and use the cookies/token to pretend to be the real user. We can only really trust the cookie data when we have end-to-end encryption between the client and the server.

Unfortunately, switching to HTTPS is not completely trivial. In order to implement a secure communication, the server needs to have an SSL certificate that the user would trust. You can generate your own, but then the user would have no reason to trust it (at least they shouldn’t). So, the best solution is to get one from a trusted certificate authority that signs the certificate in a way that your browser can verify independently. This used to cost money, but now Let’s Encrypt offers certificates for free. PaaS like csci312.dev and Fly.io also typically offer HTTPS for applications.

Finishing Up

  1. Add and commit your changes.
  2. Create the git repository for your practical by accepting the assignment from GitHub Classroom. This will create an empty repository for you (unlike previous practicals that had an initial skeleton).
  3. Replace the origin remote to point to the address of the newly created repository
     💻 git remote set-url origin <GitHub address you would use to clone>
    
  4. 💻 git pull --no-rebase --allow-unrelated-histories origin main. This retrieves starter code created by GitHub classroom that you don’t have.
    • You’ll get a low-stakes merge conflict. Fix it before continuing!
  5. Push your changes to the new GitHub repository with 💻 git push -u origin main
  6. Submit your repository to Gradescope.

Grading

Required functionality:

  • Add authentication to your RDBMS practical
  • Implement secured API routes and NextJS pages
  • Add lastEditedBy to your Article model
  • Pass all tests
  • Pass all ESLint checks

Recall that the Practical exercises are evaluated as “Satisfactory/Not yet satisfactory”. Your submission will need to implement all of the required functionality (i.e., pass all the tests) to be Satisfactory (2 points).


© Laura Biester, Michael Linderman, and Christopher Andrews 2019-2024.