Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@

HASH_GRAPH_ALLOWED_URL_DOMAIN_PATTERN="(?:http://localhost:3000|https://hash\\.ai)/@(?P<shortname>[\\w-]+)/types/(?P<kind>(?:data-type)|(?:property-type)|(?:entity-type))/[\\w\\-_%]+/"

USER_EMAIL_ALLOW_LIST='["charlie@example.com", "signup-allow@example.com", "signin-test@example.com", "signout-test@example.com", "pw-change@example.com", "pw-recovery@example.com", "mfa-enable@example.com", "mfa-login@example.com", "mfa-backup@example.com", "mfa-disable@example.com", "mfa-wrong-code@example.com"]'
USER_EMAIL_ALLOW_LIST='["charlie@example.com", "signup-allow@example.com", "signup-uppercase@example.com", "signin-test@example.com", "signout-test@example.com", "pw-change@example.com", "pw-recovery@example.com", "mfa-enable@example.com", "mfa-login@example.com", "mfa-backup@example.com", "mfa-disable@example.com", "mfa-wrong-code@example.com"]'
18 changes: 3 additions & 15 deletions apps/hash-api/src/auth/create-auth-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { publicUserAccountId } from "@local/hash-backend-utils/public-user-accou
import { createUser, getUser } from "../graph/knowledge/system-types/user";
import { systemAccountId } from "../graph/system-account";
import { hydraAdmin } from "./ory-hydra";
import { isUserEmailVerified, kratosFrontendApi } from "./ory-kratos";
import { kratosFrontendApi } from "./ory-kratos";

