Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
5686c48
add plans
CiaranMn Feb 9, 2026
64ac604
wip: add email verification requirement, optional TOTP MFA
CiaranMn Feb 10, 2026
101e3be
Merge branch 'main' into cm/add-email-verification
CiaranMn Feb 11, 2026
6e3cc17
improve unverified email cleanup logic
CiaranMn Feb 11, 2026
a5b862a
tidy up resolver list
CiaranMn Feb 11, 2026
ba49360
remove unnecessary code
CiaranMn Feb 11, 2026
ae63dde
add HASH account name to TOTP identifier
CiaranMn Feb 11, 2026
e17a95d
shorten rate limiter window
CiaranMn Feb 11, 2026
b9f69ac
validate password / TOTP before allowing relevant settings change
CiaranMn Feb 11, 2026
9997510
prevent unnecessary re-renders
CiaranMn Feb 11, 2026
e9b076d
move / clean up files
CiaranMn Feb 11, 2026
66474b9
update Ory email templates
CiaranMn Feb 11, 2026
a0b9608
signup flow UI improvements, bug fixes
CiaranMn Feb 11, 2026
7a1c4e1
improve kratos email formatting
CiaranMn Feb 11, 2026
313a2e1
bug / ui fixes
CiaranMn Feb 11, 2026
49ecd7c
sign up flow fixes, test fixes, email template padding
CiaranMn Feb 12, 2026
434de7b
Merge branch 'main' into cm/add-email-verification
CiaranMn Feb 12, 2026
94e4b02
Update Ory Kratos URLs in external services docker-compose.yml
TimDiekmann Feb 12, 2026
a2b6c5d
fix tests
CiaranMn Feb 12, 2026
3163037
fix tests
CiaranMn Feb 13, 2026
4cae255
fix tests(?), incorrect code handling, Kratos continue_as config
CiaranMn Feb 13, 2026
13f1e46
remove unneeded verify button clicks
CiaranMn Feb 13, 2026
32d4260
add mailslurper diagnostics
CiaranMn Feb 13, 2026
dbead58
Merge branch 'main' into cm/add-email-verification
CiaranMn Feb 13, 2026
5981b00
disable email verification cleanup for now
CiaranMn Feb 13, 2026
c11a507
Merge branch 'cm/add-email-verification' of github.com:hashintel/hash…
CiaranMn Feb 13, 2026
cdf0a2f
comment out unused import
CiaranMn Feb 13, 2026
03b771c
handle signup page for already-verified users
CiaranMn Feb 13, 2026
5aae8cd
further tweak signup logic
CiaranMn Feb 13, 2026
bb436e7
Merge branch 'main' into cm/add-email-verification
CiaranMn Feb 13, 2026
24ce4cc
more test fixes
CiaranMn Feb 13, 2026
450f4d8
comment out TOTP UI for now
CiaranMn Feb 13, 2026
160ddc7
slightly bump rate limit
CiaranMn Feb 13, 2026
3c513f5
skip TOTP tests
CiaranMn Feb 13, 2026
ccc35f6
fix gate on getPendingInvitationByEntityId
CiaranMn Feb 13, 2026
936ba0f
PR feedback
CiaranMn Feb 13, 2026
4c6c4ee
fix docker compose Kratos public URL
CiaranMn Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ AWS_S3_UPLOADS_ACCESS_KEY_ID="dev-s3-access-key-id"
AWS_S3_UPLOADS_SECRET_ACCESS_KEY="dev-s3-secret-access-key"
AWS_S3_UPLOADS_FORCE_PATH_STYLE=true
FILE_UPLOAD_PROVIDER="AWS_S3"

USER_EMAIL_ALLOW_LIST='["charlie@example.com", "mfa-enable-totp@example.com", "mfa-totp-login@example.com", "mfa-backup-code@example.com", "mfa-disable-totp@example.com", "mfa-wrong-code@example.com"]'
47 changes: 20 additions & 27 deletions apps/hash-api/src/auth/create-auth-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { createUser, getUser } from "../graph/knowledge/system-types/user";
import { systemAccountId } from "../graph/system-account";
import { hydraAdmin } from "./ory-hydra";
import type { KratosUserIdentity } from "./ory-kratos";
import { kratosFrontendApi } from "./ory-kratos";
import { isUserEmailVerified, kratosFrontendApi } from "./ory-kratos";

const KRATOS_API_KEY = getRequiredEnv("KRATOS_API_KEY");

