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;
}