mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
Chat was minting its own credentials on top of an identity IAM had already established: the browser ran the PKCE flow, posted the resulting IAM token to a bridge, and got back a Chat JWT, a refresh cookie and a Session row. Everything after login was authenticated by that second credential, and IAM's was filed away server-side for calls to cloud. The token IAM issues is now the only one. It rides every request as the bearer, is verified against IAM's JWKS on the spot, and is not stored — so there is one credential, and the principal that authenticates here is by construction the principal cloud bills. `resolveTenantBearer` loses the machinery that existed to keep a stored copy honest (principal binding, expiry checks, cookie fallback, the renewal middleware in front of the completion route); a token that IS the request cannot name a different user or outlive the request it arrived on. The audience is now checked. One issuer serves every Hanzo relying party, so a token addressed to another app would otherwise authenticate here on the strength of a shared signature. Removed, because IAM owns them: two-factor enrolment and verification, email verification, password reset, the admin panel's OAuth code exchange, and the server-initiated OpenID redirect. Sign-out now ends the session where it lives — revoking both tokens at the issuer — rather than dropping the local copy and leaving the browser recognised. A guest holds nothing at all. Anonymous chat used to mint a signed guest token, which proved nothing: minting was unauthenticated and the free-message quota keyed on the client address regardless. A request without a bearer is now the guest, identified by that same address, hashed so it stays out of job metadata and logs.
149 lines
4.8 KiB
JavaScript
149 lines
4.8 KiB
JavaScript
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), issuer equality with
|
|
* `OPENID_ISSUER` (trailing-slash-normalized), and audience equality with
|
|
* `OPENID_CLIENT_ID`.
|
|
*
|
|
* Audience is checked because this verifier now stands alone. One issuer serves
|
|
* every Hanzo relying party, so without `aud` a token minted for a different app
|
|
* — a different product, with a different reason to trust its holder — would
|
|
* authenticate here on the strength of being signed by the same issuer. Reading
|
|
* `aud` is what makes a token addressed to this app.
|
|
*
|
|
* The client id itself and the org-scoped form are both accepted because those
|
|
* are the two shapes IAM mints for one client (`audienceFor`, RFC 8707): a
|
|
* shared application scopes the audience to its org. Both name THIS client; no
|
|
* other client's token matches either.
|
|
*/
|
|
|
|
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(/\/+$/, '');
|
|
}
|
|
|
|
/** Quote a client id so it matches literally inside the audience pattern. */
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
/**
|
|
* Resolve IAM's JWKS URI by asking the issuer where it publishes its keys.
|
|
* Discovered once and cached process-wide.
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async function resolveJwksUri() {
|
|
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 options = { algorithms: ['RS256'] };
|
|
const clientId = process.env.OPENID_CLIENT_ID;
|
|
if (clientId) {
|
|
options.audience = new RegExp(`^${escapeRegExp(clientId)}(-org-.+)?$`);
|
|
}
|
|
const claims = await new Promise((resolve, reject) => {
|
|
jwt.verify(token, keyResolver(client), options, (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 };
|