Google sign-in with server-side token verification

Two paths, one session. The GSI popup posts an ID token to /student/google;
where a popup is refused a PKCE authorization-code flow redeems server side at
/student/google/callback, keeping the client secret out of the browser.

ID tokens are verified here rather than trusted from the browser: RS256
signature against Google's published JWKS (cached an hour), audience pinned to
this client, issuer restricted to Google, expiry enforced, email must be
verified. State is single use and expires in ten minutes. Without
GOOGLE_CLIENT_ID the button is hidden and the email form is offered instead.

13 tests cover the verification rules using locally generated RSA keys,
including rejection of a foreign signature, a wrong audience, a wrong issuer,
an expired token and a rewritten payload. An example signs a token from an
untrusted key so the rejection path can be checked without a Google account.
This commit is contained in:
dev 2026-10-11 20:37:56 +05:30
parent d3001612b2
commit 3f88f6c388
11 changed files with 2058 additions and 35 deletions

1260
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,9 +6,13 @@ edition = "2024"
[dependencies]
anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["form", "query", "json", "http1", "http2"] }
base64 = "0.23.1"
chrono = { version = "0.4.45", features = ["serde"] }
jsonwebtoken = { version = "11.1.0", features = ["rust_crypto", "use_pem"] }
reqwest = { version = "0.13.5", features = ["form", "json"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.152"
sha2 = "0.11.0"
sqlx = { version = "0.9.0", features = ["sqlite", "runtime-tokio", "macros", "chrono", "uuid"] }
tokio = { version = "1.53.2", features = ["full"] }
tower-cookies = "0.11.0"
@ -18,4 +22,6 @@ tracing-subscriber = "0.3.23"
uuid = { version = "1.28.0", features = ["v4", "serde"] }
[dev-dependencies]
rand = "0.8"
rsa = { version = "0.9", features = ["pem"] }
serde_urlencoded = "0.7.1"

View file

@ -19,13 +19,55 @@ Environment:
| `PYQ_ADDR` | `127.0.0.1:8080` | listen address |
| `PYQ_DB` | `pyq.db` | SQLite file, created if absent |
| `PYQ_BASE` | empty | URL prefix when mounted in a subpath |
| `GOOGLE_CLIENT_ID` | empty | enables Google sign-in; unset hides the button |
| `GOOGLE_CLIENT_SECRET` | empty | needed only by the full-page fallback |
## Google sign-in
Set a client id and the button appears on `/student`. Without it the page says
Google is off and offers the email form instead.
GOOGLE_CLIENT_ID=1234-ab.apps.googleusercontent.com \
GOOGLE_CLIENT_SECRET=... \
cargo run
In the [Google Cloud console](https://console.cloud.google.com/apis/credentials)
add an **OAuth 2.0 Client ID / Web application**, then list every origin this runs
on, for example `http://127.0.0.1:8080` and `http://localhost:8080`. The callback
URL is derived from the incoming request, so it follows the port you are on and
follows `x-forwarded-proto` behind a proxy. Register each of those origins rather
than trying to predict one URL.
Two paths lead to the same session:
- **Popup.** Google Identity Services returns an ID token to the page, which posts
it to `POST /student/google`.
- **Full page.** Where a popup is refused, the link beside the button starts an
authorization-code flow with PKCE. The code is redeemed server side at
`GET /student/google/callback`, so the client secret never reaches the browser.
Either way the ID token is verified here, not in the browser. The signature is
checked against the keys Google publishes at
`https://www.googleapis.com/oauth2/v3/certs`, and the audience must be this client,
the issuer must be a Google issuer, the expiry must hold, and the email must be
verified. A token failing any of that is rejected and no session is created. The
`state` parameter is single use and expires after ten minutes.
To confirm the rejection path without a real Google account:
cargo run --example forge_google_token -- YOUR_CLIENT_ID attacker@example.com
That prints a correctly signed token from a key the server has never seen.
Posting it to `/student/google` returns 401 and creates no user.
## Routes
| route | what it does |
|---------------------------|-----------------------------------------------|
| `GET /` | landing page with live bank counts |
| `GET /student`, `POST /student` | local sign-in, sets an HttpOnly session cookie |
| `GET /student`, `POST /student` | email sign-in, sets an HttpOnly session cookie |
| `POST /student/google` | verifies a Google ID token, signs in |
| `GET /student/google/callback` | redeems the authorization code for the full-page flow |
| `GET /logout` | clears the session |
| `GET /build`, `POST /build` | chapter picker and paper generation |
| `POST /attempt/:paper` | starts or resumes an attempt |

View file

@ -2,12 +2,14 @@
<h3>What is stored</h3>
<p class="meta">Your name and email, the papers you build, your answers to each question, how long you spent on each one, and the score of every submitted paper.</p>
<h3>Where it lives</h3>
<p class="meta">Everything is written to a SQLite file on the machine running this service. Nothing is sent to a third party. There is no analytics script, no tracking pixel, and no external font request beyond the Google Fonts stylesheet used for typography.</p>
<h3>What is never collected</h3>
<p class="meta">No contacts, no location, no device identifiers, no advertising profile.</p>
<p class="meta">Everything is written to a SQLite file on the machine running this service. There is no analytics script, no tracking pixel, and no advertising profile.</p>
<h3>Signing in with Google</h3>
<p class="meta">If you sign in with Google, your browser contacts Google directly and Google returns a signed identity token to this server. The server checks that signature against Google's published keys before it trusts anything in it, then stores only your name and email address. Your Google password is never seen here, no Google account permissions are requested, and no data is sent back to Google. Google separately keeps its own record of the sign-in under its privacy policy.</p>
<h3>Signing in without Google</h3>
<p class="meta">The email form on the sign-in page does not contact Google or anything else. It writes the name and email you type straight to the database, with no password, so anyone who can reach the page could claim an address that is not theirs. Treat it as a practice account rather than a real one.</p>
<h3>Who can read it</h3>
<p class="meta">Anyone with access to the database file or to a signed-in browser session. Sessions are held in an HttpOnly cookie scoped to this site.</p>
<h3>Deleting your data</h3>
<p class="meta">Delete your account and every paper, answer and result attached to it by removing your row from the users table. Nothing is retained elsewhere, so that removal is complete.</p>
<p class="meta">Delete your account and every paper, answer and result attached to it by removing your row from the users table. Nothing is retained elsewhere, so that removal is complete. Signing in with Google does not change this: the copy here is deleted on its own, and unlinking it from your Google account is a separate action you take at myaccount.google.com.</p>
<h3>Questions bank</h3>
<p class="meta">Question content is study material held in the same database. It is not personal data and is not linked to you beyond the papers you build from it.</p>

View file

@ -2,7 +2,7 @@
<h3>What the service does</h3>
<p class="meta">It builds practice papers from a bank of JEE Main style questions, runs them on a timed screen with +4 and −1 marking, and keeps your submitted papers so you can review results.</p>
<h3>Your account</h3>
<p class="meta">Sign-in is local to this installation. There is no password: anyone who knows the email can attach papers to that account. Do not use it for anything you would mind losing.</p>
<p class="meta">Signing in with Google attaches your papers to that Google account, verified server side, so they follow you to another device. The email form does not check that you own the address, so treat an account made that way as a practice account and do not use it for anything you would mind losing.</p>
<h3>Question accuracy</h3>
<p class="meta">Questions, answers and solutions are study material and may contain errors. Treat a wrong answer key as a bug, not a fact, and check anything you plan to rely on.</p>
<h3>Acceptable use</h3>

View file

@ -0,0 +1,48 @@
//! Signs a token the way Google would, but with a key the server does not trust.
//! Used to confirm the callback refuses a well-formed token from an unknown issuer.
//!
//! cargo run --example forge_google_token -- <client_id> <email>
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::Serialize;
#[derive(Serialize)]
struct Claims {
sub: String,
email: String,
email_verified: bool,
name: String,
exp: i64,
aud: String,
iss: String,
}
fn main() {
let mut args = std::env::args().skip(1);
let client_id = args.next().unwrap_or_else(|| "test-client.apps.googleusercontent.com".into());
let email = args.next().unwrap_or_else(|| "attacker@example.com".into());
// A throwaway key. The server has never seen it, so its signature must not verify.
let pem = std::process::Command::new("openssl")
.args(["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048"])
.output()
.expect("openssl is needed to generate the throwaway key")
.stdout;
let key = EncodingKey::from_rsa_pem(&pem).expect("key");
let claims = Claims {
sub: "9999999999".into(),
email,
email_verified: true,
name: "Attacker".into(),
exp: chrono::Utc::now().timestamp() + 3600,
aud: client_id,
iss: "https://accounts.google.com".into(),
};
// A real Google token names the key that signed it. Reusing a published kid
// forces the server down its "check the signature" path rather than the
// "which key is this" one, which is the case worth proving.
let mut header = Header::new(Algorithm::RS256);
header.kid = Some("a-key-id-nobody-signed-with".into());
println!("{}", encode(&header, &claims, &key).expect("sign"));
}

View file

@ -6,6 +6,7 @@ use serde::Deserialize;
use sqlx::SqlitePool;
use std::sync::Arc;
use crate::google::Google;
use crate::views;
pub const COOKIE: &str = "pyq_session";
@ -13,6 +14,8 @@ pub const COOKIE: &str = "pyq_session";
pub struct Ctx {
pub pool: SqlitePool,
pub base: String,
pub google_client_id: String,
pub google: Option<Arc<Google>>,
}
impl Ctx {
@ -84,10 +87,10 @@ pub async fn student_post(State(ctx): State<Arc<Ctx>>, Form(f): Form<StudentForm
.unwrap_or_else(|| "/build".into());
if name.is_empty() || name.chars().count() > 80 {
return html_page(views::student(&ctx, &next, Some("Enter the name your account shows.")), StatusCode::BAD_REQUEST);
return html_page(views::student(&ctx, &next, Some("Enter the name your account shows."), None), StatusCode::BAD_REQUEST);
}
if !email.contains('@') || email.len() > 120 {
return html_page(views::student(&ctx, &next, Some("That email address does not look right.")), StatusCode::BAD_REQUEST);
return html_page(views::student(&ctx, &next, Some("That email address does not look right."), None), StatusCode::BAD_REQUEST);
}
let candidate = uuid::Uuid::new_v4().to_string();
@ -123,6 +126,32 @@ pub async fn student_post(State(ctx): State<Arc<Ctx>>, Form(f): Form<StudentForm
.expect("static redirect response")
}
/// Creates a session for a verified identity and returns the redirect that carries it.
pub async fn start_session(ctx: &Ctx, uid: &str, name: &str, email: &str, next: Option<String>) -> axum::response::Response {
let _ = sqlx::query("UPDATE users SET name=?, email=? WHERE id=?").bind(name).bind(email).bind(uid).execute(&ctx.pool).await;
let token = uuid::Uuid::new_v4().simple().to_string();
if let Err(e) = sqlx::query("INSERT INTO sessions(token,user_id,created_at) VALUES(?,?,?)")
.bind(&token)
.bind(uid)
.bind(Utc::now().to_rfc3339())
.execute(&ctx.pool)
.await
{
tracing::error!("could not start session: {e}");
}
tracing::info!("sign in: {email}");
let to = next.filter(|n| n.starts_with('/') && !n.contains("..")).unwrap_or_else(|| "/build".into());
axum::response::Response::builder()
.status(StatusCode::FOUND)
.header(axum::http::header::LOCATION, format!("{}{}", ctx.base, to))
.header(
axum::http::header::SET_COOKIE,
format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000"),
)
.body(axum::body::Body::empty())
.expect("static redirect response")
}
#[derive(Deserialize)]
pub struct StudentForm {
pub name: String,

View file

@ -12,6 +12,7 @@ pub async fn init_db(pool: &SqlitePool) -> anyhow::Result<()> {
CREATE TABLE IF NOT EXISTS answers(attempt_id TEXT NOT NULL, question_id INTEGER NOT NULL, selected TEXT DEFAULT '', marked_review INTEGER DEFAULT 0, time_secs INTEGER DEFAULT 0, PRIMARY KEY(attempt_id, question_id));
CREATE TABLE IF NOT EXISTS mistake_tags(attempt_id TEXT NOT NULL, question_id INTEGER NOT NULL, tag TEXT NOT NULL, PRIMARY KEY(attempt_id, question_id, tag));
CREATE TABLE IF NOT EXISTS sessions(token TEXT PRIMARY KEY, user_id TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS pkce(state TEXT PRIMARY KEY, verifier TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS seen_questions(user_id TEXT NOT NULL, question_id INTEGER NOT NULL, PRIMARY KEY(user_id, question_id));",
)
.execute(pool)

419
src/google.rs Normal file
View file

@ -0,0 +1,419 @@
// Google Identity Services.
//
// Two paths end at the same place:
// 1. The One Tap / button popup hands the browser an ID token, posted to /student/google.
// 2. Where a popup is blocked (in-app browsers, hardened settings) the page falls back
// to the authorization-code + PKCE redirect, which returns the same ID token.
//
// Both carry a token this module verifies before anything is trusted. The signature is
// checked against Google's published keys and the audience, issuer and expiry must all
// match this deployment. A token that fails any of that is rejected here, not in the
// browser.
use std::sync::Arc;
use std::time::{Duration, Instant};
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use base64::Engine as _;
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use tokio::sync::RwLock;
const JWKS_URL: &str = "https://www.googleapis.com/oauth2/v3/certs";
const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
const ISSUERS: &[&str] = &["https://accounts.google.com", "accounts.google.com"];
const CLOCK_SKEW: u64 = 60;
const JWKS_TTL: Duration = Duration::from_secs(3600);
const MAX_CREDENTIAL: usize = 8192;
#[derive(Deserialize, Debug, Clone)]
pub struct Claims {
/// Google's stable account id. Decoded so the token round-trips fully; the
/// account here is keyed on email instead.
#[allow(dead_code)]
pub sub: String,
pub email: String,
pub email_verified: Option<bool>,
pub name: Option<String>,
}
impl Claims {
pub fn display_name(&self) -> String {
self.name
.clone()
.filter(|n| !n.trim().is_empty())
.or_else(|| self.email.split('@').next().map(str::to_string))
.unwrap_or_else(|| "Student".into())
}
}
pub struct Google {
client_id: String,
client_secret: String,
http: reqwest::Client,
keys: Arc<RwLock<Cached>>,
}
struct Cached {
fetched: Instant,
keys: Vec<(String, DecodingKey)>,
}
impl Google {
pub fn new(client_id: &str, client_secret: &str) -> Self {
Self {
client_id: client_id.to_string(),
client_secret: client_secret.to_string(),
http: reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.user_agent(concat!("pyq-clone/", env!("CARGO_PKG_VERSION")))
.build()
.unwrap_or_default(),
keys: Arc::new(RwLock::new(Cached {
// Starts stale so the first sign-in fetches the keys.
fetched: Instant::now() - JWKS_TTL,
keys: Vec::new(),
})),
}
}
async fn jwks(&self) -> anyhow::Result<Vec<(String, DecodingKey)>> {
let cached = self.keys.read().await;
if !cached.keys.is_empty() && cached.fetched.elapsed() < JWKS_TTL {
return Ok(cached.keys.clone());
}
drop(cached);
let doc: Jwks = self
.http
.get(JWKS_URL)
.send()
.await?
.error_for_status()?
.json()
.await?;
let mut keys = Vec::new();
for k in doc.keys {
if k.alg.as_deref() != Some("RS256") {
continue;
}
// One unusable entry must not sink the set, so a bad key is skipped.
if let Ok(dec) = DecodingKey::from_rsa_components(&k.n, &k.e) {
keys.push((k.kid, dec));
}
}
if keys.is_empty() {
anyhow::bail!("Google published no usable RS256 signing key");
}
let mut guard = self.keys.write().await;
guard.fetched = Instant::now();
guard.keys = keys.clone();
Ok(keys)
}
/// Verifies an ID token end to end: signature, audience, issuer, expiry, then the
/// account fields this app actually depends on.
pub async fn verify(&self, id_token: &str) -> Result<Claims, String> {
let kid = header_kid(id_token).ok_or("that credential is not a Google sign-in token")?;
let keys = self.jwks().await.map_err(|e| format!("cannot reach Google right now: {e}"))?;
let key = keys
.iter()
.find(|(k, _)| k == &kid)
.map(|(_, key)| key)
.ok_or("Google rotated this signing key, so that token cannot be checked")?;
let claims = decode_claims(id_token, key, &self.client_id)?;
usable_account(&claims)?;
Ok(claims)
}
/// Authorization-code redemption, used by the redirect fallback.
pub async fn exchange_code(&self, code: &str, verifier: &str, redirect_uri: &str) -> Result<Claims, String> {
let mut form = vec![
("code", code.to_string()),
("client_id", self.client_id.clone()),
("code_verifier", verifier.to_string()),
("redirect_uri", redirect_uri.to_string()),
("grant_type", "authorization_code".to_string()),
];
if !self.client_secret.is_empty() {
form.push(("client_secret", self.client_secret.clone()));
}
let res: TokenResponse = self
.http
.post(TOKEN_URL)
.form(&form)
.send()
.await
.map_err(|e| format!("cannot reach Google right now: {e}"))?
.error_for_status()
.map_err(|e| format!("Google refused the code exchange: {e}"))?
.json()
.await
.map_err(|e| format!("Google sent an unexpected reply: {e}"))?;
if let Some(err) = res.error {
return Err(format!("Google rejected that sign-in: {}", err.error_description.unwrap_or(err.error)));
}
self.verify(&res.id_token).await
}
}
/// Pure half of `verify`, kept separate so it can be tested without a network call.
fn decode_claims(token: &str, key: &DecodingKey, client_id: &str) -> Result<Claims, String> {
if token.len() > MAX_CREDENTIAL {
return Err("that credential is too long to be a token".into());
}
let mut v = Validation::new(Algorithm::RS256);
v.set_audience(&[client_id]);
v.set_issuer(ISSUERS);
v.leeway = CLOCK_SKEW;
v.validate_exp = true;
jsonwebtoken::decode::<Claims>(token, key, &v)
.map(|d| d.claims)
.map_err(|e| format!("that sign-in could not be verified: {e}"))
}
fn usable_account(c: &Claims) -> Result<(), String> {
if c.email_verified == Some(false) {
return Err("that Google account has no verified email address".into());
}
if c.email.trim().is_empty() || !c.email.contains('@') {
return Err("that Google account has no email address to key the account on".into());
}
Ok(())
}
/// Reads `kid` from the JWT header so the matching key is chosen before decoding.
fn header_kid(token: &str) -> Option<String> {
#[derive(Deserialize)]
struct Header {
kid: Option<String>,
}
let raw = B64.decode(token.split('.').next()?).ok()?;
serde_json::from_slice::<Header>(&raw).ok()?.kid
}
pub fn new_verifier() -> String {
random_b64()
}
pub fn challenge(verifier: &str) -> String {
B64.encode(Sha256::digest(verifier.as_bytes()))
}
pub fn new_state() -> String {
random_b64()
}
fn random_b64() -> String {
// Two UUIDs give 32 bytes, base64url-encoded to 43 characters: inside the
// 43..128 range RFC 7636 asks of a PKCE verifier.
let bytes: Vec<u8> = uuid::Uuid::new_v4()
.as_bytes()
.iter()
.chain(uuid::Uuid::new_v4().as_bytes().iter())
.copied()
.collect();
B64.encode(bytes)
}
#[derive(Deserialize)]
struct Jwks {
keys: Vec<Jwk>,
}
#[derive(Deserialize)]
struct Jwk {
kid: String,
alg: Option<String>,
n: String,
e: String,
}
#[derive(Deserialize)]
struct TokenResponse {
#[serde(default)]
id_token: String,
#[serde(default)]
error: Option<TokenError>,
}
#[derive(Deserialize)]
struct TokenError {
error: String,
#[serde(default)]
error_description: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{encode, EncodingKey, Header};
use rsa::pkcs1::EncodeRsaPrivateKey;
use rsa::pkcs8::SecretDocument;
use rsa::traits::PublicKeyParts;
use rsa::RsaPrivateKey;
use serde::Serialize;
const APP: &str = "client-abc.apps.googleusercontent.com";
#[derive(Serialize)]
struct Claims {
sub: String,
email: String,
#[serde(skip_serializing_if = "Option::is_none")]
email_verified: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
exp: usize,
aud: String,
iss: String,
}
struct Fixture {
signing: EncodingKey,
verifying: DecodingKey,
foreign_signing: EncodingKey,
}
fn fixture() -> Fixture {
let mine = RsaPrivateKey::new(&mut rand::thread_rng(), 2048).expect("rsa key");
let theirs = RsaPrivateKey::new(&mut rand::thread_rng(), 2048).expect("rsa key");
// jsonwebtoken wants the raw PKCS#1 private key, not the PKCS#8 wrapper.
let der = |k: &RsaPrivateKey| {
let doc: SecretDocument = k.to_pkcs1_der().expect("der");
EncodingKey::from_rsa_der(doc.as_bytes())
};
Fixture {
signing: der(&mine),
verifying: DecodingKey::from_rsa_components(&B64.encode(mine.n().to_bytes_be()), &B64.encode(mine.e().to_bytes_be()))
.expect("verifying key"),
foreign_signing: der(&theirs),
}
}
fn claims(mutate: impl FnOnce(&mut Claims)) -> Claims {
let mut c = Claims {
sub: "1234567890".into(),
email: "student@example.com".into(),
email_verified: Some(true),
name: Some("Test Student".into()),
exp: chrono::Utc::now().timestamp() as usize + 3600,
aud: APP.into(),
iss: ISSUERS[0].into(),
};
mutate(&mut c);
c
}
fn token(f: &Fixture, mutate: impl FnOnce(&mut Claims)) -> String {
encode(&Header::new(Algorithm::RS256), &claims(mutate), &f.signing).expect("sign")
}
#[test]
fn accepts_a_well_formed_token() {
let f = fixture();
let c = decode_claims(&token(&f, |_| {}), &f.verifying, APP).expect("should verify");
assert_eq!(c.email, "student@example.com");
assert_eq!(c.display_name(), "Test Student");
}
#[test]
fn accepts_the_bare_issuer_without_a_scheme() {
let f = fixture();
let t = token(&f, |c| c.iss = "accounts.google.com".into());
assert!(decode_claims(&t, &f.verifying, APP).is_ok());
}
#[test]
fn rejects_a_token_signed_by_another_key() {
let f = fixture();
let forged = encode(&Header::new(Algorithm::RS256), &claims(|_| {}), &f.foreign_signing).expect("sign");
assert!(decode_claims(&forged, &f.verifying, APP).is_err(), "a foreign signing key must not verify");
}
#[test]
fn rejects_a_token_audienced_to_another_app() {
let f = fixture();
let t = token(&f, |c| c.aud = "somebody-elses-app.apps.googleusercontent.com".into());
let err = decode_claims(&t, &f.verifying, APP).unwrap_err();
assert!(err.contains("Audience"), "an audience mismatch must be reported as such, got: {err}");
}
#[test]
fn rejects_a_token_from_another_issuer() {
let f = fixture();
let t = token(&f, |c| c.iss = "https://evil.example".into());
assert!(decode_claims(&t, &f.verifying, APP).is_err());
}
#[test]
fn rejects_an_expired_token() {
let f = fixture();
let past = chrono::Utc::now().timestamp() as usize - 3600;
let t = token(&f, |c| c.exp = past);
assert!(decode_claims(&t, &f.verifying, APP).is_err());
}
#[test]
fn rejects_a_tampered_payload() {
let f = fixture();
let good = token(&f, |_| {});
let mut parts: Vec<String> = good.split('.').map(str::to_string).collect();
let forged = format!(
r#"{{"sub":"1","email":"attacker@example.com","email_verified":true,"exp":{},"aud":"{APP}","iss":"https://accounts.google.com"}}"#,
chrono::Utc::now().timestamp() as usize + 3600
);
parts[1] = B64.encode(forged);
assert!(decode_claims(&parts.join("."), &f.verifying, APP).is_err(), "a rewritten payload must not verify");
}
#[test]
fn rejects_an_unverified_email() {
let f = fixture();
let t = token(&f, |c| c.email_verified = Some(false));
let c = decode_claims(&t, &f.verifying, APP).expect("the signature is still valid");
assert!(usable_account(&c).is_err());
}
#[test]
fn falls_back_to_the_email_when_a_name_is_absent() {
let f = fixture();
let t = token(&f, |c| c.name = None);
let c = decode_claims(&t, &f.verifying, APP).expect("verifies");
assert_eq!(c.display_name(), "student");
}
#[test]
fn pkce_challenge_is_the_sha256_of_the_verifier() {
assert_eq!(
challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
}
#[test]
fn verifiers_and_states_are_unique_and_sized_for_rfc_7636() {
let a = new_verifier();
let b = new_verifier();
assert_ne!(a, b);
assert!((43..=128).contains(&a.len()), "verifier length {} is out of range", a.len());
assert_ne!(new_state(), new_state());
}
#[test]
fn a_header_without_a_kid_is_not_treated_as_a_token() {
let head = B64.encode(br#"{"alg":"RS256"}"#);
let t = format!("{head}.e30.c2ln");
assert_eq!(header_kid(&t), None);
assert_eq!(header_kid("not-a-jwt"), None);
}
#[test]
fn a_credential_longer_than_a_token_is_rejected() {
let f = fixture();
let padded = "x".repeat(MAX_CREDENTIAL + 1);
assert!(decode_claims(&padded, &f.verifying, APP).is_err());
}
}

View file

@ -2,6 +2,7 @@ mod analytics;
mod auth;
mod db;
mod exam;
mod google;
mod views;
use std::sync::Arc;
@ -41,12 +42,26 @@ async fn main() -> anyhow::Result<()> {
.await?;
sqlx::query("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;").execute(&pool).await?;
db::init_db(&pool).await?;
let ctx = Arc::new(Ctx { pool, base: base.clone() });
let client_id = std::env::var("GOOGLE_CLIENT_ID").unwrap_or_default();
let client_secret = std::env::var("GOOGLE_CLIENT_SECRET").unwrap_or_default();
let google = if client_id.is_empty() {
tracing::warn!("GOOGLE_CLIENT_ID is unset: the Google button stays hidden and only local sign-in works");
None
} else {
if client_secret.is_empty() {
tracing::warn!("GOOGLE_CLIENT_SECRET is unset: the popup flow still works, the full-page fallback does not");
}
tracing::info!("Google sign-in enabled for {}", &client_id[..client_id.len().min(16)]);
Some(Arc::new(google::Google::new(&client_id, &client_secret)))
};
let ctx = Arc::new(Ctx { pool, base: base.clone(), google_client_id: client_id, google });
let static_dir = format!("{}/static", std::env::current_dir()?.display());
let app = Router::new()
.route("/", get(home))
.route("/student", get(student_get).post(student_post))
.route("/student/google", post(google_callback))
.route("/student/google/callback", get(google_redirect))
.route("/logout", get(auth::logout))
.route("/terms", get(terms))
.route("/privacy", get(privacy))
@ -92,13 +107,145 @@ async fn student_get(State(ctx): State<CtxArc>, h: HeaderMap, q: Query<std::coll
if auth::user_id(&ctx.pool, &h).await.is_some() {
return Redirect::to(&format!("{}{}", ctx.base, next)).into_response();
}
hx(views::student(&ctx, &next, None), StatusCode::OK).into_response()
// The fallback needs a PKCE verifier bound to this visit, held here rather than
// in a cookie so it cannot be read back by script.
let mut fallback = None;
if ctx.google.is_some() {
let state = google::new_state();
let verifier = google::new_verifier();
sqlx::query("INSERT INTO pkce(state, verifier, created_at) VALUES(?,?,?)")
.bind(&state)
.bind(&verifier)
.bind(chrono::Utc::now().to_rfc3339())
.execute(&ctx.pool)
.await
.ok();
let uri = format!("{}{}/student/google/callback", public_origin(&h), ctx.base);
fallback = Some((state, google::challenge(&verifier), uri));
}
hx(views::student(&ctx, &next, None, fallback), StatusCode::OK).into_response()
}
/// Scheme and host for the callback Google redirects back to, derived from the
/// request so it works on a port or behind a tunnel without extra configuration.
fn public_origin(h: &HeaderMap) -> String {
let host = h
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("localhost");
let scheme = if h
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.split(',').next().unwrap_or("http").trim() == "https")
{
"https"
} else {
"http"
};
format!("{scheme}://{host}")
}
async fn student_post(State(ctx): State<CtxArc>, Form(f): Form<auth::StudentForm>) -> axum::response::Response {
auth::student_post(State(ctx), axum::Form(f)).await
}
#[derive(serde::Deserialize)]
struct GoogleForm {
credential: String,
#[serde(default)]
next: Option<String>,
}
#[derive(serde::Deserialize)]
struct GoogleRedirect {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
/// Verifies the ID token Google issued, then attaches the account to a session.
/// A failure here is never trusted client-side: the token is re-checked here.
async fn google_callback(State(ctx): State<CtxArc>, Form(f): Form<GoogleForm>) -> axum::response::Response {
let Some(g) = ctx.google.clone() else {
return auth::html_page(
views::student(&ctx, "/build", Some("Google sign-in is not configured on this server."), None),
StatusCode::BAD_REQUEST,
)
.into_response();
};
let claims = match g.verify(&f.credential).await {
Ok(c) => c,
Err(msg) => {
tracing::warn!("google sign-in rejected: {msg}");
return auth::html_page(views::student(&ctx, f.next.as_deref().unwrap_or("/build"), Some(&msg), None), StatusCode::UNAUTHORIZED)
.into_response();
}
};
sign_in_with_google(&ctx, &claims, f.next).await
}
/// The redirect fallback: Google sends the authorization code back here, and it is
/// redeemed server side so no secret ever reaches the browser.
async fn google_redirect(State(ctx): State<CtxArc>, h: HeaderMap, Query(q): Query<GoogleRedirect>) -> axum::response::Response {
let fail = |m: &str| auth::html_page(views::student(&ctx, "/build", Some(m), None), StatusCode::BAD_REQUEST).into_response();
let Some(g) = ctx.google.clone() else {
return fail("Google sign-in is not configured on this server.");
};
if let Some(e) = q.error.as_deref() {
return fail(&format!("Google declined that sign-in: {e}"));
}
let (Some(code), Some(state)) = (q.code, q.state) else {
return fail("That sign-in link is incomplete. Try again.");
};
// The state must match one issued here, and it is spent on first use.
let verifier: Option<String> = sqlx::query_scalar(
"SELECT verifier FROM pkce WHERE state=? AND created_at > ?",
)
.bind(&state)
.bind((chrono::Utc::now() - chrono::Duration::minutes(10)).to_rfc3339())
.fetch_optional(&ctx.pool)
.await
.unwrap_or(None);
sqlx::query("DELETE FROM pkce WHERE state=?").bind(&state).execute(&ctx.pool).await.ok();
let Some(verifier) = verifier else {
return fail("That sign-in link has expired or was not started here.");
};
let redirect_uri = format!("{}{}/student/google/callback", public_origin(&h), ctx.base);
let claims = match g.exchange_code(&code, &verifier, &redirect_uri).await {
Ok(c) => c,
Err(msg) => {
tracing::warn!("google code exchange failed: {msg}");
return auth::html_page(views::student(&ctx, "/build", Some(&msg), None), StatusCode::UNAUTHORIZED).into_response();
}
};
sign_in_with_google(&ctx, &claims, None).await
}
async fn sign_in_with_google(ctx: &CtxArc, claims: &google::Claims, next: Option<String>) -> axum::response::Response {
let email = claims.email.to_lowercase();
let name = claims.display_name();
let candidate = uuid::Uuid::new_v4().to_string();
if let Err(e) = sqlx::query("INSERT INTO users(id,name,email,created_at) VALUES(?,?,?,?) ON CONFLICT(email) DO UPDATE SET name=excluded.name")
.bind(&candidate)
.bind(&name)
.bind(&email)
.bind(chrono::Utc::now().to_rfc3339())
.execute(&ctx.pool)
.await
{
tracing::error!("could not store google user: {e}");
}
let uid: String = sqlx::query_scalar("SELECT id FROM users WHERE email=?")
.bind(&email)
.fetch_one(&ctx.pool)
.await
.unwrap_or(candidate);
auth::start_session(ctx, &uid, &name, &email, next).await
}
async fn build_page(State(ctx): State<CtxArc>, h: HeaderMap) -> axum::response::Response {
let (_uid, name) = match ctx.require_user(&h).await {
Ok(v) => v,

View file

@ -103,25 +103,130 @@ pub fn landing(ctx: &Ctx, q: i64, shifts: i64, subtopics: i64, subjects: i64, st
shell(ctx, None, "Home", body)
}
pub fn student(ctx: &Ctx, next: &str, err: Option<&str>) -> String {
let e = err.map(|m| format!(r#"<p class="bad-t">{m}</p>"#)).unwrap_or_default();
/// PKCE parameters for the full-page fallback: `(state, challenge, redirect uri)`.
pub type Fallback = Option<(String, String, String)>;
pub fn student(ctx: &Ctx, next: &str, err: Option<&str>, fallback: Fallback) -> String {
let e = err.map(|m| format!(r#"<p class="bad-t" role="alert">{m}</p>"#)).unwrap_or_default();
let (intro, google_block, action) = match (&fallback, ctx.google.is_some()) {
(Some(_), true) => (
"Sign in with Google and every paper you build, submit and analyse is attached to that account, so a score is still there on the next device.",
format!(
r#"<div id="google-btn" style="display:flex;justify-content:center;margin:18px 0;min-height:44px"></div>
<form method="post" action="{b}/student/google" id="google-form" hidden>
<input type="hidden" name="credential" value="">
<input type="hidden" name="next" value="{next}">
</form>
<p class="dim" id="gsi-status" role="status">loading the Google button&hellip;</p>"#,
b = ctx.base,
next = esc(next)
),
"sign in with an email instead",
),
(None, true) => (
"Google sign-in is available but its button did not get its parameters. Refresh the page, or use the form below.",
String::new(),
"sign in",
),
_ => (
"Google sign-in is switched off on this server because no client id was configured. The form below keys your papers to an email so the whole flow still works.",
String::new(),
"sign in",
),
};
let body = format!(
r#"<h1>Sign in</h1>
<p class="meta">This clone runs a local sign-in so you can use the whole flow without a Google account. Enter any name and email; your papers stay in this database.</p>
<form method="post" action="{b}/student" style="max-width:420px;margin-top:18px">
<p class="meta">{intro}</p>
{google_block}
<h2>{action}</h2>
<form method="post" action="{b}/student" style="max-width:420px;margin-top:8px">
<input type="hidden" name="next" value="{next}">
<label for="name">name</label><input id="name" name="name" type="text" autocomplete="name" required>
<label for="email">email</label><input id="email" name="email" type="email" autocomplete="email" required>
{e}
<div style="margin-top:14px"><button class="btn primary" type="submit">sign in</button></div>
</form>"#,
<div style="margin-top:14px"><button class="btn primary" type="submit">{action}</button></div>
</form>
{gsi_script}"#,
intro = intro,
google_block = google_block,
action = action,
b = ctx.base,
next = esc(next),
e = e
e = e,
gsi_script = match &fallback {
Some((state, challenge, redirect_uri)) if ctx.google.is_some() => {
gsi_script(&ctx.google_client_id, state, challenge, redirect_uri)
}
_ => String::new(),
}
);
shell(ctx, None, "Sign in", body)
}
/// Google Identity Services. The popup is the normal path; the anchor is the
/// fallback for in-app browsers and hardened settings where a popup is refused.
fn gsi_script(client_id: &str, state: &str, challenge: &str, redirect_uri: &str) -> String {
format!(
r#"<script src="https://accounts.google.com/gsi/client" async defer></script>
<script>
(() => {{
const CLIENT_ID = {cid};
const STATUS = document.getElementById('gsi-status');
const FALLBACK = {url};
// The popup path: Google hands the credential to this page, which posts it on.
function onCredential(r) {{
const f = document.getElementById('google-form');
f.credential.value = r.credential;
f.submit();
}}
function wireFallback() {{
const a = document.getElementById('gsi-full');
a.href = 'https://accounts.google.com/o/oauth2/v2/auth'
+ '?client_id=' + encodeURIComponent(CLIENT_ID)
+ '&redirect_uri=' + encodeURIComponent(FALLBACK.redirect_uri)
+ '&response_type=code'
+ '&scope=' + encodeURIComponent('openid email profile')
+ '&code_challenge=' + encodeURIComponent(FALLBACK.challenge)
+ '&code_challenge_method=S256'
+ '&state=' + encodeURIComponent(FALLBACK.state)
+ '&prompt=select_account';
}}
function init() {{
try {{
google.accounts.id.initialize({{ client_id: CLIENT_ID, callback: onCredential, ux_mode: 'popup', context: 'signin' }});
const box = document.getElementById('google-btn');
google.accounts.id.renderButton(box, {{
theme: 'outline_black', size: 'large', text: 'signin_with', shape: 'rectangular',
width: Math.min(box.clientWidth || 320, 400),
}});
wireFallback();
STATUS.textContent = 'no popup? ';
STATUS.appendChild(document.getElementById('gsi-full'));
}} catch (err) {{
wireFallback();
STATUS.textContent = 'the Google button did not load. ';
STATUS.appendChild(document.getElementById('gsi-full'));
}}
}}
const link = document.createElement('a');
link.id = 'gsi-full';
link.href = '#';
link.textContent = 'use a full page instead';
link.addEventListener('click', e => {{ e.preventDefault(); wireFallback(); location.href = link.href; }});
if (window.google) init();
else window.addEventListener('load', init);
}})();
</script>"#,
cid = serde_json::to_string(client_id).unwrap_or_else(|_| "\"\"".into()),
url = serde_json::to_string(&serde_json::json!({ "redirect_uri": redirect_uri, "challenge": challenge, "state": state }))
.unwrap_or_else(|_| "{}".into())
)
}
pub fn legal(ctx: &Ctx, title: &str, body_md: &str) -> String {
let body = format!(r#"<h1>{t}</h1><div style="max-width:70ch">{b}</div>"#, t = esc(title), b = body_md);
shell(ctx, None, title, body)