Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Office Hours Khoury OAuth Implementation #683

Draft
wants to merge 15 commits into
base: master
Choose a base branch
from

Conversation

jtavera235
Copy link
Contributor

@jtavera235 jtavera235 commented Jun 28, 2021

Description

This PR implements the whole OAuth flow for the Office Hours app using the new Khoury Admin OAuth server that was supervised by Alex Grob and Alan Mislove. This will be the new way in which users will be able to sign into Office Hours. Currently, this PR is a draft for the following 2 reasons:

  • Due to the service outage affecting all of Northeastern's hosted applications, there has been a delay in merging the OAuth server into the Khoury Admin server.
  • There are some things that need to be done with this code before being able to be merged.

Once the Khoury Admin team merges in the OAuth server, the Office hours team will be contacted with credentials that the Office hours team needs to add. These credentials include the client secret, client id, and redirect uri. Currently, there are variables inside the common package's index.ts file that was used as testing but once the Office Hours team has these credentials, they must remove those values from the common package and add it to the application's environment variable. They must also change every instance that uses these variables to now point to the values inside the environment variables. This is mainly inside the login.tsx file and the login.controller.ts file.

If there are any code changes required that does not change the OAuth logic, then the Office Hours team is free to make those changes as needed. As my co-op will about to end, I will be inactive for the rest of the Summer term but if the Office Hours team has any questions regarding the OAuth logic or general questions do not hesitate to reach out to me via Microsoft Teams. Any general questions you may have may also be answered by Alex Grob. The plan is to have OAuth be the default login path starting in the Fall semester. It is HIGHLY recommended to read the below summary as to how OAuth works before jumping into the codebase. This is the documentation that new Khoury OAuth clients will need to follow. This PR implements everything for the Office Hours app but is still encouraged to read and understand.

Khoury Admin OAuth2 Implementation

How it works

At a high level, Khoury Admin's OAuth2 implementation works by registering clients, which are applications that wish to provide an option to sign in with Khoury Admin, and giving users the ability to see whether they wish to connect their Khoury Admin account, and then providing the clients the ability to make API requests to access a user's resources. For security reasons, clients are given certain permissions (i.e. scope) and they can only access the resources based on their permissions. This is just a high level overview of OAuth2 and will make more sense in a little bit as more details is provided below.

