This repository has been archived on 2026-10-08. You can view files and clone it, but cannot push or open issues or pull requests.

153 lines
4.4 KiB
JavaScript

import crypto from "crypto";
import { Router } from "express";
import { Issuer, generators } from "openid-client";
import config from "../config.js";
import { getLogger } from "../logger.js";
const logger = getLogger("OIDC");
export const SESSION_COOKIE = "ta.sid";
const DEFAULT_APP_REDIRECT = "allotment://auth";
let client;
/** Same format as the express-session cookie value (`s:` + cookie-signature). */
function signSessionId(sessionId) {
const mac = crypto
.createHmac("sha256", config.auth.sessionSecret)
.update(sessionId)
.digest("base64")
.replace(/=+$/, "");
return `s:${sessionId}.${mac}`;
}
/**
* Lets the iOS app authenticate with `Authorization: Bearer <token>` by
* presenting the token from the app login redirect as the session cookie.
* Must run before the session middleware.
*/
export function bearerSessionMiddleware(req, _res, next) {
const match = /^Bearer\s+(s:.+)$/i.exec(req.headers.authorization || "");
if (match) {
req.headers.cookie = `${SESSION_COOKIE}=${encodeURIComponent(match[1].trim())}`;
}
next();
}
export async function createOidcClient() {
const {
issuer: issuerUrl,
clientId,
clientSecret,
redirectUri,
appCallbackUri,
} = config.auth.oidc;
if (!issuerUrl || !clientId || !redirectUri) {
logger.warn(
"OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_REDIRECT_URI not set — OIDC disabled.",
);
return null;
}
const issuer = await Issuer.discover(issuerUrl);
client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret || undefined,
redirect_uris: appCallbackUri
? [redirectUri, appCallbackUri]
: [redirectUri],
response_types: ["code"],
});
return client;
}
export function createOidcRouter(clientUrl) {
const router = Router();
router.get("/auth/login", async (req, res, next) => {
if (!client) {
return res.status(503).send("OIDC not configured");
}
try {
const nonce = generators.nonce();
const state = generators.state();
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const appLogin = req.query.app === "1";
const redirectUri =
(appLogin && config.auth.oidc.appCallbackUri) ||
config.auth.oidc.redirectUri;
req.session.oidc = { nonce, state, code_verifier, appLogin, redirectUri };
const authorizationUrl = client.authorizationUrl({
redirect_uri: redirectUri,
scope: "openid profile email",
state,
nonce,
code_challenge,
code_challenge_method: "S256",
});
await new Promise((resolve, reject) => {
req.session.save((err) => (err ? reject(err) : resolve()));
});
res.redirect(authorizationUrl);
} catch (e) {
next(e);
}
});
router.get("/auth/callback", async (req, res, next) => {
if (!client) {
return res.status(503).send("OIDC not configured");
}
try {
const params = client.callbackParams(req);
const expectedState = req.session.oidc?.state;
const checks = {
state: expectedState,
nonce: req.session.oidc?.nonce,
code_verifier: req.session.oidc?.code_verifier,
};
const redirectUri =
req.session.oidc?.redirectUri || config.auth.oidc.redirectUri;
const tokenSet = await client.callback(redirectUri, params, checks);
const claims = tokenSet.claims();
req.session.user = {
sub: claims.sub,
email: claims.email,
name: claims.name,
};
const appLogin = req.session.oidc?.appLogin === true;
delete req.session.oidc;
await new Promise((resolve, reject) => {
req.session.save((err) => (err ? reject(err) : resolve()));
});
if (appLogin) {
const token = signSessionId(req.sessionID);
const target = new URL(config.auth.appRedirectUri || DEFAULT_APP_REDIRECT);
target.searchParams.set("token", token);
res.redirect(target.toString());
return;
}
res.redirect(clientUrl || "/");
} catch (e) {
logger.error("callback error:", e);
next(e);
}
});
router.post("/auth/logout", (req, res) => {
req.session.destroy(() => {
res.json({ ok: true });
});
});
router.get("/auth/logout", (req, res) => {
req.session.destroy(() => {
res.redirect(clientUrl || "/");
});
});
return router;
}