Building the one-time sign-in link
For a vendor whose users sign in on its own page — a Laravel web app for staff, a React app for pupils, as Zoala has — this is what to build so a user who opens the app from EduSpaze lands already signed in, with no EduSpaze page in between and no sign-in page of yours.
Why it needs the vendor: a signed-in session lives in the browser *on your site* — a Laravel session cookie, or a React app's token in sessionStorage. No other site can create either; browsers forbid it. So EduSpaze asks you, server to server, for a link; your site does the signing in when the user arrives on it. The contract is §10 of the vendor integration guide.
The flow
pupil clicks Open in EduSpaze
→ EduSpaze POSTs user.signin to your endpoint (signed, server to server)
← you reply { "url": "https://www.student.yourapp.com/eduspaze?code=…" } (single use, 2 min)
→ EduSpaze redirects the pupil's browser to that url
→ your page exchanges the code for a session, exactly as a password sign-in would, and opens /home
1. The endpoint EduSpaze calls (Laravel)
// routes/api.php
Route::post('/eduspaze/sign-in-link', [EduSpazeController::class, 'link']);
// app/Http/Controllers/EduSpazeController.php
public function link(Request $request)
{
// 1. Verify the signature over the raw body, and freshness (5 minutes).
$ts = $request->header('X-EduSpaze-Timestamp');
$sig = $request->header('X-EduSpaze-Signature'); // "v1=<hex>"
$raw = $request->getContent();
$want = 'v1=' . hash_hmac('sha256', $ts . '.' . $raw, config('services.eduspaze.signing_secret'));
abort_unless($sig && hash_equals($want, $sig), 401);
abort_unless(abs(now()->getTimestampMs() - (int) $ts) < 5 * 60 * 1000, 401);
abort_unless($request->header('X-EduSpaze-Event') === 'user.signin', 400);
// 2. Find the account. `account` is your own username the school assigned in EduSpaze;
// for staff, `loginHint` is their school email.
$login = $request->input('account') ?? $request->input('loginHint');
$user = User::where('email', $login)->first();
abort_unless($user, 404);
// 3. A single-use code, valid two minutes. Store only its hash.
$code = Str::random(48);
Cache::put('eduspaze:' . hash('sha256', $code), $user->id, now()->addMinutes(2));
// 4. Where to send them: the pupils' React app or the staff web app.
$base = $request->input('role') === 'student'
? 'https://www.student.zoala.co/eduspaze'
: 'https://app.zoala.co/eduspaze/login';
return response()->json(['url' => $base . '?code=' . $code]);
}
The signing secret is the launch signing secret shown in EduSpaze under Integration — the same one that signs the launch and the activation API.
2a. Staff: the web app signs them in (Laravel)
// routes/web.php
Route::get('/eduspaze/login', function (Request $request) {
$userId = Cache::pull('eduspaze:' . hash('sha256', (string) $request->query('code'))); // pull: single use
abort_unless($userId, 403, 'This sign-in link has expired. Open the app from EduSpaze again.');
Auth::loginUsingId($userId, remember: false);
$request->session()->regenerate();
return redirect('/app/dashboard/school/overview');
});
2b. Pupils: the React app exchanges the code
// routes/api.php — the same answer as /api/app/mobile/login, for a code instead of a password
Route::post('/app/mobile/login/eduspaze', function (Request $request) {
$userId = Cache::pull('eduspaze:' . hash('sha256', (string) $request->input('code')));
abort_unless($userId, 403);
return app(MobileLoginController::class)->respondWithSession(User::findOrFail($userId));
});
// a new route in the pupils' app: <Route path="/eduspaze" element={<EduSpazeLogin />} />
function EduSpazeLogin() {
const navigate = useNavigate();
useEffect(() => {
const code = new URLSearchParams(window.location.search).get("code");
axios.post("https://app.zoala.co/api/app/mobile/login/eduspaze", { code })
.then(({ data }) => {
// exactly what the password sign-in already does
sessionStorage.setItem("api_token", data.response.user.api_token);
sessionStorage.setItem("user", JSON.stringify(data.response.user));
navigate("/home", { replace: true });
})
.catch(() => navigate("/login", { replace: true }));
}, []);
return <p>Signing you in…</p>;
}
3. Switch it on in EduSpaze
Startup portal → Integration → Sign-in pages → for each audience choose Sign-in link, enter https://app.zoala.co/api/eduspaze/sign-in-link, save, and Run test sign-in: EduSpaze sends a test: true call for the test account and checks you return an https link. Green, and from then on Open goes straight in. If your endpoint is ever down, users get EduSpaze's sign-in help page instead, so nobody is stranded.
Checklist
- Verify the signature with a constant-time compare, and reject anything older than 5 minutes.
- Codes: random, single use (
Cache::pull), short-lived, stored hashed, never logged. - Look the user up only by
account/loginHint; never create one from this call. - Answer within 8 seconds.