The Khoury Admin OAuth2 implementation was built following the OAuth2 protocol. For a general understanding as to how OAuth2 works this link contains great resources that explains it very well. (https://www.digitalocean.com/community/tutorials/an-introduction-to-oauth-2)

Khoury Admin OAuth2 General Flow

TOAuth starts by a client registering for an account. Their account will contain the following information:

  • Organization name
  • Client ID
  • Client secret
  • Scope
  • Redirect URI

An organization name is just the name of the client, such as GraduateNU. The client ID is one of the identifiers for a client, it is generally okay if it is public but it's meant to identify the client is certain API requests. The client secret is another identifier for the client but should be kept secret and is only used when a client is requesting JWT Access tokens. A scope translates to a list of a client's permission. The different scope types are user.info, student.info, ta.info, student.courses, instructor.courses.

  • user.info scope represents being able to access the current user's email, name, photo url, and the account type (such as student, faculty, staff, or advisor)
  • student.info scope represents being able to access a students major, past courses (transcript), NUID, and catalog year
  • ta.info scope represents being able to access if the current user is a ta.info and which course they are ta.info'ing for
  • student.courses scope represents being able to access the current courses the current user is registered in
  • instructor.courses scope represents being able to access the current courses the current user is instructing if they are an instructor

There will be many instances when a client's scope has to be passed into the request url. However, a client contains a list of scopes, so to pass in the scopes to the URL the scopes will go in the following format: /&scopes=<First scope>&scopes=<Second Scope> etc. Let's say a client has the user.info and ta.info scopes. Those scopes will be passed in like <request_url>/&scopes=user.info&scopes=ta.info. If a client were to have user.info, ta.info, and student.info, then the route would look like <request_url>/&scopes=user.info&scopes=ta.info&scopes=student.info. The Redirect URI refers to the full URL the OAuth login screen will redirect a user to after they successfully login and accepted the permissions. This would generally be an account's home page or the screen that an application would take a user after logging in. The redirect uri must begin with https://<uri> or else the redirection will not work properly. It is important that a client saves all their account information in a secure and reliable way, especially a client's ID and secret. A Khoury admin will contact the client with their client secret, client id, and list of scopes once their account has been created.

After a client has an account with the OAuth provider, the client must then provide the option to log a user in with their OAuth provider account. This would generally be done with a button that links to the OAuth provider's login page. The URL for the login page is https://admin.khoury.northeastern.edu/oauth/login. In our case a client would have something on their page that looks like this:

 <button id="loginWithKhouryButton" type="button">Login with Khoury</button>
 <script>
    var button = document.getElementById("loginWithKhouryButton");
    button.addEventListener ("click", function() {
        windowReference = window.open("https://admin.khoury.northeastern.edu/oauth/login?response_type=code&client_id=<client_id>&redirect_uri=<redirect_uri>&challenge=<challenge>&state=<state>&scopes=<scope1>&scopes=<scope2>", "_blank");
        if (window.focus) {
            windowReference.focus();
        }
    });
</script>

It is critically important that a client opens the link with window.open and not an <a/> tag or another linking mechanism. The response_type parameter is mandatory and has to contain the value code as shown above. This indicates to the OAuth provider that the client will be using the authorization code method of OAuth2 authentication.

  • Notice the challenge portion of the URL. This is part of a security mechanism of OAuth 2.0 called PKCE. Essentially, the client is suppose to generate a sufficiently random value as well as a SHA-256 hash of this value. The client then passes the hash value into the URL with the parameter named challenge. The client then needs to store the original value somewhere to pass in a later request. A good place to store the original value would be a browser's local storage. It is recommended that clients use the node-forge library to create the SHA-256 values. Below is a recommended example as to how to implement this in javascript:
  let forge = require('node-forge');

  // A very basic Method that creates a sudo-random string.
  generateRandomCode(length) {
    return Math.random().toString(20).substr(2, length);
  }

  generateCodeChallenge() {
    // Represents the value that will be hashed. Recommend a code with at least a size of 8 characters. This value should be saved because it will be sent to the client later. (i.e. in local storage)
    const code = this.generateRandomCode(8);
    let md = forge.md.sha256.create();
    // Creates a SHA-256 value for the created code and saves it in the 'md' variable 
    md.update(code);
    // Returns the SHA-256 value as a readable string that will then be given to the OAuth provider
    const hash = md.digest().toHex();

    // Send the hash value to the provider
    // ...
  }
  • Notice the state portion of the URL. This is part of a security mechanism of OAuth 2.0 meant to protect against CRSF attacks. Essentially, the client is suppose to generate a sufficiently large value (at least 10 characters/numbers) and store it somewhere (local storage) for verification. When the provider returns the authorization code, after a client successfully logs in, it will also return a state value. The client is in charge of comparing the state value the client has stored and the state value that the provider returns. If these values are different, the client should terminate the OAuth flow and NOT proceed as a CRSF attack may be happening. Usually these values will remain the same but as a client it is very important to check.

If a user accepts the clients permissions, they will be redirected back to the client's indicated redirect uri with an authorization code and the same state that was initially passed into the login page from the client. This code, called the authorization code, does not signify a JWT but rather is a way for a client to request JWT access and refresh tokens for the current user. As an example, say a client's redirect uri is https://google.com. If a user logs in successfully and accepts the clients scope, they will be redirected back to https://www.google.com/?code=<authorization_code>&state=<state>. It is now the client's responsibility to verify the state value is the same as the state value they have stored and get the authorization code from the request parameters and request the access and refresh tokens. Once a client has received and extracted the authorization code, a client has to make a POST request to /api/oauth/token?client_id=<client_id>&client_secret=<client_secret>&grant_type=authorization_code&redirect_uri=<redirect_uri>&verifier=<verifier>code=<auth_code>&scopes=<scope1>

  • Notice the verifier field which represents the original value of the challenge value passed in earlier. This gets passed in the request and the provder will verify that the SHA-256 hash of this random value is the same as the code_challenge field of the authorization code saved in the database.

  • Notice the grant_type field which indicates that this request is an authorization request and its value has to be equal to authorization_code. When this request is made, the provider will verify that the information is correct and on success it will return a json response that looks like:

{
    "access": "<access_token>",
    "refresh": "<refresh_token>"
}

This contains both the access token and refresh tokens which can be used to access resources. The access token must be put on the Authorization request header with the value Bearer <access_token>

Once the valid access token is in the header, the client is able to make API requests to the allowed endpoints and not receieve an authorization/authentication error. A recommendation would be that the process of getting an authorization code, requesting the access token, and using the access token to request resources should be done in an intermediate component right before the component that laods the page after logging in appears. For example, a client would have the Login component which allows the option to sign in with a provider. This takes a user to the prover's login page where they log in and accept/deny permissions. Then, the provider will redirect the user back to the intermediate component where the client processes the authorization code and requests the tokens with it. Upon successfully receiving the tokens, the client can then redirect a user to the post-login component where the client can use a user's tokens to request resources. The intermediate component

Since the client has a user's access and refresh tokens, the client can now create a session for the user and can keep using the tokens to continously request resources and keep a user logged in if a client desires to.

The access token has a lifespan of 12 hours in which afterwards it will expire and the client needs to get new access tokens. To get a new access token for the user, the client needs to make a POST request to https://admin.khoury.northeastern.edu/api/oauth/token/refresh?client_id=<client_id>&client_secret=<client_secret>&refresh_token=<refresh_token>&grant_type=authorization_code&scopes=<list of scopes>
Notice how this request also needs the grant_type parameter and make sure that the value is set to refresh_token
If this request is successful, the response will be a json object that looks like:

{
    "access": "access_token"
}

A client can continously use the refresh tokens to get new access tokens until the refresh token expires. The refresh token has a lifetime of 3 months (one semester) after which a user will have to log in again.

Client OAuth API Routes

These are the following routes that clients will have access to access their resources:

  • /api/oauth/userdata/read/ returns the current logged in user's name, email, account type, and photo url
  • /api/oauth/ta/read/ returns whether the current logged in user is a TA and for what course they TA for
  • /api/oauth/studentcourses/read/ returns the current courses the logged in user is enrolled in
  • /api/oauth/instructorcourses/read/ returns the current courses the logged in user is teaching
  • /api/oauth/studentdetails/read/ returns the current user's student information such as major, catalog year, and past courses

Summary

  • A client needs to create an account and save their account information securely in their application.
  • A client needs to provide a mechansim to redirect a user to the Admin's page OAuth login page using window.open
  • On successful login and redirect, the client needs to extract the authorization code from the request parameter and use it to request access and refresh tokens
  • A client adds the access token to the Authorization header and can now make requests to access it's allowed resources
  • A client uses the refresh token to receieve a new access token once the access token expires
  • A client is responsible for creating user sessions to keep users logged with their tokens if the client desires

Type of change

Please delete options that are not relevant.

  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This requires a run of yarn install (node-forge needs to be installed onto the application)

How Has This Been Tested?

Please describe how you tested this PR (both manually and with tests)
Provide instructions so we can reproduce.

  • This has been tested manually by me and presented to the Khoury Admin team. There cannot be unit tests for OAuth because it requires some tokens and logic on the Khoury admin side that cannot be given or done because it requires an actual student's Khoury login as a mock student will not be able to work. Therefore, all tests that were done were manual.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code where needed
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

@vercel
Copy link

vercel bot commented Jun 28, 2021

@jtavera235 is attempting to deploy a commit to the Sandbox NU Team on Vercel.

A member of the Team first needs to authorize it.

let md = forge.md.sha256.create();
md.update(codeVal);
const hashedCodeChallenge: string = md.digest().toHex();
const windowReference = window.open(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel free to change it whenever we re-visit this, but can this be a string literal?

return (
<Container>
<ContentContainer>
<h1>You are currently not logged in</h1>
<p>Click the button below to login via Khoury Admin</p>
<Button href="https://admin.khoury.northeastern.edu/teaching/officehourslogin/">
<Button
onClick={() => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont think you have to wrap this function in a lambda, you can just pass it in as data

KHOURY_ADMIN_OAUTH_API_URL=https://admin-alpha.khoury.northeastern.edu/api/oauth
KHOURY_ADMIN_OAUTH_URL=https://admin-alpha.khoury.northeastern.edu/oauth
OAUTH_CLIENT_ID=10aac8aac0f1f711756f
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i hope these arent very important (i think the PR comment said something about these being dummy values but I want to make sure lol)

"https://admin.khoury.northeastern.edu/api/oauth";
export const KHOURY_ADMIN_OAUTH_URL =
"https://admin.khoury.northeastern.edu/oauth";
export const OAUTH_CLIENT_ID = "<replace_with_khoury_client_id>";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you put a TODO here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants