Skip to content

Commit 22ed146

Browse files
bankworachedclaude
andcommitted
feat(auth): add ChulaSSO 2.0 login (uni-gated)
ChulaSSO is Chulalongkorn University's SSO — a custom ticket flow, NOT OIDC, so Kutt's built-in OIDC can't talk to it. This adds a native integration: - GET /login/chulasso/start -> redirect to ChulaSSO with our callback as `service` - GET /login/chulasso -> validate the returned ticket via POST /serviceValidation (DeeAppId/DeeAppSecret/DeeTicket), then find-or-create a verified user and issue the Kutt session (mirrors the OIDC handler). - Optional CHULASSO_ALLOWED_ROLES gate (e.g. student,faculty); empty = any Chula account. - "Login with ChulaSSO" button on the login page; login_disabled exempts ChulaSSO. - New CHULASSO_* env, documented in .example.env. Pure role-check helper + unit test. CI: build/push ghcr.io/isd-sgcu/kutt; drop the upstream Docker Hub workflows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c4c7ab9 commit 22ed146

12 files changed

Lines changed: 206 additions & 128 deletions

.example.env

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,16 @@ OIDC_CLIENT_ID=
9898
OIDC_CLIENT_SECRET=
9999
OIDC_SCOPE=
100100
OIDC_EMAIL_CLAIM=
101+
102+
# Optional - Login with ChulaSSO 2.0 (Chulalongkorn University; custom ticket flow, not OIDC)
103+
# Register an app at https://account.it.chula.ac.th to get app_id/app_secret, and add
104+
# this site's base URL (e.g. https://chula.me/) as an allowed Service URL prefix.
105+
# Callback is {DEFAULT_DOMAIN}/login/chulasso. First login auto-creates a verified user.
106+
CHULASSO_ENABLED=false
107+
CHULASSO_BASE=https://account.it.chula.ac.th
108+
CHULASSO_APP_ID=
109+
CHULASSO_APP_SECRET=
110+
# Comma-separated roles allowed in (e.g. student,faculty). Empty = any Chula account.
111+
CHULASSO_ALLOWED_ROLES=
112+
# Display name shown on the ChulaSSO grant screen. Empty = SITE_NAME.
113+
CHULASSO_SERVICE_NAME=

.github/workflows/docker-build-development.yaml

Lines changed: 0 additions & 45 deletions
This file was deleted.

.github/workflows/docker-build-latest.yaml

Lines changed: 0 additions & 45 deletions
This file was deleted.

.github/workflows/docker-build-release.yaml

Lines changed: 0 additions & 36 deletions
This file was deleted.

.github/workflows/ghcr-isd.yaml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Build & push image (ghcr.io/isd-sgcu)
2+
3+
on:
4+
push:
5+
branches: [main, "feat/**"]
6+
tags: ["v*"]
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
packages: write
12+
13+
jobs:
14+
image:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: docker/setup-buildx-action@v3
19+
- uses: docker/login-action@v3
20+
with:
21+
registry: ghcr.io
22+
username: ${{ github.actor }}
23+
password: ${{ secrets.GITHUB_TOKEN }}
24+
- id: meta
25+
uses: docker/metadata-action@v5
26+
with:
27+
images: ghcr.io/isd-sgcu/kutt
28+
tags: |
29+
type=ref,event=branch
30+
type=ref,event=pr
31+
type=semver,pattern={{version}}
32+
type=sha,format=short
33+
type=raw,value=latest,enable={{is_default_branch}}
34+
- uses: docker/build-push-action@v6
35+
with:
36+
context: .
37+
push: true
38+
tags: ${{ steps.meta.outputs.tags }}
39+
labels: ${{ steps.meta.outputs.labels }}
40+
cache-from: type=gha
41+
cache-to: type=gha,mode=max

server/env.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ const spec = {
6868
OIDC_CLIENT_SECRET: str({ default: "" }),
6969
OIDC_SCOPE: str({ default: "openid profile email" }),
7070
OIDC_EMAIL_CLAIM: str({ default: "email" }),
71+
// ChulaSSO 2.0 (custom ticket flow — not OIDC). See server/handlers/chulasso.handler.js
72+
CHULASSO_ENABLED: bool({ default: false }),
73+
CHULASSO_BASE: str({ default: "https://account.it.chula.ac.th" }),
74+
CHULASSO_APP_ID: str({ default: "" }),
75+
CHULASSO_APP_SECRET: str({ default: "" }),
76+
CHULASSO_ALLOWED_ROLES: str({ default: "" }), // csv e.g. "student,faculty"; empty = any Chula account
77+
CHULASSO_SERVICE_NAME: str({ default: "" }), // grant-screen name; defaults to SITE_NAME
7178
ENABLE_RATE_LIMIT: bool({ default: false }),
7279
REPORT_EMAIL: str({ default: "" }),
7380
CONTACT_EMAIL: str({ default: "" }),
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// ChulaSSO 2.0 authentication (custom ticket flow — NOT OIDC).
2+
// Docs: https://account.it.chula.ac.th/html/docs
3+
// 1. Redirect the browser to {BASE}/login?service={callback}&app_id={id}
4+
// 2. ChulaSSO redirects back to {callback}?ticket={id}
5+
// 3. Validate server-side: POST {BASE}/serviceValidation with headers
6+
// DeeAppId / DeeAppSecret / DeeTicket -> JSON profile { email, roles, ... }
7+
// On success we find-or-create a verified Kutt user (mirrors the OIDC handler
8+
// in passport.js) and issue the normal Kutt session cookie.
9+
const bcrypt = require("bcryptjs");
10+
11+
const { roleAllowed } = require("./chulasso.util");
12+
const query = require("../queries");
13+
const utils = require("../utils");
14+
const env = require("../env");
15+
16+
const CustomError = utils.CustomError;
17+
18+
// Step 1 — send the browser to ChulaSSO with our callback as `service`.
19+
async function start(req, res) {
20+
const params = new URLSearchParams({
21+
service: utils.getSiteURL() + "/login/chulasso",
22+
app_id: env.CHULASSO_APP_ID,
23+
serviceName: env.CHULASSO_SERVICE_NAME || env.SITE_NAME,
24+
});
25+
res.redirect(`${env.CHULASSO_BASE}/login?${params.toString()}`);
26+
}
27+
28+
// Step 3 — validate the returned ticket and log the user in.
29+
async function callback(req, res) {
30+
const ticket = req.query.ticket;
31+
if (!ticket) {
32+
throw new CustomError("ChulaSSO did not return a ticket.", 400);
33+
}
34+
35+
const response = await fetch(`${env.CHULASSO_BASE}/serviceValidation`, {
36+
method: "POST",
37+
headers: {
38+
DeeAppId: env.CHULASSO_APP_ID,
39+
DeeAppSecret: env.CHULASSO_APP_SECRET,
40+
DeeTicket: String(ticket),
41+
},
42+
});
43+
44+
if (!response.ok) {
45+
throw new CustomError("ChulaSSO ticket validation failed.", 401);
46+
}
47+
48+
const profile = await response.json();
49+
const email = profile && profile.email;
50+
if (!email) {
51+
throw new CustomError("ChulaSSO profile did not include an email.", 400);
52+
}
53+
54+
if (!roleAllowed(profile.roles, env.CHULASSO_ALLOWED_ROLES)) {
55+
throw new CustomError("Your Chula account is not permitted to use this service.", 403);
56+
}
57+
58+
let user = await query.user.find({ email });
59+
60+
// First login for this account: create it, pre-verified (no email/password flow).
61+
if (!user) {
62+
const salt = await bcrypt.genSalt(12);
63+
const password = await bcrypt.hash(utils.generateRandomPassword(), salt);
64+
const created = await query.user.add({ email, password });
65+
user = await query.user.update(created, {
66+
verified: true,
67+
verification_token: null,
68+
verification_expires: null,
69+
});
70+
}
71+
72+
if (user.banned) {
73+
throw new CustomError("You're banned from using this website.", 403);
74+
}
75+
76+
const token = utils.signToken(user);
77+
utils.setToken(res, token);
78+
res.redirect("/");
79+
}
80+
81+
module.exports = { start, callback, roleAllowed };

server/handlers/chulasso.util.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Pure, dependency-free helpers for the ChulaSSO integration so they can be
2+
// unit-tested without loading env/db. See chulasso.util.test.js.
3+
4+
// Does the authenticated user's roles satisfy the allow-list?
5+
// allowedCsv blank/empty => any authenticated Chula account is allowed.
6+
function roleAllowed(roles, allowedCsv) {
7+
const allow = String(allowedCsv || "")
8+
.split(",")
9+
.map(r => r.trim().toLowerCase())
10+
.filter(Boolean);
11+
if (allow.length === 0) return true;
12+
const have = (Array.isArray(roles) ? roles : [])
13+
.map(r => String(r).trim().toLowerCase());
14+
return have.some(r => allow.includes(r));
15+
}
16+
17+
module.exports = { roleAllowed };
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Run: node server/handlers/chulasso.util.test.js
2+
const assert = require("node:assert");
3+
const { roleAllowed } = require("./chulasso.util");
4+
5+
// empty allow-list => any authenticated account is allowed
6+
assert.strictEqual(roleAllowed(["student"], ""), true);
7+
assert.strictEqual(roleAllowed([], ""), true);
8+
assert.strictEqual(roleAllowed(undefined, ""), true);
9+
10+
// allow-list set => must intersect
11+
assert.strictEqual(roleAllowed(["student"], "student,faculty"), true);
12+
assert.strictEqual(roleAllowed(["faculty"], "student,faculty"), true);
13+
assert.strictEqual(roleAllowed(["alumni"], "student,faculty"), false);
14+
assert.strictEqual(roleAllowed([], "student"), false);
15+
assert.strictEqual(roleAllowed(undefined, "student"), false);
16+
17+
// case + whitespace tolerant
18+
assert.strictEqual(roleAllowed(["Student"], " student "), true);
19+
assert.strictEqual(roleAllowed(["STUDENT"], "student"), true);
20+
21+
console.log("chulasso.util ok");

server/handlers/locals.handler.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ function config(req, res, next) {
2828
res.locals.server_cname_address = env.SERVER_CNAME_ADDRESS;
2929
res.locals.disallow_registration = env.DISALLOW_REGISTRATION;
3030
res.locals.disallow_login_form = env.DISALLOW_LOGIN_FORM;
31-
res.locals.login_disabled = env.DISALLOW_LOGIN_FORM && !env.OIDC_ENABLED;
31+
res.locals.login_disabled = env.DISALLOW_LOGIN_FORM && !env.OIDC_ENABLED && !env.CHULASSO_ENABLED;
3232
res.locals.oidc_enabled = env.OIDC_ENABLED;
33+
res.locals.chulasso_enabled = env.CHULASSO_ENABLED;
3334
res.locals.mail_enabled = env.MAIL_ENABLED;
3435
res.locals.report_email = env.REPORT_EMAIL;
3536
res.locals.custom_styles = utils.getCustomCSSFileNames();

0 commit comments

Comments
 (0)