Files
zooqueenandHanzo Dev 472b2a4643 Admit by one rule on every sign-in and every use of a credential
A person is admitted when IAM says they administer the app's org, or when
an ADMIN vouches for them: through a membership that came from that ADMIN's
invitation, or through such an invitation pending for their verified
address. The rule (isAdmitted, with vouches: an ADMIN who is not disabled)
decides every IAM sign-in, linked or new. A linked account that fails it is
refused and its sessions and API tokens are deleted.

Sessions and API tokens work only while their holder is linked to IAM, not
disabled and admitted (credentialIsCurrent over readStanding); a team
token's holder is the organisation's owner. Accepting an invitation needs
its sender to vouch for it now, and the membership records that sender as
the member's inviter.

The cutoff is the migration that ships with this: 1_iam_only_sign_in
deletes every session and API token and adds OrganisationMember.invitedById
beside OrganisationMemberInvite.inviterId, NULL for every row already
standing, so nothing from before it vouches for anyone. IAM_ONLY_SINCE goes.
IAM_PROVIDER names the one provider in lib.

The e2e fixture admits its users through one ADMIN voucher. Tests cover the
decision for returning and first sign-ins, vouching, credentials, and the
migration against real SQLite.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 06:51:56 -07:00

149 lines
4.9 KiB
TypeScript

import type { Role } from '@prisma/client';
import { SignInRefusal } from '@hanzo/esign-lib/constants/auth';
import { AppError } from '@hanzo/esign-lib/errors/app-error';
import { isAdmitted } from '@hanzo/esign-lib/utils/iam-sign-in';
import { AuthenticationErrorCode } from '../errors/error-codes';
/**
* The person IAM signed in, as its userinfo endpoint states them. Userinfo is
* read rather than the id_token because it is IAM's live answer from its store:
* `email_verified`, `isAdmin` and `orgs` are there and nowhere else, and a grant
* IAM revoked a minute ago is already gone from it.
*/
export type IamPerson = {
sub: string;
email: string;
emailVerified: boolean;
name: string;
/** The org the application belongs to; IAM stamps it on every sign-in. */
owner: string;
isAdmin: boolean;
/** The orgs the person may act in, the one their account lives in first. */
orgs: Array<{ org: string; role?: string }>;
};
/**
* Whether the person administers the org this application serves: their
* account lives in that org and holds its admin role there. IAM's `isAdmin` is
* admin of the account's OWN org, so it counts only when that org is the
* application's.
*/
export const administersAppOrg = (person: IamPerson): boolean => {
const home = person.orgs[0];
return (
person.isAdmin &&
Boolean(person.owner) &&
home?.org === person.owner &&
(home.role === 'admin' || home.role === 'owner')
);
};
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
/**
* Reads IAM's userinfo answer into an IamPerson, refusing one that does not
* describe the identity the id_token was issued for.
*/
export const readIamPerson = (info: Record<string, unknown>, idTokenSub: string): IamPerson => {
const sub = str(info.sub);
if (!sub || sub !== idTokenSub) {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'The identity provider described a different account',
});
}
const email = str(info.email);
if (!email) {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Missing email',
});
}
const orgs = Array.isArray(info.orgs)
? info.orgs
.filter((o): o is Record<string, unknown> => Boolean(o) && typeof o === 'object')
.map((o) => ({ org: str(o.org), role: str(o.role) }))
.filter((o) => o.org !== '')
: [];
return {
sub,
email,
emailVerified: info.email_verified === true,
name: str(info.displayName) || str(info.name) || email,
owner: str(info.owner),
isAdmin: info.isAdmin === true,
orgs,
};
};
/**
* The roles an account holds after an IAM sign-in: ADMIN exactly when IAM says
* the person administers the org the application serves. It is restated on
* every sign-in, so a person IAM stops calling an admin loses it on their next.
*/
export const rolesFor = (person: IamPerson, current: readonly Role[]): Role[] => {
const rest = current.filter((role) => role !== 'ADMIN' && role !== 'USER');
return administersAppOrg(person) ? ['USER', 'ADMIN', ...rest] : ['USER', ...rest];
};
export type SignInDecision =
| { kind: 'sign-in'; userId: number }
| { kind: 'create' }
| { kind: 'refuse'; reason: SignInRefusal };
/**
* What an IAM sign-in leads to, decided from what IAM said and what the store
* holds.
*
* Every sign-in, returning or first, passes the one admission rule
* (`isAdmitted`): IAM says the person administers the app's org, or an ADMIN
* vouches for them through a membership or a pending invitation to their
* verified address. A returning person is found by the IAM identity their
* account is linked to; a disabled one is refused. A first sign-in also needs
* an address IAM has verified that no unlinked account already holds.
*/
export const decideSignIn = (facts: {
person: IamPerson;
linked: { id: number; disabled: boolean } | null;
addressHeldByUnlinkedAccount: boolean;
/** The linked account holds a membership whose inviter vouches for it. */
vouchedMembership: boolean;
/** A pending invitation to the address IAM signed in, whose sender vouches for it. */
vouchedInvite: boolean;
}): SignInDecision => {
const { person, linked } = facts;
const admitted = isAdmitted({
iamAdmin: administersAppOrg(person),
vouchedMembership: linked !== null && facts.vouchedMembership,
vouchedInvite: person.emailVerified && facts.vouchedInvite,
});
if (linked) {
if (linked.disabled) {
return { kind: 'refuse', reason: SignInRefusal.Disabled };
}
return admitted
? { kind: 'sign-in', userId: linked.id }
: { kind: 'refuse', reason: SignInRefusal.NotInvited };
}
if (!person.emailVerified) {
return { kind: 'refuse', reason: SignInRefusal.UnverifiedEmail };
}
if (facts.addressHeldByUnlinkedAccount) {
return { kind: 'refuse', reason: SignInRefusal.UnlinkedAccount };
}
return admitted ? { kind: 'create' } : { kind: 'refuse', reason: SignInRefusal.NotInvited };
};