Making a ClassLink frontend from scratch

Reverse engineering the horrendous ClassLink API and then using it to make a much lighter frontend

Posted on July 25, 2023

The Classlinkv2 GitHub repo is now public at BetterLMS/classlinkv2.

What is ClassLink?

Many of you may have clicked on this post seeing that I've finally made a new one with no clue what ClassLink is. ClassLink is another one of those portals that your school tells you to use to log into applications with your school account, kinda like SSO. It's similar to Clever, if you've used that.

The proper way to log in

My district is a Google district (we have Chromebooks and we used to log into our LMS with Google before we switched to Classlink this summer), but we have linked Microsoft accounts as well. We use those linked MS accounts to log into Classlink. Here's the gist of the process:

  1. Open ClassLink LaunchPad and wait for the login page to load just to immediately press Log in with Microsoft afterwards. They could automatically redirect to login, but nah they decided to make us wait for the bloated page to load.
  2. Enter MS credentials and wait for the SSO to complete. This is probably the least bloated part of the login process.
  3. Wait for a redirect to and wait for the splash screen to load and then wait for another redirect to where yet another splash screen loads. This wait between redirects will be useful later.
  4. Wait for the splash screen to slowly hide with a transition.
  5. Click on the desired app.
  6. Wait for the SSO redirect page to load and then redirect you (multiple times) to the app.

You can probably tell that there's a lot of waiting, and this waiting means that logging into my LMS (Schoology) is much longer than before, where I just had to open my LMS and it would automatically redirect to Google OAuth where I could select my school account and log in. This was the main reason that I wanted to make this frontend, so that at least I could log in without looking at the bloat of Classlink's default frontend.

Approach 1: MITM & Reverse Engineering

I decided that the best option would be to use a packet capture tool and MITM to decrypt HTTPS to look at how the mobile app logs in. I chose the Android app at first, but unpatched it wouldn't trust the MITM SSL certificate and once I patched the network security config it would crash on start, probably due to some certificate pinning magic. So I reluctantly searched for packet capture tools in the Apple App Store, which surprisingly did exist. WebProxyTool from "Freely Dating LLC" worked perfectly, although it didn't have support for filtering requests by app like the tool I was using for Android, PCAPdroid. Luckily after setting up the app, the Classlink app launched and worked perfectly and I was able to inspect all of its requests.

Horrendous API

The Search for your district part of the login was relatively sane, with one endpoint returning the search info, and another returning more detailed info about a specific district (or Tenant as the API calls it). But once I pressed Log in with Microsoft, the number of requests skyrocketed. I learned that was actually a necessary domain in the login, and there were multiple API domains with no documentation on any of them. Here's a list of some of the domains I found with API endpoints used in the mobile login process:

  • - This has a tiny bit of documentation, but it just details one specific endpoint.
  • <some subdomain>

Eventually I gave up on this approach since looking at the network logs I couldn't decipher how the MS login link was even generated.

Approach 2: Trolling the Classlink devs

I decided to revisit the login process. There was one point which stood out to me: Afer a successful OAuth, you were redirected to the myapps OAuth page with a code in the query string. After capturing network requests from that page with DevTools, I found out that it sends a request to which then returns a bearer token (!!) and information about the logged in user.

With this information, I quickly made a interceptor userscript that immediately overwrites the page content and redirects to an API endpoint on my frontend, which accesses the exchangeCode API endpoint on the server-side, sets the bearer token as a cookie, and then redirects to the dashboard which is how it should have been in my opinion.

Looking at the Network pane in DevTools again shows that the application metadata is loaded from the same domain this time with Bearer authorization and the endpoint /v1/applicationsPageLoad. I fetch this endpoint and another one (this time from the 2 pages of actual useful docs) which returns user info, both with Bearer authorization, in getServerSideProps. I then use the JSON returned to populate the application table and user info in the dashboard.

Logging into apps is also simple-ish. The app metadata has a type key that can be used to figure out how exactly the login works. I have included a list of the app types that I know about at the end of the post.

I did run into a small issue where the login URL would redirect back to my domain to /sso?.... I just redirect that to which is where the SSO logic is, supposedly.

Logging into an app with my frontend

The login process still is a bit janky, since I intercept the login process halfway. But it works, and it works well. Here's the summary:

  1. Install jump script via uBlock Origin or a userscript manager
  2. Log into Classlink normally
  3. Get redirected to the /api/login/stage2 endpoint on my domain which then redirects you to the dashboard
  4. Dashboard loads quickly and you can sign into your apps with a Catppuccin (:3) themed UI with the press of a button

I don't know how, but if you try to navigate to again, it'll proceed with login normally and you will be logged into both my frontend and Classlink. My current theory is that since there's a "cookie session" and a "code session", and I only intercept the "code session", login is still able to proceed with the cookie session.

The trolling ramps up

