From 9afaa48a72344e251ecae1c62333658e2812eaf8 Mon Sep 17 00:00:00 2001 From: lcsc0 Date: Wed, 15 Apr 2026 20:42:42 -0400 Subject: [PATCH] Integrate UMich Shibboleth SSO and rewrite user data endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authentication: - Add SAML strategy (src/auth/saml.ts) to authenticate users via UMich Shibboleth, supporting both test and production IdP endpoints. SAML disables gracefully if certificate files are missing so the server can still start in development. - Add auth routes (src/routes/auth.ts) for login redirect, SAML callback, logout with Single Logout, session introspection (/me), and SP metadata serving for IdP registration. - Back sessions with MongoDB via connect-mongo so sessions survive server restarts and are production-ready. Cookies are httpOnly, secure in production, and sameSite=lax. Middleware: - Add requireAuth to gate user-specific endpoints behind a valid Shibboleth session, returning 401 for unauthenticated requests. - Add requireAdmin to gate future usage-data endpoints behind an "admin" role check on InternalAccount, returning 403 for non-admins. This replaces the separate DevAccount model — admin users log in via Shibboleth like everyone else and are promoted in MongoDB. User data routes: - Rewrite all CRUD endpoints to use implicit ownership (Approach A): no :accountId in URLs, all queries scoped to req.user.accountId. This eliminates authorization bypass bugs where a missing ownership check could expose another user's data. - Remove POST /account since accounts are auto-provisioned on first SAML login via upsert in the verify callback. - Validate all write request bodies with Zod schemas. Cleanup: - Remove DevAccount model, dev routes, and requireDev middleware. Admin access is handled by the roles array on InternalAccount. - Remove JWT_SECRET and DEV_ADMIN_KEY from .env_example. - Add SAML and session environment variables to .env_example with setup instructions for certificate files. --- .env_example | 13 + .../2026-04-15-saml-shibboleth-auth-design.md | 354 +++++++++++++++++ package.json | 10 + src/app.ts | 40 +- src/auth/saml.ts | 130 ++++++ src/middleware/requireAdmin.ts | 24 ++ src/middleware/requireAuth.ts | 9 + src/routes/auth.ts | 57 +++ src/routes/users.ts | 376 +++++++++--------- src/types/express.d.ts | 10 + 10 files changed, 822 insertions(+), 201 deletions(-) create mode 100644 docs/superpowers/specs/2026-04-15-saml-shibboleth-auth-design.md create mode 100644 src/auth/saml.ts create mode 100644 src/middleware/requireAdmin.ts create mode 100644 src/middleware/requireAuth.ts create mode 100644 src/routes/auth.ts create mode 100644 src/types/express.d.ts diff --git a/.env_example b/.env_example index ba390df..c7a4d19 100644 --- a/.env_example +++ b/.env_example @@ -3,3 +3,16 @@ RIDE_API_KEY=YOUR_API_KEY GOOGLE_APPLICATION_CREDENTIALS=secrets/YOUR_KEY.json MONGODB_URI=mongodb://localhost:27017/mbus +# SAML / Shibboleth SSO +# SAML_ENV=test # "test" or "production" +# SAML_SP_ENTITY_ID=https://yourdomain.com/shibboleth # your SP entity ID +# SAML_SP_ACS_URL=https://yourdomain.com/auth/saml/callback +# SAML_SP_CERT=secrets/sp-cert.pem +# SAML_SP_KEY=secrets/sp-key.pem +# Cert files: download from UMich and place in secrets/ +# Test: https://shibboleth.umich.edu/md/shibboleth-nonprod-cert.pem -> secrets/shibboleth-nonprod-cert.pem +# Prod: https://shibboleth.umich.edu/md/shibboleth-production-cert.pem -> secrets/shibboleth-production-cert.pem +# SP: openssl req -x509 -newkey rsa:2048 -keyout secrets/sp-key.pem -out secrets/sp-cert.pem -days 3650 -nodes + +# Session (required when SAML is enabled) +SESSION_SECRET=replace-with-a-long-random-string diff --git a/docs/superpowers/specs/2026-04-15-saml-shibboleth-auth-design.md b/docs/superpowers/specs/2026-04-15-saml-shibboleth-auth-design.md new file mode 100644 index 0000000..daa59a7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-saml-shibboleth-auth-design.md @@ -0,0 +1,354 @@ +# SAML Shibboleth Authentication & User Data API Design + +**Date:** 2026-04-15 +**Status:** Draft +**Scope:** Integrate UMich Shibboleth SSO, implement user data CRUD endpoints, add admin role support + +## Context + +MaizeBus is an Express/TypeScript backend serving University of Michigan transit data. The committed main branch has no authentication — all endpoints are publicly accessible. We need to gate user-specific functionality behind UMich Shibboleth SSO so that only authenticated UMich users can access personalized features (saved stops, preferences, trip history, etc.). + +Dev/admin accounts are UMich users with an elevated `"admin"` role in the existing `InternalAccount.roles` array. No separate DevAccount model or auth flow is needed — admins authenticate via Shibboleth like everyone else. Admin role is assigned manually in MongoDB. Future usage-data endpoints (not yet defined) will be gated by a `requireAdmin` middleware. + +## Auth Architecture + +### Flow + +``` +User opens app + -> GET /auth/login + -> Redirect to UMich Shibboleth IdP + -> User authenticates with UMich credentials + -> IdP POSTs signed SAML assertion to /auth/saml/callback + -> Passport validates assertion signature using UMich public cert + -> Extracts uniqname, email, displayName from assertion attributes + -> InternalAccount upserted (created on first login, lastLoginAt updated on return) + -> express-session creates session, stored in MongoDB via connect-mongo + -> httpOnly secure cookie set on client (opaque session ID only) + -> User redirected to app + +Subsequent requests: + -> express-session loads session from MongoDB using cookie + -> passport.session() deserializes user onto req.user + -> requireAuth middleware checks req.isAuthenticated() + -> Route handler uses req.user.accountId to scope all data queries +``` + +### Why Sessions Over JWT + +Server-side sessions (MongoDB-backed) are the right choice because: + +- **Instant revocation:** Delete the session row and the user is logged out immediately. +- **No token leakage risk:** Client only holds an opaque session ID, not a signed blob with user data. +- **Clean logout:** Destroy session + Shibboleth SLO. With JWT, tokens remain valid until expiry. +- **Simpler:** No token refresh logic, no secret rotation, no blacklist tables. + +JWT is useful when you cannot maintain server-side state. We can — MongoDB is already our primary datastore. + +### SAML Strategy Configuration + +**Library:** `@node-saml/passport-saml` via Passport + +**IdP endpoints (UMich Shibboleth):** +- Test: `https://shib-idp-staging.dsc.umich.edu/idp/profile/SAML2/POST/SSO` +- Production: `https://shibboleth.umich.edu/idp/profile/SAML2/POST/SSO` + +**IdP certificates (downloaded from UMich):** +- Test: `secrets/shibboleth-nonprod-cert.pem` +- Production: `secrets/shibboleth-production-cert.pem` + +**SP configuration:** +- Entity ID: `SAML_SP_ENTITY_ID` env var (default: `https://maizebus.app/shibboleth`) +- ACS URL: `SAML_SP_ACS_URL` env var (default: `https://maizebus.app/auth/saml/callback`) +- SP cert/key: `secrets/sp-cert.pem`, `secrets/sp-key.pem` (self-signed, generated via OpenSSL) + +**SAML assertion attributes extracted:** +- `urn:oid:0.9.2342.19200300.100.1.1` -> uniqname +- `urn:oid:0.9.2342.19200300.100.1.3` -> email +- `urn:oid:2.16.840.1.113730.3.1.241` -> displayName + +**Graceful degradation:** If cert files are missing, SAML is disabled and `/auth/login` returns 503. The server still starts and serves transit data. + +## Middleware + +### requireAuth + +Checks `req.isAuthenticated()` (set by Passport session deserialization). Returns 401 if no valid session. Applied to all `/account/*` routes. + +### requireAdmin + +Wraps `requireAuth`, then checks if the user's `InternalAccount.roles` array contains `"admin"`. Returns 403 if not an admin. Applied to future usage-data endpoints (not built in this iteration). + +### Session Configuration + +``` +express-session: + store: MongoStore (connect-mongo) -> same MongoDB instance + secret: SESSION_SECRET env var + resave: false + saveUninitialized: false + cookie: + httpOnly: true (no JS access - XSS protection) + secure: true in prod (HTTPS only) + maxAge: 8 hours + sameSite: lax +``` + +Passport serialize/deserialize stores the user object in the session and restores it on each request. + +## Route Structure + +All user data endpoints use Approach A: no `:accountId` in URLs. Every query is scoped to `req.user.accountId` implicitly. This eliminates authorization bugs where a missing ownership check could expose another user's data. + +### Auth Routes (public, no middleware) + +| Method | Path | Purpose | +|--------|------|---------| +| GET | /auth/login | Redirect to UMich IdP | +| POST | /auth/saml/callback | Validate assertion, establish session | +| GET | /auth/logout | Destroy session + IdP SLO | +| GET | /auth/me | Return current user info (401 if not authed) | +| GET | /auth/saml/metadata | Serve SP metadata XML | + +### User Data Routes (all require `requireAuth`) + +**Account:** + +| Method | Path | Purpose | +|--------|------|---------| +| GET | /account | Get own account | +| PATCH | /account | Update own account | +| DELETE | /account | Soft-delete (set active=false) | + +No `POST /account` — accounts are auto-provisioned during SAML callback on first login. + +**Preferences:** + +| Method | Path | Purpose | +|--------|------|---------| +| GET | /account/preferences | Get preferences | +| PUT | /account/preferences | Upsert preferences | + +**Saved Stops:** + +| Method | Path | Purpose | +|--------|------|---------| +| GET | /account/saved-stops | List saved stops | +| POST | /account/saved-stops | Add a saved stop | +| PATCH | /account/saved-stops/:stopId | Update label/pin | +| DELETE | /account/saved-stops/:stopId | Remove saved stop | + +**Search History:** + +| Method | Path | Purpose | +|--------|------|---------| +| GET | /account/search-history | List (paginated, default 20) | +| POST | /account/search-history | Upsert entry (increments searchCount) | +| DELETE | /account/search-history | Clear all | +| DELETE | /account/search-history/:entryId | Delete one | + +**Notification Log:** + +| Method | Path | Purpose | +|--------|------|---------| +| GET | /account/notification-log | List (paginated, default 50) | +| POST | /account/notification-log | Create entry | +| PATCH | /account/notification-log/:logId | Mark as opened | + +**Trip History:** + +| Method | Path | Purpose | +|--------|------|---------| +| GET | /account/trip-history | List trips | +| POST | /account/trip-history | Record trip | +| DELETE | /account/trip-history/:tripId | Delete trip | + +**Commute Patterns (system-generated, read + delete only):** + +| Method | Path | Purpose | +|--------|------|---------| +| GET | /account/commute-patterns | List patterns | +| DELETE | /account/commute-patterns/:patternId | Delete pattern | + +**Personalization Profile (system-generated, read-only):** + +| Method | Path | Purpose | +|--------|------|---------| +| GET | /account/personalization-profile | Get profile | + +## Data Models + +All 8 models are already committed to main. No schema changes needed. The uncommitted `DevAccount` model is dropped entirely. + +### InternalAccount + +| Field | Type | Constraints | +|-------|------|-------------| +| uniqname | String | required, unique | +| roles | [String] | default: [] | +| active | Boolean | default: true | +| createdAt | Date | default: now | +| lastLoginAt | Date | default: now | + +### UserPreferences + +| Field | Type | Constraints | +|-------|------|-------------| +| accountId | ObjectId | required, unique, ref: InternalAccount | +| defaultView | String | default: "map" | +| notificationsEnabled | Boolean | default: true | +| notifyMinutesBefore | Number | default: 5 | +| preferredRouteIds | [String] | default: [] | +| accessibilityMode | Boolean | default: false | +| theme | String | default: "system" | +| updatedAt | Date | default: now | + +### SavedStop + +| Field | Type | Constraints | +|-------|------|-------------| +| accountId | ObjectId | required, ref: InternalAccount | +| stopId | String | required | +| customLabel | String | default: "" | +| pinned | Boolean | default: false | +| pinnedOrder | Number | default: 0 | +| savedAt | Date | default: now | + +Index: compound unique `{accountId, stopId}` + +### SearchHistory + +| Field | Type | Constraints | +|-------|------|-------------| +| accountId | ObjectId | required, ref: InternalAccount | +| query | String | required | +| resultType | String | default: "" | +| resultId | String | default: "" | +| searchCount | Number | default: 1 | +| lastSearchedAt | Date | default: now | + +Index: compound unique `{accountId, query, resultId}` + +### NotificationLog + +| Field | Type | Constraints | +|-------|------|-------------| +| accountId | ObjectId | required, ref: InternalAccount | +| type | String | required | +| routeId | String | default: "" | +| stopId | String | default: "" | +| sentAt | Date | default: now | +| opened | Boolean | default: false | +| openedAt | Date or null | default: null | + +### TripHistory + +| Field | Type | Constraints | +|-------|------|-------------| +| accountId | ObjectId | required, ref: InternalAccount | +| routeId | String | required | +| boardStopId | String | required | +| alightStopId | String | required | +| startedAt | Date | required | +| endedAt | Date | required | +| source | String | default: "manual" | + +### CommutePattern + +| Field | Type | Constraints | +|-------|------|-------------| +| accountId | ObjectId | required, ref: InternalAccount | +| fromStopId | String | required | +| toStopId | String | required | +| routeId | String | required | +| daysOfWeek | [Number] | default: [] (0-6, Sun-Sat) | +| typicalHour | Number | required | +| confidence | Number | default: 0 | +| lastSeen | Date | default: now | + +### PersonalizationProfile + +| Field | Type | Constraints | +|-------|------|-------------| +| accountId | ObjectId | required, unique, ref: InternalAccount | +| topStopIds | [String] | default: [] | +| topRouteIds | [String] | default: [] | +| topBuildingIds | [String] | default: [] | +| peakUsageHours | [Number] | default: [] | +| computedAt | Date | default: now | + +## Error Handling + +| Condition | Status | Response | +|-----------|--------|----------| +| No session / expired | 401 | `{ error: "Authentication required" }` | +| Valid session, not admin | 403 | `{ error: "Insufficient permissions" }` | +| SAML assertion invalid | — | Passport rejects, redirect to login | +| SAML certs missing | 503 | `/auth/login` returns service unavailable | +| Resource not found | 404 | `{ error: "Not found" }` | +| Duplicate saved stop | 409 | `{ error: "Already saved" }` | +| Invalid request body | 400 | `{ error: "" }` | +| Server error | 500 | `{ error: "Internal server error" }` | + +## Security Summary + +- SAML assertions cryptographically signed by UMich IdP — verified using their public cert +- Sessions stored server-side in MongoDB — client holds only an opaque session ID +- Cookies: `httpOnly` (no JS access), `secure` (HTTPS only in prod), `sameSite: lax` +- `saveUninitialized: false` — no phantom sessions for unauthenticated visitors +- All data queries scoped to `req.user.accountId` — no URL-based account ID, no ownership bypass possible +- Request body validation via Zod on all write endpoints +- Soft deletes for accounts (reversible), hard deletes for user-generated data + +## Environment Variables + +| Variable | Required | Purpose | +|----------|----------|---------| +| MONGODB_URI | Yes | MongoDB connection string | +| SESSION_SECRET | Yes | express-session signing secret | +| SAML_ENV | Yes | "test" or "production" — selects IdP endpoints | +| SAML_SP_ENTITY_ID | No | SP entity ID (default: https://maizebus.app/shibboleth) | +| SAML_SP_ACS_URL | No | Assertion callback URL (default: https://maizebus.app/auth/saml/callback) | +| PORT | No | Server port (default: 3000) | +| NODE_ENV | No | "production" enables secure cookies | + +## Files to Create or Modify + +| File | Action | Purpose | +|------|--------|---------| +| src/auth/saml.ts | Create | SAML strategy, user provisioning, passport config | +| src/routes/auth.ts | Create | Auth endpoints (login, callback, logout, me, metadata) | +| src/routes/users.ts | Rewrite | User data CRUD — Approach A (no :accountId in URLs) | +| src/middleware/requireAuth.ts | Create | Session auth guard | +| src/middleware/requireAdmin.ts | Create | Admin role guard | +| src/types/express.d.ts | Create | Express.User type augmentation | +| src/app.ts | Modify | Add session, passport, connect-mongo, mount auth + user routes | +| .env_example | Modify | Add SAML and session env vars | +| package.json | Modify | Add passport, passport-saml, express-session, connect-mongo, zod | + +## Files NOT Created (Excluded From Design) + +These files exist in the current uncommitted working directory but are intentionally excluded from this design. They should be discarded, not carried forward. + +| File | Reason | +|------|--------| +| src/db/models/DevAccount.ts | Not needed — admin role on InternalAccount replaces it | +| src/routes/dev.ts | Not needed — no separate dev auth flow | +| src/middleware/requireDev.ts | Not needed — replaced by requireAdmin | + +## Deferred (Not In Scope) + +- Usage data GET endpoints for admin users (data collection strategy TBD) +- Transit API auth gating (decision deferred) +- Rate limiting +- CSRF protection (JSON API, not HTML forms) + +## Verification + +1. **SAML flow:** Start server with `SAML_ENV=test`, hit `/auth/login`, confirm redirect to UMich staging IdP. After login, confirm session is established, `/auth/me` returns user, and InternalAccount is created in MongoDB. +2. **Session persistence:** Restart server, confirm session survives (connect-mongo). +3. **User data CRUD:** Authenticate, then test each endpoint group — create, read, update, delete. Confirm all queries are scoped to the logged-in user's accountId. +4. **Ownership isolation:** Authenticate as two different users. Confirm neither can see the other's data. +5. **Auth rejection:** Hit `/account/*` endpoints without a session — confirm 401. +6. **Admin role:** Set `roles: ["admin"]` on an account in MongoDB, confirm `requireAdmin` passes. Remove it, confirm 403. +7. **Logout:** Hit `/auth/logout`, confirm session destroyed, subsequent requests return 401. diff --git a/package.json b/package.json index 35866e3..b3872ad 100644 --- a/package.json +++ b/package.json @@ -14,22 +14,32 @@ "license": "ISC", "dependencies": { "@datastructures-js/priority-queue": "^6.3.3", + "@node-saml/passport-saml": "^5.1.0", "axios": "^1.7.2", + "bcrypt": "^6.0.0", + "connect-mongo": "^6.0.0", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-session": "^1.19.0", "fast-json-stable-stringify": "^2.1.0", "fast-xml-parser": "^5.3.2", "firebase-admin": "^10.3.0", + "jsonwebtoken": "^9.0.3", "lru-cache": "^11.2.5", "mongoose": "^9.3.3", + "passport": "^0.7.0", "ts-array-utils": "^0.5.0", "tsx": "^4.11.0", "zod": "^4.3.6" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/express": "^4.17.21", + "@types/express-session": "^1.18.2", + "@types/jsonwebtoken": "^9.0.10", "@types/mongoose": "^5.11.96", "@types/node": "^20.12.12", + "@types/passport": "^1.0.17", "@vitest/coverage-v8": "^4.1.2", "typedoc": "^0.28.15", "vitest": "^4.1.2" diff --git a/src/app.ts b/src/app.ts index 570fd4e..869aaf6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,19 +1,47 @@ +import "dotenv/config"; import express from "express"; +import session from "express-session"; +import MongoStore from "connect-mongo"; +import passport from "passport"; +import "./auth/saml.js"; // registers passport SAML strategy (side-effect import) import { connectToDatabase } from "./db/connection.js"; import mbus from "./routes/api.js"; import users from "./routes/users.js"; +import auth from "./routes/auth.js"; const app = express(); app.use(express.json()); -app.use("/mbus/api/v3", mbus); -app.use("/mbus/api/v3/account", users); +app.use(express.urlencoded({ extended: true })); // required to parse SAML POST assertions + +// Session — backed by MongoDB via connect-mongo +const mongoUri = process.env.MONGODB_URI ?? "mongodb://localhost:27017/mbus"; + +app.use(session({ + secret: process.env.SESSION_SECRET ?? "change-me-in-production", + resave: false, + saveUninitialized: false, + store: MongoStore.create({ mongoUrl: mongoUri }), + cookie: { + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: 8 * 60 * 60 * 1000, // 8 hours + sameSite: "lax", + }, +})); + +app.use(passport.initialize()); +app.use(passport.session()); + +app.use("/auth", auth); // public — login flow must be accessible +app.use("/mbus/api/v3", mbus); // transit data (auth gating deferred) +app.use("/mbus/api/v3/account", users); // requireAuth applied inside users router app.use("/docs", express.static("docs")); const PORT = process.env.PORT || 3000; connectToDatabase().then(() => { - app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); - }); -}); \ No newline at end of file + app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + }); +}); diff --git a/src/auth/saml.ts b/src/auth/saml.ts new file mode 100644 index 0000000..a3e4a50 --- /dev/null +++ b/src/auth/saml.ts @@ -0,0 +1,130 @@ +import fs from "fs"; +import passport from "passport"; +import { Strategy as SamlStrategy, type Profile, type VerifiedCallback } from "@node-saml/passport-saml"; +import { InternalAccount } from "../db/models/InternalAccount.js"; + +// ---------- IdP configuration ---------- + +const IDP_CONFIG = { + test: { + entryPoint: "https://shib-idp-staging.dsc.umich.edu/idp/profile/SAML2/Redirect/SSO", + certPath: "secrets/shibboleth-nonprod-cert.pem", + logoutUrl: "https://shib-idp-staging.dsc.umich.edu/idp/profile/SAML2/Redirect/SLO", + }, + production: { + entryPoint: "https://shibboleth.umich.edu/idp/profile/SAML2/Redirect/SSO", + certPath: "secrets/shibboleth-production-cert.pem", + logoutUrl: "https://shibboleth.umich.edu/idp/profile/SAML2/Redirect/SLO", + }, +} as const; + +// ---------- Initialization ---------- + +const samlEnv = (process.env.SAML_ENV ?? "") as keyof typeof IDP_CONFIG; +const idpConf = IDP_CONFIG[samlEnv]; + +export let samlEnabled = false; +export let samlStrategy: SamlStrategy | null = null; +export let logoutUrl = ""; +export const spCertPath = process.env.SAML_SP_CERT ?? "secrets/sp-cert.pem"; + +if (!idpConf) { + console.warn(`[SAML] SAML_ENV not set or invalid ("${samlEnv}"). SAML authentication disabled.`); +} else { + const spKeyPath = process.env.SAML_SP_KEY ?? "secrets/sp-key.pem"; + const idpCertExists = fs.existsSync(idpConf.certPath); + const spKeyExists = fs.existsSync(spKeyPath); + const spCertExists = fs.existsSync(spCertPath); + + if (!idpCertExists || !spKeyExists || !spCertExists) { + const missing = [ + !idpCertExists && idpConf.certPath, + !spKeyExists && spKeyPath, + !spCertExists && spCertPath, + ].filter(Boolean); + console.warn(`[SAML] Missing certificate files: ${missing.join(", ")}. SAML disabled.`); + } else { + const idpCert = fs.readFileSync(idpConf.certPath, "utf-8"); + const spKey = fs.readFileSync(spKeyPath, "utf-8"); + + const entityId = process.env.SAML_SP_ENTITY_ID ?? "https://maizebus.app/shibboleth"; + const acsUrl = process.env.SAML_SP_ACS_URL ?? "https://maizebus.app/auth/saml/callback"; + + samlStrategy = new SamlStrategy( + { + entryPoint: idpConf.entryPoint, + issuer: entityId, + callbackUrl: acsUrl, + cert: idpCert, + privateKey: spKey, + decryptionPvk: spKey, + wantAssertionsSigned: true, + identifierFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + signatureAlgorithm: "sha256", + }, + // Verify callback — runs after a successful SAML assertion + async (profile: Profile | null, done: VerifiedCallback) => { + try { + if (!profile) return done(new Error("No SAML profile returned")); + + // Extract UMich attributes from SAML assertion + const attrs = (profile as any).attributes ?? profile; + const uniqname = String( + attrs["urn:oid:0.9.2342.19200300.100.1.1"] ?? + profile.nameID ?? + "", + ); + const email = String( + attrs["urn:oid:0.9.2342.19200300.100.1.3"] ?? "", + ); + const displayName = String( + attrs["urn:oid:2.16.840.1.113730.3.1.241"] ?? + attrs["urn:oid:2.5.4.3"] ?? + "", + ); + + if (!uniqname) return done(new Error("No uniqname found in SAML assertion")); + + // Upsert InternalAccount — create on first login, update lastLoginAt on return + const account = await InternalAccount.findOneAndUpdate( + { uniqname }, + { lastLoginAt: new Date(), active: true }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ); + + const user: Express.User = { + uniqname, + email, + displayName, + samlNameId: profile.nameID ?? "", + sessionIndex: (profile as any).sessionIndex ?? "", + accountId: account._id.toString(), + }; + + done(null, user as unknown as Record); + } catch (err) { + done(err as Error); + } + }, + // Logout callback + (_profile: Profile | null, done: VerifiedCallback) => { + done(null, {}); + }, + ); + + passport.use("saml", samlStrategy); + logoutUrl = idpConf.logoutUrl; + samlEnabled = true; + console.log(`[SAML] Initialized for ${samlEnv} environment (entity: ${entityId})`); + } +} + +// ---------- Passport serialization ---------- + +passport.serializeUser((user, done) => { + done(null, user); +}); + +passport.deserializeUser((user: Express.User, done) => { + done(null, user); +}); diff --git a/src/middleware/requireAdmin.ts b/src/middleware/requireAdmin.ts new file mode 100644 index 0000000..314649b --- /dev/null +++ b/src/middleware/requireAdmin.ts @@ -0,0 +1,24 @@ +import { Request, Response, NextFunction } from "express"; +import { InternalAccount } from "../db/models/InternalAccount.js"; + +/** + * Requires the user to be authenticated AND have the "admin" role. + * Must be used after requireAuth or on routes where requireAuth is already applied. + */ +export async function requireAdmin(req: Request, res: Response, next: NextFunction): Promise { + if (!req.isAuthenticated()) { + res.status(401).json({ error: "Authentication required" }); + return; + } + + try { + const account = await InternalAccount.findById(req.user!.accountId); + if (!account || !account.roles.includes("admin")) { + res.status(403).json({ error: "Insufficient permissions" }); + return; + } + next(); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +} diff --git a/src/middleware/requireAuth.ts b/src/middleware/requireAuth.ts new file mode 100644 index 0000000..84211c3 --- /dev/null +++ b/src/middleware/requireAuth.ts @@ -0,0 +1,9 @@ +import { Request, Response, NextFunction } from "express"; + +export function requireAuth(req: Request, res: Response, next: NextFunction): void { + if (req.isAuthenticated()) { + next(); + } else { + res.status(401).json({ error: "Authentication required" }); + } +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..04d80ba --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,57 @@ +import express from "express"; +import fs from "fs"; +import passport from "passport"; +import { samlEnabled, samlStrategy, logoutUrl, spCertPath } from "../auth/saml.js"; + +const router = express.Router(); + +const unavailable = (_req: express.Request, res: express.Response) => + res.status(503).json({ error: "SAML not configured — see secrets/ directory" }); + +// GET /auth/login — redirect browser to UMich IdP +router.get( + "/login", + samlEnabled ? passport.authenticate("saml") : unavailable, +); + +// POST /auth/saml/callback — UMich IdP posts the SAML assertion here +router.post( + "/saml/callback", + samlEnabled + ? passport.authenticate("saml", { failureRedirect: "/auth/login" }) + : unavailable, + (_req, res) => { + res.redirect("/"); + }, +); + +// GET /auth/logout — end local session and redirect to IdP SLO +router.get("/logout", (req, res, next) => { + req.logout((err) => { + if (err) return next(err); + req.session.destroy(() => { + res.redirect(logoutUrl || "/"); + }); + }); +}); + +// GET /auth/me — return current session user +router.get("/me", (req, res) => { + if (!req.isAuthenticated()) { + res.status(401).json({ error: "Not authenticated" }); + return; + } + res.json(req.user); +}); + +// GET /auth/saml/metadata — serve SP metadata XML for submission to UMich IAM +router.get("/saml/metadata", (req, res) => { + if (!samlEnabled || !samlStrategy) return unavailable(req, res); + const spCert = fs.existsSync(spCertPath) + ? fs.readFileSync(spCertPath, "utf-8") + : null; + res.type("application/xml"); + res.send(samlStrategy.generateServiceProviderMetadata(null, spCert)); +}); + +export default router; diff --git a/src/routes/users.ts b/src/routes/users.ts index ae8a20c..71e5408 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -1,6 +1,7 @@ import express from "express"; -import * as z from "zod"; +import { z } from "zod"; import mongoose from "mongoose"; +import { requireAuth } from "../middleware/requireAuth.js"; import { InternalAccount } from "../db/models/InternalAccount.js"; import { UserPreferences } from "../db/models/UserPreferences.js"; import { SavedStop } from "../db/models/SavedStop.js"; @@ -12,341 +13,326 @@ import { PersonalizationProfile } from "../db/models/PersonalizationProfile.js"; const router = express.Router(); +// All routes require authentication — user can only access their own data +router.use(requireAuth); + +/** Helper: get the logged-in user's accountId as an ObjectId */ +function getAccountId(req: express.Request): mongoose.Types.ObjectId { + return new mongoose.Types.ObjectId(req.user!.accountId!); +} + +/** Helper: parse a string as an ObjectId, or null if invalid */ function parseObjectId(id: string): mongoose.Types.ObjectId | null { if (!mongoose.Types.ObjectId.isValid(id)) return null; return new mongoose.Types.ObjectId(id); } -// POST /account — create account -const CreateAccountBody = z.object({ - uniqname: z.string(), - roles: z.array(z.string()).optional(), -}); - -export async function createAccount(req: express.Request, res: express.Response) { - const parsed = CreateAccountBody.safeParse(req.body); - if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); - const existing = await InternalAccount.findOne({ uniqname: parsed.data.uniqname }); - if (existing) return res.status(409).json({ error: "Account already exists" }); - const account = await InternalAccount.create(parsed.data); - res.status(201).json(account); -} -router.post("/", createAccount); +// ────────────────────────────────────────────── +// Account +// ────────────────────────────────────────────── -// GET /account/:accountId -export async function getAccount(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); - const account = await InternalAccount.findById(id); +// GET /account — get own account +router.get("/", async (req, res) => { + const account = await InternalAccount.findById(getAccountId(req)); if (!account) return res.status(404).json({ error: "Not found" }); res.json(account); -} -router.get("/:accountId", getAccount); +}); -// PATCH /account/:accountId — update roles or active +// PATCH /account — update own account const UpdateAccountBody = z.object({ roles: z.array(z.string()).optional(), active: z.boolean().optional(), - lastLoginAt: z.string().datetime().optional(), }); -export async function updateAccount(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); +router.patch("/", async (req, res) => { const parsed = UpdateAccountBody.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); - const account = await InternalAccount.findByIdAndUpdate(id, parsed.data, { new: true }); + const account = await InternalAccount.findByIdAndUpdate( + getAccountId(req), + parsed.data, + { new: true }, + ); if (!account) return res.status(404).json({ error: "Not found" }); res.json(account); -} -router.patch("/:accountId", updateAccount); +}); -// DELETE /account/:accountId — soft delete -export async function deactivateAccount(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); - const account = await InternalAccount.findByIdAndUpdate(id, { active: false }, { new: true }); +// DELETE /account — soft-delete (set active=false) +router.delete("/", async (req, res) => { + const account = await InternalAccount.findByIdAndUpdate( + getAccountId(req), + { active: false }, + { new: true }, + ); if (!account) return res.status(404).json({ error: "Not found" }); res.json({ success: true }); -} -router.delete("/:accountId", deactivateAccount); +}); +// ────────────────────────────────────────────── +// Preferences +// ────────────────────────────────────────────── -// GET /account/:accountId/preferences -export async function getPreferences(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); - const prefs = await UserPreferences.findOne({ accountId: id }); +// GET /account/preferences +router.get("/preferences", async (req, res) => { + const prefs = await UserPreferences.findOne({ accountId: getAccountId(req) }); if (!prefs) return res.status(404).json({ error: "Not found" }); res.json(prefs); -} -router.get("/:accountId/preferences", getPreferences); +}); -// PUT /account/:accountId/preferences — upsert +// PUT /account/preferences — upsert const UpsertPreferencesBody = z.object({ - defaultView: z.string().optional(), + defaultView: z.string().optional(), notificationsEnabled: z.boolean().optional(), - notifyMinutesBefore: z.number().int().optional(), - preferredRouteIds: z.array(z.string()).optional(), - accessibilityMode: z.boolean().optional(), - theme: z.string().optional(), + notifyMinutesBefore: z.number().int().optional(), + preferredRouteIds: z.array(z.string()).optional(), + accessibilityMode: z.boolean().optional(), + theme: z.string().optional(), }); -export async function upsertPreferences(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); +router.put("/preferences", async (req, res) => { const parsed = UpsertPreferencesBody.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); const prefs = await UserPreferences.findOneAndUpdate( - { accountId: id }, + { accountId: getAccountId(req) }, { ...parsed.data, updatedAt: new Date() }, - { new: true, upsert: true } + { new: true, upsert: true }, ); res.json(prefs); -} -router.put("/:accountId/preferences", upsertPreferences); +}); +// ────────────────────────────────────────────── +// Saved Stops +// ────────────────────────────────────────────── -// GET /account/:accountId/saved-stops -export async function getSavedStops(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); - const stops = await SavedStop.find({ accountId: id }).sort({ pinnedOrder: 1, savedAt: -1 }); +// GET /account/saved-stops +router.get("/saved-stops", async (req, res) => { + const stops = await SavedStop.find({ accountId: getAccountId(req) }) + .sort({ pinnedOrder: 1, savedAt: -1 }); res.json(stops); -} -router.get("/:accountId/saved-stops", getSavedStops); +}); -// POST /account/:accountId/saved-stops +// POST /account/saved-stops const CreateSavedStopBody = z.object({ - stopId: z.string(), + stopId: z.string(), customLabel: z.string().optional(), - pinned: z.boolean().optional(), + pinned: z.boolean().optional(), pinnedOrder: z.number().int().optional(), }); -export async function createSavedStop(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); +router.post("/saved-stops", async (req, res) => { const parsed = CreateSavedStopBody.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); try { - const stop = await SavedStop.create({ accountId: id, ...parsed.data }); + const stop = await SavedStop.create({ accountId: getAccountId(req), ...parsed.data }); res.status(201).json(stop); } catch (e: any) { - if (e.code === 11000) return res.status(409).json({ error: "Stop already saved" }); + if (e.code === 11000) return res.status(409).json({ error: "Already saved" }); throw e; } -} -router.post("/:accountId/saved-stops", createSavedStop); +}); -// PATCH /account/:accountId/saved-stops/:stopId +// PATCH /account/saved-stops/:stopId const UpdateSavedStopBody = z.object({ customLabel: z.string().optional(), - pinned: z.boolean().optional(), + pinned: z.boolean().optional(), pinnedOrder: z.number().int().optional(), }); -export async function updateSavedStop(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); +router.patch("/saved-stops/:stopId", async (req, res) => { const parsed = UpdateSavedStopBody.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); const stop = await SavedStop.findOneAndUpdate( - { accountId: id, stopId: req.params.stopId }, + { accountId: getAccountId(req), stopId: req.params.stopId }, parsed.data, - { new: true } + { new: true }, ); if (!stop) return res.status(404).json({ error: "Not found" }); res.json(stop); -} -router.patch("/:accountId/saved-stops/:stopId", updateSavedStop); +}); -// DELETE /account/:accountId/saved-stops/:stopId -export async function deleteSavedStop(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); - const result = await SavedStop.findOneAndDelete({ accountId: id, stopId: req.params.stopId }); +// DELETE /account/saved-stops/:stopId +router.delete("/saved-stops/:stopId", async (req, res) => { + const result = await SavedStop.findOneAndDelete({ + accountId: getAccountId(req), + stopId: req.params.stopId, + }); if (!result) return res.status(404).json({ error: "Not found" }); res.json({ success: true }); -} -router.delete("/:accountId/saved-stops/:stopId", deleteSavedStop); +}); +// ────────────────────────────────────────────── +// Search History +// ────────────────────────────────────────────── -// GET /account/:accountId/search-history — optional ?limit -export async function getSearchHistory(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); +// GET /account/search-history — optional ?limit (default 20) +router.get("/search-history", async (req, res) => { const limit = parseInt(req.query.limit as string) || 20; - const entries = await SearchHistory.find({ accountId: id }) + const entries = await SearchHistory.find({ accountId: getAccountId(req) }) .sort({ lastSearchedAt: -1 }) .limit(limit); res.json(entries); -} -router.get("/:accountId/search-history", getSearchHistory); +}); -// POST /account/:accountId/search-history — upsert on (accountId + query + resultId) +// POST /account/search-history — upsert on (accountId + query + resultId) const AddSearchEntryBody = z.object({ - query: z.string(), + query: z.string(), resultType: z.string().optional(), - resultId: z.string().optional(), + resultId: z.string().optional(), }); -export async function addSearchEntry(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); +router.post("/search-history", async (req, res) => { const parsed = AddSearchEntryBody.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); + const accountId = getAccountId(req); const { query, resultType = "", resultId = "" } = parsed.data; const entry = await SearchHistory.findOneAndUpdate( - { accountId: id, query, resultId }, + { accountId, query, resultId }, { $inc: { searchCount: 1 }, $set: { lastSearchedAt: new Date(), resultType }, - $setOnInsert: { accountId: id, query, resultId }, + $setOnInsert: { accountId, query, resultId }, }, - { new: true, upsert: true } + { new: true, upsert: true }, ); res.json(entry); -} -router.post("/:accountId/search-history", addSearchEntry); +}); -// DELETE /account/:accountId/search-history — clear all -export async function clearSearchHistory(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); - await SearchHistory.deleteMany({ accountId: id }); +// DELETE /account/search-history — clear all +router.delete("/search-history", async (req, res) => { + await SearchHistory.deleteMany({ accountId: getAccountId(req) }); res.json({ success: true }); -} -router.delete("/:accountId/search-history", clearSearchHistory); +}); -// DELETE /account/:accountId/search-history/:entryId -export async function deleteSearchEntry(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); +// DELETE /account/search-history/:entryId +router.delete("/search-history/:entryId", async (req, res) => { const entryId = parseObjectId(req.params.entryId); - if (!id || !entryId) return res.status(400).json({ error: "Invalid id" }); - const result = await SearchHistory.findOneAndDelete({ _id: entryId, accountId: id }); + if (!entryId) return res.status(400).json({ error: "Invalid entryId" }); + const result = await SearchHistory.findOneAndDelete({ + _id: entryId, + accountId: getAccountId(req), + }); if (!result) return res.status(404).json({ error: "Not found" }); res.json({ success: true }); -} -router.delete("/:accountId/search-history/:entryId", deleteSearchEntry); +}); +// ────────────────────────────────────────────── +// Notification Log +// ────────────────────────────────────────────── -// GET /account/:accountId/notification-log — optional ?limit -export async function getNotificationLog(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); +// GET /account/notification-log — optional ?limit (default 50) +router.get("/notification-log", async (req, res) => { const limit = parseInt(req.query.limit as string) || 50; - const logs = await NotificationLog.find({ accountId: id }) + const logs = await NotificationLog.find({ accountId: getAccountId(req) }) .sort({ sentAt: -1 }) .limit(limit); res.json(logs); -} -router.get("/:accountId/notification-log", getNotificationLog); +}); -// POST /account/:accountId/notification-log +// POST /account/notification-log const CreateNotificationLogBody = z.object({ - type: z.string(), + type: z.string(), routeId: z.string().optional(), - stopId: z.string().optional(), + stopId: z.string().optional(), }); -export async function createNotificationLog(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); +router.post("/notification-log", async (req, res) => { const parsed = CreateNotificationLogBody.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); - const log = await NotificationLog.create({ accountId: id, ...parsed.data }); + const log = await NotificationLog.create({ + accountId: getAccountId(req), + ...parsed.data, + }); res.status(201).json(log); -} -router.post("/:accountId/notification-log", createNotificationLog); +}); -// PATCH /account/:accountId/notification-log/:logId — mark as opened -export async function markNotificationOpened(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); +// PATCH /account/notification-log/:logId — mark as opened +router.patch("/notification-log/:logId", async (req, res) => { const logId = parseObjectId(req.params.logId); - if (!id || !logId) return res.status(400).json({ error: "Invalid id" }); + if (!logId) return res.status(400).json({ error: "Invalid logId" }); const log = await NotificationLog.findOneAndUpdate( - { _id: logId, accountId: id }, + { _id: logId, accountId: getAccountId(req) }, { opened: true, openedAt: new Date() }, - { new: true } + { new: true }, ); if (!log) return res.status(404).json({ error: "Not found" }); res.json(log); -} -router.patch("/:accountId/notification-log/:logId", markNotificationOpened); +}); +// ────────────────────────────────────────────── +// Trip History +// ────────────────────────────────────────────── -// GET /account/:accountId/trip-history -export async function getTripHistory(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); - const trips = await TripHistory.find({ accountId: id }).sort({ startedAt: -1 }); +// GET /account/trip-history +router.get("/trip-history", async (req, res) => { + const trips = await TripHistory.find({ accountId: getAccountId(req) }) + .sort({ startedAt: -1 }); res.json(trips); -} -router.get("/:accountId/trip-history", getTripHistory); +}); -// POST /account/:accountId/trip-history +// POST /account/trip-history const CreateTripBody = z.object({ - routeId: z.string(), - boardStopId: z.string(), + routeId: z.string(), + boardStopId: z.string(), alightStopId: z.string(), - startedAt: z.string().datetime(), - endedAt: z.string().datetime(), - source: z.string().optional(), + startedAt: z.string().datetime(), + endedAt: z.string().datetime(), + source: z.string().optional(), }); -export async function createTripHistory(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); +router.post("/trip-history", async (req, res) => { const parsed = CreateTripBody.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); - const trip = await TripHistory.create({ accountId: id, ...parsed.data }); + const trip = await TripHistory.create({ + accountId: getAccountId(req), + ...parsed.data, + }); res.status(201).json(trip); -} -router.post("/:accountId/trip-history", createTripHistory); +}); -// DELETE /account/:accountId/trip-history/:tripId -export async function deleteTripHistory(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); +// DELETE /account/trip-history/:tripId +router.delete("/trip-history/:tripId", async (req, res) => { const tripId = parseObjectId(req.params.tripId); - if (!id || !tripId) return res.status(400).json({ error: "Invalid id" }); - const result = await TripHistory.findOneAndDelete({ _id: tripId, accountId: id }); + if (!tripId) return res.status(400).json({ error: "Invalid tripId" }); + const result = await TripHistory.findOneAndDelete({ + _id: tripId, + accountId: getAccountId(req), + }); if (!result) return res.status(404).json({ error: "Not found" }); res.json({ success: true }); -} -router.delete("/:accountId/trip-history/:tripId", deleteTripHistory); +}); +// ────────────────────────────────────────────── +// Commute Patterns (system-generated, read + delete only) +// ────────────────────────────────────────────── -// GET /account/:accountId/commute-patterns -export async function getCommutePatterns(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); - const patterns = await CommutePattern.find({ accountId: id }); +// GET /account/commute-patterns +router.get("/commute-patterns", async (req, res) => { + const patterns = await CommutePattern.find({ accountId: getAccountId(req) }); res.json(patterns); -} -router.get("/:accountId/commute-patterns", getCommutePatterns); +}); -// DELETE /account/:accountId/commute-patterns/:patternId -export async function deleteCommutePattern(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); +// DELETE /account/commute-patterns/:patternId +router.delete("/commute-patterns/:patternId", async (req, res) => { const patternId = parseObjectId(req.params.patternId); - if (!id || !patternId) return res.status(400).json({ error: "Invalid id" }); - const result = await CommutePattern.findOneAndDelete({ _id: patternId, accountId: id }); + if (!patternId) return res.status(400).json({ error: "Invalid patternId" }); + const result = await CommutePattern.findOneAndDelete({ + _id: patternId, + accountId: getAccountId(req), + }); if (!result) return res.status(404).json({ error: "Not found" }); res.json({ success: true }); -} -router.delete("/:accountId/commute-patterns/:patternId", deleteCommutePattern); +}); +// ────────────────────────────────────────────── +// Personalization Profile (system-generated, read-only) +// ────────────────────────────────────────────── -// GET /account/:accountId/personalization-profile -export async function getPersonalizationProfile(req: express.Request, res: express.Response) { - const id = parseObjectId(req.params.accountId); - if (!id) return res.status(400).json({ error: "Invalid accountId" }); - const profile = await PersonalizationProfile.findOne({ accountId: id }); +// GET /account/personalization-profile +router.get("/personalization-profile", async (req, res) => { + const profile = await PersonalizationProfile.findOne({ + accountId: getAccountId(req), + }); if (!profile) return res.status(404).json({ error: "Not found" }); res.json(profile); -} -router.get("/:accountId/personalization-profile", getPersonalizationProfile); - +}); export default router; diff --git a/src/types/express.d.ts b/src/types/express.d.ts new file mode 100644 index 0000000..7e3f30f --- /dev/null +++ b/src/types/express.d.ts @@ -0,0 +1,10 @@ +declare namespace Express { + interface User { + uniqname: string; + email: string; + displayName: string; + samlNameId: string; + sessionIndex: string; + accountId?: string; + } +}