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

148 lines
4.1 KiB
TypeScript

import type { JWTPayload } from 'jose';
import { decodeJwt, jwtVerify } from 'jose';
import { prisma } from '@hanzo/esign-prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { credentialIsCurrent } from '../../utils/iam-sign-in';
import { readStanding } from '../user/standing';
export type VerifyEmbeddingPresignTokenOptions = {
token: string;
scope?: string;
};
export const verifyEmbeddingPresignToken = async ({
token,
scope,
}: VerifyEmbeddingPresignTokenOptions) => {
// First decode the JWT to get the claims without verification
let decodedToken: JWTPayload;
try {
decodedToken = decodeJwt<JWTPayload>(token);
} catch (error) {
console.error('Error decoding JWT token:', error);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token format',
});
}
// Validate the required claims
if (!decodedToken.sub || typeof decodedToken.sub !== 'string') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token format: missing or invalid subject claim',
});
}
if (!decodedToken.aud || typeof decodedToken.aud !== 'string') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token format: missing or invalid audience claim',
});
}
// Convert string IDs to numbers
const tokenId = Number(decodedToken.sub);
const audienceId = Number(decodedToken.aud);
if (Number.isNaN(tokenId) || !Number.isInteger(tokenId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid token ID format in subject claim',
});
}
if (Number.isNaN(audienceId) || !Number.isInteger(audienceId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid user ID format in audience claim',
});
}
// Get the API token to use as the verification secret
const apiToken = await prisma.apiToken.findFirst({
where: {
id: tokenId,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
if (!apiToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token not found',
});
}
const standing = apiToken.userId ? await readStanding(apiToken.userId) : null;
if (!standing || !credentialIsCurrent(standing)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token not found',
});
}
// This should never happen but we need to narrow types
if (!apiToken.userId || !apiToken.user) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token does not have a user attached',
});
}
const userId = apiToken.userId;
if (audienceId !== apiToken.teamId && audienceId !== apiToken.userId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token does not match audience',
});
}
if (decodedToken.scope && scope && decodedToken.scope !== scope) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Presign token scope not matched',
});
}
// Now verify the token with the actual secret
const secret = new TextEncoder().encode(apiToken.token);
try {
await jwtVerify(token, secret);
} catch (error) {
// Check if the token has expired
if (error instanceof Error && error.name === 'JWTExpired') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Presign token has expired',
});
}
// Handle invalid signature
if (error instanceof Error && error.name === 'JWSInvalid') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token signature',
});
}
// Log and rethrow other errors
console.error('Error verifying JWT token:', error);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Failed to verify presign token',
});
}
return {
...apiToken,
userId,
user: {
id: apiToken.user.id,
name: apiToken.user.name,
email: apiToken.user.email,
},
};
};