A few days after writing the above sections, I was working on supporting more app types and needed to log into Classlink normally. Turns out my own capture script redirects me back now (it wouldn't before), but after caching the login code in cookies and adding a bypass query parameter to my capture script, the login still works. Once I finished supporting all the app types I had in my dashboard, I looked at the 3 buttons at the bottom: "My Backpack", "My Files", and "My Analytics". My Backpack just shows me the list of classes I'm enrolled in for me, My Files is some shitty frontend for Google Drive, SkyDrive/OneDrive, Box, and Dropbox, and My Analytics shows analytics about logins and app logins. The funniest part is that My Files errors when I start it:

Classic failure to properly convert an object to a string in JavaScript Classic failure to properly convert an object to a string in JavaScript

I never thought I would see [object Object] in a production product that millions of students use daily, but here I am.

Moving on, I decided that there was absolutely zero point in adding My Files to the Classlink frontend since that's just a wrapper around other storage providers, but My Backpack and My Analytics seemed to be easy-ish to implement. I got started with the DevTools Network panel and Neovim markdown document combo to write down any interesting API endpoints. Here's a list of the main API endpoints My Backpack uses straight from my notes:

  • enabled:
  • class list
  • icon location for class list
  • more info about class<id>
  • school year info

And the same for My Analytics:

  • get logins:[?limit=<int>]
  • get logins data:{daily,weekly,monthly,yearly,records}
    • daily, weekly, monthly, yearly: [{Date, Logins}]
    • records: {daily, month, weeks, yearly}
      • daily, month, yearly: {Logins, endDate, startDate}
      • weeks: {Logins, Week, Year, endDate, startDate}
  • get apps logins:[?limit=<int>]
    • {Browser, Date, IP, Id, OS, Resolution}
  • get apps logins data:[?order=Count&sort=DESC&limit=<int>]
    • {AppId, AppName, Count, HireIconPath, IconPath, activeS} - activeS = active seconds
  • get apps logins data: - this 500'd
  • get apps logins data: - this 500'd

Most, if not all, of the My Analytics endpoints accept startDate and endDate as query parameters with a valid %Y-%m-%d date, also known as the YYYY-MM-DD format.

Once I finished implementing the frontends for My Backpack and My Analytics, I started to look at how I could spoof actual logins and active seconds, so that I could make the data available to admins as useless as possible. I quickly figured out how everything except the active seconds was monitored: logins meant exchangeCode calls and app logins meant a call to<id>. But there was still one last thing: apptimer.

The final troll: Spoofing apptimer

I searched in the obfuscated Classlink code for some reference to apptimer and only found two references, one in the function for launching a "favorite" app and another in the function for launching a regular app. The code created an object with one of the values as "apptimer", base64'd it, and then postMessaged it to... itself. This was where I was confused. There were no event handlers for "message" registered, so it was effectively going nowhere.

But then I remembered: Classlink also has it's own extension used when logging into apps of type 15. And when I looked in the extension source (which was also obfuscated), it was all right there. A script was injected into all pages which registered a handler for "message" and unbase64'd whatever it got! It handled the apptimer request by postMessage-ing the background script. And there it was: the 2 API endpoints used for apptimer. Never mind the fact that the code polls every 10 milliseconds just to check if the tab's active and add to the count if it is. I don't care, I just want to test those endpoints out. Here's the basics on how apptimer works:

  1. Launch app using a different endpoint than the one that registers app launches:<id>
  2. Recieve launch token.
  3. Send<token>&activeS=<int> occasionally.
  4. Server responds with the correct activeS sadly, I thought I could just add immense amounts of seconds without it caring.
  5. Once app closes send<token>&activeS=<int>
  6. Server responds with correct activeS and marks the token as invalid.

I wish I could just send big activeS counts and the server wouldn't care, but sadly that's not the case. Time for plan B.

Automated trolling

I started making a simple-ish Python script that would automatically launch apps, refresh my token, and rack up apptimer seconds. It wasn't that hard, I just had to send some POST requests on a regular basis. I started off by locking my sleeps to the system clock (like so: sleeping for 2.0 - (time.time() - starttime) % 2.0 seconds would run code every 2 seconds with decent accuracy), then using the modulo operation to figure out when the difference of the start time and current time reached a certain threshold. For example: (time.time() - starttime) % 30.0 would be close to zero every 30 seconds. I just checked if the value of the modulo expression was less than 1. I also decided to print stats every once in a while so I could see how well the script is doing.

I have 4 frequencies in the trolling script:

  • launchAppFrequency: (currently set to 2 seconds) controls how long the script sleeps for between polls and the frequency of app launch calls.
  • timeMod300S: controls when the script sends apptimer "activity" events.
  • timeMod23H: controls when the script sends apptimer "close" events and renews the token.
  • timeMod24H: controls when the script increments the day count and prints stats.

I started off with all of those set to really low values like 16, 20 seconds, just to make sure all the API calls worked. I have the script running in the background while writing these sections with timeMod23H set to 3.75 hours and timeMod24H set to 4 hours. Once I decide to make this a systemd service, I'll probably set the variables to the real values of 23 and 24 hours and set launchAppFrequency to probably 1 minute.

Schoology: 3,104 app launches Success!

Where is the frontend?!

You can visit my Classlink frontend at or I added some nice features such as passing the app ID as a query parameter so you can instant login via a bookmark (if you're signed in, of course). The script is available on the Classlink frontend site here and requires the requests Python library.

PS: I looked at the extension code and found all the app types in a switch statement, as well as an obfuscated polyfill. :facepalm:

Classlink truly is a giant mess.

App types (updated with switch statement from extension source):

  • 1, 14, 25, 26, 30, 31, 32, 37: open url[0]
  • 7,8: uses window.CloudApp.MyApps.Controller.openRDPClient
  • 9: open<id>
  • 15: open<id>
  • 16, 36: open<id>
  • 17: open<id>
  • 18: open<id>
  • 19: open<id>
  • 20: open<id>
  • 21: open<id>
  • 22: open<id>
  • 23: open<id>
  • 24: open<id>
  • 3,27: uses window.CloudApp.MyApps.Controller.launchLocalApp
  • 28: open<id>
  • 29,33: open<id>
  • 34: open<id>
  • 35: open<id>

Note: some of these pages redirect you back to the original domain with a path like /sso?resolvedUrl=<url fragment>. The proper thing to do is redirect to<resolvedUrl> (the omitted forward slash is intended).