Replace IDP token passthrough with backend-issued JWT for iOS auth

iOS clients now exchange the OIDC authorization code for a backend-signed
HS256 JWT via POST /auth/token. All subsequent API requests authenticate
using this JWT as a Bearer token, verified locally — no per-request IDP
call is needed. Web frontend session-cookie auth is unchanged.
This commit is contained in:
2026-02-19 18:45:03 +01:00
parent 1ca76b0fec
commit 946cd35832
10 changed files with 662 additions and 85 deletions

45
backend/src/auth/jwt.ts Normal file
View File

@@ -0,0 +1,45 @@
import jwt from 'jsonwebtoken';
import { config } from '../config';
import type { AuthenticatedUser } from '../types';
export interface JwtPayload {
sub: string;
username: string;
fullName: string | null;
email: string;
}
/**
* Mint a backend-signed JWT for a native (iOS) client.
* The token is self-contained — no IDP call is needed to verify it.
*/
export function signBackendJwt(user: AuthenticatedUser): string {
const payload: JwtPayload = {
sub: user.id,
username: user.username,
fullName: user.fullName,
email: user.email,
};
return jwt.sign(payload, config.jwt.secret, {
expiresIn: config.jwt.expiresIn,
algorithm: 'HS256',
});
}
/**
* Verify a backend-signed JWT and return the encoded user.
* Throws if the token is invalid or expired.
*/
export function verifyBackendJwt(token: string): AuthenticatedUser {
const payload = jwt.verify(token, config.jwt.secret, {
algorithms: ['HS256'],
}) as JwtPayload;
return {
id: payload.sub,
username: payload.username,
fullName: payload.fullName,
email: payload.email,
};
}

View File

@@ -2,6 +2,9 @@ import { Issuer, generators, Client, TokenSet } from 'openid-client';
import { config } from '../config';
import type { AuthenticatedUser } from '../types';
// Note: bearer-token (JWT) verification for native clients lives in auth/jwt.ts.
// This module is responsible solely for the OIDC protocol flows.
let oidcClient: Client | null = null;
export async function initializeOIDC(): Promise<void> {
@@ -160,48 +163,3 @@ export async function getUserInfo(tokenSet: TokenSet): Promise<AuthenticatedUser
return { id, username, fullName, email };
}
export async function verifyToken(tokenSet: TokenSet): Promise<boolean> {
try {
const client = getOIDCClient();
await client.userinfo(tokenSet);
return true;
} catch {
return false;
}
}
// Cache userinfo responses to avoid hitting the OIDC provider on every request.
// Entries expire after 5 minutes. The cache is keyed by the raw access token.
const userinfoCache = new Map<string, { user: AuthenticatedUser; expiresAt: number }>();
const USERINFO_CACHE_TTL_MS = 5 * 60 * 1000;
export async function verifyBearerToken(accessToken: string): Promise<AuthenticatedUser> {
const cached = userinfoCache.get(accessToken);
if (cached && Date.now() < cached.expiresAt) {
return cached.user;
}
const client = getOIDCClient();
let userInfo: Awaited<ReturnType<typeof client.userinfo>>;
try {
userInfo = await client.userinfo(accessToken);
} catch (err) {
// Remove any stale cache entry for this token
userinfoCache.delete(accessToken);
throw err;
}
const id = String(userInfo.sub);
const username = String(userInfo.preferred_username || userInfo.name || id);
const email = String(userInfo.email || '');
const fullName = String(userInfo.name || '') || null;
if (!email) {
throw new Error('Email not provided by OIDC provider');
}
const user: AuthenticatedUser = { id, username, fullName, email };
userinfoCache.set(accessToken, { user, expiresAt: Date.now() + USERINFO_CACHE_TTL_MS });
return user;
}

View File

@@ -25,6 +25,13 @@ export const config = {
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
jwt: {
// Dedicated secret for backend-issued JWTs. Falls back to SESSION_SECRET so
// existing single-secret deployments work without any config change.
secret: process.env.JWT_SECRET || process.env.SESSION_SECRET || "default-secret-change-in-production",
expiresIn: 30 * 24 * 60 * 60, // 30 days in seconds
},
cors: {
origin: process.env.APP_URL || "http://localhost:5173",
credentials: true,

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import { prisma } from '../prisma/client';
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
import { getOIDCClient, verifyBearerToken } from '../auth/oidc';
import { verifyBackendJwt } from '../auth/jwt';
export async function requireAuth(
req: AuthenticatedRequest,
@@ -14,18 +14,16 @@ export async function requireAuth(
return next();
}
// 2. Bearer token auth (iOS / native clients)
// 2. Bearer JWT auth (iOS / native clients)
const authHeader = req.headers.authorization;
console.log('[requireAuth] authorization header:', authHeader ? `${authHeader.slice(0, 20)}` : '(none)');
if (authHeader?.startsWith('Bearer ')) {
const accessToken = authHeader.slice(7);
const token = authHeader.slice(7);
try {
const user = await verifyBearerToken(accessToken);
req.user = user;
// Verify the backend-signed JWT locally — no IDP network call needed.
req.user = verifyBackendJwt(token);
return next();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('[requireAuth] verifyBearerToken failed:', err);
res.status(401).json({ error: `Unauthorized: ${message}` });
return;
}

View File

@@ -7,8 +7,9 @@ import {
exchangeNativeCode,
getUserInfo,
} from "../auth/oidc";
import { signBackendJwt } from "../auth/jwt";
import { requireAuth, syncUser } from "../middleware/auth";
import type { AuthenticatedRequest, AuthenticatedUser } from "../types";
import type { AuthenticatedRequest } from "../types";
import type { AuthSession } from "../auth/oidc";
const router = Router();
@@ -119,9 +120,10 @@ router.get("/me", requireAuth, (req: AuthenticatedRequest, res) => {
res.json(req.user);
});
// 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.
// POST /auth/token - Exchange OIDC authorization code for a backend JWT (native app flow).
// The iOS app calls this after the OIDC redirect; it receives a backend-signed JWT which
// it then uses as a Bearer token for all subsequent API requests. The backend verifies
// this JWT locally — no per-request IDP call is needed.
router.post("/token", async (req, res) => {
try {
await ensureOIDC();
@@ -140,15 +142,16 @@ router.post("/token", async (req, res) => {
}
const tokenSet = await exchangeNativeCode(code, oidcSession.codeVerifier, redirect_uri);
const user = await getUserInfo(tokenSet);
await syncUser(user);
// Mint a backend JWT. The iOS app stores this and sends it as Bearer <token>.
const backendJwt = signBackendJwt(user);
res.json({
access_token: tokenSet.access_token,
id_token: tokenSet.id_token,
token_type: tokenSet.token_type,
expires_in: tokenSet.expires_in,
access_token: backendJwt,
token_type: "Bearer",
expires_in: 30 * 24 * 60 * 60, // 30 days
user,
});
} catch (error) {