Sign in through Hanzo IAM only, and make accounts for invited people only

The IAM callback at /auth/callback reads IAM's userinfo answer and refuses
an address IAM has not verified. An account is found by its linked IAM
identity alone (provider hanzo, the IAM subject); an existing account that
only shares the address is refused, never merged. A first sign-in makes an
account only for an admin of the org the application serves or for someone
an organisation invited at that verified address. Every refusal returns to
/signin with a sentence saying what to do.

Accepting an organisation invitation joins the signed-in person, and only
when their verified address is the invited one; opening the link no longer
adds whoever holds the address.

The local email/password sign-up, sign-in, reset, verify-email and update
routes, pages, jobs and mail are gone, as are passkey sign-in, the Google,
Microsoft and generic OIDC providers, the per-organisation SSO portal and
its account-link flow. Passkeys and two-factor stay for confirming a
signature. The e2e suite starts sessions the way a finished IAM sign-in
leaves them.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-10-16 01:52:24 -07:00
co-authored by Hanzo Dev
parent d6fa542992
commit ecee5e40aa
95 changed files with 539 additions and 6049 deletions
+1 -16
View File
@@ -17,22 +17,7 @@ IAM_CLIENT_ID=""
IAM_CLIENT_SECRET=""
# NEXT_PUBLIC_IAM_PROVIDER_NAME="Hanzo"
# [[AUTH OPTIONAL]]
# Find documentation on setting up Google OAuth here:
# https://docs.sign.hanzo.ai/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
# Find documentation on setting up Microsoft OAuth here:
# https://docs.sign.hanzo.ai/developers/self-hosting/setting-up-oauth-providers#microsoft-oauth-azure-ad
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=""
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
NEXT_PRIVATE_OIDC_CLIENT_ID=""
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
# Specifies the prompt to use for OIDC signin, explicitly setting
# The prompt sent to Hanzo IAM at sign-in; explicitly setting
# an empty string will omit the prompt parameter.
# See: https://www.cerberauth.com/blog/openid-connect-oauth2-prompts/
NEXT_PRIVATE_OIDC_PROMPT="login"
@@ -1,94 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { authClient } from '@hanzo/esign-auth/client';
import { cn } from '@hanzo/esign-ui/lib/utils';
import { Button } from '@hanzo/esign-ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@hanzo/esign-ui/primitives/form/form';
import { Input } from '@hanzo/esign-ui/primitives/input';
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
export const ZForgotPasswordFormSchema = z.object({
email: z.string().email().min(1),
});
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
export type ForgotPasswordFormProps = {
className?: string;
};
export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const form = useForm<TForgotPasswordFormSchema>({
values: {
email: '',
},
resolver: zodResolver(ZForgotPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => {
await authClient.emailPassword.forgotPassword({ email }).catch(() => null);
await navigate('/check-email');
toast({
title: _(msg`Reset email sent`),
description: _(
msg`A password reset email has been sent, if you have an account you should see it in your inbox shortly.`,
),
duration: 5000,
});
form.reset();
};
return (
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button size="lg" loading={isSubmitting}>
{isSubmitting ? <Trans>Sending Reset Email...</Trans> : <Trans>Reset Password</Trans>}
</Button>
</form>
</Form>
);
};
@@ -1,160 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { authClient } from '@hanzo/esign-auth/client';
import type { SessionUser } from '@hanzo/esign-auth/server/lib/session/session';
import { AppError } from '@hanzo/esign-lib/errors/app-error';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '@hanzo/esign-trpc/server/auth-router/schema';
import { cn } from '@hanzo/esign-ui/lib/utils';
import { Button } from '@hanzo/esign-ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@hanzo/esign-ui/primitives/form/form';
import { PasswordInput } from '@hanzo/esign-ui/primitives/password-input';
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
export const ZPasswordFormSchema = z
.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
path: ['repeatedPassword'],
});
export type TPasswordFormSchema = z.infer<typeof ZPasswordFormSchema>;
export type PasswordFormProps = {
className?: string;
user: SessionUser;
};
export const PasswordForm = ({ className }: PasswordFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const form = useForm<TPasswordFormSchema>({
values: {
currentPassword: '',
password: '',
repeatedPassword: '',
},
resolver: zodResolver(ZPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
try {
await authClient.emailPassword.updatePassword({
currentPassword,
password,
});
form.reset();
toast({
title: _(msg`Password updated`),
description: _(msg`Your password has been updated successfully.`),
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with('NO_PASSWORD', () => msg`User has no password.`)
.with('INCORRECT_PASSWORD', () => msg`Current password is incorrect.`)
.with(
'SAME_PASSWORD',
() => msg`Your new password cannot be the same as your old password.`,
)
.otherwise(
() =>
msg`We encountered an unknown error while attempting to update your password. Please try again later.`,
);
toast({
title: _(msg`An error occurred`),
description: _(errorMessage),
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Current Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput autoComplete="current-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>New Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput autoComplete="new-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repeatedPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Repeat Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput autoComplete="new-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<div className="ml-auto mt-4">
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? <Trans>Updating password...</Trans> : <Trans>Update password</Trans>}
</Button>
</div>
</form>
</Form>
);
};
@@ -1,144 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { authClient } from '@hanzo/esign-auth/client';
import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
import { ZPasswordSchema } from '@hanzo/esign-trpc/server/auth-router/schema';
import { cn } from '@hanzo/esign-ui/lib/utils';
import { Button } from '@hanzo/esign-ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@hanzo/esign-ui/primitives/form/form';
import { PasswordInput } from '@hanzo/esign-ui/primitives/password-input';
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
export const ZResetPasswordFormSchema = z
.object({
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.password === data.repeatedPassword, {
path: ['repeatedPassword'],
message: "Passwords don't match",
});
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
export type ResetPasswordFormProps = {
className?: string;
token: string;
};
export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) => {
const navigate = useNavigate();
const { _ } = useLingui();
const { toast } = useToast();
const form = useForm<TResetPasswordFormSchema>({
values: {
password: '',
repeatedPassword: '',
},
resolver: zodResolver(ZResetPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ password }: Omit<TResetPasswordFormSchema, 'repeatedPassword'>) => {
try {
await authClient.emailPassword.resetPassword({
password,
token,
});
await navigate('/signin');
form.reset();
toast({
title: _(msg`Password updated`),
description: _(msg`Your password has been updated successfully.`),
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.EXPIRED_CODE, () => msg`Token has expired. Please try again.`)
.with('INVALID_TOKEN', () => msg`Invalid token provided. Please try again.`)
.with(
'SAME_PASSWORD',
() => msg`Your new password cannot be the same as your old password.`,
)
.otherwise(
() =>
msg`We encountered an unknown error while attempting to reset your password. Please try again later.`,
);
toast({
title: _(msg`An error occurred`),
description: _(errorMessage),
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repeatedPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Repeat Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" size="lg" loading={isSubmitting}>
{isSubmitting ? <Trans>Resetting Password...</Trans> : <Trans>Reset Password</Trans>}
</Button>
</form>
</Form>
);
};
@@ -1,98 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { authClient } from '@hanzo/esign-auth/client';
import { cn } from '@hanzo/esign-ui/lib/utils';
import { Button } from '@hanzo/esign-ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@hanzo/esign-ui/primitives/form/form';
import { Input } from '@hanzo/esign-ui/primitives/input';
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
export const ZSendConfirmationEmailFormSchema = z.object({
email: z.string().email().min(1),
});
export type TSendConfirmationEmailFormSchema = z.infer<typeof ZSendConfirmationEmailFormSchema>;
export type SendConfirmationEmailFormProps = {
className?: string;
};
export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const form = useForm<TSendConfirmationEmailFormSchema>({
values: {
email: '',
},
resolver: zodResolver(ZSendConfirmationEmailFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ email }: TSendConfirmationEmailFormSchema) => {
try {
await authClient.emailPassword.resendVerifyEmail({ email });
toast({
title: _(msg`Confirmation email sent`),
description: _(
msg`A confirmation email has been sent, and it should arrive in your inbox shortly.`,
),
duration: 5000,
});
form.reset();
} catch (err) {
toast({
variant: 'destructive',
title: _(msg`An error occurred while sending your confirmation email`),
description: _(msg`Please try again and make sure you enter the correct email address.`),
});
}
};
return (
<Form {...form}>
<form
className={cn('mt-6 flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email address</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormMessage />
<Button size="lg" type="submit" disabled={isSubmitting} loading={isSubmitting}>
<Trans>Send confirmation email</Trans>
</Button>
</fieldset>
</form>
</Form>
);
};
@@ -10,30 +10,20 @@ import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
export type DocumentSigningAuthPageViewProps = {
email?: string;
emailHasAccount?: boolean;
};
export const DocumentSigningAuthPageView = ({
email,
emailHasAccount,
}: DocumentSigningAuthPageViewProps) => {
export const DocumentSigningAuthPageView = ({ email }: DocumentSigningAuthPageViewProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isSigningOut, setIsSigningOut] = useState(false);
const handleChangeAccount = async (email?: string) => {
const handleChangeAccount = async () => {
try {
setIsSigningOut(true);
let redirectPath = '/signin';
if (email) {
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
}
await authClient.signOut({
redirectPath,
redirectPath: '/signin',
});
} catch {
toast({
@@ -54,7 +44,7 @@ export const DocumentSigningAuthPageView = ({
<Trans>Authentication required</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<p className="mt-2 text-sm text-muted-foreground">
{email ? (
<Trans>
You need to be logged in as <strong>{email}</strong> to view this page.
@@ -67,10 +57,10 @@ export const DocumentSigningAuthPageView = ({
<Button
className="mt-4 w-full"
type="submit"
onClick={async () => handleChangeAccount(email)}
onClick={handleChangeAccount}
loading={isSigningOut}
>
{emailHasAccount ? <Trans>Login</Trans> : <Trans>Sign up</Trans>}
<Trans>Sign in with Hanzo</Trans>
</Button>
</div>
</div>
@@ -1,139 +0,0 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle } from 'lucide-react';
import { authClient } from '@hanzo/esign-auth/client';
import { ONE_DAY, ONE_SECOND } from '@hanzo/esign-lib/constants/time';
import { Button } from '@hanzo/esign-ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@hanzo/esign-ui/primitives/dialog';
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
export type VerifyEmailBannerProps = {
email: string;
};
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
const onResendConfirmationEmail = async () => {
if (isPending) {
return;
}
setIsPending(true);
try {
setIsButtonDisabled(true);
await authClient.emailPassword.resendVerifyEmail({ email: email });
toast({
title: _(msg`Success`),
description: _(msg`Verification email sent successfully.`),
});
setIsOpen(false);
setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT);
} catch (err) {
setIsButtonDisabled(false);
toast({
title: _(msg`Error`),
description: _(msg`Something went wrong while sending the confirmation email.`),
variant: 'destructive',
});
}
setIsPending(false);
};
useEffect(() => {
// Check localStorage to see if we've recently automatically displayed the dialog
// if it was within the past 24 hours, don't show it again
// otherwise, show it again and update the localStorage timestamp
const emailVerificationDialogLastShown = localStorage.getItem(
'emailVerificationDialogLastShown',
);
if (emailVerificationDialogLastShown) {
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
if (Date.now() - lastShownTimestamp < ONE_DAY) {
return;
}
}
setIsOpen(true);
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
}, []);
return (
<>
<div className="bg-foreground">
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium text-background">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
<Trans>Verify your email address to unlock all features.</Trans>
</div>
<div>
<Button
variant="ghost"
className="h-auto px-2.5 py-1.5 text-background hover:bg-background/20 hover:text-background"
disabled={isButtonDisabled}
onClick={() => setIsOpen(true)}
size="sm"
>
{isButtonDisabled ? (
<Trans>Verification Email Sent</Trans>
) : (
<Trans>Verify Now</Trans>
)}
</Button>
</div>
</div>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogTitle>
<Trans>Verify your email address</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
We've sent a confirmation email to <strong>{email}</strong>. Please check your inbox
and click the link in the email to verify your account.
</Trans>
</DialogDescription>
<div>
<Button
disabled={isButtonDisabled}
loading={isPending}
onClick={onResendConfirmationEmail}
>
{isPending ? <Trans>Sending...</Trans> : <Trans>Resend Confirmation Email</Trans>}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};
@@ -13,7 +13,6 @@ import { Button } from '@hanzo/esign-ui/primitives/button';
import { AppBanner } from '~/components/general/app-banner';
import { Header } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
import { TeamProvider } from '~/providers/team';
import type { Route } from './+types/_layout';
@@ -109,8 +108,6 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
return (
<OrganisationProvider organisation={currentOrganisation}>
<TeamProvider team={currentTeam || null}>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && !hideHeader && <AppBanner banner={banner} />}
{!hideHeader && <Header />}
@@ -1,13 +1,6 @@
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import {
Building2Icon,
GroupIcon,
MailboxIcon,
Settings2Icon,
ShieldCheckIcon,
Users2Icon,
} from 'lucide-react';
import { Building2Icon, GroupIcon, MailboxIcon, Settings2Icon, Users2Icon } from 'lucide-react';
import { FaUsers } from 'react-icons/fa6';
import { Link, NavLink, Outlet } from 'react-router';
@@ -76,11 +69,6 @@ export default function SettingsLayout() {
label: t`Groups`,
icon: GroupIcon,
},
{
path: `/o/${organisation.url}/settings/sso`,
label: t`SSO`,
icon: ShieldCheckIcon,
},
];
if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole)) {
@@ -1,438 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentOrganisation } from '@hanzo/esign-lib/client-only/providers/organisation';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@hanzo/esign-lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@hanzo/esign-lib/constants/organisations-translations';
import {
formatOrganisationCallbackUrl,
formatOrganisationLoginUrl,
} from '@hanzo/esign-lib/utils/organisation-authentication-portal';
import { useZapMutation, useZapQuery } from '@hanzo/esign-trpc/zap/react';
import { domainRegex } from '@hanzo/esign-trpc/server/organisation-router/create-organisation-email-domain.types';
import type { TGetOrganisationAuthenticationPortalResponse } from '@hanzo/esign-trpc/server/organisation-router/get-organisation-authentication-portal.types';
import type { TUpdateOrganisationAuthenticationPortalRequest } from '@hanzo/esign-trpc/server/organisation-router/update-organisation-authentication-portal.types';
import { ZUpdateOrganisationAuthenticationPortalRequestSchema } from '@hanzo/esign-trpc/server/organisation-router/update-organisation-authentication-portal.types';
import { CopyTextButton } from '@hanzo/esign-ui/components/common/copy-text-button';
import { Alert, AlertDescription } from '@hanzo/esign-ui/primitives/alert';
import { Button } from '@hanzo/esign-ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@hanzo/esign-ui/primitives/form/form';
import { Input } from '@hanzo/esign-ui/primitives/input';
import { Label } from '@hanzo/esign-ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@hanzo/esign-ui/primitives/select';
import { SpinnerBox } from '@hanzo/esign-ui/primitives/spinner';
import { Switch } from '@hanzo/esign-ui/primitives/switch';
import { Textarea } from '@hanzo/esign-ui/primitives/textarea';
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
.pick({
enabled: true,
wellKnownUrl: true,
clientId: true,
autoProvisionUsers: true,
defaultOrganisationRole: true,
})
.extend({
clientSecret: z.string().nullable(),
allowedDomains: z.string().refine(
(value) => {
const domains = value.split(' ').filter(Boolean);
return domains.every((domain) => domainRegex.test(domain));
},
{
message: msg`Invalid domains`.id,
},
),
});
type TProviderFormSchema = z.infer<typeof ZProviderFormSchema>;
export function meta() {
return appMetaTags('Organisation SSO Portal');
}
export default function OrganisationSettingSSOLoginPage() {
const { t } = useLingui();
const organisation = useCurrentOrganisation();
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
useZapQuery<TGetOrganisationAuthenticationPortalResponse>(
'organisation.authenticationPortal.get',
{
organisationId: organisation.id,
},
);
if (isLoadingAuthenticationPortal || !authenticationPortal) {
return <SpinnerBox className="py-32" />;
}
return (
<div className="max-w-2xl">
<SettingsHeader
title={t`Organisation SSO Portal`}
subtitle={t`Manage a custom SSO login portal for your organisation.`}
/>
<SSOProviderForm authenticationPortal={authenticationPortal} />
</div>
);
}
type SSOProviderFormProps = {
authenticationPortal: TGetOrganisationAuthenticationPortalResponse;
};
const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { mutateAsync: updateOrganisationAuthenticationPortal } = useZapMutation<
void,
TUpdateOrganisationAuthenticationPortalRequest
>('organisation.authenticationPortal.update');
const form = useForm<TProviderFormSchema>({
resolver: zodResolver(ZProviderFormSchema),
defaultValues: {
enabled: authenticationPortal.enabled,
clientId: authenticationPortal.clientId,
clientSecret: authenticationPortal.clientSecretProvided ? null : '',
wellKnownUrl: authenticationPortal.wellKnownUrl,
autoProvisionUsers: authenticationPortal.autoProvisionUsers,
defaultOrganisationRole: authenticationPortal.defaultOrganisationRole,
allowedDomains: authenticationPortal.allowedDomains.join(' '),
},
});
const onSubmit = async (values: TProviderFormSchema) => {
const { enabled, clientId, clientSecret, wellKnownUrl } = values;
if (enabled && !clientId) {
form.setError('clientId', {
message: t`Client ID is required`,
});
return;
}
if (enabled && clientSecret === '') {
form.setError('clientSecret', {
message: t`Client secret is required`,
});
return;
}
if (enabled && !wellKnownUrl) {
form.setError('wellKnownUrl', {
message: t`Well-known URL is required`,
});
return;
}
try {
await updateOrganisationAuthenticationPortal({
organisationId: organisation.id,
data: {
enabled,
clientId,
clientSecret: values.clientSecret ?? undefined,
wellKnownUrl,
autoProvisionUsers: values.autoProvisionUsers,
defaultOrganisationRole: values.defaultOrganisationRole,
allowedDomains: values.allowedDomains.split(' ').filter(Boolean),
},
});
toast({
title: t`Success`,
description: t`Provider has been updated successfully`,
duration: 5000,
});
} catch (err) {
console.error(err);
toast({
title: t`An error occurred`,
description: t`We couldn't update the provider. Please try again.`,
variant: 'destructive',
});
}
};
const isSsoEnabled = form.watch('enabled');
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-6">
<div className="space-y-2">
<Label>
<Trans>Organisation authentication portal URL</Trans>
</Label>
<div className="relative">
<Input
className="pr-12"
disabled
value={formatOrganisationLoginUrl(organisation.url)}
/>
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={formatOrganisationLoginUrl(organisation.url)}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
<Trans>This is the URL which users will use to sign in to your organisation.</Trans>
</p>
</div>
<div className="space-y-2">
<Label>
<Trans>Redirect URI</Trans>
</Label>
<div className="relative">
<Input
className="pr-12"
disabled
value={formatOrganisationCallbackUrl(organisation.url)}
/>
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={formatOrganisationCallbackUrl(organisation.url)}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
<Trans>Add this URL to your provider's allowed redirect URIs</Trans>
</p>
</div>
<div className="space-y-2">
<Label>
<Trans>Required scopes</Trans>
</Label>
<Input className="pr-12" disabled value={`openid profile email`} />
<p className="text-muted-foreground text-xs">
<Trans>This is the required scopes you must set in your provider's settings</Trans>
</p>
</div>
<FormField
control={form.control}
name="wellKnownUrl"
render={({ field }) => (
<FormItem>
<FormLabel required={isSsoEnabled}>
<Trans>Issuer URL</Trans>
</FormLabel>
<FormControl>
<Input
placeholder={'https://your-provider.com/.well-known/openid-configuration'}
{...field}
/>
</FormControl>
{!form.formState.errors.wellKnownUrl && (
<p className="text-muted-foreground text-xs">
<Trans>The OpenID discovery endpoint URL for your provider</Trans>
</p>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel required={isSsoEnabled}>
<Trans>Client ID</Trans>
</FormLabel>
<FormControl>
<Input id="client-id" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel required={isSsoEnabled}>
<Trans>Client Secret</Trans>
</FormLabel>
<FormControl>
<Input
id="client-secret"
type="password"
{...field}
value={field.value === null ? '**********************' : field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="defaultOrganisationRole"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Default Organisation Role for New Users</Trans>
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t`Select default role`} />
</SelectTrigger>
<SelectContent>
{ORGANISATION_MEMBER_ROLE_HIERARCHY[OrganisationMemberRole.MANAGER].map(
(role) => (
<SelectItem key={role} value={role}>
{t(ORGANISATION_MEMBER_ROLE_MAP[role])}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="allowedDomains"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Allowed Email Domains</Trans>
</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t`your-domain.com another-domain.com`}
className="min-h-[80px]"
/>
</FormControl>
{!form.formState.errors.allowedDomains && (
<p className="text-muted-foreground text-xs">
<Trans>
Space-separated list of domains. Leave empty to allow all domains.
</Trans>
</p>
)}
<FormMessage />
</FormItem>
)}
/>
{/* Todo: This is just dummy toggle, we need to decide what this does first. */}
{/* <FormField
control={form.control}
name="autoProvisionUsers"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
<div className="space-y-0.5">
<FormLabel>
<Trans>Auto-provision Users</Trans>
</FormLabel>
<p className="text-muted-foreground text-sm">
<Trans>Automatically create accounts for new users on first login</Trans>
</p>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
<div className="space-y-0.5">
<FormLabel>
<Trans>Enable SSO portal</Trans>
</FormLabel>
<p className="text-muted-foreground text-sm">
<Trans>Whether to enable the SSO portal for your organisation</Trans>
</p>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Alert variant="warning">
<AlertDescription>
<Trans>
Please note that anyone who signs in through your portal will be added to your
organisation as a member.
</Trans>
</AlertDescription>
</Alert>
<div className="flex justify-end gap-2">
<Button loading={form.formState.isSubmitting} type="submit">
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};
@@ -3,61 +3,21 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { getSession } from '@hanzo/esign-auth/server/lib/utils/get-session';
import { useSession } from '@hanzo/esign-lib/client-only/providers/session';
import { prisma } from '@hanzo/esign-prisma';
import { Alert, AlertDescription, AlertTitle } from '@hanzo/esign-ui/primitives/alert';
import { Button } from '@hanzo/esign-ui/primitives/button';
import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog';
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
import { PasswordForm } from '~/components/forms/password';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/security._index';
export function meta() {
return appMetaTags('Security');
}
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
// Todo: Use providers instead after RR7 migration.
// const accounts = await prisma.account.findMany({
// where: {
// userId: user.id,
// },
// select: {
// provider: true,
// },
// });
// const providers = accounts.map((account) => account.provider);
// let hasEmailPasswordAccount = providers.includes('HANZO_SIGN');
const hasEmailPasswordAccount: boolean = await prisma.user
.count({
where: {
id: user.id,
password: {
not: null,
},
},
})
.then((value) => value > 0);
return {
// providers,
hasEmailPasswordAccount,
};
}
export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
const { hasEmailPasswordAccount } = loaderData;
export default function SettingsSecurity() {
const { _ } = useLingui();
const { user } = useSession();
@@ -65,16 +25,8 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
<div>
<SettingsHeader
title={_(msg`Security`)}
subtitle={_(msg`Here you can manage your password and security settings.`)}
subtitle={_(msg`Here you can manage your security settings. You sign in with Hanzo.`)}
/>
{hasEmailPasswordAccount && (
<>
<PasswordForm user={user} />
<hr className="mt-6 border-border/50" />
</>
)}
<div className="mt-6 space-y-6">
<Alert
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
@@ -86,17 +38,10 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
</AlertTitle>
<AlertDescription className="mr-4">
{hasEmailPasswordAccount ? (
<Trans>
Add an authenticator to serve as a secondary authentication method when signing
in, or when signing documents.
</Trans>
) : (
<Trans>
Add an authenticator to serve as a secondary authentication method for signing
documents.
</Trans>
)}
<Trans>
Add an authenticator to serve as a secondary authentication method for signing
documents.
</Trans>
</AlertDescription>
</div>
@@ -140,7 +85,8 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
<AlertDescription className="mr-4">
<Trans>
Allows authenticating using biometrics, password managers, hardware keys, etc.
Confirms it is you when signing documents, using biometrics, password managers,
hardware keys, etc.
</Trans>
</AlertDescription>
</div>
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { PlusIcon } from 'lucide-react';
import { ChevronLeft } from 'lucide-react';
import { Link, Outlet, isRouteErrorResponse } from 'react-router';
@@ -63,30 +62,6 @@ export default function PublicProfileLayout() {
className="h-10 w-auto sm:hidden dark:invert"
/>
</Link>
<div className="flex flex-row items-center justify-center">
<p className="mr-4 text-muted-foreground">
<span className="text-sm sm:hidden">
<Trans>Want your own public profile?</Trans>
</span>
<span className="hidden text-sm sm:block">
<Trans>Like to have your own public profile with agreements?</Trans>
</span>
</p>
<Button asChild variant="secondary">
<Link to="/signup">
<div className="hidden flex-row items-center sm:flex">
<PlusIcon className="mr-1 h-5 w-5" />
<Trans>Create now</Trans>
</div>
<span className="sm:hidden">
<Trans>Create</Trans>
</span>
</Link>
</Button>
</div>
</div>
</header>
)}
@@ -223,7 +223,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
const user = sessionData?.user;
if (!data.isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
return <DocumentSigningAuthPageView email={''} />;
}
const { envelope, recipient } = data.envelopeForSigning;
@@ -321,12 +321,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
const user = sessionData?.user;
if (!data.isDocumentAccessValid) {
return (
<DocumentSigningAuthPageView
email={data.recipientEmail}
emailHasAccount={!!data.recipientHasAccount}
/>
);
return <DocumentSigningAuthPageView email={data.recipientEmail} />;
}
const {
@@ -428,12 +423,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
const user = sessionData?.user;
if (!data.isDocumentAccessValid) {
return (
<DocumentSigningAuthPageView
email={data.recipientEmail}
emailHasAccount={!!data.recipientHasAccount}
/>
);
return <DocumentSigningAuthPageView email={data.recipientEmail} />;
}
const { envelope, recipientSignature, recipient } = data.envelopeForSigning;
@@ -1,35 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { Button } from '@hanzo/esign-ui/primitives/button';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Forgot password');
}
export default function ForgotPasswordPage() {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">
<Trans>Email sent!</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<Trans>
A password reset email has been sent, if you have an account you should see it in your
inbox shortly.
</Trans>
</p>
<Button asChild>
<Link to="/signin">
<Trans>Return to sign in</Trans>
</Link>
</Button>
</div>
</div>
);
}
@@ -1,39 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Forgot Password');
}
export default function ForgotPasswordPage() {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-3xl font-semibold">
<Trans>Forgot your password?</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
No worries, it happens! Enter your email and we'll email you a special link to reset
your password.
</Trans>
</p>
<ForgotPasswordForm className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
<Trans>
Remembered your password?{' '}
<Link to="/signin" className="text-primary duration-200 hover:opacity-70">
Sign In
</Link>
</Trans>
</p>
</div>
</div>
);
}
@@ -1,218 +0,0 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { MailsIcon } from 'lucide-react';
import { Link, redirect, useSearchParams } from 'react-router';
import { authClient } from '@hanzo/esign-auth/client';
import { getOptionalSession } from '@hanzo/esign-auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
import { prisma } from '@hanzo/esign-prisma';
import { Button } from '@hanzo/esign-ui/primitives/button';
import { Checkbox } from '@hanzo/esign-ui/primitives/checkbox';
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/o.$orgUrl.signin';
export function meta() {
return appMetaTags('Sign In');
}
export function ErrorBoundary() {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Authentication Portal Not Found`,
subHeading: msg`404 Not Found`,
message: msg`The organisation authentication portal does not exist, or is not configured`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/`}>
<Trans>Go back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
export async function loader({ request, params }: Route.LoaderArgs) {
const { isAuthenticated, user } = await getOptionalSession(request);
const orgUrl = params.orgUrl;
const organisation = await prisma.organisation.findFirst({
where: {
url: orgUrl,
},
select: {
name: true,
organisationClaim: true,
organisationAuthenticationPortal: {
select: {
enabled: true,
},
},
members: {
select: {
userId: true,
},
},
},
});
if (
!organisation ||
!organisation.organisationAuthenticationPortal.enabled ||
!organisation.organisationClaim.flags.authenticationPortal
) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
// Redirect to organisation if already signed in and a member of the organisation.
if (isAuthenticated && user && organisation.members.find((member) => member.userId === user.id)) {
throw redirect(`/o/${orgUrl}`);
}
return {
organisationName: organisation.name,
orgUrl,
};
}
export default function OrganisationSignIn({ loaderData }: Route.ComponentProps) {
const [searchParams] = useSearchParams();
const { organisationName, orgUrl } = loaderData;
const { t } = useLingui();
const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isConfirmationChecked, setIsConfirmationChecked] = useState(false);
const action = searchParams.get('action');
const onSignInWithOIDCClick = async () => {
setIsSubmitting(true);
try {
await authClient.oidc.org.signIn({
orgUrl,
});
} catch (err) {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to sign you In. Please try again later.`,
variant: 'destructive',
});
}
setIsSubmitting(false);
};
if (action === 'verification-required') {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex items-start">
<div className="mr-4 mt-1 hidden md:block">
<MailsIcon className="h-10 w-10 text-primary" strokeWidth={2} />
</div>
<div className="">
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Confirmation email sent</Trans>
</h2>
<p className="mt-4 text-muted-foreground">
<Trans>
To gain access to your account, please confirm your email address by clicking on the
confirmation link from your inbox.
</Trans>
</p>
<div className="mt-4 flex items-center gap-x-2">
<Button asChild>
<Link to={`/o/${orgUrl}/signin`} replace>
<Trans>Return</Trans>
</Link>
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="w-screen max-w-lg px-4">
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
<h1 className="text-2xl font-semibold">
<Trans>Welcome to {organisationName}</Trans>
</h1>
<p className="mt-2 text-sm text-muted-foreground">
<Trans>Sign in to your account</Trans>
</p>
<hr className="-mx-6 my-4" />
<div className="mb-4 flex items-center gap-x-2">
<Checkbox
id={`flag-3rd-party-service`}
checked={isConfirmationChecked}
onCheckedChange={(checked) =>
setIsConfirmationChecked(checked === 'indeterminate' ? false : checked)
}
/>
<label
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={`flag-3rd-party-service`}
>
<Trans>
I understand that I am providing my credentials to a 3rd party service configured by
this organisation
</Trans>
</label>
</div>
<Button
type="button"
size="lg"
variant="outline"
className="w-full bg-background"
loading={isSubmitting}
disabled={!isConfirmationChecked}
onClick={onSignInWithOIDCClick}
>
<Trans>Sign In</Trans>
</Button>
<div className="relative mt-2 flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="h-px flex-1 bg-border" />
<span className="bg-transparent text-muted-foreground">
<Trans>OR</Trans>
</span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="mt-1 flex items-center justify-center text-xs text-muted-foreground">
<Link to="/signin">
<Trans>Return to Hanzo Sign sign in page here</Trans>
</Link>
</div>
</div>
</div>
);
}
@@ -1,8 +1,10 @@
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { Link } from 'react-router';
import { getOptionalSession } from '@hanzo/esign-auth/server/lib/utils/get-session';
import { acceptOrganisationInvitation } from '@hanzo/esign-lib/server-only/organisation/accept-organisation-invitation';
import { isInvitedPerson } from '@hanzo/esign-lib/server-only/organisation/is-invited-person';
import { prisma } from '@hanzo/esign-prisma';
import { Button } from '@hanzo/esign-ui/primitives/button';
@@ -32,43 +34,45 @@ export async function loader({ params, request }: Route.LoaderArgs) {
},
});
if (!organisationMemberInvite) {
if (
!organisationMemberInvite ||
organisationMemberInvite.status === OrganisationMemberInviteStatus.DECLINED
) {
return {
state: 'InvalidLink',
} as const;
}
const user = await prisma.user.findFirst({
where: {
// Emails are stored lowercased (see create-user.ts); SQLite has no
// case-insensitive filter `mode`, so normalise the invite email instead.
email: organisationMemberInvite.email.toLowerCase(),
},
select: {
id: true,
},
});
const signInPath = `/signin?returnTo=${encodeURIComponent(`/organisation/invite/${token}`)}`;
// Directly convert the team member invite to a team member if they already have an account.
if (user) {
await acceptOrganisationInvitation({ token: organisationMemberInvite.token });
}
if (!user) {
// The invitation joins the person signed in, so nobody is added until the
// invited person is the one holding the session.
if (!session.user) {
return {
state: 'LoginRequired',
email: organisationMemberInvite.email,
organisationName: organisationMemberInvite.organisation.name,
signInPath,
} as const;
}
if (!isInvitedPerson(session.user, organisationMemberInvite.email)) {
return {
state: 'WrongAccount',
email: organisationMemberInvite.email,
organisationName: organisationMemberInvite.organisation.name,
} as const;
}
const isSessionUserTheInvitedUser = user.id === session.user?.id;
await acceptOrganisationInvitation({
token: organisationMemberInvite.token,
userId: session.user.id,
});
return {
state: 'Success',
email: organisationMemberInvite.email,
organisationName: organisationMemberInvite.organisation.name,
isSessionUserTheInvitedUser,
} as const;
}
@@ -83,7 +87,7 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
<Trans>Invalid token</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<p className="mb-4 mt-2 text-sm text-muted-foreground">
<Trans>
This token is invalid or has expired. Please contact your team for a new invitation.
</Trans>
@@ -106,20 +110,45 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
<Trans>Organisation invitation</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<p className="mt-2 text-sm text-muted-foreground">
<Trans>
You have been invited by <strong>{data.organisationName}</strong> to join their
organisation.
</Trans>
</p>
<p className="text-muted-foreground mb-4 mt-1 text-sm">
<Trans>To accept this invitation you must create an account.</Trans>
<p className="mb-4 mt-1 text-sm text-muted-foreground">
<Trans>
Sign in with Hanzo as <strong>{data.email}</strong> to accept this invitation.
</Trans>
</p>
<Button asChild>
<Link to={`/signup#email=${encodeURIComponent(data.email)}`}>
<Trans>Create account</Trans>
<Link to={data.signInPath}>
<Trans>Sign in to accept</Trans>
</Link>
</Button>
</div>
);
}
if (data.state === 'WrongAccount') {
return (
<div>
<h1 className="text-4xl font-semibold">
<Trans>Organisation invitation</Trans>
</h1>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
<Trans>
This invitation from <strong>{data.organisationName}</strong> was sent to{' '}
<strong>{data.email}</strong>. Sign in as that address to accept it.
</Trans>
</p>
<Button asChild>
<Link to="/">
<Trans>Return</Trans>
</Link>
</Button>
</div>
@@ -132,26 +161,18 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
<Trans>Invitation accepted!</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<p className="mb-4 mt-2 text-sm text-muted-foreground">
<Trans>
You have accepted an invitation from <strong>{data.organisationName}</strong> to join
their organisation.
</Trans>
</p>
{data.isSessionUserTheInvitedUser ? (
<Button asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
) : (
<Button asChild>
<Link to={`/signin#email=${encodeURIComponent(data.email)}`}>
<Trans>Continue to login</Trans>
</Link>
</Button>
)}
<Button asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
);
}
@@ -1,343 +0,0 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, Building2, Database, Eye, Settings, UserCircle2 } from 'lucide-react';
import { data, isRouteErrorResponse } from 'react-router';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@hanzo/esign-lib/constants/organisations';
import { ZOrganisationAccountLinkMetadataSchema } from '@hanzo/esign-lib/types/organisation';
import { formatAvatarUrl } from '@hanzo/esign-lib/utils/avatars';
import { formatOrganisationLoginPath } from '@hanzo/esign-lib/utils/organisation-authentication-portal';
import { extractInitials } from '@hanzo/esign-lib/utils/recipient-formatter';
import { prisma } from '@hanzo/esign-prisma';
import type { TDeclineLinkOrganisationAccountRequest } from '@hanzo/esign-trpc/server/organisation-router/decline-link-organisation-account.types';
import type { TLinkOrganisationAccountRequest } from '@hanzo/esign-trpc/server/organisation-router/link-organisation-account.types';
import { useZapMutation } from '@hanzo/esign-trpc/zap/react';
import { Alert, AlertDescription } from '@hanzo/esign-ui/primitives/alert';
import { AvatarWithText } from '@hanzo/esign-ui/primitives/avatar';
import { Badge } from '@hanzo/esign-ui/primitives/badge';
import { Button } from '@hanzo/esign-ui/primitives/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@hanzo/esign-ui/primitives/card';
import { Checkbox } from '@hanzo/esign-ui/primitives/checkbox';
import { Separator } from '@hanzo/esign-ui/primitives/separator';
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
import { GenericErrorLayout, defaultErrorCodeMap } from '~/components/general/generic-error-layout';
import type { Route } from './+types/organisation.sso.confirmation.$token';
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.data.type : 500;
const errorMap = match(errorCode)
.with('invalid-token', () => ({
subHeading: msg`400 Error`,
heading: msg`Invalid Token`,
message: msg`The token is invalid or has expired.`,
}))
.otherwise(() => defaultErrorCodeMap[500]);
return (
<GenericErrorLayout errorCode={500} errorCodeMap={{ 500: errorMap }} secondaryButton={null} />
);
}
export async function loader({ params }: Route.LoaderArgs) {
const { token } = params;
if (!token) {
throw data({
type: 'invalid-token',
});
}
const verificationToken = await prisma.verificationToken.findFirst({
where: {
token,
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
},
include: {
user: {
select: {
name: true,
email: true,
avatarImageId: true,
},
},
},
});
if (!verificationToken || verificationToken.expires < new Date()) {
throw data({
type: 'invalid-token',
});
}
const metadata = ZOrganisationAccountLinkMetadataSchema.safeParse(verificationToken.metadata);
if (!metadata.success) {
throw data({
type: 'invalid-token',
});
}
const organisation = await prisma.organisation.findFirst({
where: {
id: metadata.data.organisationId,
},
select: {
name: true,
url: true,
avatarImageId: true,
},
});
if (!organisation) {
throw data({
type: 'invalid-token',
});
}
return {
token,
type: metadata.data.type,
user: {
name: verificationToken.user.name,
email: verificationToken.user.email,
avatar: verificationToken.user.avatarImageId,
},
organisation: {
name: organisation.name,
url: organisation.url,
avatar: organisation.avatarImageId,
},
} as const;
}
export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Route.ComponentProps) {
const { token, type, user, organisation } = loaderData;
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [isConfirmationChecked, setIsConfirmationChecked] = useState(false);
const { mutate: declineLinkOrganisationAccount, isPending: isDeclining } =
useZapMutation<void, TDeclineLinkOrganisationAccountRequest>(
'organisation.authenticationPortal.declineLinkAccount',
{
onSuccess: async () => {
await navigate('/');
toast({
title: _(msg`Account link declined`),
});
},
onError: (error) => {
toast({
title: _(msg`Error declining account link`),
description: error.message,
});
},
},
);
const { mutate: linkOrganisationAccount, isPending: isLinking } =
useZapMutation<void, TLinkOrganisationAccountRequest>(
'organisation.authenticationPortal.linkAccount',
{
onSuccess: async () => {
await navigate(formatOrganisationLoginPath(organisation.url));
toast({
title: _(msg`Account linked successfully`),
});
},
onError: (error) => {
toast({
title: _(msg`Error linking account`),
description: error.message,
});
},
},
);
return (
<div>
<Card className="w-full max-w-2xl border">
<CardHeader>
<CardTitle>
{type === 'link' ? (
<Trans>Account Linking Request</Trans>
) : (
<Trans>Account Creation Request</Trans>
)}
</CardTitle>
<CardDescription>
{type === 'link' ? (
<Trans>
An organisation wants to link your account. Please review the details below.
</Trans>
) : (
<Trans>
An organisation wants to create an account for you. Please review the details below.
</Trans>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Current User Section */}
<div className="space-y-3">
<h3 className="flex items-center gap-2 font-semibold text-muted-foreground">
<UserCircle2 className="h-4 w-4" />
<Trans>Your Account</Trans>
</h3>
<div className="flex items-center justify-between gap-3 rounded-lg bg-muted/50 p-3">
<AvatarWithText
avatarSrc={formatAvatarUrl(user.avatar)}
avatarFallback={extractInitials(user.name || user.email)}
primaryText={user.name}
secondaryText={user.email}
/>
<Badge variant="secondary">
<Trans>Account</Trans>
</Badge>
</div>
</div>
<Separator />
{/* Organisation Section */}
<div className="space-y-3">
<h3 className="flex items-center gap-2 font-semibold text-muted-foreground">
<Building2 className="h-4 w-4" />
<Trans>Requesting Organisation</Trans>
</h3>
<div className="flex items-center justify-between gap-3 rounded-lg bg-muted/50 p-3">
<AvatarWithText
avatarSrc={formatAvatarUrl(organisation.avatar)}
avatarFallback={extractInitials(organisation.name)}
primaryText={organisation.name}
secondaryText={`/o/${organisation.url}`}
/>
<Badge variant="secondary">
<Trans>Organisation</Trans>
</Badge>
</div>
</div>
<Separator />
{/* Warnings Section */}
<div className="space-y-3">
<h3 className="flex items-center gap-2 font-semibold text-muted-foreground">
<AlertTriangle className="h-4 w-4" />
<Trans>Important: What This Means</Trans>
</h3>
<div className="space-y-3 rounded-lg border p-4">
<p className="text-sm font-medium">
<Trans>
By accepting this request, you grant {organisation.name} the following
permissions:
</Trans>
</p>
<ul className="space-y-2 text-sm">
<li className="flex items-start gap-2">
<Eye className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
<Trans>
<span className="font-semibold text-muted-foreground">
Full account access:
</span>{' '}
View all your profile information, settings, and activity
</Trans>
</span>
</li>
<li className="flex items-start gap-2">
<Settings className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
<Trans>
<span className="font-semibold text-muted-foreground">
Account management:
</span>{' '}
Modify your account settings, permissions, and preferences
</Trans>
</span>
</li>
<li className="flex items-start gap-2">
<Database className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
<Trans>
<span className="font-semibold text-muted-foreground">Data access:</span>{' '}
Access all data associated with your account
</Trans>
</span>
</li>
</ul>
<Alert variant="warning" className="mt-3">
<AlertDescription>
<Trans>
This organisation will have administrative control over your account. You can
revoke this access later, but they will retain access to any data they've
already collected.
</Trans>
</AlertDescription>
</Alert>
</div>
</div>
<div className="mb-4 flex items-center gap-x-2">
<Checkbox
id={`accept-conditions`}
checked={isConfirmationChecked}
onCheckedChange={(checked) =>
setIsConfirmationChecked(checked === 'indeterminate' ? false : checked)
}
/>
<label
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={`accept-conditions`}
>
<Trans>I agree to link my account with this organization</Trans>
</label>
</div>
</CardContent>
<CardFooter className="flex justify-end gap-3">
<Button
variant="outline"
disabled={isDeclining || isLinking}
onClick={() => declineLinkOrganisationAccount({ token })}
>
<Trans>Decline</Trans>
</Button>
<Button
disabled={!isConfirmationChecked || isDeclining || isLinking}
loading={isLinking}
onClick={() => linkOrganisationAccount({ token })}
>
<Trans>Accept & Link Account</Trans>
</Button>
</CardFooter>
</Card>
</div>
);
}
@@ -1,56 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Link, redirect } from 'react-router';
import { getResetTokenValidity } from '@hanzo/esign-lib/server-only/user/get-reset-token-validity';
import { ResetPasswordForm } from '~/components/forms/reset-password';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/reset-password.$token';
export function meta() {
return appMetaTags('Reset Password');
}
export async function loader({ params }: Route.LoaderArgs) {
const { token } = params;
const isValid = await getResetTokenValidity({ token });
if (!isValid) {
throw redirect('/reset-password');
}
return {
token,
};
}
export default function ResetPasswordPage({ loaderData }: Route.ComponentProps) {
const { token } = loaderData;
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">
<Trans>Reset Password</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Please choose your new password</Trans>
</p>
<ResetPasswordForm token={token} className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
<Trans>
Don't have an account?{' '}
<Link to="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</Trans>
</p>
</div>
</div>
);
}
@@ -1,35 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { Button } from '@hanzo/esign-ui/primitives/button';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Reset Password');
}
export default function ResetPasswordPage() {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-3xl font-semibold">
<Trans>Unable to reset password</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
The token you have used to reset your password is either expired or it never existed. If
you have still forgotten your password, please request a new reset link.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/signin">
<Trans>Return to sign in</Trans>
</Link>
</Button>
</div>
</div>
);
}
@@ -2,6 +2,7 @@ import { Trans } from '@lingui/react/macro';
import { redirect } from 'react-router';
import { getOptionalSession } from '@hanzo/esign-auth/server/lib/utils/get-session';
import { SignInRefusal } from '@hanzo/esign-lib/constants/auth';
import { isValidReturnTo, normalizeReturnTo } from '@hanzo/esign-lib/utils/is-valid-return-to';
import { brand } from '~/components/branding/brand';
@@ -16,10 +17,14 @@ export function meta() {
return appMetaTags('Sign In');
}
const REFUSALS = new Set<string>(Object.values(SignInRefusal));
export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request);
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
const searchParams = new URL(request.url).searchParams;
let returnTo = searchParams.get('returnTo') ?? undefined;
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
@@ -27,13 +32,43 @@ export async function loader({ request }: Route.LoaderArgs) {
throw redirect(returnTo || '/');
}
const error = searchParams.get('error') ?? '';
return {
returnTo,
refusal: REFUSALS.has(error) ? (error as SignInRefusal) : null,
};
}
const Refusal = ({ refusal }: { refusal: SignInRefusal }) => {
switch (refusal) {
case SignInRefusal.NotInvited:
return (
<Trans>
Hanzo Sign is open to invited people only. Ask an admin to invite you, then sign in again.
</Trans>
);
case SignInRefusal.UnverifiedEmail:
return (
<Trans>
Your Hanzo account's email address is not verified. Verify it at Hanzo, then sign in
again.
</Trans>
);
case SignInRefusal.UnlinkedAccount:
return (
<Trans>
An account with this email address exists but was never linked to your Hanzo sign-in. Ask
an admin to link it.
</Trans>
);
case SignInRefusal.Disabled:
return <Trans>This account has been disabled. Ask an admin to restore it.</Trans>;
}
};
export default function SignIn({ loaderData }: Route.ComponentProps) {
const { returnTo } = loaderData;
const { returnTo, refusal } = loaderData;
const { primary, suffix } = brand();
@@ -54,6 +89,12 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
descriptionClassName="text-zinc-400"
/>
{refusal && (
<p role="alert" className="mt-6 text-sm text-red-400">
<Refusal refusal={refusal} />
</p>
)}
<div className="mt-8">
<SignInForm returnTo={returnTo} />
</div>
@@ -1,21 +0,0 @@
import { redirect } from 'react-router';
import { isValidReturnTo, normalizeReturnTo } from '@hanzo/esign-lib/utils/is-valid-return-to';
import type { Route } from './+types/signup';
/**
* Accounts are created at the identity provider, so /signup leads to the same
* door as /signin. The provider offers account creation on that page.
*
* This used to render a local name/email/password form, which produced an
* account /signin could not then authenticate: sign-in offers the provider and
* nothing else, so a locally created password had nowhere to be typed.
*/
export function loader({ request }: Route.LoaderArgs) {
const returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
const target = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
throw redirect(target ? `/signin?returnTo=${encodeURIComponent(target)}` : '/signin');
}
@@ -1,37 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Mails } from 'lucide-react';
import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-email';
export default function UnverifiedAccount() {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex items-start">
<div className="mr-4 mt-1 hidden md:block">
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
</div>
<div className="">
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Confirm email</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
To gain access to your account, please confirm your email address by clicking on the
confirmation link from your inbox.
</Trans>
</p>
<p className="text-muted-foreground mt-4">
<Trans>
If you don't find the confirmation link in your inbox, you can request a new one
below.
</Trans>
</p>
<SendConfirmationEmailForm />
</div>
</div>
</div>
);
}
@@ -1,191 +0,0 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, CheckCircle2, Loader, XCircle } from 'lucide-react';
import { Link, redirect, useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { authClient } from '@hanzo/esign-auth/client';
import { useOptionalSession } from '@hanzo/esign-lib/client-only/providers/session';
import { EMAIL_VERIFICATION_STATE } from '@hanzo/esign-lib/constants/email';
import { Button } from '@hanzo/esign-ui/primitives/button';
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
import type { Route } from './+types/verify-email.$token';
export const loader = ({ params }: Route.LoaderArgs) => {
const { token } = params;
if (!token) {
throw redirect('/verify-email');
}
return {
token,
};
};
export default function VerifyEmailPage({ loaderData }: Route.ComponentProps) {
const { token } = loaderData;
const { refreshSession } = useOptionalSession();
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [state, setState] = useState<keyof typeof EMAIL_VERIFICATION_STATE | null>(null);
const [isLoading, setIsLoading] = useState(false);
const verifyToken = async () => {
setIsLoading(true);
try {
const response = await authClient.emailPassword.verifyEmail({
token,
});
await refreshSession();
setState(response.state);
} catch (err) {
console.error(err);
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to verify your email at this time.`),
});
await navigate('/verify-email');
}
setIsLoading(false);
};
useEffect(() => {
void verifyToken();
}, []);
if (isLoading || state === null) {
return (
<div className="relative">
<Loader className="h-8 w-8 animate-spin text-sign" />
</div>
);
}
return match(state)
.with(EMAIL_VERIFICATION_STATE.NOT_FOUND, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<AlertTriangle className="h-10 w-10 text-muted-foreground" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Something went wrong</Trans>
</h2>
<p className="mt-4 text-muted-foreground">
<Trans>
We were unable to verify your email. If your email is not verified already, please
try again.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(EMAIL_VERIFICATION_STATE.EXPIRED, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="h-10 w-10 text-destructive" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Your token has expired!</Trans>
</h2>
<p className="mt-4 text-muted-foreground">
<Trans>
It seems that the provided token has expired. We've just sent you another token,
please check your email and try again.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(EMAIL_VERIFICATION_STATE.VERIFIED, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-foreground" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Email Confirmed!</Trans>
</h2>
<p className="mt-4 text-muted-foreground">
<Trans>
Your email has been successfully confirmed! You can now use all features of Hanzo
Sign.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(EMAIL_VERIFICATION_STATE.ALREADY_VERIFIED, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-foreground" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Email already confirmed</Trans>
</h2>
<p className="mt-4 text-muted-foreground">
<Trans>
Your email has already been confirmed. You can now use all features of Hanzo Sign.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.exhaustive();
}
@@ -1,42 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { XCircle } from 'lucide-react';
import { Link } from 'react-router';
import { Button } from '@hanzo/esign-ui/primitives/button';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Verify Email');
}
export default function EmailVerificationWithoutTokenPage() {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Uh oh! Looks like you're missing a token</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
It seems that there is no token provided, if you are trying to verify your email
please follow the link in your email.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
);
}
-2
View File
@@ -209,8 +209,6 @@ Here's a markdown table documenting all the provided environment variables:
| `NEXT_PRIVATE_AUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `BASE_PATH` | Root directory for per-tenant SQLite (Hanzo Base). Each org gets `${BASE_PATH}/${orgId}/sign.db`. |
| `DATABASE_URL` | SQLite file URL for the default (CLI / migration) database, e.g. `file:${BASE_PATH}/_dev/sign.db`. |
-2
View File
@@ -11,8 +11,6 @@ services:
- NEXT_PRIVATE_AUTH_SECRET=${NEXT_PRIVATE_AUTH_SECRET:?err}
- NEXT_PRIVATE_ENCRYPTION_KEY=${NEXT_PRIVATE_ENCRYPTION_KEY:?err}
- NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=${NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY:?err}
- NEXT_PRIVATE_GOOGLE_CLIENT_ID=${NEXT_PRIVATE_GOOGLE_CLIENT_ID}
- NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=${NEXT_PRIVATE_GOOGLE_CLIENT_SECRET}
- NEXT_PUBLIC_WEBAPP_URL=${NEXT_PUBLIC_WEBAPP_URL:?err}
- NEXT_PRIVATE_INTERNAL_WEBAPP_URL=${NEXT_PRIVATE_INTERNAL_WEBAPP_URL:-http://localhost:$PORT}
- BASE_PATH=${BASE_PATH:-/opt/hanzo-sign/base/data}
@@ -1,11 +1,13 @@
import type { Page } from '@playwright/test';
import { createHash, createHmac, randomBytes } from 'node:crypto';
import { NEXT_PUBLIC_WEBAPP_URL } from '@hanzo/esign-lib/constants/app';
import { formatSecureCookieName } from '@hanzo/esign-lib/constants/auth';
import { prisma } from '@hanzo/esign-prisma';
type LoginOptions = {
page: Page;
email?: string;
password?: string;
/**
* Where to navigate after login.
@@ -13,24 +15,58 @@ type LoginOptions = {
redirectPath?: string;
};
/**
* Signs the page in as the seeded user with this email.
*
* The app signs people in through Hanzo IAM only, and a test cannot drive IAM.
* So the suite starts the session the way a completed IAM sign-in leaves it: a
* Session row for the user and the signed `sessionId` cookie that names it,
* signed with the app's own NEXT_PRIVATE_AUTH_SECRET exactly as hono signs it.
*/
export const apiSignin = async ({
page,
email = '[email protected]',
password = 'password',
redirectPath = '/',
}: LoginOptions) => {
const { request } = page.context();
const csrfToken = await getCsrfToken(page);
await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/v1/auth/email-password/authorize`, {
data: {
email,
password,
csrfToken,
const user = await prisma.user.findFirstOrThrow({
where: {
email: email.toLowerCase(),
},
select: {
id: true,
},
});
const token = randomBytes(20).toString('hex');
const id = createHash('sha256').update(token).digest('hex');
await prisma.session.create({
data: {
id,
sessionToken: id,
userId: user.id,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
},
});
const secret = process.env.NEXT_PRIVATE_AUTH_SECRET;
if (!secret) {
throw new Error('NEXT_PRIVATE_AUTH_SECRET is not set');
}
const signature = createHmac('sha256', secret).update(token).digest('base64');
await page.context().addCookies([
{
name: formatSecureCookieName('sessionId'),
value: encodeURIComponent(`${token}.${signature}`),
url: NEXT_PUBLIC_WEBAPP_URL(),
httpOnly: true,
sameSite: 'Lax',
},
]);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${redirectPath}`);
await page.waitForTimeout(500);
};
@@ -43,22 +79,6 @@ export const apiSignout = async ({ page }: { page: Page }) => {
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/signin`);
};
const getCsrfToken = async (page: Page) => {
const { request } = page.context();
const response = await request.fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/v1/auth/csrf`, {
method: 'get',
});
const { csrfToken } = await response.json();
if (!csrfToken) {
throw new Error('Invalid session');
}
return csrfToken;
};
export const checkSessionValid = async (page: Page): Promise<boolean> => {
const { request } = page.context();
@@ -13,7 +13,6 @@ test('[TEAMS]: send team email request', async ({ page }) => {
await apiSignin({
page,
email: user.email,
password: 'password',
redirectPath: `/t/${team.url}/settings`,
});
@@ -11,7 +11,6 @@ test('[USER] revoke sessions', async ({ page }: { page: Page }) => {
await apiSignin({
page,
email: user.email,
password: 'password',
redirectPath: '/settings/security/sessions',
});
@@ -24,7 +23,6 @@ test('[USER] revoke sessions', async ({ page }: { page: Page }) => {
await apiSignin({
page,
email: user.email,
password: 'password',
redirectPath: '/settings/security/sessions',
});
@@ -33,7 +31,6 @@ test('[USER] revoke sessions', async ({ page }: { page: Page }) => {
await apiSignin({
page,
email: user.email,
password: 'password',
redirectPath: '/settings/security/sessions',
});
@@ -54,7 +51,6 @@ test('[USER] revoke sessions', async ({ page }: { page: Page }) => {
await apiSignin({
page,
email: user.email,
password: 'password',
redirectPath: '/settings/security/sessions',
});
@@ -1,227 +0,0 @@
import { type Page, expect, test } from '@playwright/test';
import { prisma } from '@hanzo/esign-prisma';
import { seedUser } from '@hanzo/esign-prisma/seed/users';
import { apiSignin, apiSignout, checkSessionValid } from '../fixtures/authentication';
test.use({ storageState: { cookies: [], origins: [] } });
test('[USER] can reset password via forgot password', async ({ page }: { page: Page }) => {
const oldPassword = 'Test123!';
const newPassword = 'Test124!';
const { user } = await seedUser({
password: oldPassword,
});
await page.goto('http://localhost:3000/signin');
await page.getByRole('link', { name: 'Forgot your password?' }).click();
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
await page.getByRole('textbox', { name: 'Email' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
await expect(page.getByRole('button', { name: 'Reset Password' })).toBeEnabled();
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Reset email sent', {
timeout: 10000,
});
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
where: {
userId: user.id,
},
include: {
user: true,
},
});
await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
// Assert that password cannot be same as old password.
await page.getByLabel('Password', { exact: true }).fill(oldPassword);
await page.getByLabel('Repeat Password').fill(oldPassword);
// Ensure both fields are filled before clicking
await expect(page.getByLabel('Password', { exact: true })).toHaveValue(oldPassword);
await expect(page.getByLabel('Repeat Password')).toHaveValue(oldPassword);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText(
'Your new password cannot be the same as your old password.',
);
// Assert password reset.
await page.getByLabel('Password', { exact: true }).fill(newPassword);
await page.getByLabel('Repeat Password').fill(newPassword);
// Ensure both fields are filled before clicking
await expect(page.getByLabel('Password', { exact: true })).toHaveValue(newPassword);
await expect(page.getByLabel('Repeat Password')).toHaveValue(newPassword);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Your password has been updated successfully.');
// Assert sign in works.
await apiSignin({
page,
email: user.email,
password: newPassword,
redirectPath: '/settings/profile',
});
await page.waitForURL('/settings/profile');
await expect(page).toHaveURL('/settings/profile');
});
test('[USER] can reset password via user settings', async ({ page }: { page: Page }) => {
const oldPassword = 'Test123!';
const newPassword = 'Test124!';
const { user } = await seedUser({
password: oldPassword,
});
await apiSignin({
page,
email: user.email,
password: oldPassword,
redirectPath: '/settings/security',
});
await page.getByLabel('Current password').fill(oldPassword);
await page.getByLabel('New password').fill(newPassword);
await page.getByLabel('Repeat password').fill(newPassword);
await page.getByRole('button', { name: 'Update password' }).click();
await expect(page.locator('body')).toContainText('Password updated');
await apiSignout({
page,
});
// Assert sign in works.
await apiSignin({
page,
email: user.email,
password: newPassword,
redirectPath: '/settings/profile',
});
await page.waitForURL('/settings/profile');
await expect(page).toHaveURL('/settings/profile');
});
test('[USER] password reset invalidates all sessions', async ({ page }: { page: Page }) => {
const oldPassword = 'Test123!';
const newPassword = 'Test124!';
const { user } = await seedUser({
password: oldPassword,
});
await apiSignin({
page,
email: user.email,
password: oldPassword,
redirectPath: '/settings/profile',
});
expect(await checkSessionValid(page)).toBe(true);
const initialCookies = await page.context().cookies();
await page.context().clearCookies();
await page.goto('http://localhost:3000/signin');
await page.getByRole('link', { name: 'Forgot your password?' }).click();
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Reset email sent', {
timeout: 10000,
});
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
where: { userId: user.id },
});
await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
await page.getByLabel('Password', { exact: true }).fill(newPassword);
await page.getByLabel('Repeat Password').fill(newPassword);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Your password has been updated successfully.');
await page.context().addCookies(initialCookies);
await page.goto('http://localhost:3000/settings/profile');
await expect(page).toHaveURL('http://localhost:3000/signin');
expect(await checkSessionValid(page)).toBe(false);
await apiSignin({
page,
email: user.email,
password: newPassword,
redirectPath: '/settings/profile',
});
await page.waitForURL('/settings/profile');
expect(await checkSessionValid(page)).toBe(true);
});
test('[USER] password update invalidates other sessions but keeps current', async ({
page,
}: {
page: Page;
}) => {
const oldPassword = 'Test123!';
const newPassword = 'Test124!';
const { user } = await seedUser({
password: oldPassword,
});
await apiSignin({
page,
email: user.email,
password: oldPassword,
redirectPath: '/settings/profile',
});
expect(await checkSessionValid(page)).toBe(true);
const initialCookies = await page.context().cookies();
await page.context().clearCookies();
await apiSignin({
page,
email: user.email,
password: oldPassword,
redirectPath: '/settings/profile',
});
expect(await checkSessionValid(page)).toBe(true);
await page.goto('http://localhost:3000/settings/security');
await page.getByLabel('Current password').fill(oldPassword);
await page.getByLabel('New password').fill(newPassword);
await page.getByLabel('Repeat password').fill(newPassword);
await page.getByRole('button', { name: 'Update password' }).click();
await expect(page.locator('body')).toContainText('Password updated');
const finalCookies = await page.context().cookies();
await page.context().clearCookies();
await page.context().addCookies(initialCookies);
await page.goto('http://localhost:3000/settings/profile');
await expect(page).toHaveURL('http://localhost:3000/signin');
expect(await checkSessionValid(page)).toBe(false);
await page.context().clearCookies();
await page.context().addCookies(finalCookies);
await page.goto('http://localhost:3000/settings/security');
await expect(page).toHaveURL('http://localhost:3000/settings/security');
expect(await checkSessionValid(page)).toBe(true);
});
+115
View File
@@ -0,0 +1,115 @@
/**
* Who a Hanzo IAM sign-in may make an account for.
*
* The callback reads IAM's userinfo answer and makes an account on a first
* sign-in only for someone who was asked in. One half of that is read here: the
* person administers the org the application serves. `isAdmin` is IAM's "admin
* of the account's own org", so it must count only when that org is the
* application's — an admin of a personal org of one is not an admin of Hanzo.
*
* Run: npx tsx --test packages/auth/__tests__/iam-person.test.ts
*/
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { administersAppOrg, readIamPerson } from '../server/lib/utils/iam-person';
const userinfo = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
sub: 'b474eaa5-e000-474d-b317-c37bbd9a6945',
email: '[email protected]',
email_verified: true,
name: 'a',
displayName: 'A',
owner: 'hanzo',
isAdmin: true,
orgs: [{ org: 'hanzo', role: 'admin' }],
...over,
});
describe('readIamPerson', () => {
test('reads the person IAM described', () => {
const person = readIamPerson(userinfo(), 'b474eaa5-e000-474d-b317-c37bbd9a6945');
assert.equal(person.email, '[email protected]');
assert.equal(person.emailVerified, true);
assert.equal(person.name, 'A');
assert.equal(person.owner, 'hanzo');
assert.deepEqual(person.orgs, [{ org: 'hanzo', role: 'admin' }]);
});
test('refuses userinfo for a different subject than the id_token', () => {
assert.throws(() => readIamPerson(userinfo(), 'someone-else'));
});
test('refuses userinfo without an email', () => {
assert.throws(() =>
readIamPerson(userinfo({ email: undefined }), 'b474eaa5-e000-474d-b317-c37bbd9a6945'),
);
});
test('an address IAM has not proved is unverified, whatever shape the claim takes', () => {
for (const email_verified of [false, 'true', 1, undefined, null]) {
const person = readIamPerson(
userinfo({ email_verified }),
'b474eaa5-e000-474d-b317-c37bbd9a6945',
);
assert.equal(person.emailVerified, false, String(email_verified));
}
});
});
describe('administersAppOrg', () => {
const sub = 'b474eaa5-e000-474d-b317-c37bbd9a6945';
test('an admin whose account lives in the app org', () => {
assert.equal(administersAppOrg(readIamPerson(userinfo(), sub)), true);
});
test('an owner whose account lives in the app org', () => {
const person = readIamPerson(userinfo({ orgs: [{ org: 'hanzo', role: 'owner' }] }), sub);
assert.equal(administersAppOrg(person), true);
});
test('a member of the app org is not', () => {
const person = readIamPerson(
userinfo({ isAdmin: false, orgs: [{ org: 'hanzo', role: 'member' }] }),
sub,
);
assert.equal(administersAppOrg(person), false);
});
test('the admin of a personal org is not, even with a membership in the app org', () => {
const person = readIamPerson(
userinfo({
orgs: [
{ org: 'gilbert-mooy2', role: 'admin' },
{ org: 'hanzo', role: 'admin' },
],
}),
sub,
);
assert.equal(administersAppOrg(person), false);
});
test('isAdmin without the admin role in the app org is not', () => {
const person = readIamPerson(userinfo({ orgs: [{ org: 'hanzo', role: 'member' }] }), sub);
assert.equal(administersAppOrg(person), false);
});
test('no owner claim is not', () => {
const person = readIamPerson(userinfo({ owner: undefined }), sub);
assert.equal(administersAppOrg(person), false);
});
test('no orgs is not', () => {
const person = readIamPerson(userinfo({ orgs: undefined }), sub);
assert.equal(administersAppOrg(person), false);
});
});
+1 -201
View File
@@ -1,4 +1,4 @@
import type { ClientResponse, InferRequestType } from 'hono/client';
import type { ClientResponse } from 'hono/client';
import { hc } from 'hono/client';
import superjson from 'superjson';
@@ -9,31 +9,14 @@ import type { AuthAppType } from '../server';
import type { SessionValidationResult } from '../server/lib/session/session';
import type { PartialAccount } from '../server/lib/utils/get-accounts';
import type { ActiveSession } from '../server/lib/utils/get-session';
import { handleSignInRedirect } from '../server/lib/utils/redirect';
import type {
TDisableTwoFactorRequestSchema,
TEnableTwoFactorRequestSchema,
TViewTwoFactorRecoveryCodesRequestSchema,
} from '../server/routes/two-factor.types';
import type {
TForgotPasswordSchema,
TResendVerifyEmailSchema,
TResetPasswordSchema,
TSignUpSchema,
TUpdatePasswordSchema,
TVerifyEmailSchema,
} from '../server/types/email-password';
type AuthClientType = ReturnType<typeof hc<AuthAppType>>;
type TEmailPasswordSignin = InferRequestType<
AuthClientType['email-password']['authorize']['$post']
>['json'] & { redirectPath?: string };
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'] & {
redirectPath?: string;
};
export class AuthClient {
public client: AuthClientType;
@@ -134,95 +117,6 @@ export class AuthClient {
},
};
public emailPassword = {
signIn: async (data: Omit<TEmailPasswordSignin, 'csrfToken'> & { csrfToken?: string }) => {
let csrfToken = data.csrfToken;
if (!csrfToken) {
csrfToken = (await this.client.csrf.$get().then(async (res) => res.json())).csrfToken;
}
const response = await this.client['email-password'].authorize.$post({
json: {
...data,
csrfToken,
},
});
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
handleSignInRedirect(data.redirectPath);
},
updatePassword: async (data: TUpdatePasswordSchema) => {
const response = await this.client['email-password']['update-password'].$post({ json: data });
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
},
forgotPassword: async (data: TForgotPasswordSchema) => {
const response = await this.client['email-password']['forgot-password'].$post({ json: data });
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
},
resetPassword: async (data: TResetPasswordSchema) => {
const response = await this.client['email-password']['reset-password'].$post({ json: data });
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
},
signUp: async (data: TSignUpSchema) => {
const response = await this.client['email-password']['signup'].$post({ json: data });
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
},
resendVerifyEmail: async (data: TResendVerifyEmailSchema) => {
const response = await this.client['email-password']['resend-verify-email'].$post({
json: data,
});
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
},
verifyEmail: async (data: TVerifyEmailSchema) => {
const response = await this.client['email-password']['verify-email'].$post({ json: data });
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
return response.json();
},
};
public twoFactor = {
setup: async () => {
const response = await this.client['two-factor'].setup.$post();
@@ -268,61 +162,6 @@ export class AuthClient {
},
};
public passkey = {
signIn: async (data: TPasskeySignin) => {
const response = await this.client['passkey'].authorize.$post({ json: data });
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
handleSignInRedirect(data.redirectPath);
},
};
public google = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.google.$post({
json: { redirectPath },
});
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
const data = await response.json();
// Redirect to external Google auth URL.
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
};
public microsoft = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.microsoft.$post({
json: { redirectPath },
});
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
const data = await response.json();
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
};
public hanzo = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.hanzo.$post({
@@ -342,45 +181,6 @@ export class AuthClient {
}
},
};
public oidc = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } });
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
const data = await response.json();
// Redirect to external OIDC provider URL.
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
org: {
signIn: async ({ orgUrl }: { orgUrl: string }) => {
const response = await this.client['oauth'].authorize.oidc.org[':orgUrl'].$post({
param: { orgUrl },
});
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
const data = await response.json();
// Redirect to external OIDC provider URL.
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
},
};
}
export const authClient = new AuthClient({
+1
View File
@@ -7,6 +7,7 @@
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "tsx --test __tests__/*.test.ts",
"clean": "rimraf node_modules"
},
"dependencies": {
+4 -32
View File
@@ -13,41 +13,14 @@ export type OAuthClientOptions = {
clientSecret: string;
wellKnownUrl: string;
redirectUrl: string;
bypassEmailVerification?: boolean;
};
export const GoogleAuthOptions: OAuthClientOptions = {
id: 'google',
scope: ['openid', 'email', 'profile'],
clientId: env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') ?? '',
clientSecret: env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET') ?? '',
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/v1/auth/callback/google`,
wellKnownUrl: 'https://accounts.google.com/.well-known/openid-configuration',
bypassEmailVerification: false,
};
export const MicrosoftAuthOptions: OAuthClientOptions = {
id: 'microsoft',
scope: ['openid', 'email', 'profile'],
clientId: env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') ?? '',
clientSecret: env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET') ?? '',
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/v1/auth/callback/microsoft`,
wellKnownUrl: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
bypassEmailVerification: false,
};
export const OidcAuthOptions: OAuthClientOptions = {
id: 'oidc',
scope: ['openid', 'email', 'profile'],
clientId: env('NEXT_PRIVATE_OIDC_CLIENT_ID') ?? '',
clientSecret: env('NEXT_PRIVATE_OIDC_CLIENT_SECRET') ?? '',
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/v1/auth/callback/oidc`,
wellKnownUrl: env('NEXT_PRIVATE_OIDC_WELL_KNOWN') ?? '',
bypassEmailVerification: env('NEXT_PRIVATE_OIDC_SKIP_VERIFY') === 'true',
};
const iamUrl = env('IAM_URL') ?? 'https://hanzo.id';
/**
* Hanzo IAM is the one way into the app. A person signs in at IAM, and the
* account they arrive with is IAM's answer, read from its userinfo endpoint.
*/
export const HanzoAuthOptions: OAuthClientOptions = {
id: 'hanzo',
scope: ['openid', 'email', 'profile'],
@@ -55,5 +28,4 @@ export const HanzoAuthOptions: OAuthClientOptions = {
clientSecret: env('IAM_CLIENT_SECRET') ?? '',
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/auth/callback`,
wellKnownUrl: `${iamUrl}/.well-known/openid-configuration`,
bypassEmailVerification: true,
};
-6
View File
@@ -10,10 +10,7 @@ import { HanzoAuthOptions } from './config';
import { setCsrfCookie } from './lib/session/session-cookies';
import { handleOAuthCallbackUrl } from './lib/utils/handle-oauth-callback-url';
import { accountRoute } from './routes/account';
import { callbackRoute } from './routes/callback';
import { emailPasswordRoute } from './routes/email-password';
import { oauthRoute } from './routes/oauth';
import { passkeyRoute } from './routes/passkey';
import { sessionRoute } from './routes/session';
import { signOutRoute } from './routes/sign-out';
import { twoFactorRoute } from './routes/two-factor';
@@ -47,10 +44,7 @@ export const auth = new Hono<HonoAuthContext>()
.route('/', sessionRoute)
.route('/', signOutRoute)
.route('/', accountRoute)
.route('/callback', callbackRoute)
.route('/oauth', oauthRoute)
.route('/email-password', emailPasswordRoute)
.route('/passkey', passkeyRoute)
.route('/two-factor', twoFactorRoute);
/**
@@ -1,8 +1,9 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { OAuth2Client, decodeIdToken } from 'arctic';
import type { Context } from 'hono';
import { deleteCookie } from 'hono/cookie';
import { SignInRefusal } from '@hanzo/esign-lib/constants/auth';
import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
import { onCreateUserHook } from '@hanzo/esign-lib/server-only/user/create-user';
import { deletedServiceAccountEmail } from '@hanzo/esign-lib/server-only/user/service-accounts/deleted-account';
@@ -13,6 +14,7 @@ import { prisma } from '@hanzo/esign-prisma';
import type { OAuthClientOptions } from '../../config';
import { AuthenticationErrorCode } from '../errors/error-codes';
import { onAuthorize } from './authorizer';
import { administersAppOrg, readIamPerson } from './iam-person';
import { getOpenIdConfiguration } from './open-id';
type HandleOAuthCallbackUrlOptions = {
@@ -23,103 +25,88 @@ type HandleOAuthCallbackUrlOptions = {
export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOptions) => {
const { c, clientOptions } = options;
const requestMeta = c.get('requestMetadata');
const { person, accessToken, accessTokenExpiresAt, idToken, redirectPath } = await validateOauth({
c,
clientOptions,
});
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken, redirectPath } =
await validateOauth({ c, clientOptions });
const refuse = (reason: SignInRefusal) => c.redirect(`/signin?error=${reason}`, 302);
if (
email.toLowerCase() === legacyServiceAccountEmail() ||
email.toLowerCase() === deletedServiceAccountEmail()
) {
const email = person.email.toLowerCase();
if (email === legacyServiceAccountEmail() || email === deletedServiceAccountEmail()) {
return c.text('FORBIDDEN', 403);
}
// Find the account if possible.
if (!person.emailVerified) {
return refuse(SignInRefusal.UnverifiedEmail);
}
// An account is found by the IAM identity it is linked to, and by nothing else.
const existingAccount = await prisma.account.findFirst({
where: {
provider: clientOptions.id,
providerAccountId: sub,
providerAccountId: person.sub,
},
include: {
user: {
select: {
id: true,
disabled: true,
},
},
},
});
// Directly log in user if account already exists.
if (existingAccount) {
if (existingAccount.user.disabled) {
return refuse(SignInRefusal.Disabled);
}
await onAuthorize({ userId: existingAccount.user.id }, c);
return c.redirect(redirectPath, 302);
}
// An address is not an identity: an account that holds this address but was
// never linked to this IAM identity is not handed to whoever signs in with it.
const userWithSameEmail = await prisma.user.findFirst({
where: {
email: email,
email,
},
select: {
id: true,
emailVerified: true,
},
});
// Handle existing user but no account.
if (userWithSameEmail) {
await prisma.$transaction(async (tx) => {
await tx.account.create({
data: {
type: 'oauth',
provider: clientOptions.id,
providerAccountId: sub,
access_token: accessToken,
expires_at: Math.floor(accessTokenExpiresAt.getTime() / 1000),
token_type: 'Bearer',
id_token: idToken,
userId: userWithSameEmail.id,
},
});
return refuse(SignInRefusal.UnlinkedAccount);
}
// Log link event.
await tx.userSecurityAuditLog.create({
data: {
userId: userWithSameEmail.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK,
},
});
// A first sign-in makes an account only for someone who was asked in: an admin
// of the org this application serves, or a person an organisation invited at
// this verified address.
const invited =
administersAppOrg(person) ||
(await prisma.organisationMemberInvite.findFirst({
where: {
email,
status: OrganisationMemberInviteStatus.PENDING,
},
select: {
id: true,
},
})) !== null;
// If account already exists in an unverified state, remove the password to ensure
// they cannot sign in since we cannot confirm the password was set by the user.
if (!userWithSameEmail.emailVerified) {
await tx.user.update({
where: {
id: userWithSameEmail.id,
},
data: {
emailVerified: new Date(),
password: null,
// Todo: (RR7) Will need to update the "password" account after the migration.
},
});
}
});
await onAuthorize({ userId: userWithSameEmail.id }, c);
return c.redirect(redirectPath, 302);
if (!invited) {
return refuse(SignInRefusal.NotInvited);
}
// Handle new user.
const createdUser = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
email: email,
name: name,
email,
name: person.name,
emailVerified: new Date(),
},
});
@@ -128,7 +115,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
data: {
type: 'oauth',
provider: clientOptions.id,
providerAccountId: sub,
providerAccountId: person.sub,
access_token: accessToken,
expires_at: Math.floor(accessTokenExpiresAt.getTime() / 1000),
token_type: 'Bearer',
@@ -141,7 +128,6 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
});
await onCreateUserHook(createdUser).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
});
@@ -157,9 +143,12 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const { token_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
requiredScopes: clientOptions.scope,
});
const { token_endpoint, userinfo_endpoint } = await getOpenIdConfiguration(
clientOptions.wellKnownUrl,
{
requiredScopes: clientOptions.scope,
},
);
const oAuthClient = new OAuth2Client(
clientOptions.clientId,
@@ -204,40 +193,30 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
const idToken = tokens.idToken();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
const claims = decodeIdToken(idToken) as Record<string, unknown>;
const email = claims.email;
const name = claims.name;
const sub = claims.sub;
if (typeof email !== 'string') {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Missing email',
});
}
if (typeof name !== 'string') {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Missing name',
});
}
if (typeof sub !== 'string') {
if (typeof claims.sub !== 'string' || claims.sub === '') {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Missing sub claim',
});
}
if (claims.email_verified !== true && !clientOptions.bypassEmailVerification) {
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
message: 'Account email is not verified',
const response = await fetch(userinfo_endpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) {
throw new AppError(AuthenticationErrorCode.Unauthorized, {
message: `The identity provider refused the userinfo request: ${response.status}`,
});
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const info = (await response.json()) as Record<string, unknown>;
return {
email,
name,
sub,
person: readIamPerson(info, claims.sub),
accessToken,
accessTokenExpiresAt,
idToken,
@@ -1,99 +0,0 @@
import type { Context } from 'hono';
import { sendOrganisationAccountLinkConfirmationEmail } from '@hanzo/esign-lib/server-only/organisation/send-organisation-account-link-confirmation-email';
import { AppError } from '@hanzo/esign-lib/errors/app-error';
import { onCreateUserHook } from '@hanzo/esign-lib/server-only/user/create-user';
import { formatOrganisationLoginUrl } from '@hanzo/esign-lib/utils/organisation-authentication-portal';
import { prisma } from '@hanzo/esign-prisma';
import { AuthenticationErrorCode } from '../errors/error-codes';
import { onAuthorize } from './authorizer';
import { validateOauth } from './handle-oauth-callback-url';
import { getOrganisationAuthenticationPortalOptions } from './organisation-portal';
type HandleOAuthOrganisationCallbackUrlOptions = {
c: Context;
orgUrl: string;
};
export const handleOAuthOrganisationCallbackUrl = async (
options: HandleOAuthOrganisationCallbackUrlOptions,
) => {
const { c, orgUrl } = options;
const { organisation, clientOptions } = await getOrganisationAuthenticationPortalOptions({
type: 'url',
organisationUrl: orgUrl,
});
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken } = await validateOauth({
c,
clientOptions: {
...clientOptions,
bypassEmailVerification: true, // Bypass for organisation OIDC because we manually verify the email.
},
});
const allowedDomains = organisation.organisationAuthenticationPortal.allowedDomains;
if (allowedDomains.length > 0 && !allowedDomains.some((domain) => email.endsWith(`@${domain}`))) {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Email domain not allowed',
});
}
// Find the account if possible.
const existingAccount = await prisma.account.findFirst({
where: {
provider: clientOptions.id,
providerAccountId: sub,
},
include: {
user: true,
},
});
// Directly log in user if account already exists.
if (existingAccount) {
await onAuthorize({ userId: existingAccount.user.id }, c);
return c.redirect(`/o/${orgUrl}`, 302);
}
let userToLink = await prisma.user.findFirst({
where: {
email,
},
});
// Handle new user.
if (!userToLink) {
userToLink = await prisma.user.create({
data: {
email: email,
name: name,
emailVerified: null, // Do not verify email.
},
});
await onCreateUserHook(userToLink).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
});
}
await sendOrganisationAccountLinkConfirmationEmail({
type: userToLink.emailVerified ? 'link' : 'create',
userId: userToLink.id,
organisationId: organisation.id,
organisationName: organisation.name,
oauthConfig: {
accessToken,
idToken,
providerAccountId: sub,
expiresAt: Math.floor(accessTokenExpiresAt.getTime() / 1000),
},
});
return c.redirect(`${formatOrganisationLoginUrl(orgUrl)}?action=verification-required`, 302);
};
@@ -0,0 +1,79 @@
import { AppError } from '@hanzo/esign-lib/errors/app-error';
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,
};
};
@@ -3,6 +3,7 @@ import { z } from 'zod';
const ZOpenIdConfigurationSchema = z.object({
authorization_endpoint: z.string(),
token_endpoint: z.string(),
userinfo_endpoint: z.string(),
scopes_supported: z.array(z.string()).optional(),
});
@@ -1,83 +0,0 @@
import { SIGN_ENCRYPTION_KEY } from '@hanzo/esign-lib/constants/crypto';
import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
import { symmetricDecrypt } from '@hanzo/esign-lib/universal/crypto';
import { formatOrganisationCallbackUrl } from '@hanzo/esign-lib/utils/organisation-authentication-portal';
import { prisma } from '@hanzo/esign-prisma';
type GetOrganisationAuthenticationPortalOptions =
| {
type: 'url';
organisationUrl: string;
}
| {
type: 'id';
organisationId: string;
};
export const getOrganisationAuthenticationPortalOptions = async (
options: GetOrganisationAuthenticationPortalOptions,
) => {
const organisation = await prisma.organisation.findFirst({
where:
options.type === 'url'
? {
url: options.organisationUrl,
}
: {
id: options.organisationId,
},
include: {
organisationAuthenticationPortal: true,
groups: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
if (!organisation.organisationAuthenticationPortal.enabled) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Authentication portal is not enabled for this organisation',
});
}
const {
clientId,
clientSecret: encryptedClientSecret,
wellKnownUrl,
} = organisation.organisationAuthenticationPortal;
if (!clientId || !encryptedClientSecret || !wellKnownUrl) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Authentication portal is not configured for this organisation',
});
}
if (!SIGN_ENCRYPTION_KEY) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Encryption key is not set',
});
}
const clientSecret = Buffer.from(
symmetricDecrypt({ key: SIGN_ENCRYPTION_KEY, data: encryptedClientSecret }),
).toString('utf-8');
return {
organisation,
clientId,
clientSecret,
wellKnownUrl,
clientOptions: {
id: organisation.id,
scope: ['openid', 'email', 'profile'],
clientId,
clientSecret,
redirectUrl: formatOrganisationCallbackUrl(organisation.url),
wellKnownUrl,
},
};
};
@@ -1,28 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@hanzo/esign-lib/constants/app';
/**
* Handle an optional redirect path.
*/
export const handleRequestRedirect = (redirectUrl?: string) => {
if (!redirectUrl) {
return;
}
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
window.location.href = '/';
} else {
window.location.href = redirectUrl;
}
};
export const handleSignInRedirect = (redirectUrl: string = '/') => {
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
window.location.href = '/';
} else {
window.location.href = redirectUrl;
}
};
-55
View File
@@ -1,55 +0,0 @@
import { Hono } from 'hono';
import { AppError } from '@hanzo/esign-lib/errors/app-error';
import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url';
import type { HonoAuthContext } from '../types/context';
/**
* Have to create this route instead of bundling callback with oauth routes to provide
* backwards compatibility for self-hosters (legacy session-cookie name compatibility).
*/
export const callbackRoute = new Hono<HonoAuthContext>()
/**
* OIDC callback verification.
*/
.get('/oidc', async (c) => handleOAuthCallbackUrl({ c, clientOptions: OidcAuthOptions }))
/**
* Organisation OIDC callback verification.
*/
.get('/oidc/org/:orgUrl', async (c) => {
const orgUrl = c.req.param('orgUrl');
try {
return await handleOAuthOrganisationCallbackUrl({
c,
orgUrl,
});
} catch (err) {
console.error(err);
if (err instanceof Error) {
throw new AppError(err.name, {
message: err.message,
statusCode: 500,
});
}
throw err;
}
})
/**
* Google callback verification.
*/
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }))
/**
* Microsoft callback verification.
*/
.get('/microsoft', async (c) =>
handleOAuthCallbackUrl({ c, clientOptions: MicrosoftAuthOptions }),
);
@@ -1,561 +0,0 @@
import { sValidator } from '@hono/standard-validator';
import { compare } from '@node-rs/bcrypt';
import { UserSecurityAuditLogType } from '@prisma/client';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { DateTime } from 'luxon';
import { z } from 'zod';
import { EMAIL_VERIFICATION_STATE } from '@hanzo/esign-lib/constants/email';
import { AppError } from '@hanzo/esign-lib/errors/app-error';
import { jobsClient } from '@hanzo/esign-lib/jobs/client';
import { disableTwoFactorAuthentication } from '@hanzo/esign-lib/server-only/2fa/disable-2fa';
import { enableTwoFactorAuthentication } from '@hanzo/esign-lib/server-only/2fa/enable-2fa';
import { isTwoFactorAuthenticationEnabled } from '@hanzo/esign-lib/server-only/2fa/is-2fa-availble';
import { setupTwoFactorAuthentication } from '@hanzo/esign-lib/server-only/2fa/setup-2fa';
import { validateTwoFactorAuthentication } from '@hanzo/esign-lib/server-only/2fa/validate-2fa';
import { viewBackupCodes } from '@hanzo/esign-lib/server-only/2fa/view-backup-codes';
import { rateLimitResponse } from '@hanzo/esign-lib/server-only/rate-limit/rate-limit-middleware';
import {
forgotPasswordRateLimit,
loginRateLimit,
resendVerifyEmailRateLimit,
resetPasswordRateLimit,
signupRateLimit,
verifyEmailRateLimit,
} from '@hanzo/esign-lib/server-only/rate-limit/rate-limits';
import { createUser } from '@hanzo/esign-lib/server-only/user/create-user';
import { forgotPassword } from '@hanzo/esign-lib/server-only/user/forgot-password';
import { getMostRecentEmailVerificationToken } from '@hanzo/esign-lib/server-only/user/get-most-recent-email-verification-token';
import { getUserByResetToken } from '@hanzo/esign-lib/server-only/user/get-user-by-reset-token';
import { resetPassword } from '@hanzo/esign-lib/server-only/user/reset-password';
import { deletedServiceAccountEmail } from '@hanzo/esign-lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@hanzo/esign-lib/server-only/user/service-accounts/legacy-service-account';
import { updatePassword } from '@hanzo/esign-lib/server-only/user/update-password';
import { verifyEmail } from '@hanzo/esign-lib/server-only/user/verify-email';
import { env } from '@hanzo/esign-lib/utils/env';
import { prisma } from '@hanzo/esign-prisma';
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { invalidateSessions } from '../lib/session/session';
import { getCsrfCookie } from '../lib/session/session-cookies';
import { onAuthorize } from '../lib/utils/authorizer';
import { getSession } from '../lib/utils/get-session';
import type { HonoAuthContext } from '../types/context';
import {
ZForgotPasswordSchema,
ZResendVerifyEmailSchema,
ZResetPasswordSchema,
ZSignInSchema,
ZSignUpSchema,
ZUpdatePasswordSchema,
ZVerifyEmailSchema,
} from '../types/email-password';
export const emailPasswordRoute = new Hono<HonoAuthContext>()
/**
* Authorize endpoint.
*/
.post('/authorize', sValidator('json', ZSignInSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email, password, totpCode, backupCode, csrfToken } = c.req.valid('json');
const loginLimitResult = await loginRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const loginLimited = rateLimitResponse(c, loginLimitResult);
if (loginLimited) {
throw new HTTPException(429, {
res: loginLimited,
});
}
const csrfCookieToken = await getCsrfCookie(c);
// Todo: (RR7) Add logging here.
if (csrfToken !== csrfCookieToken || !csrfCookieToken) {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Invalid CSRF token',
});
}
if (
email.toLowerCase() === legacyServiceAccountEmail() ||
email.toLowerCase() === deletedServiceAccountEmail()
) {
return c.text('FORBIDDEN', 403);
}
const user = await prisma.user.findFirst({
where: {
email: email.toLowerCase(),
},
});
if (!user || !user.password) {
throw new AppError(AuthenticationErrorCode.InvalidCredentials, {
message: 'Invalid email or password',
});
}
const isPasswordsSame = await compare(password, user.password);
if (!isPasswordsSame) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_FAIL,
},
});
throw new AppError(AuthenticationErrorCode.InvalidCredentials, {
message: 'Invalid email or password',
});
}
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
if (is2faEnabled) {
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
if (!isValid) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
},
});
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode);
}
}
if (!user.emailVerified) {
const mostRecentToken = await getMostRecentEmailVerificationToken({
userId: user.id,
});
if (
!mostRecentToken ||
mostRecentToken.expires.valueOf() <= Date.now() ||
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
email: user.email,
},
});
}
throw new AppError('UNVERIFIED_EMAIL', {
message: 'Unverified email',
});
}
if (user.disabled) {
throw new AppError('ACCOUNT_DISABLED', {
message: 'Account disabled',
});
}
await onAuthorize({ userId: user.id }, c);
return c.text('', 201);
})
/**
* Signup endpoint.
*/
.post('/signup', sValidator('json', ZSignUpSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
throw new AppError('SIGNUP_DISABLED', {
message: 'Signups are disabled.',
});
}
const { name, email, password, signature } = c.req.valid('json');
const signupLimitResult = await signupRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
});
const signupLimited = rateLimitResponse(c, signupLimitResult);
if (signupLimited) {
throw new HTTPException(429, {
res: signupLimited,
});
}
const user = await createUser({ name, email, password, signature }).catch((err) => {
console.error(err);
throw err;
});
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
email: user.email,
},
});
return c.text('OK', 201);
})
/**
* Update password endpoint.
*/
.post('/update-password', sValidator('json', ZUpdatePasswordSchema), async (c) => {
const { password, currentPassword } = c.req.valid('json');
const requestMetadata = c.get('requestMetadata');
const { session, user } = await getSession(c);
await updatePassword({
userId: user.id,
password,
currentPassword,
requestMetadata,
});
const userSessionIds = await prisma.session
.findMany({
where: {
userId: user.id satisfies number, // Incase we pass undefined somehow.
id: {
not: session.id,
},
},
select: {
id: true,
},
})
.then((sessions) => sessions.map((s) => s.id));
if (userSessionIds.length > 0) {
await invalidateSessions({
userId: user.id,
sessionIds: userSessionIds,
metadata: requestMetadata,
isRevoke: true,
});
}
return c.text('OK', 201);
})
/**
* Verify email endpoint.
*/
.post('/verify-email', sValidator('json', ZVerifyEmailSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { token } = c.req.valid('json');
const verifyLimitResult = await verifyEmailRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
const verifyLimited = rateLimitResponse(c, verifyLimitResult);
if (verifyLimited) {
throw new HTTPException(429, {
res: verifyLimited,
});
}
const { state, userId } = await verifyEmail({ token });
// If email is verified, automatically authenticate user.
if (state === EMAIL_VERIFICATION_STATE.VERIFIED && userId !== null) {
await onAuthorize({ userId }, c);
}
return c.json({
state,
});
})
/**
* Resend verification email endpoint.
*/
.post('/resend-verify-email', sValidator('json', ZResendVerifyEmailSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email } = c.req.valid('json');
const resendLimitResult = await resendVerifyEmailRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const resendLimited = rateLimitResponse(c, resendLimitResult);
if (resendLimited) {
throw new HTTPException(429, {
res: resendLimited,
});
}
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
email,
},
});
return c.text('OK', 201);
})
/**
* Forgot password endpoint.
*/
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email } = c.req.valid('json');
const forgotLimitResult = await forgotPasswordRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const forgotLimited = rateLimitResponse(c, forgotLimitResult);
if (forgotLimited) {
throw new HTTPException(429, {
res: forgotLimited,
});
}
if (
email.toLowerCase() === legacyServiceAccountEmail() ||
email.toLowerCase() === deletedServiceAccountEmail()
) {
return c.text('FORBIDDEN', 403);
}
await forgotPassword({
email,
});
return c.text('OK', 201);
})
/**
* Reset password endpoint.
*/
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { token, password } = c.req.valid('json');
const resetLimitResult = await resetPasswordRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
const resetLimited = rateLimitResponse(c, resetLimitResult);
if (resetLimited) {
throw new HTTPException(429, {
res: resetLimited,
});
}
const user = await getUserByResetToken({ token });
if (
user.email.toLowerCase() === legacyServiceAccountEmail() ||
user.email.toLowerCase() === deletedServiceAccountEmail()
) {
return c.text('FORBIDDEN', 403);
}
const { userId } = await resetPassword({
token,
password,
requestMetadata,
});
// Invalidate all sessions after successful password reset
const userSessionIds = await prisma.session
.findMany({
where: {
userId: userId satisfies number, // Incase we pass undefined somehow.
},
select: {
id: true,
},
})
.then((sessions) => sessions.map((session) => session.id));
if (userSessionIds.length > 0) {
await invalidateSessions({
userId,
sessionIds: userSessionIds,
metadata: requestMetadata,
isRevoke: true,
});
}
return c.text('OK', 201);
})
/**
* Setup two factor authentication.
*/
.post('/2fa/setup', async (c) => {
const { user } = await getSession(c);
const result = await setupTwoFactorAuthentication({
user,
});
return c.json({
success: true,
secret: result.secret,
uri: result.uri,
});
})
/**
* Enable two factor authentication.
*/
.post(
'/2fa/enable',
sValidator(
'json',
z.object({
code: z.string(),
}),
),
async (c) => {
const requestMetadata = c.get('requestMetadata');
const { user: sessionUser } = await getSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { code } = c.req.valid('json');
const result = await enableTwoFactorAuthentication({
user,
code,
requestMetadata,
});
return c.json({
success: true,
recoveryCodes: result.recoveryCodes,
});
},
)
/**
* Disable two factor authentication.
*/
.post(
'/2fa/disable',
sValidator(
'json',
z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
}),
),
async (c) => {
const requestMetadata = c.get('requestMetadata');
const { user: sessionUser } = await getSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { totpCode, backupCode } = c.req.valid('json');
await disableTwoFactorAuthentication({
user,
totpCode,
backupCode,
requestMetadata,
});
return c.text('OK', 201);
},
)
/**
* View backup codes.
*/
.post(
'/2fa/view-recovery-codes',
sValidator(
'json',
z.object({
token: z.string(),
}),
),
async (c) => {
const { user: sessionUser } = await getSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { token } = c.req.valid('json');
const backupCodes = await viewBackupCodes({
user,
token,
});
return c.json({
success: true,
backupCodes,
});
},
);
+2 -59
View File
@@ -2,9 +2,8 @@ import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { GoogleAuthOptions, HanzoAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
import { HanzoAuthOptions } from '../config';
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
import type { HonoAuthContext } from '../types/context';
const ZOAuthAuthorizeSchema = z.object({
@@ -13,33 +12,7 @@ const ZOAuthAuthorizeSchema = z.object({
export const oauthRoute = new Hono<HonoAuthContext>()
/**
* Google authorize endpoint.
*/
.post('/authorize/google', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
const { redirectPath } = c.req.valid('json');
return handleOAuthAuthorizeUrl({
c,
clientOptions: GoogleAuthOptions,
redirectPath,
});
})
/**
* Microsoft authorize endpoint.
*/
.post('/authorize/microsoft', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
const { redirectPath } = c.req.valid('json');
return handleOAuthAuthorizeUrl({
c,
clientOptions: MicrosoftAuthOptions,
redirectPath,
});
})
/**
* Hanzo IAM authorize endpoint.
* Hanzo IAM authorize endpoint — the one way to sign in.
*/
.post('/authorize/hanzo', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
const { redirectPath } = c.req.valid('json');
@@ -49,34 +22,4 @@ export const oauthRoute = new Hono<HonoAuthContext>()
clientOptions: HanzoAuthOptions,
redirectPath,
});
})
/**
* OIDC authorize endpoint.
*/
.post('/authorize/oidc', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
const { redirectPath } = c.req.valid('json');
return handleOAuthAuthorizeUrl({
c,
clientOptions: OidcAuthOptions,
redirectPath,
});
})
/**
* Organisation OIDC authorize endpoint.
*/
.post('/authorize/oidc/org/:orgUrl', async (c) => {
const orgUrl = c.req.param('orgUrl');
const { clientOptions } = await getOrganisationAuthenticationPortalOptions({
type: 'url',
organisationUrl: orgUrl,
});
return await handleOAuthAuthorizeUrl({
c,
clientOptions,
prompt: 'select_account',
});
});
-146
View File
@@ -1,146 +0,0 @@
import { sValidator } from '@hono/standard-validator';
import { UserSecurityAuditLogType } from '@prisma/client';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
import { rateLimitResponse } from '@hanzo/esign-lib/server-only/rate-limit/rate-limit-middleware';
import { passkeyRateLimit } from '@hanzo/esign-lib/server-only/rate-limit/rate-limits';
import { deletedServiceAccountEmail } from '@hanzo/esign-lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@hanzo/esign-lib/server-only/user/service-accounts/legacy-service-account';
import type { TAuthenticationResponseJSONSchema } from '@hanzo/esign-lib/types/webauthn';
import { ZAuthenticationResponseJSONSchema } from '@hanzo/esign-lib/types/webauthn';
import { getAuthenticatorOptions } from '@hanzo/esign-lib/utils/authenticator';
import { prisma } from '@hanzo/esign-prisma';
import { onAuthorize } from '../lib/utils/authorizer';
import type { HonoAuthContext } from '../types/context';
import { ZPasskeyAuthorizeSchema } from '../types/passkey';
export const passkeyRoute = new Hono<HonoAuthContext>()
/**
* Authorize endpoint.
*/
.post('/authorize', sValidator('json', ZPasskeyAuthorizeSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const passkeyLimitResult = await passkeyRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
});
const passkeyLimited = rateLimitResponse(c, passkeyLimitResult);
if (passkeyLimited) {
throw new HTTPException(429, {
res: passkeyLimited,
});
}
const { csrfToken, credential } = c.req.valid('json');
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
let requestBodyCrediential: TAuthenticationResponseJSONSchema;
try {
const parsedBodyCredential = JSON.parse(credential);
requestBodyCrediential = ZAuthenticationResponseJSONSchema.parse(parsedBodyCredential);
} catch {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
const challengeToken = await prisma.anonymousVerificationToken
.delete({
where: {
id: csrfToken,
},
})
.catch(() => null);
if (!challengeToken) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
if (challengeToken.expiresAt < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE);
}
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(requestBodyCrediential.id, 'base64'),
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
emailVerified: true,
},
},
},
});
if (!passkey) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const user = passkey.user;
if (
user.email.toLowerCase() === legacyServiceAccountEmail() ||
user.email.toLowerCase() === deletedServiceAccountEmail()
) {
return c.text('FORBIDDEN', 403);
}
const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({
response: requestBodyCrediential,
expectedChallenge: challengeToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
credential: {
id: isoBase64URL.fromBuffer(passkey.credentialId),
publicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null);
if (!verification?.verified) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
},
});
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
await prisma.passkey.update({
where: {
id: passkey.id,
},
data: {
lastUsedAt: new Date(),
counter: verification.authenticationInfo.newCounter,
},
});
await onAuthorize({ userId: user.id }, c);
return c.json(
{
url: '/',
},
200,
);
});
@@ -1,74 +0,0 @@
import { z } from 'zod';
export const ZCurrentPasswordSchema = z
.string()
.min(6, { message: 'Must be at least 6 characters in length' })
.max(72);
export const ZSignInSchema = z.object({
email: z.string().email().min(1),
password: ZCurrentPasswordSchema,
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
csrfToken: z.string().trim(),
});
export type TSignInSchema = z.infer<typeof ZSignInSchema>;
export const ZPasswordSchema = z
.string()
.min(8, { message: 'Must be at least 8 characters in length' })
.max(72, { message: 'Cannot be more than 72 characters in length' })
.refine((value) => value.length > 25 || /[A-Z]/.test(value), {
message: 'One uppercase character',
})
.refine((value) => value.length > 25 || /[a-z]/.test(value), {
message: 'One lowercase character',
})
.refine((value) => value.length > 25 || /\d/.test(value), {
message: 'One number',
})
.refine((value) => value.length > 25 || /[`~<>?,./!@#$%^&*()\-_"'+=|{}[\];:\\]/.test(value), {
message: 'One special character is required',
});
export const ZSignUpSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: ZPasswordSchema,
signature: z.string().nullish(),
});
export type TSignUpSchema = z.infer<typeof ZSignUpSchema>;
export const ZForgotPasswordSchema = z.object({
email: z.string().email().min(1),
});
export type TForgotPasswordSchema = z.infer<typeof ZForgotPasswordSchema>;
export const ZResetPasswordSchema = z.object({
password: ZPasswordSchema,
token: z.string().min(1),
});
export type TResetPasswordSchema = z.infer<typeof ZResetPasswordSchema>;
export const ZVerifyEmailSchema = z.object({
token: z.string().min(1),
});
export type TVerifyEmailSchema = z.infer<typeof ZVerifyEmailSchema>;
export const ZResendVerifyEmailSchema = z.object({
email: z.string().email().min(1),
});
export type TResendVerifyEmailSchema = z.infer<typeof ZResendVerifyEmailSchema>;
export const ZUpdatePasswordSchema = z.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
});
export type TUpdatePasswordSchema = z.infer<typeof ZUpdatePasswordSchema>;
-8
View File
@@ -1,8 +0,0 @@
import { z } from 'zod';
export const ZPasskeyAuthorizeSchema = z.object({
csrfToken: z.string().min(1),
credential: z.string().min(1),
});
export type TPasskeyAuthorizeSchema = z.infer<typeof ZPasskeyAuthorizeSchema>;
@@ -1,47 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export type TemplateConfirmationEmailProps = {
confirmationLink: string;
assetBaseUrl: string;
};
export const TemplateConfirmationEmail = ({
confirmationLink,
assetBaseUrl,
}: TemplateConfirmationEmailProps) => {
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold text-primary">
<Trans>Welcome to Hanzo Sign!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>
Before you get started, please confirm your email address by clicking the button below:
</Trans>
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-sign-500 px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={confirmationLink}
>
<Trans>Confirm email</Trans>
</Button>
<Text className="mt-8 text-center text-sm italic text-slate-400">
<Trans>
You can also copy and paste this link into your browser: {confirmationLink} (link
expires in 1 hour)
</Trans>
</Text>
</Section>
</Section>
</>
);
};
@@ -1,8 +1,6 @@
import { Trans } from '@lingui/react/macro';
import { env } from '@hanzo/esign-lib/utils/env';
import { Button, Column, Img, Link, Section, Text } from '../components';
import { Column, Img, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentSelfSignedProps {
@@ -14,10 +12,6 @@ export const TemplateDocumentSelfSigned = ({
documentName,
assetBaseUrl,
}: TemplateDocumentSelfSignedProps) => {
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
const signUpUrl = `${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
@@ -42,44 +36,6 @@ export const TemplateDocumentSelfSigned = ({
<Text className="mb-0 mt-6 text-center text-lg font-semibold text-primary">
<Trans>You have signed “{documentName}”</Trans>
</Text>
<Text className="mx-auto mb-6 mt-1 max-w-[80%] text-center text-base text-slate-400">
<Trans>
Create a{' '}
<Link
href={signUpUrl}
target="_blank"
className="whitespace-nowrap text-sign-700 hover:text-sign-600"
>
free account
</Link>{' '}
to access your signed documents at any time.
</Trans>
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
href={signUpUrl}
className="mr-4 rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
>
<Img
src={getAssetUrl('/static/user-plus.png')}
className="mb-0.5 mr-2 inline h-5 w-5 align-middle"
/>
<Trans>Create account</Trans>
</Button>
<Button
className="rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href="https://esign.hanzo.ai/pricing"
>
<Img
src={getAssetUrl('/static/review.png')}
className="mb-0.5 mr-2 inline h-5 w-5 align-middle"
/>
<Trans>View plans</Trans>
</Button>
</Section>
</Section>
</>
);
@@ -1,41 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export type TemplateForgotPasswordProps = {
resetPasswordLink: string;
assetBaseUrl: string;
};
export const TemplateForgotPassword = ({
resetPasswordLink,
assetBaseUrl,
}: TemplateForgotPasswordProps) => {
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
<Trans>Forgot your password?</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>That's okay, it happens! Click the button below to reset your password.</Trans>
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-sign-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={resetPasswordLink}
>
<Trans>Reset Password</Trans>
</Button>
</Section>
</Section>
</>
);
};
export default TemplateForgotPassword;
@@ -1,43 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { env } from '@hanzo/esign-lib/utils/env';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateResetPasswordProps {
userName: string;
userEmail: string;
assetBaseUrl: string;
}
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
<Trans>Password updated!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>Your password has been updated.</Trans>
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-sign-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={`${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
>
<Trans>Sign In</Trans>
</Button>
</Section>
</Section>
</>
);
};
export default TemplateResetPassword;
@@ -1,58 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
import { useBranding } from '../providers/branding';
import type { TemplateConfirmationEmailProps } from '../template-components/template-confirmation-email';
import { TemplateConfirmationEmail } from '../template-components/template-confirmation-email';
import { TemplateFooter } from '../template-components/template-footer';
export const ConfirmEmailTemplate = ({
confirmationLink,
assetBaseUrl = 'http://localhost:3002',
}: TemplateConfirmationEmailProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`Please confirm your email address`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
) : (
<Img
src={getAssetUrl('/static/logo.png')}
alt="Hanzo Sign Logo"
className="mb-4 h-6"
/>
)}
<TemplateConfirmationEmail
confirmationLink={confirmationLink}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<div className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default ConfirmEmailTemplate;
@@ -1,62 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import type { TemplateForgotPasswordProps } from '../template-components/template-forgot-password';
import { TemplateForgotPassword } from '../template-components/template-forgot-password';
export type ForgotPasswordTemplateProps = Partial<TemplateForgotPasswordProps>;
export const ForgotPasswordTemplate = ({
resetPasswordLink = 'https://esign.hanzo.ai',
assetBaseUrl = 'http://localhost:3002',
}: ForgotPasswordTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`Password Reset Requested`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
) : (
<Img
src={getAssetUrl('/static/logo.png')}
alt="Hanzo Sign Logo"
className="mb-4 h-6"
/>
)}
<TemplateForgotPassword
resetPasswordLink={resetPasswordLink}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<div className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default ForgotPasswordTemplate;
@@ -1,145 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Img,
Preview,
Section,
Text,
} from '../components';
import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
type OrganisationAccountLinkConfirmationTemplateProps = {
type: 'create' | 'link';
confirmationLink: string;
organisationName: string;
assetBaseUrl: string;
};
export const OrganisationAccountLinkConfirmationTemplate = ({
type = 'link',
confirmationLink = '<CONFIRMATION_LINK>',
organisationName = '<ORGANISATION_NAME>',
assetBaseUrl = 'http://localhost:3002',
}: OrganisationAccountLinkConfirmationTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText =
type === 'create'
? msg`A request has been made to create an account for you`
: msg`A request has been made to link your Hanzo Sign account`;
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
) : (
<TemplateImage
assetBaseUrl={assetBaseUrl}
className="mb-4 h-6 p-2"
staticAsset="logo.png"
/>
)}
<Section>
<TemplateImage
className="mx-auto h-12 w-12"
assetBaseUrl={assetBaseUrl}
staticAsset="building-2.png"
/>
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center text-lg font-medium text-black">
{type === 'create' ? (
<Trans>Account creation request</Trans>
) : (
<Trans>Link your Hanzo Sign account</Trans>
)}
</Text>
<Text className="text-center text-base">
{type === 'create' ? (
<Trans>
<span className="font-bold">{organisationName}</span> has requested to create an
account on your behalf.
</Trans>
) : (
<Trans>
<span className="font-bold">{organisationName}</span> has requested to link your
current Hanzo Sign account to their organisation.
</Trans>
)}
</Text>
{/* Placeholder text if we want to have the warning in the email as well. */}
{/* <Section className="mt-6">
<Text className="my-0 text-sm">
<Trans>
By accepting this request, you will be granting{' '}
<strong>{organisationName}</strong> full access to:
</Trans>
</Text>
<ul className="mb-0 mt-2">
<li className="text-sm">
<Trans>Your account, and everything associated with it</Trans>
</li>
<li className="mt-1 text-sm">
<Trans>Something something something</Trans>
</li>
<li className="mt-1 text-sm">
<Trans>Something something something</Trans>
</li>
</ul>
<Text className="mt-2 text-sm">
<Trans>
You can unlink your account at any time in your security settings on Hanzo Sign{' '}
<Link href={`${assetBaseUrl}/settings/security/linked-accounts`}>here.</Link>
</Trans>
</Text>
</Section> */}
<Section className="mb-6 mt-8 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-sign-500 px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={confirmationLink}
>
<Trans>Review request</Trans>
</Button>
</Section>
</Section>
<Text className="text-center text-xs text-slate-500">
<Trans>Link expires in 30 minutes.</Trans>
</Text>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default OrganisationAccountLinkConfirmationTemplate;
@@ -1,95 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Body, Container, Head, Hr, Html, Img, Link, Preview, Section, Text } from '../components';
import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import type { TemplateResetPasswordProps } from '../template-components/template-reset-password';
import { TemplateResetPassword } from '../template-components/template-reset-password';
export type ResetPasswordTemplateProps = Partial<TemplateResetPasswordProps>;
export const ResetPasswordTemplate = ({
userName = 'Lucas Smith',
userEmail = '[email protected]',
assetBaseUrl = 'http://localhost:3002',
}: ResetPasswordTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`Password Reset Successful`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
) : (
<Img
src={getAssetUrl('/static/logo.png')}
alt="Hanzo Sign Logo"
className="mb-4 h-6"
/>
)}
<TemplateResetPassword
userName={userName}
userEmail={userEmail}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<Container className="mx-auto mt-12 max-w-xl">
<Section>
<Text className="my-4 text-base font-semibold">
<Trans>
Hi, {userName}{' '}
<Link className="font-normal text-slate-400" href={`mailto:${userEmail}`}>
({userEmail})
</Link>
</Trans>
</Text>
<Text className="mt-2 text-base text-slate-400">
<Trans>
We've changed your password as you asked. You can now sign in with your new
password.
</Trans>
</Text>
<Text className="mt-2 text-base text-slate-400">
<Trans>
Didn't request a password change? We are here to help you secure your account,
just{' '}
<Link className="font-normal text-sign-700" href="mailto:[email protected]">
contact us
</Link>
.
</Trans>
</Text>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default ResetPasswordTemplate;
+36
View File
@@ -0,0 +1,36 @@
/**
* An organisation invitation joins the person it was sent to, and nobody else.
*
* Holding the link proves the invitation was received, not who received it, so
* acceptance binds the signed-in person only when their verified address is the
* invited one.
*
* Run: npx tsx --test packages/lib/__tests__/invitation.test.ts
*/
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { isInvitedPerson } from '../server-only/organisation/is-invited-person';
describe('isInvitedPerson', () => {
const verified = new Date('2026-10-16T00:00:00Z');
test('the invited address, verified', () => {
assert.equal(isInvitedPerson({ email: '[email protected]', emailVerified: verified }, '[email protected]'), true);
});
test('case does not make a different person', () => {
assert.equal(isInvitedPerson({ email: '[email protected]', emailVerified: verified }, '[email protected]'), true);
});
test('a different address is refused', () => {
assert.equal(
isInvitedPerson({ email: '[email protected]', emailVerified: verified }, '[email protected]'),
false,
);
});
test('the invited address, never verified, is refused', () => {
assert.equal(isInvitedPerson({ email: '[email protected]', emailVerified: null }, '[email protected]'), false);
});
});
+18 -16
View File
@@ -10,24 +10,26 @@ export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
OIDC: 'OIDC',
};
export const IS_GOOGLE_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') && env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET'),
);
export const IS_MICROSOFT_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') && env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET'),
);
export const IS_OIDC_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_OIDC_WELL_KNOWN') &&
env('NEXT_PRIVATE_OIDC_CLIENT_ID') &&
env('NEXT_PRIVATE_OIDC_CLIENT_SECRET'),
);
export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
export const IS_IAM_SSO_ENABLED = Boolean(env('IAM_CLIENT_ID'));
/**
* Why a Hanzo IAM sign-in was turned away. The callback redirects to
* `/signin?error=<reason>` and the sign-in page says it in a sentence, so the
* person knows what to do next.
*/
export const SignInRefusal = {
/** IAM has not proved the address the account would be named after. */
UnverifiedEmail: 'unverified-email',
/** An account holds this address but was never linked to an IAM sign-in. */
UnlinkedAccount: 'unlinked-account',
/** Nobody invited this person and they do not administer the org. */
NotInvited: 'not-invited',
/** An admin disabled the account. */
Disabled: 'disabled',
} as const;
export type SignInRefusal = (typeof SignInRefusal)[keyof typeof SignInRefusal];
export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
ACCOUNT_SSO_LINK: 'Linked account to SSO',
ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO',
-2
View File
@@ -127,6 +127,4 @@ export const isOrganisationUrlProtected = (url: string) => {
return PROTECTED_ORGANISATION_URLS.some((protectedUrl) => url.startsWith(`/${protectedUrl}`));
};
export const ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER = 'organisation-account-link';
export const ORGANISATION_USER_ACCOUNT_TYPE = 'org-oidc';
-4
View File
@@ -1,10 +1,8 @@
import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
@@ -25,13 +23,11 @@ import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-e
*/
export const jobsClient = new JobClient([
SEND_SIGNING_EMAIL_JOB_DEFINITION,
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
@@ -1,9 +0,0 @@
import { sendConfirmationToken } from '../../../server-only/user/send-confirmation-token';
import type { TSendConfirmationEmailJobDefinition } from './send-confirmation-email';
export const run = async ({ payload }: { payload: TSendConfirmationEmailJobDefinition }) => {
await sendConfirmationToken({
email: payload.email,
force: payload.force,
});
};
@@ -1,32 +0,0 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID = 'send.signup.confirmation.email';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
email: z.string().email(),
force: z.boolean().optional(),
});
export type TSendConfirmationEmailJobDefinition = z.infer<
typeof SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION = {
id: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
name: 'Send Confirmation Email',
version: '1.0.0',
trigger: {
name: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
schema: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload }) => {
const handler = await import('./send-confirmation-email.handler');
await handler.run({ payload });
},
} as const satisfies JobDefinition<
typeof SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
TSendConfirmationEmailJobDefinition
>;
@@ -1,12 +0,0 @@
import { sendResetPassword } from '../../../server-only/auth/send-reset-password';
import type { TSendPasswordResetSuccessEmailJobDefinition } from './send-password-reset-success-email';
export const run = async ({
payload,
}: {
payload: TSendPasswordResetSuccessEmailJobDefinition;
}) => {
await sendResetPassword({
userId: payload.userId,
});
};
@@ -1,31 +0,0 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID = 'send.password.reset.success.email';
const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
});
export type TSendPasswordResetSuccessEmailJobDefinition = z.infer<
typeof SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION = {
id: SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID,
name: 'Send Password Reset Email',
version: '1.0.0',
trigger: {
name: SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID,
schema: SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload }) => {
const handler = await import('./send-password-reset-success-email.handler');
await handler.run({ payload });
},
} as const satisfies JobDefinition<
typeof SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID,
TSendPasswordResetSuccessEmailJobDefinition
>;
@@ -1,41 +0,0 @@
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import { DateTime } from 'luxon';
import { prisma } from '@hanzo/esign-prisma';
import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeySigninOptions = {
sessionId: string;
};
export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => {
const { rpId, timeout } = getAuthenticatorOptions();
const options = await generateAuthenticationOptions({
rpID: rpId,
userVerification: 'preferred',
timeout,
});
const { challenge } = options;
await prisma.anonymousVerificationToken.upsert({
where: {
id: sessionId,
},
update: {
token: challenge,
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
createdAt: new Date(),
},
create: {
id: sessionId,
token: challenge,
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
createdAt: new Date(),
},
});
return options;
};
@@ -1,70 +0,0 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@hanzo/esign-email/mailer';
import { ConfirmEmailTemplate } from '@hanzo/esign-email/templates/confirm-email';
import { prisma } from '@hanzo/esign-prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
SIGN_INTERNAL_EMAIL,
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
} from '../../constants/email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendConfirmationEmailProps {
userId: number;
}
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
verificationTokens: {
where: {
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
const [verificationToken] = user.verificationTokens;
if (!verificationToken?.token) {
throw new Error('Verification token not found for the user');
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
assetBaseUrl,
confirmationLink,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(confirmationTemplate),
renderEmailWithI18N(confirmationTemplate, { plainText: true }),
]);
const i18n = await getI18nInstance();
return mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: SIGN_INTERNAL_EMAIL,
subject: i18n._(msg`Please confirm your email`),
html,
text,
});
};
@@ -1,66 +0,0 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@hanzo/esign-email/mailer';
import { ForgotPasswordTemplate } from '@hanzo/esign-email/templates/forgot-password';
import { prisma } from '@hanzo/esign-prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendForgotPasswordOptions {
userId: number;
}
export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
passwordResetTokens: {
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
if (!user) {
throw new Error('User not found');
}
const token = user.passwordResetTokens[0].token;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const resetPasswordLink = `${NEXT_PUBLIC_WEBAPP_URL()}/reset-password/${token}`;
const template = createElement(ForgotPasswordTemplate, {
assetBaseUrl,
resetPasswordLink,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
const i18n = await getI18nInstance();
return await mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Hanzo Sign',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || '[email protected]',
},
subject: i18n._(msg`Forgot Password?`),
html,
text,
});
};
@@ -1,48 +0,0 @@
import { createElement } from 'react';
import { mailer } from '@hanzo/esign-email/mailer';
import { ResetPasswordTemplate } from '@hanzo/esign-email/templates/reset-password';
import { prisma } from '@hanzo/esign-prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendResetPasswordOptions {
userId: number;
}
export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(ResetPasswordTemplate, {
assetBaseUrl,
userEmail: user.email,
userName: user.name || '',
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
return await mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Hanzo Sign',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || '[email protected]',
},
subject: 'Password Reset Success!',
html,
text,
});
};
@@ -6,13 +6,25 @@ import { prisma } from '@hanzo/esign-prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { generateDatabaseId } from '../../universal/id';
import { isInvitedPerson } from './is-invited-person';
export type AcceptOrganisationInvitationOptions = {
token: string;
/** The signed-in person accepting. The invitation must name them. */
userId: number;
};
/**
* Accept an organisation invitation as the signed-in person it was sent to.
*
* The token proves the invitation was received; it does not say who received
* it. So the invitation joins the person holding the session, and only when that
* person's verified address is the address it was sent to. Holding the link is
* never enough to put somebody else into the organisation.
*/
export const acceptOrganisationInvitation = async ({
token,
userId,
}: AcceptOrganisationInvitationOptions) => {
const organisationMemberInvite = await prisma.organisationMemberInvite.findFirst({
where: {
@@ -40,18 +52,24 @@ export const acceptOrganisationInvitation = async ({
const user = await prisma.user.findFirst({
where: {
// Emails are stored lowercased (see create-user.ts); SQLite has no
// case-insensitive filter `mode`, so normalise the invite email instead.
email: organisationMemberInvite.email.toLowerCase(),
id: userId,
},
select: {
id: true,
email: true,
emailVerified: true,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User must exist to accept an organisation invitation',
message: 'Sign in to accept an organisation invitation',
});
}
if (!isInvitedPerson(user, organisationMemberInvite.email)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'This invitation was sent to a different address',
});
}
@@ -0,0 +1,12 @@
/**
* Whether user is the person an invitation sent to invitedEmail names: their
* address is verified and is that address. Emails are stored lowercased (see
* the invite schema), and SQLite has no case-insensitive filter, so both sides
* are compared lowercased.
*/
export const isInvitedPerson = (
user: { email: string; emailVerified: Date | null },
invitedEmail: string,
): boolean => {
return user.emailVerified !== null && user.email.toLowerCase() === invitedEmail.toLowerCase();
};
@@ -1,157 +0,0 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import { getOrganisationAuthenticationPortalOptions } from '@hanzo/esign-auth/server/lib/utils/organisation-portal';
import { prisma } from '@hanzo/esign-prisma';
import {
ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
ORGANISATION_USER_ACCOUNT_TYPE,
} from '../../constants/organisations';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { ZOrganisationAccountLinkMetadataSchema } from '../../types/organisation';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { addUserToOrganisation } from './accept-organisation-invitation';
export interface LinkOrganisationAccountOptions {
token: string;
requestMeta: RequestMetadata;
}
export const linkOrganisationAccount = async ({
token,
requestMeta,
}: LinkOrganisationAccountOptions) => {
// Delete the token since it contains unnecessary sensitive data.
const verificationToken = await prisma.verificationToken.delete({
where: {
token,
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
},
include: {
user: {
select: {
id: true,
emailVerified: true,
accounts: {
select: {
provider: true,
providerAccountId: true,
},
},
},
},
},
});
if (!verificationToken) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Verification token not found, used or expired',
});
}
if (verificationToken.completed) {
throw new AppError('ALREADY_USED');
}
if (verificationToken.expires < new Date()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Verification token not found, used or expired',
});
}
const tokenMetadata = ZOrganisationAccountLinkMetadataSchema.safeParse(
verificationToken.metadata,
);
if (!tokenMetadata.success) {
console.error('Invalid token metadata', tokenMetadata.error);
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Verification token not found, used or expired',
});
}
const user = verificationToken.user;
const { clientOptions, organisation } = await getOrganisationAuthenticationPortalOptions({
type: 'id',
organisationId: tokenMetadata.data.organisationId,
});
const organisationMember = await prisma.organisationMember.findFirst({
where: {
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
},
});
const oauthConfig = tokenMetadata.data.oauthConfig;
const userAlreadyLinked = user.accounts.find(
(account) =>
account.provider === clientOptions.id &&
account.providerAccountId === oauthConfig.providerAccountId,
);
if (organisationMember && userAlreadyLinked) {
return;
}
await prisma.$transaction(
async (tx) => {
// Link the user if not linked yet.
if (!userAlreadyLinked) {
await tx.account.create({
data: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: clientOptions.id,
providerAccountId: oauthConfig.providerAccountId,
access_token: oauthConfig.accessToken,
expires_at: oauthConfig.expiresAt,
token_type: 'Bearer',
id_token: oauthConfig.idToken,
userId: user.id,
},
});
// Log link event.
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
},
});
// If account already exists in an unverified state, remove the password to ensure
// they cannot sign in using that method since we cannot confirm the password
// was set by the user.
if (!user.emailVerified) {
await tx.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
password: null,
// Todo: (RR7) Will need to update the "password" account after the migration.
},
});
}
}
},
{ timeout: 30_000 },
);
// Membership carries its own transaction, so it runs once the link above has
// committed and released the write lock.
if (!organisationMember) {
await addUserToOrganisation({
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
organisationGroups: organisation.groups,
organisationMemberRole: organisation.organisationAuthenticationPortal.defaultOrganisationRole,
});
}
};
@@ -1,120 +0,0 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import crypto from 'crypto';
import { DateTime } from 'luxon';
import { mailer } from '@hanzo/esign-email/mailer';
import { OrganisationAccountLinkConfirmationTemplate } from '@hanzo/esign-email/templates/organisation-account-link-confirmation';
import { prisma } from '@hanzo/esign-prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { SIGN_INTERNAL_EMAIL } from '../../constants/email';
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/organisations';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TOrganisationAccountLinkMetadata } from '../../types/organisation';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
export type SendOrganisationAccountLinkConfirmationEmailProps = TOrganisationAccountLinkMetadata & {
organisationName: string;
};
export const sendOrganisationAccountLinkConfirmationEmail = async ({
type,
userId,
organisationId,
organisationName,
oauthConfig,
}: SendOrganisationAccountLinkConfirmationEmailProps) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
include: {
verificationTokens: {
where: {
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
},
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const [previousVerificationToken] = user.verificationTokens;
// If we've sent a token in the last 5 minutes, don't send another one
if (
previousVerificationToken?.createdAt &&
DateTime.fromJSDate(previousVerificationToken.createdAt).diffNow('minutes').minutes > -5
) {
return;
}
const token = crypto.randomBytes(20).toString('hex');
const createdToken = await prisma.verificationToken.create({
data: {
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
token,
expires: DateTime.now().plus({ minutes: 30 }).toJSDate(),
metadata: {
type,
userId,
organisationId,
oauthConfig,
} satisfies TOrganisationAccountLinkMetadata,
userId,
},
});
const { emailLanguage } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId,
},
meta: null,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const confirmationLink = `${assetBaseUrl}/organisation/sso/confirmation/${createdToken.token}`;
const confirmationTemplate = createElement(OrganisationAccountLinkConfirmationTemplate, {
type,
assetBaseUrl,
confirmationLink,
organisationName,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage }),
renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
return mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: SIGN_INTERNAL_EMAIL,
subject:
type === 'create'
? i18n._(msg`Account creation request`)
: i18n._(msg`Account linking request`),
html,
text,
});
};
@@ -2,26 +2,6 @@ import { createRateLimit } from './rate-limit';
// ---- Auth (Tier 1 - Critical, sends emails) ----
export const signupRateLimit = createRateLimit({
action: 'auth.signup',
max: 10,
window: '1h',
});
export const forgotPasswordRateLimit = createRateLimit({
action: 'auth.forgot-password',
max: 3,
globalMax: 20,
window: '1h',
});
export const resendVerifyEmailRateLimit = createRateLimit({
action: 'auth.resend-verify-email',
max: 3,
globalMax: 20,
window: '1h',
});
export const request2FAEmailRateLimit = createRateLimit({
action: 'auth.request-2fa-email',
max: 5,
@@ -29,43 +9,6 @@ export const request2FAEmailRateLimit = createRateLimit({
window: '15m',
});
// ---- Auth (Tier 2 - Unauthenticated) ----
export const loginRateLimit = createRateLimit({
action: 'auth.login',
max: 10,
globalMax: 50,
window: '15m',
});
export const resetPasswordRateLimit = createRateLimit({
action: 'auth.reset-password',
max: 5,
globalMax: 20,
window: '1h',
});
export const verifyEmailRateLimit = createRateLimit({
action: 'auth.verify-email',
max: 5,
globalMax: 20,
window: '15m',
});
export const passkeyRateLimit = createRateLimit({
action: 'auth.passkey',
max: 10,
globalMax: 50,
window: '15m',
});
export const linkOrgAccountRateLimit = createRateLimit({
action: 'auth.link-org-account',
max: 5,
globalMax: 20,
window: '1h',
});
// ---- API (Tier 4 - Standard) ----
export const apiV1RateLimit = createRateLimit({
+1 -59
View File
@@ -1,67 +1,9 @@
import { hash } from '@node-rs/bcrypt';
import type { User } from '@prisma/client';
import { prisma } from '@hanzo/esign-prisma';
import { SALT_ROUNDS } from '../../constants/auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { createPersonalOrganisation } from '../organisation/create-organisation';
export interface CreateUserOptions {
name: string;
email: string;
password: string;
signature?: string | null;
}
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
const hashedPassword = await hash(password, SALT_ROUNDS);
const userExists = await prisma.user.findFirst({
where: {
email: email.toLowerCase(),
},
});
if (userExists) {
throw new AppError(AppErrorCode.ALREADY_EXISTS);
}
const user = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
name,
email: email.toLowerCase(),
password: hashedPassword, // Todo: (RR7) Drop password.
signature,
},
});
// Todo: (RR7) Migrate to use this after RR7.
// await tx.account.create({
// data: {
// userId: user.id,
// type: 'emailPassword', // Todo: (RR7)
// provider: 'HANZO_SIGN', // Todo: (RR7) Enums
// providerAccountId: user.id.toString(),
// password: hashedPassword,
// },
// });
return user;
});
// Not used at the moment, uncomment if required.
await onCreateUserHook(user).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
});
return user;
};
/**
* Should be run after a user is created, example during email password signup or google sign in.
* Should be run after a user is created, on their first Hanzo IAM sign-in.
*
* @returns User
*/
@@ -1,51 +0,0 @@
import crypto from 'crypto';
import { prisma } from '@hanzo/esign-prisma';
import { ONE_DAY } from '../../constants/time';
import { sendForgotPassword } from '../auth/send-forgot-password';
export const forgotPassword = async ({ email }: { email: string }) => {
const user = await prisma.user.findFirst({
where: {
// Emails are stored lowercased (see create-user.ts); normalise the lookup
// to match. SQLite has no case-insensitive filter `mode`.
email: email.toLowerCase(),
},
});
if (!user) {
return;
}
// Find a token that was created in the last hour and hasn't expired
// const existingToken = await prisma.passwordResetToken.findFirst({
// where: {
// userId: user.id,
// expiry: {
// gt: new Date(),
// },
// createdAt: {
// gt: new Date(Date.now() - ONE_HOUR),
// },
// },
// });
// if (existingToken) {
// return;
// }
const token = crypto.randomBytes(18).toString('hex');
await prisma.passwordResetToken.create({
data: {
token,
expiry: new Date(Date.now() + ONE_DAY),
userId: user.id,
},
});
await sendForgotPassword({
userId: user.id,
}).catch((err) => console.error(err));
};
@@ -1,21 +0,0 @@
import { prisma } from '@hanzo/esign-prisma';
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
export type getMostRecentEmailVerificationTokenOptions = {
userId: number;
};
export const getMostRecentEmailVerificationToken = async ({
userId,
}: getMostRecentEmailVerificationTokenOptions) => {
return await prisma.verificationToken.findFirst({
where: {
userId,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
orderBy: {
createdAt: 'desc',
},
});
};
@@ -1,24 +0,0 @@
import { prisma } from '@hanzo/esign-prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface GetUserByResetTokenOptions {
token: string;
}
export const getUserByResetToken = async ({ token }: GetUserByResetTokenOptions) => {
const result = await prisma.passwordResetToken.findFirst({
where: {
token,
},
include: {
user: true,
},
});
if (!result || !result.user) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return result.user;
};
@@ -1,92 +0,0 @@
import { compare, hash } from '@node-rs/bcrypt';
import { UserSecurityAuditLogType } from '@prisma/client';
import { prisma } from '@hanzo/esign-prisma';
import { SALT_ROUNDS } from '../../constants/auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobsClient } from '../../jobs/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
export type ResetPasswordOptions = {
token: string;
password: string;
requestMetadata?: RequestMetadata;
};
export const resetPassword = async ({ token, password, requestMetadata }: ResetPasswordOptions) => {
if (!token) {
throw new AppError('INVALID_TOKEN');
}
const foundToken = await prisma.passwordResetToken.findFirst({
where: {
token,
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
password: true,
},
},
},
});
if (!foundToken) {
throw new AppError('INVALID_TOKEN');
}
const now = new Date();
if (now > foundToken.expiry) {
throw new AppError(AppErrorCode.EXPIRED_CODE);
}
const isSamePassword = await compare(password, foundToken.user.password || '');
if (isSamePassword) {
throw new AppError('SAME_PASSWORD');
}
const hashedPassword = await hash(password, SALT_ROUNDS);
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: {
id: foundToken.userId,
},
data: {
password: hashedPassword,
},
});
await tx.passwordResetToken.deleteMany({
where: {
userId: foundToken.userId,
},
});
await tx.userSecurityAuditLog.create({
data: {
userId: foundToken.userId,
type: UserSecurityAuditLogType.PASSWORD_RESET,
userAgent: requestMetadata?.userAgent,
ipAddress: requestMetadata?.ipAddress,
},
});
});
await jobsClient.triggerJob({
name: 'send.password.reset.success.email',
payload: {
userId: foundToken.userId,
},
});
return {
userId: foundToken.userId,
};
};
@@ -1,69 +0,0 @@
import crypto from 'crypto';
import { DateTime } from 'luxon';
import { prisma } from '@hanzo/esign-prisma';
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
import { getMostRecentEmailVerificationToken } from './get-most-recent-email-verification-token';
type SendConfirmationTokenOptions = { email: string; force?: boolean };
export const sendConfirmationToken = async ({
email,
force = false,
}: SendConfirmationTokenOptions) => {
const token = crypto.randomBytes(20).toString('hex');
const user = await prisma.user.findFirst({
where: {
email: email,
},
});
if (!user) {
throw new Error('User not found');
}
if (user.emailVerified) {
throw new Error('Email verified');
}
const mostRecentToken = await getMostRecentEmailVerificationToken({ userId: user.id });
// If we've sent a token in the last 5 minutes, don't send another one
if (
!force &&
mostRecentToken?.createdAt &&
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
// return;
}
const createdToken = await prisma.verificationToken.create({
data: {
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
token: token,
expires: new Date(Date.now() + ONE_HOUR),
user: {
connect: {
id: user.id,
},
},
},
});
if (!createdToken) {
throw new Error(`Failed to create the verification token`);
}
try {
await sendConfirmationEmail({ userId: user.id });
return { success: true };
} catch (err) {
console.log(err);
throw new Error(`Failed to send the confirmation email`, { cause: err });
}
};
@@ -1,66 +0,0 @@
import { compare, hash } from '@node-rs/bcrypt';
import { UserSecurityAuditLogType } from '@prisma/client';
import { SALT_ROUNDS } from '@hanzo/esign-lib/constants/auth';
import type { RequestMetadata } from '@hanzo/esign-lib/universal/extract-request-metadata';
import { prisma } from '@hanzo/esign-prisma';
import { AppError } from '../../errors/app-error';
export type UpdatePasswordOptions = {
userId: number;
password: string;
currentPassword: string;
requestMetadata?: RequestMetadata;
};
export const updatePassword = async ({
userId,
password,
currentPassword,
requestMetadata,
}: UpdatePasswordOptions) => {
// Existence check
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
if (!user.password) {
throw new AppError('NO_PASSWORD');
}
const isCurrentPasswordValid = await compare(currentPassword, user.password);
if (!isCurrentPasswordValid) {
throw new AppError('INCORRECT_PASSWORD');
}
// Compare the new password with the old password
const isSamePassword = await compare(password, user.password);
if (isSamePassword) {
throw new AppError('SAME_PASSWORD');
}
const hashedNewPassword = await hash(password, SALT_ROUNDS);
return await prisma.$transaction(async (tx) => {
await tx.userSecurityAuditLog.create({
data: {
userId,
type: UserSecurityAuditLogType.PASSWORD_UPDATE,
userAgent: requestMetadata?.userAgent,
ipAddress: requestMetadata?.ipAddress,
},
});
return await tx.user.update({
where: {
id: userId,
},
data: {
password: hashedNewPassword,
},
});
});
};
@@ -1,114 +0,0 @@
import { DateTime } from 'luxon';
import { prisma } from '@hanzo/esign-prisma';
import {
EMAIL_VERIFICATION_STATE,
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
} from '../../constants/email';
import { jobsClient } from '../../jobs/client';
export type VerifyEmailProps = {
token: string;
};
export const verifyEmail = async ({ token }: VerifyEmailProps) => {
const verificationToken = await prisma.verificationToken.findFirst({
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
where: {
token,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
});
if (!verificationToken) {
return {
state: EMAIL_VERIFICATION_STATE.NOT_FOUND,
userId: null,
};
}
// check if the token is valid or expired
const valid = verificationToken.expires > new Date();
if (!valid) {
const mostRecentToken = await prisma.verificationToken.findFirst({
where: {
userId: verificationToken.userId,
},
orderBy: {
createdAt: 'desc',
},
});
// If there isn't a recent token or it's older than 1 hour, send a new token
if (
!mostRecentToken ||
DateTime.now().minus({ hours: 1 }).toJSDate() > mostRecentToken.createdAt
) {
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
email: verificationToken.user.email,
},
});
}
return {
state: EMAIL_VERIFICATION_STATE.EXPIRED,
userId: null,
};
}
if (verificationToken.completed) {
return {
state: EMAIL_VERIFICATION_STATE.ALREADY_VERIFIED,
userId: null,
};
}
const [updatedUser] = await prisma.$transaction([
prisma.user.update({
where: {
id: verificationToken.userId,
},
data: {
emailVerified: new Date(),
},
}),
prisma.verificationToken.updateMany({
where: {
userId: verificationToken.userId,
},
data: {
completed: true,
},
}),
// Tidy up old expired tokens
prisma.verificationToken.deleteMany({
where: {
userId: verificationToken.userId,
expires: {
lt: new Date(),
},
},
}),
]);
if (!updatedUser) {
throw new Error('Something went wrong while verifying your email. Please try again.');
}
return {
state: EMAIL_VERIFICATION_STATE.VERIFIED,
userId: updatedUser.id,
};
};
@@ -1,13 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
export const formatOrganisationLoginUrl = (organisationUrl: string) => {
return NEXT_PUBLIC_WEBAPP_URL() + formatOrganisationLoginPath(organisationUrl);
};
export const formatOrganisationLoginPath = (organisationUrl: string) => {
return `/o/${organisationUrl}/signin`;
};
export const formatOrganisationCallbackUrl = (organisationUrl: string) => {
return `${NEXT_PUBLIC_WEBAPP_URL()}/v1/auth/callback/oidc/org/${organisationUrl}`;
};
@@ -1,15 +0,0 @@
import { z } from 'zod';
export const ZCreatePasskeySigninOptionsRequestSchema = z.void();
export const ZCreatePasskeySigninOptionsResponseSchema = z.object({
options: z.any(), // PublicKeyCredentialRequestOptions type
sessionId: z.string(),
});
export type TCreatePasskeySigninOptionsRequest = z.infer<
typeof ZCreatePasskeySigninOptionsRequestSchema
>;
export type TCreatePasskeySigninOptionsResponse = z.infer<
typeof ZCreatePasskeySigninOptionsResponseSchema
>;
@@ -1,23 +0,0 @@
import { z } from 'zod';
export const ZCurrentPasswordSchema = z
.string()
.min(6, { message: 'Must be at least 6 characters in length' })
.max(72);
export const ZPasswordSchema = z
.string()
.min(8, { message: 'Must be at least 8 characters in length' })
.max(72, { message: 'Cannot be more than 72 characters in length' })
.refine((value) => value.length > 25 || /[A-Z]/.test(value), {
message: 'One uppercase character',
})
.refine((value) => value.length > 25 || /[a-z]/.test(value), {
message: 'One lowercase character',
})
.refine((value) => value.length > 25 || /\d/.test(value), {
message: 'One number',
})
.refine((value) => value.length > 25 || /[`~<>?,./!@#$%^&*()\-_"'+=|{}[\];:\\]/.test(value), {
message: 'One special character is required',
});
@@ -1,11 +0,0 @@
import { z } from 'zod';
export const ZDeclineLinkOrganisationAccountRequestSchema = z.object({
token: z.string(),
});
export const ZDeclineLinkOrganisationAccountResponseSchema = z.void();
export type TDeclineLinkOrganisationAccountRequest = z.infer<
typeof ZDeclineLinkOrganisationAccountRequestSchema
>;
@@ -1,58 +0,0 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@hanzo/esign-lib/constants/organisations';
import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@hanzo/esign-lib/utils/organisations';
import { prisma } from '@hanzo/esign-prisma';
import {
ZGetOrganisationAuthenticationPortalRequestSchema,
ZGetOrganisationAuthenticationPortalResponseSchema,
} from './get-organisation-authentication-portal.types';
type GetOrganisationAuthenticationPortalOptions = {
userId: number;
organisationId: string;
};
export const getOrganisationAuthenticationPortal = async ({
userId,
organisationId,
}: GetOrganisationAuthenticationPortalOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
organisationAuthenticationPortal: {
select: {
defaultOrganisationRole: true,
enabled: true,
clientId: true,
wellKnownUrl: true,
autoProvisionUsers: true,
allowedDomains: true,
clientSecret: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
const portal = organisation.organisationAuthenticationPortal;
return {
defaultOrganisationRole: portal.defaultOrganisationRole,
enabled: portal.enabled,
clientId: portal.clientId,
wellKnownUrl: portal.wellKnownUrl,
autoProvisionUsers: portal.autoProvisionUsers,
allowedDomains: portal.allowedDomains,
clientSecretProvided: Boolean(portal.clientSecret),
};
};
@@ -1,28 +0,0 @@
import { z } from 'zod';
import { OrganisationAuthenticationPortalSchema } from '@hanzo/esign-prisma/generated/zod/modelSchema/OrganisationAuthenticationPortalSchema';
export const ZGetOrganisationAuthenticationPortalRequestSchema = z.object({
organisationId: z.string(),
});
export const ZGetOrganisationAuthenticationPortalResponseSchema =
OrganisationAuthenticationPortalSchema.pick({
defaultOrganisationRole: true,
enabled: true,
clientId: true,
wellKnownUrl: true,
autoProvisionUsers: true,
allowedDomains: true,
}).extend({
/**
* Whether we have the client secret in the database.
*
* Do not expose the actual client secret.
*/
clientSecretProvided: z.boolean(),
});
export type TGetOrganisationAuthenticationPortalResponse = z.infer<
typeof ZGetOrganisationAuthenticationPortalResponseSchema
>;
@@ -1,9 +0,0 @@
import { z } from 'zod';
export const ZLinkOrganisationAccountRequestSchema = z.object({
token: z.string(),
});
export const ZLinkOrganisationAccountResponseSchema = z.void();
export type TLinkOrganisationAccountRequest = z.infer<typeof ZLinkOrganisationAccountRequestSchema>;
@@ -1,24 +0,0 @@
import { z } from 'zod';
import OrganisationMemberRoleSchema from '@hanzo/esign-prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
import { domainRegex } from './create-organisation-email-domain.types';
export const ZUpdateOrganisationAuthenticationPortalRequestSchema = z.object({
organisationId: z.string(),
data: z.object({
defaultOrganisationRole: OrganisationMemberRoleSchema,
enabled: z.boolean(),
clientId: z.string(),
clientSecret: z.string().optional(),
wellKnownUrl: z.union([z.string().url(), z.literal('')]),
autoProvisionUsers: z.boolean(),
allowedDomains: z.array(z.string().regex(domainRegex)),
}),
});
export const ZUpdateOrganisationAuthenticationPortalResponseSchema = z.void();
export type TUpdateOrganisationAuthenticationPortalRequest = z.infer<
typeof ZUpdateOrganisationAuthenticationPortalRequestSchema
>;
+5 -20
View File
@@ -1,24 +1,24 @@
// esign ZAP handlers — auth router (fully ported, 7/7 procedures).
// esign ZAP handlers — auth router: the signed-in person's passkeys.
//
// Verbatim tRPC procedure bodies rewired to (ctx, raw). Input is validated by
// the SAME Zod schema the tRPC procedure used; the server-only functions are
// reused unchanged. Routes:
// auth.passkey.create / createAuthenticationOptions /
// createRegistrationOptions / createSigninOptions / delete / find / update.
// createRegistrationOptions / delete / find / update.
//
// A passkey re-authenticates a signed-in person for an action; it is never a
// way to sign in, which is Hanzo IAM's alone.
import type { RegistrationResponseJSON } from '@simplewebauthn/server';
import { createPasskey } from '@hanzo/esign-lib/server-only/auth/create-passkey';
import { createPasskeyAuthenticationOptions } from '@hanzo/esign-lib/server-only/auth/create-passkey-authentication-options';
import { createPasskeyRegistrationOptions } from '@hanzo/esign-lib/server-only/auth/create-passkey-registration-options';
import { createPasskeySigninOptions } from '@hanzo/esign-lib/server-only/auth/create-passkey-signin-options';
import { deletePasskey } from '@hanzo/esign-lib/server-only/auth/delete-passkey';
import { findPasskeys } from '@hanzo/esign-lib/server-only/auth/find-passkeys';
import { updatePasskey } from '@hanzo/esign-lib/server-only/auth/update-passkey';
import { nanoid } from '@hanzo/esign-lib/universal/id';
import { ZCreatePasskeyAuthenticationOptionsRequestSchema } from '../../../server/auth-router/create-passkey-authentication-options.types';
import { ZCreatePasskeyRegistrationOptionsRequestSchema } from '../../../server/auth-router/create-passkey-registration-options.types';
import { ZCreatePasskeySigninOptionsRequestSchema } from '../../../server/auth-router/create-passkey-signin-options.types';
import { ZCreatePasskeyRequestSchema } from '../../../server/auth-router/create-passkey.types';
import { ZDeletePasskeyRequestSchema } from '../../../server/auth-router/delete-passkey.types';
import { ZFindPasskeysRequestSchema } from '../../../server/auth-router/find-passkeys.types';
@@ -58,21 +58,6 @@ export const authRoutes: ZapRouteMap = {
});
},
'auth.passkey.createSigninOptions': async (_ctx: ZapContext, raw) => {
ZCreatePasskeySigninOptionsRequestSchema.parse(raw);
const sessionIdToken = nanoid(16);
const [sessionId] = decodeURI(sessionIdToken).split('|');
const options = await createPasskeySigninOptions({ sessionId });
return {
options,
sessionId,
};
},
'auth.passkey.delete': async (ctx: ZapContext, raw) => {
const { passkeyId } = ZDeletePasskeyRequestSchema.parse(raw);
@@ -1,4 +1,4 @@
// esign ZAP handlers — organisation router (37/37 procedures).
// esign ZAP handlers — organisation router.
//
// Verbatim tRPC procedure bodies rewired to (ctx, raw). Input is validated by
// the SAME Zod schema the tRPC procedure used; the server-only functions and
@@ -6,17 +6,12 @@
// findOrganisationMembers / deleteOrganisationMembers / findOrganisationMemberInvites /
// resendOrganisationMemberInvitation / findOrganisationGroups / findOrganisationEmails /
// getOrganisationEmailDomain / findOrganisationEmailDomains /
// getOrganisationAuthenticationPortal / getOrganisationSession) are reused
// getOrganisationSession) are reused
// unchanged. Route keys mirror the tRPC nested router shape (see
// organisation-router.zap). The .meta() calls are dropped.
//
// Auth note: in tRPC, member.invite.accept / member.invite.decline used
// maybeAuthenticatedProcedure and authenticationPortal.linkAccount /
// declineLinkAccount used the unauthenticated `procedure`. Their bodies read
// only `input` (and, for linkAccount, ctx.metadata) — never ctx.user — so they
// port verbatim onto the authenticated ZapContext without behavioural change;
// the ZAP transport establishes auth once at connection, and these handlers
// simply don't depend on it.
// An invitation is accepted by the person it names: member.invite.accept binds
// the signed-in user, and only when their verified address is the invited one.
import {
OrganisationGroupType,
OrganisationMemberInviteStatus,
@@ -25,9 +20,7 @@ import {
} from '@prisma/client';
import { unique } from 'remeda';
import { SIGN_ENCRYPTION_KEY } from '@hanzo/esign-lib/constants/crypto';
import {
ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
ORGANISATION_USER_ACCOUNT_TYPE,
} from '@hanzo/esign-lib/constants/organisations';
@@ -40,12 +33,8 @@ import { orphanEnvelopes } from '@hanzo/esign-lib/server-only/envelope/orphan-en
import { acceptOrganisationInvitation } from '@hanzo/esign-lib/server-only/organisation/accept-organisation-invitation';
import { createOrganisation } from '@hanzo/esign-lib/server-only/organisation/create-organisation';
import { createOrganisationMemberInvites } from '@hanzo/esign-lib/server-only/organisation/create-organisation-member-invites';
import { linkOrganisationAccount } from '@hanzo/esign-lib/server-only/organisation/link-organisation-account';
import { assertRateLimit } from '@hanzo/esign-lib/server-only/rate-limit/rate-limit-middleware';
import { linkOrgAccountRateLimit } from '@hanzo/esign-lib/server-only/rate-limit/rate-limits';
import { getMemberOrganisationRole } from '@hanzo/esign-lib/server-only/team/get-member-roles';
import { INTERNAL_CLAIM_ID, internalClaims } from '@hanzo/esign-lib/types/subscription';
import { symmetricEncrypt } from '@hanzo/esign-lib/universal/crypto';
import { generateDatabaseId } from '@hanzo/esign-lib/universal/id';
import {
buildOrganisationWhereQuery,
@@ -65,7 +54,6 @@ import { ZCreateOrganisationEmailRequestSchema } from '../../../server/organisat
import { ZCreateOrganisationGroupRequestSchema } from '../../../server/organisation-router/create-organisation-group.types';
import { ZCreateOrganisationMemberInvitesRequestSchema } from '../../../server/organisation-router/create-organisation-member-invites.types';
import { ZCreateOrganisationRequestSchema } from '../../../server/organisation-router/create-organisation.types';
import { ZDeclineLinkOrganisationAccountRequestSchema } from '../../../server/organisation-router/decline-link-organisation-account.types';
import { ZDeclineOrganisationMemberInviteRequestSchema } from '../../../server/organisation-router/decline-organisation-member-invite.types';
import { ZDeleteOrganisationEmailDomainRequestSchema } from '../../../server/organisation-router/delete-organisation-email-domain.types';
import { ZDeleteOrganisationEmailRequestSchema } from '../../../server/organisation-router/delete-organisation-email.types';
@@ -86,8 +74,6 @@ import { ZFindOrganisationMemberInvitesRequestSchema } from '../../../server/org
import { findOrganisationMembers } from '../../../server/organisation-router/find-organisation-members';
import { ZFindOrganisationMembersRequestSchema } from '../../../server/organisation-router/find-organisation-members.types';
import { getOrganisation } from '../../../server/organisation-router/get-organisation';
import { getOrganisationAuthenticationPortal } from '../../../server/organisation-router/get-organisation-authentication-portal';
import { ZGetOrganisationAuthenticationPortalRequestSchema } from '../../../server/organisation-router/get-organisation-authentication-portal.types';
import { getOrganisationEmailDomain } from '../../../server/organisation-router/get-organisation-email-domain';
import { ZGetOrganisationEmailDomainRequestSchema } from '../../../server/organisation-router/get-organisation-email-domain.types';
import { ZGetOrganisationMemberInvitesRequestSchema } from '../../../server/organisation-router/get-organisation-member-invites.types';
@@ -95,10 +81,8 @@ import { getOrganisationSession } from '../../../server/organisation-router/get-
import { ZGetOrganisationRequestSchema } from '../../../server/organisation-router/get-organisation.types';
import { getOrganisations } from '../../../server/organisation-router/get-organisations';
import { ZLeaveOrganisationRequestSchema } from '../../../server/organisation-router/leave-organisation.types';
import { ZLinkOrganisationAccountRequestSchema } from '../../../server/organisation-router/link-organisation-account.types';
import { resendOrganisationMemberInvitation } from '../../../server/organisation-router/resend-organisation-member-invite';
import { ZResendOrganisationMemberInviteRequestSchema } from '../../../server/organisation-router/resend-organisation-member-invite.types';
import { ZUpdateOrganisationAuthenticationPortalRequestSchema } from '../../../server/organisation-router/update-organisation-authentication-portal.types';
import { ZUpdateOrganisationEmailRequestSchema } from '../../../server/organisation-router/update-organisation-email.types';
import { ZUpdateOrganisationGroupRequestSchema } from '../../../server/organisation-router/update-organisation-group.types';
import { ZUpdateOrganisationMemberRequestSchema } from '../../../server/organisation-router/update-organisation-members.types';
@@ -585,11 +569,12 @@ export const organisationRoutes: ZapRouteMap = {
});
},
'organisation.member.invite.accept': async (_ctx: ZapContext, raw) => {
'organisation.member.invite.accept': async (ctx: ZapContext, raw) => {
const { token } = ZAcceptOrganisationMemberInviteRequestSchema.parse(raw);
return await acceptOrganisationInvitation({
token,
userId: ctx.user.id,
});
},
@@ -1350,129 +1335,6 @@ export const organisationRoutes: ZapRouteMap = {
await Promise.all(emailsToVerify.map(async (email) => verifyEmailDomain(email.id)));
},
'organisation.authenticationPortal.get': async (ctx: ZapContext, raw) => {
const { organisationId } = ZGetOrganisationAuthenticationPortalRequestSchema.parse(raw);
ctx.logger.info({
input: {
organisationId,
},
});
return await getOrganisationAuthenticationPortal({
userId: ctx.user.id,
organisationId,
});
},
'organisation.authenticationPortal.update': async (ctx: ZapContext, raw) => {
const { organisationId, data } =
ZUpdateOrganisationAuthenticationPortalRequestSchema.parse(raw);
const { user } = ctx;
ctx.logger.info({
input: {
organisationId,
},
});
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
organisationAuthenticationPortal: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
const {
defaultOrganisationRole,
enabled,
clientId,
clientSecret,
wellKnownUrl,
autoProvisionUsers,
allowedDomains,
} = data;
if (
enabled &&
(!wellKnownUrl ||
!clientId ||
(!clientSecret && !organisation.organisationAuthenticationPortal.clientSecret))
) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message:
'Client ID, client secret, and well known URL are required when authentication portal is enabled',
});
}
// Allow empty string to be passed in to remove the client secret from the database.
let encryptedClientSecret: string | undefined = clientSecret;
// Encrypt the secret if it is provided.
if (clientSecret) {
const encryptionKey = SIGN_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error('Missing SIGN_ENCRYPTION_KEY');
}
encryptedClientSecret = symmetricEncrypt({
key: encryptionKey,
data: clientSecret,
});
}
await prisma.organisationAuthenticationPortal.update({
where: {
id: organisation.organisationAuthenticationPortal.id,
},
data: {
defaultOrganisationRole,
enabled,
clientId,
clientSecret: encryptedClientSecret,
wellKnownUrl,
autoProvisionUsers,
allowedDomains,
},
});
},
'organisation.authenticationPortal.linkAccount': async (ctx: ZapContext, raw) => {
const { token } = ZLinkOrganisationAccountRequestSchema.parse(raw);
const rateLimitResult = await linkOrgAccountRateLimit.check({
ip: ctx.metadata.requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
assertRateLimit(rateLimitResult);
await linkOrganisationAccount({
token,
requestMeta: ctx.metadata.requestMetadata,
});
},
'organisation.authenticationPortal.declineLinkAccount': async (_ctx: ZapContext, raw) => {
const { token } = ZDeclineLinkOrganisationAccountRequestSchema.parse(raw);
await prisma.verificationToken.delete({
where: {
token,
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
},
});
},
'organisation.internal.getOrganisationSession': async (ctx: ZapContext) => {
return await getOrganisationSession({ userId: ctx.user.id });
},
-9
View File
@@ -4,15 +4,6 @@ declare namespace NodeJS {
NEXT_PUBLIC_WEBAPP_URL?: string;
NEXT_PRIVATE_SIGN_LICENSE_KEY?: string;
NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string;
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;
NEXT_PRIVATE_OIDC_WELL_KNOWN?: string;
NEXT_PRIVATE_OIDC_CLIENT_ID?: string;
NEXT_PRIVATE_OIDC_CLIENT_SECRET?: string;
NEXT_PRIVATE_OIDC_PROVIDER_LABEL?: string;
NEXT_PRIVATE_OIDC_SKIP_VERIFY?: string;
// Base SQLite (single shared file). BASE_PATH is the directory that contains
// the one `sign.db` file; DATABASE_URL is the `file:` URL Prisma / the CLI /
// migrations open. Tenancy is enforced in-query via buildTeamWhereQuery, not
-6
View File
@@ -96,12 +96,6 @@ services:
- key: NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY
generateValue: true
# Auth Optional
- key: NEXT_PRIVATE_GOOGLE_CLIENT_ID
sync: false
- key: NEXT_PRIVATE_GOOGLE_CLIENT_SECRET
sync: false
# Signing
- key: NEXT_PRIVATE_SIGNING_TRANSPORT
sync: false
-7
View File
@@ -71,13 +71,6 @@
"NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY",
"NEXT_PUBLIC_SIGNING_CONTACT_INFO",
"NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
"NEXT_PRIVATE_OIDC_WELL_KNOWN",
"NEXT_PRIVATE_OIDC_CLIENT_ID",
"NEXT_PRIVATE_OIDC_CLIENT_SECRET",
"NEXT_PRIVATE_OIDC_PROVIDER_LABEL",
"NEXT_PRIVATE_OIDC_SKIP_VERIFY",
"NEXT_PUBLIC_UPLOAD_TRANSPORT",
"NEXT_PRIVATE_UPLOAD_ENDPOINT",
"NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE",