mirror of
https://github.com/hanzoai/sign.git
synced 2026-10-21 01:21:51 +00:00
A team chooses where its webhook points, so the origin was not enough: an admin could aim eSign's identity at any Hanzo API route and read the answer in the webhook log. One sender now serves sends and resends: a URL HANZO_WEBHOOKS names exactly is posted with eSign's IAM token and its answer is not kept; every other URL gets the webhook's own secret and nothing of ours. notify's token is minted for the Hanzo API rather than for eSign itself, and a bearer eSign's own client minted is refused.
84 lines
2.7 KiB
TypeScript
84 lines
2.7 KiB
TypeScript
import { prisma } from '@hanzo/esign-prisma';
|
|
|
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
|
import { env } from '../../utils/env';
|
|
import { credentialIsCurrent } from '../../utils/iam-sign-in';
|
|
import { verifyIamToken } from '../iam/token';
|
|
import { readStanding } from '../user/standing';
|
|
|
|
const holder = { select: { id: true, name: true, email: true, disabled: true } } as const;
|
|
|
|
const refuse = (message = 'Invalid token') =>
|
|
new AppError(AppErrorCode.UNAUTHORIZED, { message, statusCode: 401 });
|
|
|
|
/**
|
|
* The team each IAM application acts for, from IAM_APP_TEAMS: `<client id>:<team
|
|
* url>`, comma-separated, e.g. `hanzo-cloud:hanzo`. An application not named
|
|
* here acts for nobody.
|
|
*/
|
|
export const appTeams = (raw = env('IAM_APP_TEAMS') ?? ''): Map<string, string> =>
|
|
new Map(
|
|
raw
|
|
.split(',')
|
|
.map((pair) => pair.trim().split(':'))
|
|
.filter((pair): pair is [string, string] => pair.length === 2 && pair.every(Boolean)),
|
|
);
|
|
|
|
/**
|
|
* The one bearer this server accepts: a Hanzo IAM access token an APPLICATION
|
|
* minted for this server — signed by a key IAM publishes, issued by IAM, its
|
|
* audience this server's own client id, unexpired (verifyIamToken) — from a
|
|
* client IAM_APP_TEAMS names. People sign in; the API is for services, and this
|
|
* server mints no tokens of its own.
|
|
*
|
|
* The application acts for exactly the one team IAM_APP_TEAMS maps its client
|
|
* to, as that team's organisation owner (who a team-wide credential has always
|
|
* acted as), and is audited under its own IAM subject. The owner must stand
|
|
* where sign-in admits them.
|
|
*/
|
|
export const getBearer = async ({ token }: { token: string }) => {
|
|
const claims = await verifyIamToken(token).catch(() => {
|
|
throw refuse();
|
|
});
|
|
|
|
if (claims.type !== 'application' || !claims.azp) {
|
|
throw refuse('The API takes an IAM application token; people sign in');
|
|
}
|
|
|
|
// A token this app minted for itself is its own, never a caller's.
|
|
if (claims.azp === env('IAM_CLIENT_ID')) {
|
|
throw refuse('This application does not call itself');
|
|
}
|
|
|
|
const url = appTeams().get(claims.azp);
|
|
|
|
if (!url) {
|
|
throw refuse('This application acts for no team here');
|
|
}
|
|
|
|
const team = await prisma.team.findUnique({
|
|
where: { url },
|
|
include: { organisation: { select: { owner: holder } } },
|
|
});
|
|
|
|
if (!team) {
|
|
throw refuse('This application acts for no team here');
|
|
}
|
|
|
|
const user = team.organisation.owner;
|
|
const standing = await readStanding(user.id);
|
|
|
|
if (!standing || !credentialIsCurrent(standing)) {
|
|
throw refuse();
|
|
}
|
|
|
|
return {
|
|
/** The application's IAM subject, which every act is audited under. */
|
|
id: claims.sub,
|
|
user,
|
|
userId: user.id,
|
|
team,
|
|
teamId: team.id,
|
|
};
|
|
};
|