import type { ImpureGraphContext } from "../graph/context-types";
import type { User } from "../graph/knowledge/system-types/user";
Expand Down Expand Up @@ -122,7 +122,6 @@ export const getUserAndSession = async ({
logger: Logger;
sessionToken?: string;
}): Promise<{
primaryEmailVerified?: boolean;
session?: Session;
user?: User;
}> => {
Expand Down Expand Up @@ -158,13 +157,6 @@ 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;

let user = await getUser(context, authentication, {
kratosIdentityId,
emails: traits.emails,
Expand All @@ -191,7 +183,7 @@ export const getUserAndSession = async ({
}
}

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

return {};
Expand Down Expand Up @@ -226,24 +218,20 @@ export const createAuthMiddleware = (params: {
},
);
if (user) {
req.primaryEmailVerified = await isUserEmailVerified(
user.kratosIdentityId,
);
req.user = user;
next();
return;
}
}
}

const { primaryEmailVerified, session, user } = await getUserAndSession({
const { session, user } = await getUserAndSession({
context,
cookie: req.header("cookie"),
logger,
sessionToken: accessOrSessionToken,
});
if (session) {
req.primaryEmailVerified = primaryEmailVerified;
req.session = session;
req.user = user;
}
Expand Down
68 changes: 68 additions & 0 deletions apps/hash-api/src/auth/create-unverified-email-cleanup-job.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";

import { isPrimaryEmailVerified } from "./create-unverified-email-cleanup-job";

import type { Identity } from "@ory/kratos-client";

/**
* Build a minimal Kratos identity for the cleanup-job predicate. Only the
* fields `isPrimaryEmailVerified` reads (`traits.emails`, `verifiable_addresses`)
* are populated; the rest of the `Identity` shape is irrelevant here.
*/
const identity = (
emails: string[],
verifiableAddresses: Array<{ value: string; verified: boolean }>,
): Identity =>
({
traits: { emails },
verifiable_addresses: verifiableAddresses,
}) as unknown as Identity;

describe("isPrimaryEmailVerified (cleanup-job deletion gate)", () => {
it("treats a verified address as verified despite trait/address casing mismatch", () => {
expect(
isPrimaryEmailVerified(
identity(
["User@Example.com"],
[{ value: "user@example.com", verified: true }],
),
),
).toBe(true);
});

it("treats a verified address as verified when casing matches", () => {
expect(
isPrimaryEmailVerified(
identity(
["user@example.com"],
[{ value: "user@example.com", verified: true }],
),
),
).toBe(true);
});

it("treats a genuinely unverified address as unverified (eligible for cleanup)", () => {
expect(
isPrimaryEmailVerified(
identity(
["User@Example.com"],
[{ value: "user@example.com", verified: false }],
),
),
).toBe(false);
});

it("returns false when there is no verifiable address", () => {
expect(isPrimaryEmailVerified(identity(["user@example.com"], []))).toBe(
false,
);
});

it("returns false when the identity has no email traits", () => {
expect(
isPrimaryEmailVerified(
identity([], [{ value: "user@example.com", verified: true }]),
),
).toBe(false);
});
});
15 changes: 9 additions & 6 deletions apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import {
currentTimeInstantTemporalAxes,
generateVersionedUrlMatchingFilter,
} from "@local/hash-isomorphic-utils/graph-queries";
import { normalizeEmail } from "@local/hash-isomorphic-utils/normalize";
import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids";

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

import type { ImpureGraphContext } from "../graph/context-types";
import type { Logger } from "@local/hash-backend-utils/logger";
Expand Down Expand Up @@ -71,18 +76,16 @@ const parseIdentityCreatedAt = (identity: Identity): Date | undefined => {
return createdAt;
};

const isPrimaryEmailVerified = (identity: Identity): boolean => {
export 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
return getVerifiedEmailsFromKratosIdentity(identity).includes(
normalizeEmail(primaryEmailAddress),
);
};

Expand Down
3 changes: 2 additions & 1 deletion apps/hash-api/src/auth/ory-kratos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Configuration } from "@ory/client";
import { FrontendApi, IdentityApi } from "@ory/kratos-client";

import { getRequiredEnv } from "@local/hash-backend-utils/environment";
import { normalizeEmail } from "@local/hash-isomorphic-utils/normalize";

import type { CreateIdentityBody, Identity } from "@ory/kratos-client";

Expand Down Expand Up @@ -31,7 +32,7 @@ export const getVerifiedEmailsFromKratosIdentity = (
): string[] =>
(identity.verifiable_addresses ?? [])
.filter((address) => address.verified === true)
.map(({ value }) => value);
.map(({ value }) => normalizeEmail(value));

export const createKratosIdentity = async (
params: Omit<CreateIdentityBody, "schema_id" | "traits"> & {
Expand Down
1 change: 0 additions & 1 deletion apps/hash-api/src/express.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ declare global {
context: ImpureGraphContext<true, true> & {
vaultClient?: VaultClient;
};
primaryEmailVerified: boolean | undefined;
session: Session | undefined;
user: User | undefined;
}
Expand Down
15 changes: 12 additions & 3 deletions apps/hash-api/src/graph/knowledge/system-types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
currentTimeInstantTemporalAxes,
generateVersionedUrlMatchingFilter,
} from "@local/hash-isomorphic-utils/graph-queries";
import { normalizeEmail } from "@local/hash-isomorphic-utils/normalize";
import {
systemEntityTypes,
systemLinkEntityTypes,
Expand Down Expand Up @@ -140,17 +141,19 @@ export const checkEmailVerificationAndUsageStatus = async (
| { status: "verified"; kratosIdentityId: string }
| { status: "not-verified"; kratosIdentityId: string }
> => {
const normalizedEmail = normalizeEmail(email);

try {
const { data: identities } = await kratosIdentityApi.listIdentities({
credentialsIdentifier: email,
credentialsIdentifier: normalizedEmail,
});

if (identities.length === 0) {
return { status: "email-not-found" };
}

const verifiedEmails = getVerifiedEmailsFromKratosIdentity(identities[0]!);
if (verifiedEmails.includes(email)) {
if (verifiedEmails.includes(normalizedEmail)) {
return { status: "verified", kratosIdentityId: identities[0]!.id };
} else {
return { status: "not-verified", kratosIdentityId: identities[0]!.id };
Expand All @@ -169,12 +172,18 @@ export const getUserFromEntity: PureGraphFunction<

const {
displayName,
email: emails,
email,
enabledFeatureFlags: maybeFeatureFlags,
kratosIdentityId,
shortname,
} = simplifyProperties(entity.properties);

// `email` is typed as a required property, but property-level permission
// masking can drop it at runtime for non-owners (see `getUser`'s Kratos
// back-fill). Model that nullability, then canonicalise so every `User` built
// from an entity compares case-insensitively regardless of signup casing.
const emails = (email as typeof email | undefined)?.map(normalizeEmail) ?? [];

Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
TimDiekmann marked this conversation as resolved.
const isAccountSignupComplete = !!shortname && !!displayName;

const enabledFeatureFlags =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
currentTimeInstantTemporalAxes,
generateVersionedUrlMatchingFilter,
} from "@local/hash-isomorphic-utils/graph-queries";
import { normalizeEmail } from "@local/hash-isomorphic-utils/normalize";
import {
blockProtocolDataTypes,
systemDataTypes,
Expand Down Expand Up @@ -173,7 +174,7 @@ export const inviteUserToOrgResolver: ResolverFn<
let existingUserToInvite: User | null = null;

const userEmail = unnormalisedUserEmail
? unnormalisedUserEmail.trim().toLowerCase()
? normalizeEmail(unnormalisedUserEmail)
: null;

if (userEmail) {
Expand Down
6 changes: 5 additions & 1 deletion apps/hash-api/src/shared/user-has-access-to-hash.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizeEmail } from "@local/hash-isomorphic-utils/normalize";

import {
getUserPendingInvitations,
type User,
Expand Down Expand Up @@ -25,7 +27,9 @@ if (process.env.USER_EMAIL_ALLOW_LIST) {
);
}

userEmailAllowList = uncheckedUserEmailAllowList;
// Normalise so the allowlist matches the canonical (lowercased) emails
// surfaced on `user.emails`, regardless of how an admin cased entries.
userEmailAllowList = uncheckedUserEmailAllowList.map(normalizeEmail);
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(
Expand Down
5 changes: 4 additions & 1 deletion apps/hash-frontend/src/lib/user-and-org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
extractWebIdFromEntityId,
} from "@blockprotocol/type-system";
import { getFirstEntityRevision } from "@local/hash-isomorphic-utils/entity";
import { normalizeEmail } from "@local/hash-isomorphic-utils/normalize";
import {
systemEntityTypes,
systemLinkEntityTypes,
Expand Down Expand Up @@ -374,9 +375,11 @@ export const constructUser = (params: {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- permissions means this may be undefined. @todo types to account for property-level permissions
const primaryEmailAddress = email?.[0] ?? "";

const normalizedPrimaryEmail = normalizeEmail(primaryEmailAddress);

const isPrimaryEmailAddressVerified =
params.verifiableAddresses?.find(
({ value }) => value === primaryEmailAddress,
({ value }) => normalizeEmail(value) === normalizedPrimaryEmail,
)?.verified === true;

const minimalUser = constructMinimalUser({ userEntity });
Expand Down
5 changes: 4 additions & 1 deletion apps/hash-frontend/src/pages/_app.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { getRoots } from "@blockprotocol/graph/stdlib";
import { createEmotionCache, theme } from "@hashintel/design-system/theme";
import { featureFlags } from "@local/hash-isomorphic-utils/feature-flags";
import { mapGqlSubgraphFieldsFragmentToSubgraph } from "@local/hash-isomorphic-utils/graph-queries";
import { normalizeEmail } from "@local/hash-isomorphic-utils/normalize";

import { getHashInstanceSettings } from "../graphql/queries/knowledge/hash-instance.queries";
import { hasAccessToHashQuery, meQuery } from "../graphql/queries/user.queries";
Expand Down Expand Up @@ -290,9 +291,11 @@ const getPrimaryEmailVerificationStatus = async (cookie?: string) =>
return false;
}

const normalizedPrimaryEmail = normalizeEmail(primaryEmailAddress);

return (
identity.verifiable_addresses?.find(
({ value }) => value === primaryEmailAddress,
({ value }) => normalizeEmail(value) === normalizedPrimaryEmail,
)?.verified === true
);
})
Expand Down
30 changes: 30 additions & 0 deletions libs/@local/hash-isomorphic-utils/src/normalize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";

import { normalizeEmail } from "./normalize.js";

describe("normalizeEmail", () => {
it("lowercases the address", () => {
expect(normalizeEmail("User@Example.com")).toBe("user@example.com");
expect(normalizeEmail("Signup-Allow@Example.COM")).toBe(
"signup-allow@example.com",
);
});

it("trims surrounding whitespace", () => {
expect(normalizeEmail(" user@example.com ")).toBe("user@example.com");
expect(normalizeEmail("\tUser@Example.com\n")).toBe("user@example.com");
});

it("is idempotent on already-normalized input", () => {
const normalized = normalizeEmail("User@Example.com");
expect(normalizeEmail(normalized)).toBe(normalized);
});

it("only trims and lowercases β€” it does not canonicalize the local part", () => {
// Guards the comparison contract: normalization must not strip `+tag`
// suffixes or dots, which would silently change email matching everywhere.
expect(normalizeEmail("First.Last+Tag@Example.com")).toBe(
"first.last+tag@example.com",
);
});
});
10 changes: 10 additions & 0 deletions libs/@local/hash-isomorphic-utils/src/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,13 @@
*/
export const normalizeWhitespace = (string: string) =>
string.replace(/\s+/g, " ").trim();

/**
* Canonical form of an email address for case-insensitive comparison.
*
* Kratos lowercases the login identifier but leaves the trait and
* `verifiable_addresses[].value` in the casing typed at signup, so emails read
* back from Kratos are inconsistently cased.
*/
export const normalizeEmail = (email: string): string =>
email.trim().toLowerCase();
Loading
Loading