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:
co-authored by
Claude Opus 4.8
parent
a31f4517c1
commit
8c6478cb82
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Generated
+9
-9
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user