Skip to content
Open
10 changes: 10 additions & 0 deletions apps/hash-external-services/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ services:
SELFSERVICE_FLOWS_REGISTRATION_UI_URL: "http://localhost:3000/signup"
SELFSERVICE_FLOWS_REGISTRATION_AFTER_PASSWORD_HOOKS_0_CONFIG_URL: "http://host.docker.internal:5001/kratos-after-registration"
SELFSERVICE_FLOWS_REGISTRATION_AFTER_PASSWORD_HOOKS_0_CONFIG_AUTH_CONFIG_VALUE: "${KRATOS_API_KEY}"
# OIDC / SSO β€” disabled by default. To enable, set in .env.local:
# KRATOS_OIDC_ENABLED=true
# KRATOS_OIDC_GOOGLE_CLIENT_ID=<your-client-id>
# KRATOS_OIDC_GOOGLE_CLIENT_SECRET=<your-client-secret>
SELFSERVICE_METHODS_OIDC_ENABLED: "${KRATOS_OIDC_ENABLED:-false}"
SELFSERVICE_FLOWS_REGISTRATION_AFTER_OIDC_HOOKS_0_CONFIG_URL: "http://host.docker.internal:5001/kratos-after-registration"
SELFSERVICE_FLOWS_REGISTRATION_AFTER_OIDC_HOOKS_0_CONFIG_AUTH_CONFIG_VALUE: "${KRATOS_API_KEY}"
SELFSERVICE_METHODS_OIDC_CONFIG_BASE_REDIRECT_URI: "http://localhost:5001/auth"
SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_CLIENT_ID: "${KRATOS_OIDC_GOOGLE_CLIENT_ID}"
SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_CLIENT_SECRET: "${KRATOS_OIDC_GOOGLE_CLIENT_SECRET}"
SELFSERVICE_FLOWS_VERIFICATION_UI_URL: "http://localhost:3000/verification"
SELFSERVICE_FLOWS_RECOVERY_UI_URL: "http://localhost:3000/recovery"
SELFSERVICE_FLOWS_SETTINGS_UI_URL: "http://localhost:3000/settings/security"
Expand Down
2 changes: 1 addition & 1 deletion apps/hash-external-services/kratos/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM oryd/kratos:v25.4
FROM oryd/kratos:v26.2

USER root

Expand Down
19 changes: 19 additions & 0 deletions apps/hash-external-services/kratos/hooks/oidc.google.jsonnet
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
local claims = std.extVar('claims');

local email =
if "email" in claims && claims.email != "" then claims.email
else error "Google OIDC: no email claim found in token";

{
identity: {
traits: {
emails: [email],
},
verified_addresses: if "email_verified" in claims && claims.email_verified then [
{
value: email,
via: "email",
},
] else [],
},
}
43 changes: 43 additions & 0 deletions apps/hash-external-services/kratos/kratos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,32 @@ selfservice:
lookup_secret:
enabled: true

oidc:
# Disabled by default. Enable by setting SELFSERVICE_METHODS_OIDC_ENABLED=true
# along with provider credentials in .env.local (see docker-compose.yml).
enabled: false
config:
# Override the callback base URL to route through the API proxy (/auth β†’ Kratos).
# Without this, Kratos would use SERVE_PUBLIC_BASE_URL (localhost:3000), which
# doesn't proxy /self-service/* paths.
# Set through the `SELFSERVICE_METHODS_OIDC_CONFIG_BASE_REDIRECT_URI` environment variable
providers:
- id: google
provider: google
label: Google
# Set `client_id` through the `SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_CLIENT_ID` environment variable
# Set `client_secret` through the `SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_CLIENT_SECRET` environment variable
mapper_url: "file:///etc/config/kratos/hooks/oidc.google.jsonnet"
scope:
- email
- profile
requested_claims:
id_token:
email:
essential: true
email_verified:
essential: true

flows:
error:
# Set `ui_url` through the `SELFSERVICE_FLOWS_ERROR_UI_URL` environment variable
Expand Down Expand Up @@ -80,6 +106,23 @@ selfservice:
in: header
- hook: show_verification_ui
- hook: session
oidc:
hooks:
- hook: web_hook
config:
response:
parse: false
# Set `url` through the `SELFSERVICE_FLOWS_REGISTRATION_AFTER_OIDC_HOOKS_0_CONFIG_URL` environment variable
method: POST
body: file:///etc/config/kratos/hooks/after.registration.jsonnet
auth:
type: api_key
config:
name: KRATOS_API_KEY
# Set `value` through the `SELFSERVICE_FLOWS_REGISTRATION_AFTER_OIDC_HOOKS_0_CONFIG_AUTH_CONFIG_VALUE` environment variable
in: header
# No show_verification_ui β€” OIDC providers deliver verified emails
- hook: session

