feat(auth): @hanzo/iam session-bridge — one-way SPA login without breaking OBO

Complete the chat auth migration onto @hanzo/iam. The @hanzo/iam SPA runs
Authorization-Code + PKCE entirely in the browser; a new backend session-bridge
turns the IAM-verified identity into the EXISTING Chat session, so live login,
reload-persistence, the 401->refresh interceptor, and the cloud-agent
on-behalf-of (OBO) flow all keep working. This is a session-bridge, not a blind
swap — the change is purely additive server-side.

Backend
- POST /oauth/iam/session (api/server/controllers/auth/iamSession.js): accepts
  { accessToken, idToken } from the SPA, JWKS-validates the id_token against
  hanzo.id (issuer + RS256 signature + expiry), reconciles the Mongo User by
  `sub` (openidId, provider=openid — create/migrate via findOpenIDUser), issues
  the existing Chat session via setAuthTokens (refresh cookie + Mongo Session +
  token_provider=chat + Chat JWT), and persists the id_token server-side
  (persistOpenIDTokensToSession) so resolveTenantBearer can forward it on OBO
  cloud calls. IAM tokens never become the app bearer; the id_token stays
  server-side.
- api/server/services/iamToken.js: standalone JWKS verifier (the openIdJwtStrategy
  JWKS path promoted to a callable), self-contained via OIDC discovery of
  OPENID_ISSUER — no dependency on the Passport OpenID login strategy.

Frontend
- OAuthCallback: after IAM.handleCallback(), POST the token to /oauth/iam/session
  and set the returned Chat JWT (dispatchTokenUpdatedEvent). Fixes the prior
  broken callback that set the IAM token as the app bearer (rejected by
  requireJwtAuth) and created no server session.
- data-provider: add the iamSession() endpoint + export.

Deps
- @hanzo/iam ^0.13.1 -> ^0.13.8 (relocked).

Verification
- New unit tests green (iamSession 10, iamToken 5); AuthService + CloudAgentsClient
  green (52 in-scope tests pass). Typecheck introduces 0 new errors.
- verifyIamToken integration-tested against LIVE hanzo.id JWKS: forged (real kid,
  wrong key), unknown-kid, and empty tokens all rejected.

