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.
569 lines
19 KiB
JavaScript
569 lines
19 KiB
JavaScript
const client = require('openid-client');
|
|
const { isEnabled } = require('@hanzochat/api');
|
|
const { logger } = require('@hanzochat/data-schemas');
|
|
const { CacheKeys } = require('@hanzochat/data-provider');
|
|
const { Client } = require('@microsoft/microsoft-graph-client');
|
|
const getLogStores = require('~/cache/getLogStores');
|
|
|
|
/**
|
|
* @import { TPrincipalSearchResult, TGraphPerson, TGraphUser, TGraphGroup, TGraphPeopleResponse, TGraphUsersResponse, TGraphGroupsResponse } from '@hanzochat/data-provider'
|
|
*/
|
|
|
|
/**
|
|
* Checks if Entra ID principal search feature is enabled based on environment variables and user authentication
|
|
* @param {Object} user - User object from request
|
|
* @param {string} user.provider - Authentication provider
|
|
* @param {string} user.openidId - OpenID subject identifier
|
|
* @returns {boolean} True if Entra ID principal search is enabled and user is authenticated via OpenID
|
|
*/
|
|
const entraIdPrincipalFeatureEnabled = (user) => {
|
|
return (
|
|
isEnabled(process.env.USE_ENTRA_ID_FOR_PEOPLE_SEARCH) &&
|
|
isEnabled(process.env.OPENID_REUSE_TOKENS) &&
|
|
user?.provider === 'openid' &&
|
|
user?.openidId
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Creates a Microsoft Graph client with on-behalf-of token exchange
|
|
* @param {string} accessToken - OpenID Connect access token from user
|
|
* @param {string} sub - Subject identifier from token claims
|
|
* @returns {Promise<Client>} Authenticated Graph API client
|
|
*/
|
|
let entraConfig;
|
|
|
|
/**
|
|
* The OAuth client Microsoft's on-behalf-of grant is exchanged at.
|
|
*
|
|
* The grant swaps a token the caller already holds for a Graph one, so it runs
|
|
* against the issuer that minted theirs — which means this feature only works
|
|
* where Entra is the identity provider people sign in with. That is what
|
|
* `entraIdPrincipalFeatureEnabled` gates on, and it is why the configuration is
|
|
* discovered here from the issuer rather than borrowed from sign-in: signing in
|
|
* with Hanzo IAM verifies a token against JWKS and needs no OAuth client at all.
|
|
*
|
|
* Discovered once, on the first search that gets past the gate.
|
|
*/
|
|
const graphExchangeClient = async () => {
|
|
if (!entraConfig) {
|
|
entraConfig = await client.discovery(
|
|
new URL(process.env.OPENID_ISSUER),
|
|
process.env.OPENID_CLIENT_ID,
|
|
process.env.OPENID_CLIENT_SECRET,
|
|
);
|
|
}
|
|
return entraConfig;
|
|
};
|
|
|
|
const createGraphClient = async (accessToken, sub) => {
|
|
try {
|
|
const exchangedToken = await exchangeTokenForGraphAccess(
|
|
await graphExchangeClient(),
|
|
accessToken,
|
|
sub,
|
|
);
|
|
|
|
const graphClient = Client.init({
|
|
authProvider: (done) => {
|
|
done(null, exchangedToken);
|
|
},
|
|
});
|
|
|
|
return graphClient;
|
|
} catch (error) {
|
|
logger.error('[createGraphClient] Error creating Graph client:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Exchange OpenID token for Graph API access using on-behalf-of flow
|
|
* Similar to exchangeAccessTokenIfNeeded in openidStrategy.js but for Graph scopes
|
|
* @param {Configuration} config - OpenID configuration
|
|
* @param {string} accessToken - Original access token
|
|
* @param {string} sub - Subject identifier
|
|
* @returns {Promise<string>} Graph API access token
|
|
*/
|
|
const exchangeTokenForGraphAccess = async (config, accessToken, sub) => {
|
|
try {
|
|
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
|
|
const cacheKey = `${sub}:graph`;
|
|
|
|
const cachedToken = await tokensCache.get(cacheKey);
|
|
if (cachedToken) {
|
|
return cachedToken.access_token;
|
|
}
|
|
|
|
const graphScopes = process.env.OPENID_GRAPH_SCOPES || 'User.Read,People.Read,Group.Read.All';
|
|
const scopeString = graphScopes
|
|
.split(',')
|
|
.map((scope) => `https://graph.microsoft.com/${scope}`)
|
|
.join(' ');
|
|
|
|
const grantResponse = await client.genericGrantRequest(
|
|
config,
|
|
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
{
|
|
scope: scopeString,
|
|
assertion: accessToken,
|
|
requested_token_use: 'on_behalf_of',
|
|
},
|
|
);
|
|
|
|
await tokensCache.set(
|
|
cacheKey,
|
|
{
|
|
access_token: grantResponse.access_token,
|
|
},
|
|
grantResponse.expires_in * 1000,
|
|
);
|
|
|
|
return grantResponse.access_token;
|
|
} catch (error) {
|
|
logger.error('[exchangeTokenForGraphAccess] Token exchange failed:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Search for principals (people and groups) using Microsoft Graph API
|
|
* Uses searchContacts first, then searchUsers and searchGroups to fill remaining slots
|
|
* @param {string} accessToken - OpenID Connect access token
|
|
* @param {string} sub - Subject identifier
|
|
* @param {string} query - Search query string
|
|
* @param {string} type - Type filter ('users', 'groups', or 'all')
|
|
* @param {number} limit - Maximum number of results
|
|
* @returns {Promise<TPrincipalSearchResult[]>} Array of principal search results
|
|
*/
|
|
const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', limit = 10) => {
|
|
try {
|
|
if (!query || query.trim().length < 2) {
|
|
return [];
|
|
}
|
|
const graphClient = await createGraphClient(accessToken, sub);
|
|
let allResults = [];
|
|
|
|
if (type === 'users' || type === 'all') {
|
|
const contactResults = await searchContacts(graphClient, query, limit);
|
|
allResults.push(...contactResults);
|
|
}
|
|
if (allResults.length >= limit) {
|
|
return allResults.slice(0, limit);
|
|
}
|
|
|
|
if (type === 'users') {
|
|
const userResults = await searchUsers(graphClient, query, limit);
|
|
allResults.push(...userResults);
|
|
} else if (type === 'groups') {
|
|
const groupResults = await searchGroups(graphClient, query, limit);
|
|
allResults.push(...groupResults);
|
|
} else if (type === 'all') {
|
|
const [userResults, groupResults] = await Promise.all([
|
|
searchUsers(graphClient, query, limit),
|
|
searchGroups(graphClient, query, limit),
|
|
]);
|
|
|
|
allResults.push(...userResults, ...groupResults);
|
|
}
|
|
|
|
const seenIds = new Set();
|
|
const uniqueResults = allResults.filter((result) => {
|
|
if (seenIds.has(result.idOnTheSource)) {
|
|
return false;
|
|
}
|
|
seenIds.add(result.idOnTheSource);
|
|
return true;
|
|
});
|
|
|
|
return uniqueResults.slice(0, limit);
|
|
} catch (error) {
|
|
logger.error('[searchEntraIdPrincipals] Error searching principals:', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get current user's Entra ID group memberships from Microsoft Graph
|
|
* Uses /me/getMemberGroups endpoint to get transitive groups the user is a member of
|
|
* @param {string} accessToken - OpenID Connect access token
|
|
* @param {string} sub - Subject identifier
|
|
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
|
|
*/
|
|
const getUserEntraGroups = async (accessToken, sub) => {
|
|
try {
|
|
const graphClient = await createGraphClient(accessToken, sub);
|
|
const response = await graphClient
|
|
.api('/me/getMemberGroups')
|
|
.post({ securityEnabledOnly: false });
|
|
|
|
const groupIds = Array.isArray(response?.value) ? response.value : [];
|
|
return [...new Set(groupIds.map((groupId) => String(groupId)))];
|
|
} catch (error) {
|
|
logger.error('[getUserEntraGroups] Error fetching user groups:', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get current user's owned Entra ID groups from Microsoft Graph
|
|
* Uses /me/ownedObjects/microsoft.graph.group endpoint to get groups the user owns
|
|
* @param {string} accessToken - OpenID Connect access token
|
|
* @param {string} sub - Subject identifier
|
|
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
|
|
*/
|
|
const getUserOwnedEntraGroups = async (accessToken, sub) => {
|
|
try {
|
|
const graphClient = await createGraphClient(accessToken, sub);
|
|
const allGroupIds = [];
|
|
let nextLink = '/me/ownedObjects/microsoft.graph.group';
|
|
|
|
while (nextLink) {
|
|
const response = await graphClient.api(nextLink).select('id').top(999).get();
|
|
const groups = response?.value || [];
|
|
allGroupIds.push(...groups.map((group) => group.id));
|
|
|
|
nextLink = response['@odata.nextLink']
|
|
? response['@odata.nextLink']
|
|
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
|
|
.trim() || null
|
|
: null;
|
|
}
|
|
|
|
return allGroupIds;
|
|
} catch (error) {
|
|
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get group members from Microsoft Graph API
|
|
* Recursively fetches all members using pagination (@odata.nextLink)
|
|
* @param {string} accessToken - OpenID Connect access token
|
|
* @param {string} sub - Subject identifier
|
|
* @param {string} groupId - Entra ID group object ID
|
|
* @returns {Promise<Array>} Array of member IDs (idOnTheSource values)
|
|
*/
|
|
const getGroupMembers = async (accessToken, sub, groupId) => {
|
|
try {
|
|
const graphClient = await createGraphClient(accessToken, sub);
|
|
const allMembers = new Set();
|
|
let nextLink = `/groups/${groupId}/transitiveMembers`;
|
|
|
|
while (nextLink) {
|
|
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
|
|
|
|
const members = membersResponse?.value || [];
|
|
members.forEach((member) => {
|
|
if (typeof member?.id === 'string' && member['@odata.type'] === '#microsoft.graph.user') {
|
|
allMembers.add(member.id);
|
|
}
|
|
});
|
|
|
|
nextLink = membersResponse['@odata.nextLink']
|
|
? membersResponse['@odata.nextLink']
|
|
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
|
|
.trim() || null
|
|
: null;
|
|
}
|
|
|
|
return Array.from(allMembers);
|
|
} catch (error) {
|
|
logger.error('[getGroupMembers] Error fetching group members:', error);
|
|
return [];
|
|
}
|
|
};
|
|
/**
|
|
* Get group owners from Microsoft Graph API
|
|
* Recursively fetches all owners using pagination (@odata.nextLink)
|
|
* @param {string} accessToken - OpenID Connect access token
|
|
* @param {string} sub - Subject identifier
|
|
* @param {string} groupId - Entra ID group object ID
|
|
* @returns {Promise<Array>} Array of owner IDs (idOnTheSource values)
|
|
*/
|
|
const getGroupOwners = async (accessToken, sub, groupId) => {
|
|
try {
|
|
const graphClient = await createGraphClient(accessToken, sub);
|
|
const allOwners = [];
|
|
let nextLink = `/groups/${groupId}/owners`;
|
|
|
|
while (nextLink) {
|
|
const ownersResponse = await graphClient.api(nextLink).select('id').top(999).get();
|
|
|
|
const owners = ownersResponse.value || [];
|
|
allOwners.push(...owners.map((member) => member.id));
|
|
|
|
nextLink = ownersResponse['@odata.nextLink']
|
|
? ownersResponse['@odata.nextLink'].split('/v1.0')[1]
|
|
: null;
|
|
}
|
|
|
|
return allOwners;
|
|
} catch (error) {
|
|
logger.error('[getGroupOwners] Error fetching group owners:', error);
|
|
return [];
|
|
}
|
|
};
|
|
/**
|
|
* Search for contacts (users only) using Microsoft Graph /me/people endpoint
|
|
* Returns mapped TPrincipalSearchResult objects for users only
|
|
* @param {Client} graphClient - Authenticated Microsoft Graph client
|
|
* @param {string} query - Search query string
|
|
* @param {number} limit - Maximum number of results (default: 10)
|
|
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped user contact results
|
|
*/
|
|
const searchContacts = async (graphClient, query, limit = 10) => {
|
|
try {
|
|
if (!query || query.trim().length < 2) {
|
|
return [];
|
|
}
|
|
if (
|
|
process.env.OPENID_GRAPH_SCOPES &&
|
|
!process.env.OPENID_GRAPH_SCOPES.toLowerCase().includes('people.read')
|
|
) {
|
|
logger.warn('[searchContacts] People.Read scope is not enabled, skipping contact search');
|
|
return [];
|
|
}
|
|
// Reason: Search only for OrganizationUser (person) type, not groups
|
|
const filter = "personType/subclass eq 'OrganizationUser'";
|
|
|
|
let apiCall = graphClient
|
|
.api('/me/people')
|
|
.search(`"${query}"`)
|
|
.select(
|
|
'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,scoredEmailAddresses,personType,phones',
|
|
)
|
|
.header('ConsistencyLevel', 'eventual')
|
|
.filter(filter)
|
|
.top(limit);
|
|
|
|
const contactsResponse = await apiCall.get();
|
|
return (contactsResponse.value || []).map(mapContactToTPrincipalSearchResult);
|
|
} catch (error) {
|
|
logger.error('[searchContacts] Error searching contacts:', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Search for users using Microsoft Graph /users endpoint
|
|
* Returns mapped TPrincipalSearchResult objects
|
|
* @param {Client} graphClient - Authenticated Microsoft Graph client
|
|
* @param {string} query - Search query string
|
|
* @param {number} limit - Maximum number of results (default: 10)
|
|
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped user results
|
|
*/
|
|
const searchUsers = async (graphClient, query, limit = 10) => {
|
|
try {
|
|
if (!query || query.trim().length < 2) {
|
|
return [];
|
|
}
|
|
|
|
// Reason: Search users by display name, email, and user principal name
|
|
const usersResponse = await graphClient
|
|
.api('/users')
|
|
.search(
|
|
`"displayName:${query}" OR "userPrincipalName:${query}" OR "mail:${query}" OR "givenName:${query}" OR "surname:${query}"`,
|
|
)
|
|
.select(
|
|
'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,mail,phones',
|
|
)
|
|
.header('ConsistencyLevel', 'eventual')
|
|
.top(limit)
|
|
.get();
|
|
|
|
return (usersResponse.value || []).map(mapUserToTPrincipalSearchResult);
|
|
} catch (error) {
|
|
logger.error('[searchUsers] Error searching users:', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Search for groups using Microsoft Graph /groups endpoint
|
|
* Returns mapped TPrincipalSearchResult objects, includes all group types
|
|
* @param {Client} graphClient - Authenticated Microsoft Graph client
|
|
* @param {string} query - Search query string
|
|
* @param {number} limit - Maximum number of results (default: 10)
|
|
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped group results
|
|
*/
|
|
const searchGroups = async (graphClient, query, limit = 10) => {
|
|
try {
|
|
if (!query || query.trim().length < 2) {
|
|
return [];
|
|
}
|
|
|
|
// Reason: Search all groups by display name and email without filtering group types
|
|
const groupsResponse = await graphClient
|
|
.api('/groups')
|
|
.search(`"displayName:${query}" OR "mail:${query}" OR "mailNickname:${query}"`)
|
|
.select('id,displayName,mail,mailNickname,description,groupTypes,resourceProvisioningOptions')
|
|
.header('ConsistencyLevel', 'eventual')
|
|
.top(limit)
|
|
.get();
|
|
|
|
return (groupsResponse.value || []).map(mapGroupToTPrincipalSearchResult);
|
|
} catch (error) {
|
|
logger.error('[searchGroups] Error searching groups:', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Test Graph API connectivity and permissions
|
|
* @param {string} accessToken - OpenID Connect access token
|
|
* @param {string} sub - Subject identifier
|
|
* @returns {Promise<Object>} Test results with available permissions
|
|
*/
|
|
const testGraphApiAccess = async (accessToken, sub) => {
|
|
try {
|
|
const graphClient = await createGraphClient(accessToken, sub);
|
|
const results = {
|
|
userAccess: false,
|
|
peopleAccess: false,
|
|
groupsAccess: false,
|
|
usersEndpointAccess: false,
|
|
groupsEndpointAccess: false,
|
|
errors: [],
|
|
};
|
|
|
|
// Test User.Read permission
|
|
try {
|
|
await graphClient.api('/me').select('id,displayName').get();
|
|
results.userAccess = true;
|
|
} catch (error) {
|
|
results.errors.push(`User.Read: ${error.message}`);
|
|
}
|
|
|
|
// Test People.Read permission with OrganizationUser filter
|
|
try {
|
|
await graphClient
|
|
.api('/me/people')
|
|
.filter("personType/subclass eq 'OrganizationUser'")
|
|
.top(1)
|
|
.get();
|
|
results.peopleAccess = true;
|
|
} catch (error) {
|
|
results.errors.push(`People.Read (OrganizationUser): ${error.message}`);
|
|
}
|
|
|
|
// Test People.Read permission with UnifiedGroup filter
|
|
try {
|
|
await graphClient
|
|
.api('/me/people')
|
|
.filter("personType/subclass eq 'UnifiedGroup'")
|
|
.top(1)
|
|
.get();
|
|
results.groupsAccess = true;
|
|
} catch (error) {
|
|
results.errors.push(`People.Read (UnifiedGroup): ${error.message}`);
|
|
}
|
|
|
|
// Test /users endpoint access (requires User.Read.All or similar)
|
|
try {
|
|
await graphClient
|
|
.api('/users')
|
|
.search('"displayName:test"')
|
|
.select('id,displayName,userPrincipalName')
|
|
.top(1)
|
|
.get();
|
|
results.usersEndpointAccess = true;
|
|
} catch (error) {
|
|
results.errors.push(`Users endpoint: ${error.message}`);
|
|
}
|
|
|
|
// Test /groups endpoint access (requires Group.Read.All or similar)
|
|
try {
|
|
await graphClient
|
|
.api('/groups')
|
|
.search('"displayName:test"')
|
|
.select('id,displayName,mail')
|
|
.top(1)
|
|
.get();
|
|
results.groupsEndpointAccess = true;
|
|
} catch (error) {
|
|
results.errors.push(`Groups endpoint: ${error.message}`);
|
|
}
|
|
|
|
return results;
|
|
} catch (error) {
|
|
logger.error('[testGraphApiAccess] Error testing Graph API access:', error);
|
|
return {
|
|
userAccess: false,
|
|
peopleAccess: false,
|
|
groupsAccess: false,
|
|
usersEndpointAccess: false,
|
|
groupsEndpointAccess: false,
|
|
errors: [error.message],
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Map Graph API user object to TPrincipalSearchResult format
|
|
* @param {TGraphUser} user - Raw user object from Graph API
|
|
* @returns {TPrincipalSearchResult} Mapped user result
|
|
*/
|
|
const mapUserToTPrincipalSearchResult = (user) => {
|
|
return {
|
|
id: null,
|
|
type: 'user',
|
|
name: user.displayName,
|
|
email: user.mail || user.userPrincipalName,
|
|
username: user.userPrincipalName,
|
|
source: 'entra',
|
|
idOnTheSource: user.id,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Map Graph API group object to TPrincipalSearchResult format
|
|
* @param {TGraphGroup} group - Raw group object from Graph API
|
|
* @returns {TPrincipalSearchResult} Mapped group result
|
|
*/
|
|
const mapGroupToTPrincipalSearchResult = (group) => {
|
|
return {
|
|
id: null,
|
|
type: 'group',
|
|
name: group.displayName,
|
|
email: group.mail || group.userPrincipalName,
|
|
description: group.description,
|
|
source: 'entra',
|
|
idOnTheSource: group.id,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Map Graph API /me/people contact object to TPrincipalSearchResult format
|
|
* Handles both user and group contacts from the people endpoint
|
|
* @param {TGraphPerson} contact - Raw contact object from Graph API /me/people
|
|
* @returns {TPrincipalSearchResult} Mapped contact result
|
|
*/
|
|
const mapContactToTPrincipalSearchResult = (contact) => {
|
|
const isGroup = contact.personType?.class === 'Group';
|
|
const primaryEmail = contact.scoredEmailAddresses?.[0]?.address;
|
|
|
|
return {
|
|
id: null,
|
|
type: isGroup ? 'group' : 'user',
|
|
name: contact.displayName,
|
|
email: primaryEmail,
|
|
username: !isGroup ? contact.userPrincipalName : undefined,
|
|
source: 'entra',
|
|
idOnTheSource: contact.id,
|
|
};
|
|
};
|
|
|
|
module.exports = {
|
|
getGroupMembers,
|
|
getGroupOwners,
|
|
createGraphClient,
|
|
getUserEntraGroups,
|
|
getUserOwnedEntraGroups,
|
|
testGraphApiAccess,
|
|
searchEntraIdPrincipals,
|
|
exchangeTokenForGraphAccess,
|
|
entraIdPrincipalFeatureEnabled,
|
|
};
|