Introduce new API endpoints and backend logic for administrative user management, including fetching, viewing, and updating user roles, while ensuring sensitive data like password hashes are excluded from responses. Implement Replit user ID formatting for internal consistency and extend storage interfaces to support user retrieval and counting. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 170e18f0-0f13-4eca-8643-546bba1dd8cc Replit-Commit-Checkpoint-Type: full_checkpoint
177 lines
4.9 KiB
TypeScript
177 lines
4.9 KiB
TypeScript
import * as client from "openid-client";
|
|
import { Strategy, type VerifyFunction } from "openid-client/passport";
|
|
import passport from "passport";
|
|
import session from "express-session";
|
|
import type { Express, RequestHandler } from "express";
|
|
import memoize from "memoizee";
|
|
import connectPg from "connect-pg-simple";
|
|
import { storage } from "./storage";
|
|
|
|
if (!process.env.REPLIT_DOMAINS) {
|
|
throw new Error("Environment variable REPLIT_DOMAINS not provided");
|
|
}
|
|
|
|
const getOidcConfig = memoize(
|
|
async () => {
|
|
return await client.discovery(
|
|
new URL(process.env.ISSUER_URL ?? "https://replit.com/oidc"),
|
|
process.env.REPL_ID!
|
|
);
|
|
},
|
|
{ maxAge: 3600 * 1000 }
|
|
);
|
|
|
|
export function getSession() {
|
|
const sessionTtl = 7 * 24 * 60 * 60 * 1000; // 1 week
|
|
const pgStore = connectPg(session);
|
|
const sessionStore = new pgStore({
|
|
conString: process.env.DATABASE_URL,
|
|
createTableIfMissing: false,
|
|
ttl: sessionTtl,
|
|
tableName: "sessions",
|
|
});
|
|
return session({
|
|
secret: process.env.SESSION_SECRET!,
|
|
store: sessionStore,
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: {
|
|
httpOnly: true,
|
|
secure: true,
|
|
maxAge: sessionTtl,
|
|
},
|
|
});
|
|
}
|
|
|
|
function updateUserSession(
|
|
user: any,
|
|
tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers
|
|
) {
|
|
user.claims = tokens.claims();
|
|
user.access_token = tokens.access_token;
|
|
user.refresh_token = tokens.refresh_token;
|
|
user.expires_at = user.claims?.exp;
|
|
}
|
|
|
|
async function upsertUser(claims: any) {
|
|
// Convert Replit numeric ID to string format compatible with our schema
|
|
const userId = `replit_${claims["sub"]}`;
|
|
|
|
await storage.upsertUser({
|
|
id: userId,
|
|
email: claims["email"],
|
|
firstName: claims["first_name"],
|
|
lastName: claims["last_name"],
|
|
profileImageUrl: claims["profile_image_url"],
|
|
});
|
|
}
|
|
|
|
export async function setupAuth(app: Express) {
|
|
app.set("trust proxy", 1);
|
|
app.use(getSession());
|
|
app.use(passport.initialize());
|
|
app.use(passport.session());
|
|
|
|
const config = await getOidcConfig();
|
|
|
|
const verify: VerifyFunction = async (
|
|
tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers,
|
|
verified: passport.AuthenticateCallback
|
|
) => {
|
|
const user = {};
|
|
updateUserSession(user, tokens);
|
|
await upsertUser(tokens.claims());
|
|
verified(null, user);
|
|
};
|
|
|
|
for (const domain of process.env.REPLIT_DOMAINS!.split(",")) {
|
|
const strategy = new Strategy(
|
|
{
|
|
name: `replitauth:${domain}`,
|
|
config,
|
|
scope: "openid email profile offline_access",
|
|
callbackURL: `https://${domain}/api/callback`,
|
|
},
|
|
verify,
|
|
);
|
|
passport.use(strategy);
|
|
}
|
|
|
|
passport.serializeUser((user: Express.User, cb) => cb(null, user));
|
|
passport.deserializeUser((user: Express.User, cb) => cb(null, user));
|
|
|
|
app.get("/api/login", (req, res, next) => {
|
|
passport.authenticate(`replitauth:${req.hostname}`, {
|
|
prompt: "login consent",
|
|
scope: ["openid", "email", "profile", "offline_access"],
|
|
})(req, res, next);
|
|
});
|
|
|
|
app.get("/api/callback", (req, res, next) => {
|
|
passport.authenticate(`replitauth:${req.hostname}`, {
|
|
successReturnToOrRedirect: "/admin",
|
|
failureRedirect: "/api/login",
|
|
})(req, res, next);
|
|
});
|
|
|
|
app.get("/api/logout", (req, res) => {
|
|
req.logout(() => {
|
|
res.redirect(
|
|
client.buildEndSessionUrl(config, {
|
|
client_id: process.env.REPL_ID!,
|
|
post_logout_redirect_uri: `${req.protocol}://${req.hostname}`,
|
|
}).href
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
export const isAuthenticated: RequestHandler = async (req, res, next) => {
|
|
const user = req.user as any;
|
|
|
|
if (!req.isAuthenticated() || !user.expires_at) {
|
|
return res.status(401).json({ message: "Unauthorized" });
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
if (now <= user.expires_at) {
|
|
return next();
|
|
}
|
|
|
|
const refreshToken = user.refresh_token;
|
|
if (!refreshToken) {
|
|
res.status(401).json({ message: "Unauthorized" });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const config = await getOidcConfig();
|
|
const tokenResponse = await client.refreshTokenGrant(config, refreshToken);
|
|
updateUserSession(user, tokenResponse);
|
|
return next();
|
|
} catch (error) {
|
|
res.status(401).json({ message: "Unauthorized" });
|
|
return;
|
|
}
|
|
};
|
|
|
|
export const isAdmin: RequestHandler = async (req, res, next) => {
|
|
const user = req.user as any;
|
|
|
|
if (!req.isAuthenticated() || !user.claims?.sub) {
|
|
return res.status(401).json({ message: "Unauthorized" });
|
|
}
|
|
|
|
try {
|
|
// Convert Replit numeric ID to our internal format
|
|
const userId = `replit_${user.claims.sub}`;
|
|
const dbUser = await storage.getUser(userId);
|
|
if (!dbUser || !dbUser.isAdmin) {
|
|
return res.status(403).json({ message: "Admin access required" });
|
|
}
|
|
next();
|
|
} catch (error) {
|
|
console.error("Error checking admin status:", error);
|
|
res.status(500).json({ message: "Internal server error" });
|
|
}
|
|
}; |