verification:
use: code
Expand Down
41 changes: 41 additions & 0 deletions apps/hash-frontend/src/pages/shared/format-kratos-message.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { UiText } from "@ory/client";
import type { ReactNode } from "react";

export const providerDisplayNames: Record<string, string> = {
google: "Google",
apple: "Apple",
microsoft: "Microsoft",
github: "GitHub",
gitlab: "GitLab",
};

/**
* Formats a Kratos UI message for display, replacing machine-generated
* messages with human-friendly versions where possible.
*/
export const formatKratosMessage = (message: UiText): ReactNode => {
const context = message.context as Record<string, unknown> | undefined;

// Account linking (Kratos message 1010016):
// Original: 'You tried to sign in with "email", but that email is already
// used by another account...'
if (message.id === 1010016 && context) {
const email = (context.duplicateIdentifier ??
context.duplicate_identifier) as string | undefined;
const providerId = context.provider as string | undefined;
const provider = providerId
? (providerDisplayNames[providerId] ?? providerId)
: undefined;

return (
<>
An account with {email ? <strong>{email}</strong> : "this email"}{" "}
already exists. Sign in with your password to link{" "}
{provider ? <strong>{provider}</strong> : "the external provider"} as
another way to sign in.
</>
);
}

return message.text;
};
125 changes: 125 additions & 0 deletions apps/hash-frontend/src/pages/shared/sso-provider-buttons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { SvgIconProps } from "@mui/material";
import { Box, Typography } from "@mui/material";
import type { LoginFlow } from "@ory/client";
import { isUiNodeInputAttributes } from "@ory/integrations/ui";
import type { AxiosError } from "axios";
import type { FunctionComponent } from "react";

import { AppleIcon } from "../../shared/icons/apple-icon";
import { GitHubIcon } from "../../shared/icons/github-icon";
import { GitLabIcon } from "../../shared/icons/gitlab-icon";
import { GoogleIcon } from "../../shared/icons/google-icon";
import { MicrosoftIcon } from "../../shared/icons/microsoft-icon";
import { Button } from "../../shared/ui";
import { providerDisplayNames } from "./format-kratos-message";
import { mustGetCsrfTokenFromFlow, oryKratosClient } from "./ory-kratos";

const providerIcons: Record<string, FunctionComponent<SvgIconProps>> = {
google: GoogleIcon,
apple: AppleIcon,
microsoft: MicrosoftIcon,
github: GitHubIcon,
gitlab: GitLabIcon,
};

const ssoButtonSx = {
borderRadius: 2,
border: "1px solid",
borderColor: "gray.30",
color: "gray.90",
fontWeight: 500,
px: 2,
py: 1,
minWidth: 0,
"& .MuiButton-startIcon": {
display: "flex",
alignItems: "center",
},
"&:hover": {
borderColor: "gray.50",
background: "gray.10",
},
} as const;

type FlowErrorHandler = (err: AxiosError) => void | Promise<void>;

export const SsoProviderButtons: FunctionComponent<{
flow: LoginFlow;
onFlowError: FlowErrorHandler;
}> = ({ flow, onFlowError }) => {
const oidcNodes = flow.ui.nodes.filter(({ group }) => group === "oidc");

if (oidcNodes.length === 0) {
return null;
}

const handleProviderClick = (provider: string) => {
const csrf_token = mustGetCsrfTokenFromFlow(flow);
void oryKratosClient
.updateLoginFlow({
flow: flow.id,
updateLoginFlowBody: {
method: "oidc",
provider,
csrf_token,
},
})
.catch((err: AxiosError) => {
const data = err.response?.data as
| { redirect_browser_to?: string }
| undefined;
if (err.response?.status === 422 && data?.redirect_browser_to) {
window.location.href = data.redirect_browser_to;
return;
}
void onFlowError(err);
});
};

return (
<Box sx={{ mt: 4, maxWidth: 350 }}>
<Typography
sx={{
color: "gray.70",
fontSize: 14,
mb: 2,
}}
>
If you use SSO, or have previously linked your account to another
service, sign in with them below
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 1,
}}
>
{oidcNodes.map((node) => {
const attrs = node.attributes;
if (!isUiNodeInputAttributes(attrs)) {
return null;
}
const providerId = attrs.value as string;
const providerName = providerDisplayNames[providerId] ?? providerId;
const Icon = providerIcons[providerId];
return (
<Button
key={providerId}
type="button"
variant="tertiary"
size="small"
sx={ssoButtonSx}
startIcon={
Icon ? <Icon sx={{ width: 20, height: 20 }} /> : undefined
}
onClick={() => handleProviderClick(providerId)}
>
{providerName}
</Button>
);
})}
</Box>
</Box>
);
};
62 changes: 57 additions & 5 deletions apps/hash-frontend/src/pages/signin.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { isUiNodeInputAttributes } from "@ory/integrations/ui";
import type { AxiosError } from "axios";
import { useRouter } from "next/router";
import type { FormEventHandler } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";