Expand Down Expand Up @@ -106,6 +106,7 @@ export const getUserAndSession = async ({
logger: Logger;
sessionToken?: string;
}): Promise<{
primaryEmailVerified?: boolean;
session?: Session;
user?: User;
}> => {
Expand All @@ -118,9 +119,11 @@ export const getUserAndSession = async ({
})
.then(({ data }) => data)
.catch((err: AxiosError) => {
// 403 on toSession means that we need to request 2FA
if (err.response && err.response.status === 403) {
/** @todo: figure out if this should be handled here, or in the next.js app (when implementing 2FA) */
logger.debug(
"Session requires AAL2 but only has AAL1. Treating as unauthenticated.",
);
return undefined;
}
logger.debug(
`Kratos response error: Could not fetch session, got: [${
Expand All @@ -139,6 +142,13 @@ export const getUserAndSession = async ({

const { id: kratosIdentityId, traits } = identity as KratosUserIdentity;

const primaryEmailAddress = traits.emails[0];

const primaryEmailVerified =
identity.verifiable_addresses?.find(
({ value }) => value === primaryEmailAddress,
)?.verified === true;

const user = await getUser(context, authentication, {
kratosIdentityId,
emails: traits.emails,
Expand All @@ -150,7 +160,7 @@ export const getUserAndSession = async ({
);
}

return { session: kratosSession, user };
return { primaryEmailVerified, session: kratosSession, user };
}

return {};
Expand Down Expand Up @@ -185,42 +195,25 @@ export const createAuthMiddleware = (params: {
},
);
if (user) {
req.primaryEmailVerified = await isUserEmailVerified(
user.kratosIdentityId,
);
req.user = user;
next();
return;
}
}
}

const { session, user } = await getUserAndSession({
const { primaryEmailVerified, session, user } = await getUserAndSession({
context,
cookie: req.header("cookie"),
logger,
sessionToken: accessOrSessionToken,
});

const kratosSession = await kratosFrontendApi
.toSession({
cookie: req.header("cookie"),
xSessionToken: accessOrSessionToken,
})
.then(({ data }) => data)
.catch((err: AxiosError) => {
// 403 on toSession means that we need to request 2FA
if (err.response && err.response.status === 403) {
/** @todo: figure out if this should be handled here, or in the next.js app (when implementing 2FA) */
}
logger.debug(
`Kratos response error: Could not fetch session, got: [${
err.response?.status
}] ${JSON.stringify(err.response?.data)}`,
);
return undefined;
});

if (kratosSession) {
if (session) {
req.primaryEmailVerified = primaryEmailVerified;
req.session = session;

req.user = user;
}

Expand Down
221 changes: 221 additions & 0 deletions apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import type { Logger } from "@local/hash-backend-utils/logger";
import { queryEntities } from "@local/hash-graph-sdk/entity";
import {
currentTimeInstantTemporalAxes,
generateVersionedUrlMatchingFilter,
} from "@local/hash-isomorphic-utils/graph-queries";
import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids";
import type { User as UserEntity } from "@local/hash-isomorphic-utils/system-types/user";
import type { Identity } from "@ory/kratos-client";

import type { ImpureGraphContext } from "../graph/context-types";
import { getUserFromEntity } from "../graph/knowledge/system-types/user";
import { systemAccountId } from "../graph/system-account";
import { deleteKratosIdentity, kratosIdentityApi } from "./ory-kratos";

/**
* Identities created before this date are excluded from cleanup, preventing
* retroactive deletion of accounts that existed before email verification
* was introduced.
*/
const DEFAULT_ROLLOUT_AT = new Date("2026-02-14T00:00:00.000Z");
const DEFAULT_RELEASE_TTL_HOURS = 24 * 7;
const DEFAULT_SWEEP_INTERVAL_MINUTES = 60;

const parsePositiveIntegerEnv = (
rawValue: string | undefined,
fallback: number,
envVarName: string,
) => {
if (!rawValue) {
return fallback;
}

const parsedValue = Number.parseInt(rawValue, 10);
if (Number.isNaN(parsedValue) || parsedValue <= 0) {
throw new Error(
`${envVarName} must be a positive integer, got "${rawValue}"`,
);
}

return parsedValue;
};

const parseRolloutDate = (rawValue: string | undefined): Date => {
if (!rawValue) {
return DEFAULT_ROLLOUT_AT;
}

const parsedDate = new Date(rawValue);
if (Number.isNaN(parsedDate.getTime())) {
throw new Error(
`HASH_EMAIL_VERIFICATION_ROLLOUT_AT must be an ISO-8601 date, got "${rawValue}"`,
);
}

return parsedDate;
};

const parseIdentityCreatedAt = (identity: Identity): Date | undefined => {
if (!identity.created_at) {
return undefined;
}

const createdAt = new Date(identity.created_at);

if (Number.isNaN(createdAt.getTime())) {
return undefined;
}

return createdAt;
};

const isPrimaryEmailVerified = (identity: Identity): boolean => {
const identityTraits = identity.traits as { emails?: string[] };
const primaryEmailAddress = identityTraits.emails?.[0];

if (!primaryEmailAddress) {
return false;
}

return (
identity.verifiable_addresses?.find(
({ value }) => value === primaryEmailAddress,
)?.verified === true
);
};

export const createUnverifiedEmailCleanupJob = ({
context,
logger,
}: {
context: ImpureGraphContext;
logger: Logger;
}) => {
const rolloutAt = parseRolloutDate(
process.env.HASH_EMAIL_VERIFICATION_ROLLOUT_AT,
);

const releaseTtlHours = parsePositiveIntegerEnv(
process.env.HASH_EMAIL_VERIFICATION_RELEASE_TTL_HOURS,
DEFAULT_RELEASE_TTL_HOURS,
"HASH_EMAIL_VERIFICATION_RELEASE_TTL_HOURS",
);

const sweepIntervalMinutes = parsePositiveIntegerEnv(
process.env.HASH_EMAIL_VERIFICATION_RELEASE_SWEEP_INTERVAL_MINUTES,
DEFAULT_SWEEP_INTERVAL_MINUTES,
"HASH_EMAIL_VERIFICATION_RELEASE_SWEEP_INTERVAL_MINUTES",
);

const releaseTtlMs = releaseTtlHours * 60 * 60 * 1_000;
const sweepIntervalMs = sweepIntervalMinutes * 60 * 1_000;

const cleanupUnverifiedUsers = async () => {
const now = Date.now();
const authentication = { actorId: systemAccountId };

const { entities: userEntities } = await queryEntities<UserEntity>(
context,
authentication,
{
filter: {
all: [
generateVersionedUrlMatchingFilter(
systemEntityTypes.user.entityTypeId,
{
ignoreParents: true,
},
),
{
equal: [{ path: ["archived"] }, { parameter: false }],
},
],
},
temporalAxes: currentTimeInstantTemporalAxes,
includeDrafts: false,
includePermissions: false,
},
);

let releasedEmailCount = 0;

for (const userEntity of userEntities) {
const user = getUserFromEntity({ entity: userEntity });

if (user.isAccountSignupComplete) {
continue;
}

try {
const { data: identity } = await kratosIdentityApi.getIdentity({
id: user.kratosIdentityId,
});

const createdAt = parseIdentityCreatedAt(identity);
if (!createdAt || createdAt < rolloutAt) {
continue;
}

if (now - createdAt.getTime() < releaseTtlMs) {
continue;
}

const primaryEmail = user.emails[0];
if (!primaryEmail) {
logger.warn(
`User ${user.accountId} (${user.kratosIdentityId}) has no email addresses, skipping`,
);
continue;
}

if (isPrimaryEmailVerified(identity)) {
continue;
}

await user.entity.archive(
context.graphApi,
authentication,
context.provenance,
);
await deleteKratosIdentity({
kratosIdentityId: user.kratosIdentityId,
});

releasedEmailCount += 1;
} catch (error) {
logger.warn(
`Failed to process unverified user ${user.accountId} (${user.kratosIdentityId}) for email release: ${error}`,
);
}
}

if (releasedEmailCount > 0) {
logger.info(
`Released ${releasedEmailCount} unverified email address${releasedEmailCount === 1 ? "" : "es"}.`,
);
}
};

let interval: NodeJS.Timeout | undefined;
let inFlightCleanup: Promise<void> | undefined;

return {
start: async () => {
logger.info(
`Starting unverified-email cleanup job (rolloutAt=${rolloutAt.toISOString()}, ttlHours=${releaseTtlHours}, intervalMinutes=${sweepIntervalMinutes})`,
);

await cleanupUnverifiedUsers();
interval = setInterval(() => {
inFlightCleanup = cleanupUnverifiedUsers();
}, sweepIntervalMs);
},
stop: async () => {
if (interval) {
clearInterval(interval);
}
await inFlightCleanup;
},
};
};
Loading
Loading