Tutorials
Send a student into the panel
Your students already sign in to your own site. In this tutorial you add an Open my exam panel button that takes a student straight into an organization's panel, signed in as themselves, with no password to remember. You build it twice, as an Express route and as a plain PHP script, and handle every way the API can refuse.
The API provides a sign-in link. This tutorial covers the one safe way to hand that link to a student.
How a sign-in link behaves
| Property | Value |
|---|---|
| Request | POST /v1/<organizationId>/auth/signin with { "studentId": "…", "redirect": "…" } |
| Permission | auth/signin on that organization |
| Lifetime | 120 seconds from the moment it is issued (expiresIn in the response) |
| Uses | One. The first request that opens it spends it |
| Who it signs in | Whoever opens it, as the student |
| What the student needs | Access to that organization. No password, and no confirmed email address |
| First use | Creates the organization's own copy of the student |
That last row matters beyond this button. Applications, supervisor links, certificates and reports on an organization all need the organization's copy of the student, which exists only after their first sign-in there. See Organizations.
The pattern: mint on click, redirect at once
- The student presses a button on your page. The button submits a form to your server with
POST. - Your server works out who the student is from your own session, and looks up the core student id you stored when you registered them.
- Your server asks the API for a link.
- Your server answers
303 See Otherwith the link inLocation, andCache-Control: no-store. - The browser follows it at once. After a short pass through the Main Team sign-in address, the student lands in the organization's panel, signed in.
Why each part matters:
- Mint on click, not on page load. A link minted when your page renders is useless two minutes later, and until then it sits in your HTML as a working credential.
POST, not a plain link. Browsers, chat and email link previews, and security scanners fetch plain links ahead of time. If one of them triggers your route, it mints a link and may spend it before the student arrives. A form sent withPOSTgoes only when the student presses the button.- Redirect, don't display. A URL shown on screen gets copied into chats and emails, and any preview of it spends it.
- Never log, email or store the URL. Anyone who opens it within the 120 seconds is signed in as the
student. Log the
request_idinstead; it is all support needs.
Security
Take the student id from your session, never from the request. If your route accepted
?studentId= from the browser, any signed-in student could sign in as any other student you
registered. The routes below have no student id in their URL at all.
Before you start
- Permissions:
auth/signinon each organization you send students to. To diagnose a refusal (below) you also needstudent/readonmto, and to fix one,student/updateon the organization (or onmtofor the flat route). See Permissions. - The core student id for each of your users, stored when you registered them with
POST /v1/student. See Register and apply. - The organization ids, from
GET /v1/organization. They never change, so load them once. - The helper file
mainteam.mjsormainteam.phpfrom Register and apply. It signs tokens, retries a429afterRetry-After, and turns refusals into anApiErrorwithstatus,codeandrequestId. - Node.js 20 or newer with Express (
npm install express), or PHP 8.1 or newer with sessions.
The request
curl -s -X POST https://api.main-team.org/v1/<organizationId>/auth/signin \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "studentId": "66e6a3f5c1d2b30012a4f9c1" }'
{
"success": true,
"message": "Sign-in link generated successfully.",
"data": {
"url": "https://auth.main-team.org/api/user/oauth/invoke?accessToken=q8x2…&redirectUrl=…",
"organization": "stem",
"studentId": "66e6a3f5c1d2b30012a4f9c1",
"expiresIn": 120
}
}
Treat url as opaque: redirect to it exactly as it came, and never build or change one yourself.
organization is the organization's slug and studentId is the id you sent.
Choosing where the student lands
redirect is optional. Leave it out and the student lands on the organization's home page. To land
somewhere else in the organization's panel, send a path on that panel:
redirect | Result |
|---|---|
| (absent) | The organization's home page |
/ | The root of the organization's panel |
/dashboard | Accepted |
/exams?tab=upcoming | Accepted |
dashboard | Refused: no leading / |
https://example.com/ | Refused: not a path |
//example.com | Refused: a second / would leave the site |
/\example.com | Refused: backslashes are not allowed anywhere |
/my exams | Refused: whitespace is not allowed |
A refused value answers 400 bad_request with
redirect must be a site-relative path starting with "/" (e.g. "/dashboard"). The rule keeps every
link inside the organization's own panel; a link can never send the student to another site.
Straight to a group challenge. A group challenge's page is where the student's group uploads its
work. getGroupChallengeStudent and the student list
give its path as panelPath, with {userId} left in it for the link to fill. Send it as is:
{
"studentId": "<studentId>",
"redirect": "/stadia/{userId}/group-challenges/6650a1b2c3d4e5f6a7b8c9eb"
}
See Group challenges.
The Express route
Save this next to mainteam.mjs as server.mjs. Replace the stand-in authentication with your own.
// server.mjs: an "Open my exam panel" button for your own students.
// Node.js 20 or newer, Express 4 or 5 (npm install express), and mainteam.mjs next to it.
import express from 'express';
import { api, ApiError } from './mainteam.mjs';
const app = express();
// Replace this with your real authentication. It must put the signed-in user
// on req.user, including the core student id you stored at registration.
app.use((req, res, next) => {
req.user = { mainTeamStudentId: '66e6a3f5c1d2b30012a4f9c1' };
next();
});
// Organization ids never change: load them once at start-up.
const organizationIds = new Map(); // slug -> _id
async function loadOrganizations() {
const res = await api('GET', '/organization?limit=100');
for (const org of res.data) organizationIds.set(org.slug, org._id);
}
app.get('/', (req, res) => {
res.type('html').send(`<!doctype html>
<form method="post" action="/panel/stem">
<button type="submit">Open my STEM exam panel</button>
</form>`);
});
app.post('/panel/:slug', async (req, res) => {
const organizationId = organizationIds.get(req.params.slug);
if (!organizationId) return res.status(404).type('text').send('Unknown organization.');
// From your session, never from the request.
const studentId = req.user.mainTeamStudentId;
try {
const link = await api('POST', `/${organizationId}/auth/signin`, { studentId });
res.set('Cache-Control', 'no-store');
return res.redirect(303, link.data.url); // never log link.data.url
} catch (error) {
const message = await explainRefusal(error, studentId, req.params.slug);
const status = error instanceof ApiError && error.status === 403 ? 403 : 502;
return res.status(status).type('text').send(message);
}
});
// Turns a refusal into a message for the student, and logs what your staff need.
async function explainRefusal(error, studentId, slug) {
if (!(error instanceof ApiError)) {
console.error(`sign-in link failed: ${error.message}`);
return 'The exam panel cannot be reached right now. Please try again in a minute.';
}
console.error(`sign-in link refused: ${error.status} ${error.code} request ${error.requestId}`);
if (error.status === 403) {
// Two causes share this status: the student has no access to this organization,
// or this API account lacks auth/signin there. The student's record tells them apart.
const student = (await api('GET', `/student/${studentId}`).catch(() => null))?.data;
const platforms = student?.activatedPlatformsThisSeason ?? [];
if (student && !platforms.includes('common') && !platforms.includes(slug)) {
return 'You are not entered for this organization yet. Please contact your school.';
}
return 'The exam panel is not available from this site at the moment. We have been notified.';
}
if (error.status === 404) {
return 'We could not find your exam account. Please contact your school.';
}
return 'The exam panel cannot be reached right now. Please try again in a minute.';
}
await loadOrganizations();
app.listen(3000, () => console.log('Listening on port 3000'));
Run it with node server.mjs, open port 3000 of that machine in your browser and press the button. Your browser's
network tab shows one POST /panel/stem answered 303, then the sign-in hand-off, then the panel.
The plain PHP route
Two files: the page with the button, and the script it posts to. Both assume your own login has
already put the student's core id in the session as mainteam_student_id.
<?php
// index.php: the page with the button. Your own login has already run.
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(16));
?>
<!doctype html>
<form method="post" action="/panel.php?org=stem">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<button type="submit">Open my STEM exam panel</button>
</form>
<?php
// panel.php: POST /panel.php?org=stem signs the current student into that organization's panel.
declare(strict_types=1);
require __DIR__ . '/mainteam.php';
session_start();
// Organization ids never change: keep the slug => _id map in your configuration.
const ORGANIZATION_IDS = [
'stem' => '64b7f1c2a9e3d45f10c2b7a1',
];
function fail(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
echo $message;
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
fail(405, 'Please use the button.');
}
$csrf = $_POST['csrf'] ?? null;
if (!is_string($csrf) || !hash_equals($_SESSION['csrf'] ?? '', $csrf)) {
fail(400, 'Please reload the page and try again.');
}
// From your session, never from the request.
$studentId = $_SESSION['mainteam_student_id'] ?? null;
if (!is_string($studentId)) {
fail(401, 'Please sign in first.');
}
$slug = (string) ($_GET['org'] ?? '');
$organizationId = ORGANIZATION_IDS[$slug] ?? null;
if ($organizationId === null) {
fail(404, 'Unknown organization.');
}
$api = MainTeam::fromEnv();
try {
$link = $api->call('POST', "/$organizationId/auth/signin", ['studentId' => $studentId]);
header('Cache-Control: no-store');
header('Location: ' . $link['data']['url'], true, 303); // never log this URL
exit;
} catch (ApiError $e) {
error_log("sign-in link refused: {$e->status} {$e->errorCode} request {$e->requestId}");
if ($e->status === 403) {
// Two causes share this status: the student has no access to this organization,
// or this API account lacks auth/signin there. The student's record tells them apart.
try {
$student = $api->call('GET', "/student/$studentId")['data'] ?? null;
} catch (ApiError) {
$student = null;
}
$platforms = $student['activatedPlatformsThisSeason'] ?? [];
if ($student !== null && !in_array('common', $platforms, true) && !in_array($slug, $platforms, true)) {
fail(403, 'You are not entered for this organization yet. Please contact your school.');
}
fail(403, 'The exam panel is not available from this site at the moment. We have been notified.');
}
if ($e->status === 404) {
fail(404, 'We could not find your exam account. Please contact your school.');
}
fail(502, 'The exam panel cannot be reached right now. Please try again in a minute.');
} catch (RuntimeException $e) {
error_log('sign-in link failed: ' . $e->getMessage());
fail(502, 'The exam panel cannot be reached right now. Please try again in a minute.');
}
The hidden csrf field is ordinary cross-site request protection for a form that acts on the
signed-in user. If your framework already protects forms, use its mechanism instead.
When the link is refused
| Status | Message | Cause | What to do |
|---|---|---|---|
403 forbidden | Student is not activated for organization stem. | The student's activatedPlatformsThisSeason holds neither common nor this organization's slug | Grant access (below), then let the student press the button again |
403 forbidden | Insufficient role permissions | Your account lacks auth/signin on this organization | Ask your operator for the role. Retrying does not help |
404 not_found | Student not found! | The id is not one of your students. A student of another account, a mistyped id and a missing student all answer the same | Check the id stored against this user |
404 not_found | Organization not found! | The organization id in the path is wrong | Reload your organization ids |
400 bad_request | studentId must be a mongodb id | The stored id is not a 24-character hex id | Fix the stored id |
400 bad_request | redirect must be a site-relative path… | The redirect value breaks the rule above | Send a path, or leave it out |
429 too_many_requests | Too many requests to this operation. Wait the number of seconds in Retry-After, then try again. | More than 100 links in 60 seconds across your account | Wait Retry-After seconds; the helper does this for you |
500 internal_error | Could not issue a sign-in token, please retry. | A rare fault while issuing the link | Retry once |
Both 403 answers carry the code forbidden, so the routes above do not read the message to tell
them apart. They read the student instead. If the student's list of organizations lacks this one,
access is the problem. Otherwise it is your account's permission. See Errors.
Granting access to an organization
activatedPlatformsThisSeason lists the organizations a student may use: common means every
organization, and otherwise it holds organization slugs. A student you register without the field gets
["common"], so you see this refusal only if you set the list yourself.
The simplest fix adds one organization and touches nothing else: send the organization route an
empty body. Here the student was on ["neo"], and <organizationId> is stem's:
curl -s -X PUT https://api.main-team.org/v1/<organizationId>/student/66e6a3f5c1d2b30012a4f9c1 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
{
"success": true,
"message": "Student updated successfully.",
"data": {
"_id": "66e6a3f5c1d2b30012a4f9c1",
"username": "XXB1045",
"firstName": "Jane",
"lastName": "Doe",
"activatedPlatformsThisSeason": ["neo", "stem"]
}
}
Without activatedPlatformsThisSeason in the body, this route puts the organization in the path into
the list, keeps the entries already there, and leaves a list that holds common as it is. Profile
fields you include are updated too. It needs student/update on that organization.
To add several organizations at once, send them in activatedPlatformsThisSeason, on either route.
They are added to the student's list, and nothing already on it is removed:
curl -s -X PUT https://api.main-team.org/v1/student/66e6a3f5c1d2b30012a4f9c1 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "activatedPlatformsThisSeason": ["stem", "gmath"] }'
This needs student/update on mto. Either route accepts common and the slugs stem,
hilingua, neo, gmath and coding; any other value, mto included, is a 400. No update
removes an organization from the list. See Students.
What the student sees
- The panel, signed in. The browser passes briefly through the Main Team sign-in address, then
lands in the organization's panel as the student, on the page
redirectnamed. - On the first visit to an organization, that organization creates its own copy of the student. From then on your applications, supervisor links, certificates and reports on that organization work for this student.
- If their email address is not confirmed, the student still gets in, but the panel asks them to
confirm the address with a 6-digit code, which it sends from
no-reply@main-team.org. The prompt appears on every page until the address is confirmed, My Exams included, so they must confirm before they can start an exam. They can't put it off; the only way out is to sign out. A student already inside an exam room is not interrupted. A code is valid for 15 minutes, and they can ask for a new one after 60 seconds. Tell your students to check their spam folder for the code, and to confirm well before an exam day, for example right after their first sign-in. - If the link was already used or has expired, the browser shows an error saying the access token is invalid or expired. Nothing is wrong with the account. Pressing your button again mints a fresh link.
Note
Changing a student's email address through the API withdraws its confirmation, because nobody has yet proved they receive mail at the new address. The next time the student uses the panel, it asks them to confirm the new address and keeps asking until they do. Sending the address they already have changes nothing.
Test it
- Press the button: you land in the organization's panel, signed in.
- In the browser's network tab, copy the
Locationof your303and open it in a private window. You get the invalid-or-expired error: the link was spent by the first visit. - Mint a link with curl, wait more than two minutes, then open it. Same error: it expired.
- Register a test student with
activatedPlatformsThisSeason: ["neo"]and press the stem button: your page shows the "not entered" message, not a raw error. An update can't take an organization away, so use a new student for this. - Search your logs for
accessToken=. You should find nothing.
Next steps
- Sign-in links: every rule for the link, in one place.
- Change an application: what to do when the student wants a different language or date.
- Security: handling links, tokens and students' data.
- Reference: createSigninLink, updateStudent, getStudent.