This commit is contained in:
2026-02-18 22:45:38 +01:00
parent 5f23961f50
commit 0d084cd546

View File

@@ -7,7 +7,8 @@ import {
getUserInfo,
} from "../auth/oidc";
import { requireAuth, syncUser } from "../middleware/auth";
import type { AuthenticatedRequest } from "../types";
import type { AuthenticatedRequest, AuthenticatedUser } from "../types";
import type { AuthSession } from "../auth/oidc";
const router = Router();
@@ -21,6 +22,26 @@ async function ensureOIDC() {
}
}
// Short-lived store for native app OIDC sessions, keyed by state.
// Entries are cleaned up after 10 minutes regardless of use.
const nativeOidcSessions = new Map<string, { session: AuthSession; expiresAt: number }>();
const NATIVE_SESSION_TTL_MS = 10 * 60 * 1000;
function storeNativeSession(session: AuthSession): void {
nativeOidcSessions.set(session.state, {
session,
expiresAt: Date.now() + NATIVE_SESSION_TTL_MS,
});
}
function popNativeSession(state: string): AuthSession | null {
const entry = nativeOidcSessions.get(state);
if (!entry) return null;
nativeOidcSessions.delete(state);
if (Date.now() > entry.expiresAt) return null;
return entry.session;
}
// GET /auth/login - Initiate OIDC login flow
router.get("/login", async (req, res) => {
try {
@@ -28,7 +49,15 @@ router.get("/login", async (req, res) => {
const redirectUri = req.query.redirect_uri as string | undefined;
const session = createAuthSession(redirectUri);
if (redirectUri) {
// Native app flow: store session by state so /auth/token can retrieve it
// without relying on the browser cookie jar.
storeNativeSession(session);
} else {
// Web flow: store session in the cookie-backed server session as before.
req.session.oidc = session;
}
const authorizationUrl = getAuthorizationUrl(session, redirectUri);
res.redirect(authorizationUrl);
@@ -38,7 +67,7 @@ router.get("/login", async (req, res) => {
}
});
// GET /auth/callback - OIDC callback handler
// GET /auth/callback - OIDC callback handler (web frontend only)
router.get("/callback", async (req, res) => {
try {
await ensureOIDC();
@@ -89,9 +118,9 @@ router.get("/me", requireAuth, (req: AuthenticatedRequest, res) => {
res.json(req.user);
});
// POST /auth/token - Exchange authorization code for tokens (for native apps)
// The session cookie set during /auth/login is sent automatically by ASWebAuthenticationSession,
// so req.session.oidc contains the original state/nonce for validation.
// POST /auth/token - Exchange authorization code for tokens (native app flow)
// Session state is retrieved from the in-memory store by state value, so no
// session cookie is required from the native client.
router.post("/token", async (req, res) => {
try {
await ensureOIDC();
@@ -103,20 +132,12 @@ router.post("/token", async (req, res) => {
return;
}
const oidcSession = req.session.oidc;
const oidcSession = popNativeSession(state);
if (!oidcSession) {
res.status(400).json({ error: "No active OIDC session. Initiate login first." });
res.status(400).json({ error: "OIDC session not found or expired. Initiate login again." });
return;
}
// Validate that the returned state matches what was stored in the session
if (oidcSession.state !== state) {
res.status(400).json({ error: "State mismatch" });
return;
}
// Use the session's own codeVerifier (generated by the backend during /auth/login)
// rather than a client-supplied one, to prevent verifier substitution attacks.
const session = {
codeVerifier: oidcSession.codeVerifier,
state: oidcSession.state,
@@ -129,9 +150,6 @@ router.post("/token", async (req, res) => {
const user = await getUserInfo(tokenSet);
await syncUser(user);
// Clear OIDC session state now that it has been consumed
delete req.session.oidc;
res.json({
access_token: tokenSet.access_token,
id_token: tokenSet.id_token,