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:
45
backend/src/auth/jwt.ts
Normal file
45
backend/src/auth/jwt.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user