import { useHashInstance } from "../components/hooks/use-hash-instance";
import { ArrowRightToBracketRegularIcon } from "../shared/icons/arrow-right-to-bracket-regular-icon";
Expand All @@ -20,7 +20,9 @@ import { AuthHeading } from "./shared/auth-heading";
import { useAuthInfo } from "./shared/auth-info-context";
import { AuthLayout } from "./shared/auth-layout";
import { AuthPaper } from "./shared/auth-paper";
import { formatKratosMessage } from "./shared/format-kratos-message";
import { mustGetCsrfTokenFromFlow, oryKratosClient } from "./shared/ory-kratos";
import { SsoProviderButtons } from "./shared/sso-provider-buttons";
import { useKratosErrorHandler } from "./shared/use-kratos-flow-error-handler";
import { WorkspaceContext } from "./shared/workspace-context";

Expand Down Expand Up @@ -167,6 +169,26 @@ const SigninPage: NextPageWithLayout = () => {
attributes.name === "traits.emails",
);

const identifierNode = flow?.ui.nodes.find(
({ attributes }) =>
isUiNodeInputAttributes(attributes) && attributes.name === "identifier",
);

// Pre-fill email from Kratos flow (e.g., during account linking).
// Keyed on flow.id so it only runs once per flow, not on every re-render.
const passwordRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (identifierNode && isUiNodeInputAttributes(identifierNode.attributes)) {
const prefilled = identifierNode.attributes.value;
if (typeof prefilled === "string" && prefilled) {
setEmail(prefilled);
// Focus password field since email is already filled
requestAnimationFrame(() => passwordRef.current?.focus());
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only on flow change
}, [flow?.id]);

const passwordInputUiNode = flow?.ui.nodes.find(
({ attributes }) =>
isUiNodeInputAttributes(attributes) && attributes.name === "password",
Expand Down Expand Up @@ -362,6 +384,35 @@ const SigninPage: NextPageWithLayout = () => {
gap: 1,
}}
>
{flow?.ui.messages && flow.ui.messages.length > 0 && (
<Box
sx={{
p: 2,
borderRadius: 1,
backgroundColor: ({ palette }) =>
flow.ui.messages?.some((msg) => msg.type === "error")
? palette.red[10]
: palette.blue[10],
border: ({ palette }) =>
`1px solid ${flow.ui.messages?.some((msg) => msg.type === "error") ? palette.red[30] : palette.blue[30]}`,
}}
>
{flow.ui.messages.map((message) => (
<Typography
key={message.id}
variant="smallTextParagraphs"
sx={{
color: ({ palette }) =>
message.type === "error"
? palette.red[80]
: palette.blue[80],
}}
>
{formatKratosMessage(message)}
</Typography>
))}
</Box>
)}
{isAal2Flow ? (
<>
<Typography sx={{ color: ({ palette }) => palette.gray[70] }}>
Expand Down Expand Up @@ -457,6 +508,7 @@ const SigninPage: NextPageWithLayout = () => {
label="Password"
type="password"
autoComplete="current-password"
inputRef={passwordRef}
placeholder="Enter your password"
value={password}
onChange={({ target }) => setPassword(target.value)}
Expand Down Expand Up @@ -516,18 +568,18 @@ const SigninPage: NextPageWithLayout = () => {
{errorMessage}
</Typography>
) : null}
{flow?.ui.messages?.map(({ text, id }) => (
<Typography key={id}>{text}</Typography>
))}
</Box>
</AuthPaper>
<Box>
<Box sx={{ maxWidth: 350 }}>
<Typography gutterBottom>
<strong>No account?</strong> No problem.
</Typography>
<Button href="/signup" disabled={!userSelfRegistrationIsEnabled}>
Create a free account
</Button>
{flow ? (
<SsoProviderButtons flow={flow} onFlowError={handleFlowError} />
) : null}
</Box>
</Box>
</AuthLayout>
Expand Down
12 changes: 12 additions & 0 deletions apps/hash-frontend/src/shared/icons/apple-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { SvgIconProps } from "@mui/material";
import { SvgIcon } from "@mui/material";
import type { FunctionComponent } from "react";

export const AppleIcon: FunctionComponent<SvgIconProps> = (props) => (
<SvgIcon {...props} viewBox="0 0 24 24">
<path
d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.53-3.23 0-1.44.62-2.2.44-3.06-.4C3.79 16.17 4.36 9.02 8.82 8.78c1.28.07 2.17.74 2.92.78.98-.2 1.92-.89 3-.81 1.51.12 2.63.71 3.36 1.78-3.07 1.87-2.34 5.98.44 7.13-.58 1.52-1.33 3.02-2.49 4.62zM12.03 8.7c-.15-2.34 1.81-4.29 4.01-4.48.3 2.65-2.38 4.63-4.01 4.48z"
fill="currentColor"
/>
</SvgIcon>
);
Loading
Loading