The server-initiated /oauth/openid routes are kept as a dormant, unreferenced
fallback (the SPA uses only the bridge); their deletion is deferred until the
full browser login flow is verified live end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zeekay
2026-07-23 22:51:11 -07:00
co-authored by Claude Opus 4.8
parent a31f4517c1
commit 8c6478cb82
10 changed files with 774 additions and 21 deletions
+206
View File
@@ -0,0 +1,206 @@
const { logger } = require('@hanzochat/data-schemas');
const { SystemRoles } = require('@hanzochat/data-provider');
const { findOpenIDUser, getBalanceConfig } = require('@hanzochat/api');
const { verifyIamToken } = require('~/server/services/iamToken');
const { setAuthTokens, persistOpenIDTokensToSession } = require('~/server/services/AuthService');
const { getAppConfig } = require('~/server/services/Config');
const { checkBan } = require('~/server/middleware');
const { findUser, createUser, updateUser, countUsers } = require('~/models');
/**
* The session-bridge for the @hanzo/iam SPA login. This is the ONE server entry
* that turns an IAM-verified identity into a Chat session.
*
* The SPA runs the Authorization-Code + PKCE flow entirely in the browser
* (@hanzo/iam), then POSTs the resulting `{ accessToken, idToken }` here. We:
* 1. JWKS-validate the id_token against hanzo.id (issuer + signature + expiry),
* 2. reconcile the Mongo User by `sub` (openidId), creating/migrating as needed,
* 3. issue the EXISTING Chat session — refresh cookie + Mongo `Session` +
* `token_provider=chat` + a Chat JWT (via `setAuthTokens`), so reload-persist
* and the 401->refresh interceptor keep working byte-identically to before,
* 4. persist the id_token/access_token server-side (`req.session.openidTokens`)
* so downstream on-behalf-of cloud calls run as this hanzo.id principal.
*
* The IAM tokens are NEVER stored in a browser-readable place: the Chat JWT is the
* only bearer returned to the SPA; the id_token stays server-side for OBO.
*/
/** Coerce a claim to a string, else ''. */
function claimStr(value) {
return typeof value === 'string' ? value : '';
}
/** Best-effort display name from OIDC claims. */
function fullNameFromClaims(claims) {
if (claimStr(claims.name)) {
return claims.name;
}
const parts = [claims.given_name, claims.family_name].filter((p) => typeof p === 'string' && p);
if (parts.length) {
return parts.join(' ');
}
return claimStr(claims.preferred_username) || claimStr(claims.email) || '';
}
/**
* Reconcile (find/create/migrate) the Mongo User for a verified IAM identity.
* Mirrors the openid strategy's `openidId = sub` model and the `findOpenIDUser`
* migration path, minus the userinfo/avatar enrichment (not needed for login/OBO;
* cloud derives the tenant org from the forwarded id_token's `owner` claim).
* @param {Record<string, any>} claims - verified IAM token claims
* @returns {Promise<import('@hanzochat/data-schemas').IUser>}
*/
async function reconcileUser(claims) {
const openidId = claimStr(claims.sub);
const email = claimStr(claims.email);
const { user: found, error } = await findOpenIDUser({
findUser,
openidId,
email: email || undefined,
idOnTheSource: claimStr(claims.oid) || undefined,
strategyName: 'iamSessionBridge',
});
if (error) {
const err = new Error(error);
err.status = 403;
throw err;
}
const username =
claimStr(claims.preferred_username) ||
claimStr(claims.username) ||
(email ? email.split('@')[0] : openidId);
const name = fullNameFromClaims(claims);
const organization =
claimStr(claims.owner) || claimStr(claims.organization) || claimStr(claims.org) || '';
const project = claimStr(claims.project) || '';
const groups = Array.isArray(claims.groups) ? claims.groups : [];
if (found) {
const update = { provider: 'openid', openidId };
if (name && name !== found.name) {
update.name = name;
}
if (username && username !== found.username) {
update.username = username;
}
if (organization) {
update.organization = organization;
}
if (project) {
update.project = project;
}
if (groups.length) {
update.groups = groups;
}
if (!found.role) {
update.role = SystemRoles.USER;
}
const updated = await updateUser(found._id, update);
return updated || found;
}
const appConfig = await getAppConfig();
const isFirstRegisteredUser = (await countUsers()) === 0;
const balanceConfig = getBalanceConfig(appConfig);
const created = await createUser(
{
provider: 'openid',
openidId,
username,
email: email || '',
emailVerified: claims.email_verified === true,
name,
idOnTheSource: claimStr(claims.oid),
organization,
project,
groups,
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
},
balanceConfig,
true,
true,
);
return created;
}
/**
* POST /oauth/iam/session — exchange an IAM SPA token for a Chat session.
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async function iamSessionController(req, res) {
try {
const { accessToken, idToken } = req.body || {};
if (!idToken && !accessToken) {
return res.status(400).json({ message: 'idToken or accessToken is required' });
}
/**
* The OIDC id_token is the identity assertion (JWKS-validatable, `aud` = the
* client the SPA authenticated with). Fall back to the access token only when
* no id_token was issued.
*/
const identityToken = idToken || accessToken;
let claims;
try {
claims = await verifyIamToken(identityToken);
} catch (err) {
logger.warn('[iamSession] IAM token verification failed:', err?.message || err);
return res.status(401).json({ message: 'invalid IAM token' });
}
if (!claims?.sub) {
return res.status(401).json({ message: 'IAM token missing subject' });
}
const user = await reconcileUser(claims);
if (!user?._id) {
return res.status(401).json({ message: 'user reconciliation failed' });
}
/** Ban check against the resolved principal (mirrors the OAuth handler). */
req.user = user;
await checkBan(req, res);
if (req.banned) {
return;
}
/**
* Issue the Chat session exactly as a local login does: refresh cookie +
* Mongo Session + `token_provider=chat` + the Chat JWT. This keeps the
* 401->refresh interceptor and reload-persist working unchanged.
*/
const token = await setAuthTokens(user._id, res);
/**
* Persist the IAM tokens server-side for on-behalf-of cloud calls. The
* decoupled default: no OIDC refresh credential is bound (session refreshes
* via the Chat JWT cookie), so this only carries the bearer material that
* `resolveTenantBearer` forwards. Best-effort — never break login.
*/
try {
persistOpenIDTokensToSession(req, {
access_token: accessToken || idToken,
id_token: idToken,
});
} catch (persistErr) {
logger.warn('[iamSession] failed to persist IAM tokens for on-behalf-of:', persistErr);
}
const safeUser = typeof user.toObject === 'function' ? user.toObject() : { ...user };
delete safeUser.password;
delete safeUser.totpSecret;
delete safeUser.backupCodes;
logger.info(
`[iamSession] session established openidId: ${claimStr(claims.sub)} | email: ${safeUser.email}`,
);
return res.status(200).json({ token, user: safeUser });
} catch (err) {
logger.error('[iamSession] error establishing session:', err);
const status = err.status || 500;
return res.status(status).json({ message: err.message || 'session bridge failed' });
}
}
module.exports = { iamSessionController, reconcileUser };
@@ -0,0 +1,281 @@
const mockVerifyIamToken = jest.fn();
const mockSetAuthTokens = jest.fn();
const mockPersistOpenIDTokensToSession = jest.fn();
const mockGetAppConfig = jest.fn();
const mockCheckBan = jest.fn();
const mockFindOpenIDUser = jest.fn();
const mockGetBalanceConfig = jest.fn();
const mockFindUser = jest.fn();
const mockCreateUser = jest.fn();
const mockUpdateUser = jest.fn();
const mockCountUsers = jest.fn();
const mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() };
jest.mock('@hanzochat/data-schemas', () => ({
logger: mockLogger,
}));
jest.mock('@hanzochat/data-provider', () => ({
SystemRoles: { USER: 'USER', ADMIN: 'ADMIN' },
}));
jest.mock('@hanzochat/api', () => ({
findOpenIDUser: (...args) => mockFindOpenIDUser(...args),
getBalanceConfig: (...args) => mockGetBalanceConfig(...args),
}));
jest.mock('~/server/services/iamToken', () => ({
verifyIamToken: (...args) => mockVerifyIamToken(...args),
}));
jest.mock('~/server/services/AuthService', () => ({
setAuthTokens: (...args) => mockSetAuthTokens(...args),
persistOpenIDTokensToSession: (...args) => mockPersistOpenIDTokensToSession(...args),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: (...args) => mockGetAppConfig(...args),
}));
jest.mock('~/server/middleware', () => ({
checkBan: (...args) => mockCheckBan(...args),
}));
jest.mock('~/models', () => ({
findUser: (...args) => mockFindUser(...args),
createUser: (...args) => mockCreateUser(...args),
updateUser: (...args) => mockUpdateUser(...args),
countUsers: (...args) => mockCountUsers(...args),
}));
const { iamSessionController } = require('./iamSession');
const makeRes = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.cookie = jest.fn().mockReturnValue(res);
return res;
};
describe('iamSessionController — @hanzo/iam session-bridge', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSetAuthTokens.mockResolvedValue('chat.jwt.token');
mockPersistOpenIDTokensToSession.mockReturnValue(true);
mockGetAppConfig.mockResolvedValue({ balance: { enabled: false } });
mockGetBalanceConfig.mockReturnValue({ enabled: false });
mockCheckBan.mockImplementation(async (req) => {
req.banned = false;
});
mockCountUsers.mockResolvedValue(5);
});
it('400 when neither token is provided', async () => {
const req = { body: {}, session: {}, headers: {} };
const res = makeRes();
await iamSessionController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(mockVerifyIamToken).not.toHaveBeenCalled();
});
it('401 when token verification fails', async () => {
mockVerifyIamToken.mockRejectedValue(new Error('bad signature'));
const req = { body: { idToken: 'x.y.z' }, session: {}, headers: {} };
const res = makeRes();
await iamSessionController(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(mockSetAuthTokens).not.toHaveBeenCalled();
});
it('401 when verified claims have no sub', async () => {
mockVerifyIamToken.mockResolvedValue({ email: 'a@b.com' });
const req = { body: { idToken: 'x.y.z' }, session: {}, headers: {} };
const res = makeRes();
await iamSessionController(req, res);
expect(res.status).toHaveBeenCalledWith(401);
});
it('validates the id_token (not the access token) as the identity assertion', async () => {
mockVerifyIamToken.mockResolvedValue({ sub: 'hanzo/alice', email: 'alice@hanzo.ai' });
mockFindOpenIDUser.mockResolvedValue({
user: { _id: 'u1', provider: 'openid', openidId: 'hanzo/alice', role: 'USER' },
error: null,
migration: false,
});
mockUpdateUser.mockResolvedValue({
_id: 'u1',
provider: 'openid',
openidId: 'hanzo/alice',
email: 'alice@hanzo.ai',
role: 'USER',
});
const req = {
body: { idToken: 'ID.TOK', accessToken: 'ACC.TOK' },
session: {},
headers: {},
};
const res = makeRes();
await iamSessionController(req, res);
expect(mockVerifyIamToken).toHaveBeenCalledWith('ID.TOK');
});
it('existing user: reconciles, issues chat session, persists id_token for OBO', async () => {
mockVerifyIamToken.mockResolvedValue({
sub: 'hanzo/alice',
email: 'alice@hanzo.ai',
name: 'Alice',
owner: 'hanzo',
});
mockFindOpenIDUser.mockResolvedValue({
user: { _id: 'u1', provider: 'openid', openidId: 'hanzo/alice', role: 'USER', name: 'Al' },
error: null,
migration: false,
});
mockUpdateUser.mockResolvedValue({
_id: 'u1',
provider: 'openid',
openidId: 'hanzo/alice',
email: 'alice@hanzo.ai',
name: 'Alice',
role: 'USER',
toObject() {
return { ...this };
},
});
const req = {
body: { idToken: 'ID.TOK', accessToken: 'ACC.TOK' },
session: {},
headers: {},
};
const res = makeRes();
await iamSessionController(req, res);
expect(mockUpdateUser).toHaveBeenCalledWith(
'u1',
expect.objectContaining({ provider: 'openid', openidId: 'hanzo/alice' }),
);
expect(mockCreateUser).not.toHaveBeenCalled();
expect(mockSetAuthTokens).toHaveBeenCalledWith('u1', res);
// OBO: the id_token is persisted server-side (never a browser cookie).
expect(mockPersistOpenIDTokensToSession).toHaveBeenCalledWith(req, {
access_token: 'ACC.TOK',
id_token: 'ID.TOK',
});
expect(res.status).toHaveBeenCalledWith(200);
const payload = res.json.mock.calls[0][0];
expect(payload.token).toBe('chat.jwt.token');
expect(payload.user.openidId).toBe('hanzo/alice');
});
it('new user: creates a provider=openid user keyed by sub', async () => {
mockVerifyIamToken.mockResolvedValue({
sub: 'hanzo/bob',
email: 'bob@hanzo.ai',
preferred_username: 'bob',
email_verified: true,
owner: 'hanzo',
});
mockFindOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false });
mockCountUsers.mockResolvedValue(3);
mockCreateUser.mockResolvedValue({
_id: 'u2',
provider: 'openid',
openidId: 'hanzo/bob',
email: 'bob@hanzo.ai',
role: 'USER',
toObject() {
return { ...this };
},
});
const req = { body: { idToken: 'ID.TOK' }, session: {}, headers: {} };
const res = makeRes();
await iamSessionController(req, res);
expect(mockCreateUser).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'openid',
openidId: 'hanzo/bob',
username: 'bob',
email: 'bob@hanzo.ai',
emailVerified: true,
role: 'USER',
}),
{ enabled: false },
true,
true,
);
expect(mockSetAuthTokens).toHaveBeenCalledWith('u2', res);
expect(res.status).toHaveBeenCalledWith(200);
});
it('first user becomes ADMIN', async () => {
mockVerifyIamToken.mockResolvedValue({ sub: 'hanzo/root', email: 'root@hanzo.ai' });
mockFindOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false });
mockCountUsers.mockResolvedValue(0);
mockCreateUser.mockResolvedValue({ _id: 'u0', openidId: 'hanzo/root', role: 'ADMIN' });
const req = { body: { idToken: 'ID.TOK' }, session: {}, headers: {} };
const res = makeRes();
await iamSessionController(req, res);
expect(mockCreateUser).toHaveBeenCalledWith(
expect.objectContaining({ role: 'ADMIN' }),
expect.anything(),
true,
true,
);
});
it('403 when findOpenIDUser blocks cross-provider takeover', async () => {
mockVerifyIamToken.mockResolvedValue({ sub: 'hanzo/eve', email: 'eve@hanzo.ai' });
mockFindOpenIDUser.mockResolvedValue({ user: null, error: 'AUTH_FAILED', migration: false });
const req = { body: { idToken: 'ID.TOK' }, session: {}, headers: {} };
const res = makeRes();
await iamSessionController(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(mockSetAuthTokens).not.toHaveBeenCalled();
});
it('banned principal: no session issued', async () => {
mockVerifyIamToken.mockResolvedValue({ sub: 'hanzo/ban', email: 'ban@hanzo.ai' });
mockFindOpenIDUser.mockResolvedValue({
user: { _id: 'u3', provider: 'openid', openidId: 'hanzo/ban', role: 'USER' },
error: null,
migration: false,
});
mockUpdateUser.mockResolvedValue({ _id: 'u3', openidId: 'hanzo/ban', role: 'USER' });
mockCheckBan.mockImplementation(async (req) => {
req.banned = true;
});
const req = { body: { idToken: 'ID.TOK' }, session: {}, headers: {} };
const res = makeRes();
await iamSessionController(req, res);
expect(mockSetAuthTokens).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
it('OBO persist failure never breaks login', async () => {
mockVerifyIamToken.mockResolvedValue({ sub: 'hanzo/al', email: 'al@hanzo.ai' });
mockFindOpenIDUser.mockResolvedValue({
user: { _id: 'u4', provider: 'openid', openidId: 'hanzo/al', role: 'USER' },
error: null,
migration: false,
});
mockUpdateUser.mockResolvedValue({ _id: 'u4', openidId: 'hanzo/al', role: 'USER' });
mockPersistOpenIDTokensToSession.mockImplementation(() => {
throw new Error('no session');
});
const req = { body: { idToken: 'ID.TOK' }, session: {}, headers: {} };
const res = makeRes();
await iamSessionController(req, res);
expect(mockSetAuthTokens).toHaveBeenCalledWith('u4', res);
expect(res.status).toHaveBeenCalledWith(200);
});
});
+16 -1
View File
@@ -7,6 +7,7 @@ const { ErrorTypes } = require('@hanzochat/data-provider');
const { createSetBalanceConfig } = require('@hanzochat/api');
const { checkDomainAllowed, loginLimiter, logHeaders } = require('~/server/middleware');
const { createOAuthHandler } = require('~/server/controllers/auth/oauth');
const { iamSessionController } = require('~/server/controllers/auth/iamSession');
const { getAppConfig } = require('~/server/services/Config');
const { Balance } = require('~/db/models');
@@ -37,7 +38,21 @@ router.get('/error', (req, res) => {
});
/**
* Hanzo IAM (OpenID Connect) Routes
* Hanzo IAM session-bridge — the ONE way the @hanzo/iam SPA establishes a Chat
* session. The SPA runs Authorization-Code + PKCE in the browser and POSTs its
* token here; the controller JWKS-validates it, reconciles the user, mints the
* Chat session (refresh cookie + Mongo Session + Chat JWT), and persists the
* id_token server-side for on-behalf-of cloud calls.
*/
router.post('/iam/session', iamSessionController);
/**
* Server-initiated OpenID Connect routes (dormant fallback).
*
* The @hanzo/iam SPA does NOT use these — user login flows entirely through the
* SPA + `/iam/session` bridge above. These remain only as an untouched safety
* net for any deployment still pinned to the server-side redirect flow; they are
* scheduled for removal once the SPA bridge is verified live end-to-end.
*/
router.get('/openid', loginLimiter, (req, res, next) => {
return passport.authenticate('openid', {
+143
View File
@@ -0,0 +1,143 @@
const jwt = require('jsonwebtoken');
const jwksRsa = require('jwks-rsa');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { logger } = require('@hanzochat/data-schemas');
/**
* JWKS verification for Hanzo IAM (hanzo.id) tokens — the ONE server-side proof
* that a token presented by the @hanzo/iam SPA was really issued by IAM.
*
* This is the JWKS verify path promoted out of the per-request `openIdJwtStrategy`
* (which uses the same `jwks-rsa` signing-key source) into a standalone, callable
* verifier the session-bridge uses. It is self-contained: it discovers the JWKS
* URI from `OPENID_ISSUER` (falling back to the already-initialized openid-client
* config's metadata when present), so it does NOT depend on the Passport OpenID
* login strategy being registered.
*
* Security properties enforced: RS256 signature against IAM's JWKS (key matched by
* `kid`), unexpired `exp` (jsonwebtoken default), and issuer equality with
* `OPENID_ISSUER` (trailing-slash-normalized). Audience is intentionally NOT
* pinned here — the SPA client id (`VITE_HANZO_IAM_APP`) and the backend
* `OPENID_CLIENT_ID` may differ per deployment, and cloud is the authoritative
* audience/claim validator on the on-behalf-of path — matching the existing
* `openIdJwtStrategy` which likewise verifies signature, not audience.
*/
const OIDC_DISCOVERY_PATH = '/.well-known/openid-configuration';
let _jwksClient;
let _discoveredJwksUri;
/** Normalize an origin/issuer by stripping trailing slashes. */
function normalizeIssuer(value) {
return (value || '').replace(/\/+$/, '');
}
/**
* Resolve IAM's JWKS URI. Prefers the openid-client Configuration's discovered
* metadata (already fetched at boot when OpenID is configured); otherwise runs a
* one-time OIDC discovery against `OPENID_ISSUER`. Cached process-wide.
* @returns {Promise<string>}
*/
async function resolveJwksUri() {
try {
// Lazy require to avoid a circular import at module load and to stay
// decoupled from whether the Passport OpenID strategy is registered.
const { getOpenIdConfig } = require('~/strategies');
const metaUri = getOpenIdConfig?.()?.serverMetadata?.().jwks_uri;
if (metaUri) {
return metaUri;
}
} catch (err) {
logger.debug('[iamToken] openid-client config unavailable; discovering JWKS', err?.message);
}
if (_discoveredJwksUri) {
return _discoveredJwksUri;
}
const issuer = normalizeIssuer(process.env.OPENID_ISSUER);
if (!issuer) {
throw new Error('OPENID_ISSUER is not configured');
}
const res = await fetch(`${issuer}${OIDC_DISCOVERY_PATH}`);
if (!res.ok) {
throw new Error(`OIDC discovery failed (${res.status}) for ${issuer}`);
}
const meta = await res.json();
if (!meta?.jwks_uri) {
throw new Error('OIDC discovery response has no jwks_uri');
}
_discoveredJwksUri = meta.jwks_uri;
return _discoveredJwksUri;
}
/** Build (once) the cached jwks-rsa client. */
async function getJwksClient() {
if (_jwksClient) {
return _jwksClient;
}
const jwksUri = await resolveJwksUri();
const options = {
cache: true,
cacheMaxAge: 600000,
rateLimit: true,
jwksUri,
};
if (process.env.PROXY) {
options.requestAgent = new HttpsProxyAgent(process.env.PROXY);
}
_jwksClient = jwksRsa(options);
return _jwksClient;
}
/** jsonwebtoken key resolver backed by the JWKS client. */
function keyResolver(client) {
return (header, callback) => {
client.getSigningKey(header.kid, (err, key) => {
if (err) {
callback(err);
return;
}
callback(null, key.getPublicKey());
});
};
}
/**
* Verify a Hanzo IAM token and return its claims. Throws on any failure
* (bad signature, expired, wrong issuer, unreachable JWKS).
* @param {string} token - a hanzo.id-issued JWT (id_token preferred)
* @returns {Promise<Record<string, unknown>>} verified claims
*/
async function verifyIamToken(token) {
if (!token || typeof token !== 'string') {
throw new Error('missing token');
}
const client = await getJwksClient();
const claims = await new Promise((resolve, reject) => {
jwt.verify(token, keyResolver(client), { algorithms: ['RS256'] }, (err, decoded) => {
if (err) {
reject(err);
return;
}
resolve(decoded);
});
});
const expectedIssuer = normalizeIssuer(process.env.OPENID_ISSUER);
if (expectedIssuer && claims?.iss && normalizeIssuer(claims.iss) !== expectedIssuer) {
throw new Error(`unexpected token issuer: ${claims.iss}`);
}
return claims;
}
/** Reset the memoized JWKS client + discovery cache (tests only). */
function _resetIamToken() {
_jwksClient = undefined;
_discoveredJwksUri = undefined;
}
module.exports = { verifyIamToken, _resetIamToken };
+75
View File
@@ -0,0 +1,75 @@
const mockVerify = jest.fn();
const mockGetSigningKey = jest.fn();
const mockJwksRsa = jest.fn(() => ({ getSigningKey: mockGetSigningKey }));
const mockGetOpenIdConfig = jest.fn();
jest.mock('jsonwebtoken', () => ({ verify: (...args) => mockVerify(...args) }));
jest.mock('jwks-rsa', () => (...args) => mockJwksRsa(...args));
jest.mock('https-proxy-agent', () => ({ HttpsProxyAgent: class {} }));
jest.mock('@hanzochat/data-schemas', () => ({
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() },
}));
jest.mock(
'~/strategies',
() => ({ getOpenIdConfig: (...args) => mockGetOpenIdConfig(...args) }),
{ virtual: true },
);
const { verifyIamToken, _resetIamToken } = require('~/server/services/iamToken');
describe('verifyIamToken — JWKS verification for hanzo.id tokens', () => {
beforeEach(() => {
jest.clearAllMocks();
_resetIamToken();
process.env.OPENID_ISSUER = 'https://hanzo.id';
// Resolve JWKS URI from the already-initialized openid-client config (no network).
mockGetOpenIdConfig.mockReturnValue({
serverMetadata: () => ({ jwks_uri: 'https://hanzo.id/v1/iam/.well-known/jwks' }),
});
mockGetSigningKey.mockImplementation((kid, cb) => cb(null, { getPublicKey: () => 'PUBKEY' }));
});
afterEach(() => {
delete process.env.OPENID_ISSUER;
});
it('throws on a missing token', async () => {
await expect(verifyIamToken('')).rejects.toThrow('missing token');
});
it('returns claims for a valid, correctly-issued token', async () => {
mockVerify.mockImplementation((token, key, opts, cb) =>
cb(null, { sub: 'hanzo/alice', iss: 'https://hanzo.id', email: 'alice@hanzo.ai' }),
);
const claims = await verifyIamToken('good.token');
expect(claims.sub).toBe('hanzo/alice');
// RS256 only, JWKS-backed key resolver used.
expect(mockVerify).toHaveBeenCalledWith(
'good.token',
expect.any(Function),
expect.objectContaining({ algorithms: ['RS256'] }),
expect.any(Function),
);
});
it('rejects a token whose issuer does not match OPENID_ISSUER', async () => {
mockVerify.mockImplementation((token, key, opts, cb) =>
cb(null, { sub: 'hanzo/eve', iss: 'https://evil.example' }),
);
await expect(verifyIamToken('forged.iss')).rejects.toThrow('unexpected token issuer');
});
it('accepts a trailing-slash issuer variant (normalized)', async () => {
process.env.OPENID_ISSUER = 'https://hanzo.id/';
mockVerify.mockImplementation((token, key, opts, cb) =>
cb(null, { sub: 'hanzo/al', iss: 'https://hanzo.id' }),
);
const claims = await verifyIamToken('ok.token');
expect(claims.sub).toBe('hanzo/al');
});
it('propagates a signature-verification failure', async () => {
mockVerify.mockImplementation((token, key, opts, cb) => cb(new Error('invalid signature')));
await expect(verifyIamToken('bad.sig')).rejects.toThrow('invalid signature');
});
});
+1 -1
View File
@@ -37,7 +37,7 @@
"@hanzo/ai": "^0.2.1",
"@hanzo/brand": "^1.4.1",
"@hanzo/event": "^0.2.0",
"@hanzo/iam": "^0.13.1",
"@hanzo/iam": "^0.13.8",
"@hanzo/logo": "^1.0.13",
"@hanzo/ui": "npm:@hanzo/ui-shadcn@^5.7.4",
"@hanzo/usage": "^0.1.6",
+29 -9
View File
@@ -1,15 +1,22 @@
/**
* OAuth Callback Handler for Hanzo IAM (hanzo.id)
* OAuth Callback Handler for Hanzo IAM (hanzo.id) — the @hanzo/iam session-bridge.
*
* Flow:
* 1. User clicks "Sign in" -> redirects to hanzo.id OIDC (via IAM)
* 2. After auth, hanzo.id redirects back with ?code=xxx&state=yyy
* 3. IAM.handleCallback() exchanges the code for tokens (PKCE)
* 4. We set the access token header and redirect to /c/new
* 1. User clicks "Log in with Hanzo" -> @hanzo/iam redirects to hanzo.id (PKCE).
* 2. hanzo.id redirects back here with ?code=xxx&state=yyy.
* 3. IAM.handleCallback() exchanges the code for the IAM tokens (PKCE).
* 4. We POST { accessToken, idToken } to the backend session-bridge
* (/oauth/iam/session), which JWKS-validates the token, reconciles the user,
* and issues the Chat session (refresh cookie + Mongo Session + Chat JWT) plus
* persists the id_token server-side for on-behalf-of cloud calls.
* 5. We set the returned Chat JWT as the app bearer and redirect to /c/new.
*
* The IAM token is never used as the app bearer — the JWKS-issued Chat JWT is;
* the id_token stays server-side for OBO.
*/
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { setTokenHeader } from '@hanzochat/data-provider';
import { request, iamSession } from '@hanzochat/data-provider';
import { Spinner } from '@hanzochat/client';
import { getHanzoIamSdk } from '~/utils/iam';
@@ -34,13 +41,26 @@ export default function OAuthCallback() {
try {
const tokens = await iamSdk.handleCallback();
if (tokens.accessToken) {
setTokenHeader(tokens.accessToken);
/** Trade the IAM token for a Chat session via the backend bridge. */
const { token } = await request.post(iamSession(), {
accessToken: tokens.accessToken,
idToken: tokens.idToken,
});
if (!token) {
throw new Error('session bridge returned no token');
}
/**
* Set the Chat JWT as the app bearer and notify AuthContext (which then
* fetches the user). The refresh cookie the bridge set drives reload
* persistence via the silent-refresh path.
*/
request.dispatchTokenUpdatedEvent(token);
navigate('/c/new', { replace: true });
} catch (err) {
console.error('OAuth code exchange failed:', err);
console.error('IAM session bridge failed:', err);
navigate('/login?error=auth_failed', { replace: true });
}
};
@@ -196,6 +196,12 @@ export const loginGoogle = () => `${BASE_URL}/v1/chat/auth/google`;
export const refreshToken = (retry?: boolean) =>
`${BASE_URL}/v1/chat/auth/refresh${retry === true ? '?retry=true' : ''}`;
/**
* Session-bridge: exchange an @hanzo/iam SPA token for a Chat session
* (refresh cookie + Mongo Session + Chat JWT). POST { accessToken, idToken }.
*/
export const iamSession = () => `${BASE_URL}/oauth/iam/session`;
export const guestToken = () => `${BASE_URL}/v1/chat/auth/guest`;
export const requestPasswordReset = () => `${BASE_URL}/v1/chat/auth/requestPasswordReset`;
+8 -1
View File
@@ -36,7 +36,14 @@ export * from './accessPermissions';
export * from './keys';
/* api call helpers */
export * from './headers-helpers';
export { loginPage, registerPage, apiBaseUrl, setApiBaseUrl, zapUrl } from './api-endpoints';
export {
loginPage,
registerPage,
apiBaseUrl,
setApiBaseUrl,
zapUrl,
iamSession,
} from './api-endpoints';
export { default as request, setPublishableKey, getWithPk } from './request';
export { dataService };
import * as dataService from './data-service';
+9 -9
View File
@@ -348,7 +348,7 @@ importers:
devDependencies:
jest:
specifier: ^30.2.0
version: 30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3))
version: 30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3))
mongodb-memory-server:
specifier: ^10.1.4
version: 10.4.3
@@ -386,8 +386,8 @@ importers:
specifier: ^0.2.0
version: 0.2.0(react@18.3.1)
'@hanzo/iam':
specifier: ^0.13.1
version: 0.13.1(react@18.3.1)
specifier: ^0.13.8
version: 0.13.8(react@18.3.1)
'@hanzo/logo':
specifier: ^1.0.13
version: 1.0.13(react@18.3.1)
@@ -405,7 +405,7 @@ importers:
version: link:../packages/data-provider
'@hanzogui/shell':
specifier: ^7.4.2
version: 7.4.2(@hanzo/iam@0.13.1(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
version: 7.4.2(@hanzo/iam@0.13.8(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@headlessui/react':
specifier: ^2.1.2
version: 2.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -3820,8 +3820,8 @@ packages:
react:
optional: true
'@hanzo/iam@0.13.1':
resolution: {integrity: sha512-MAqXY7jL5pcPkaUE0Qb/ePL2WKvk3p6EQDLHGZENj+/gbNKs7e/9qG+nmRlPMFVcwXPk+suzkBAEyIMT2EiJ8w==}
'@hanzo/iam@0.13.8':
resolution: {integrity: sha512-6D4lZSSuTBWV/bof5u5l6VEE0VZhNnpF99svirmHTRko5MsZ5sEd3+7xZ28KH0UL0oquvHwRJ6tkvn+E+f5YuQ==}
engines: {node: '>=18'}
peerDependencies:
react: '>=17'
@@ -17736,7 +17736,7 @@ snapshots:
optionalDependencies:
react: 18.3.1
'@hanzo/iam@0.13.1(react@18.3.1)':
'@hanzo/iam@0.13.8(react@18.3.1)':
dependencies:
jose: 6.2.2
libphonenumber-js: 1.13.7
@@ -17885,9 +17885,9 @@ snapshots:
- ws
- zod
'@hanzogui/shell@7.4.2(@hanzo/iam@0.13.1(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
'@hanzogui/shell@7.4.2(@hanzo/iam@0.13.8(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@hanzo/iam': 0.13.1(react@18.3.1)
'@hanzo/iam': 0.13.8(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)