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
12 changes: 12 additions & 0 deletions src/frontend/src/lib/components/icons/SsoIcon.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script lang="ts">
import type { SVGAttributes } from "svelte/elements";

const { class: className, ...props }: SVGAttributes<SVGSVGElement> = $props();
</script>

<svg viewBox="0 0 20 20" {...props} class={["size-5", className]}>
<path
d="M10 1a4.5 4.5 0 0 0-4.5 4.5c0 1.56.8 2.93 2 3.74V18a1 1 0 0 0 1.7.7L11 16.92l1.3 1.3a1 1 0 0 0 1.42 0l1.3-1.3a1 1 0 0 0 0-1.42l-1.3-1.3 1-1a1 1 0 0 0 0-1.42L13.5 10.6V9.24a4.5 4.5 0 0 0 1-7.74A4.5 4.5 0 0 0 10 1Zm0 2.5a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z"
class="fill-current"
/>
</svg>
6 changes: 6 additions & 0 deletions src/frontend/src/lib/components/ui/IdentityAvatar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
identity.authMethod.openid.metadata !== undefined
? openIdLogo(
identity.authMethod.openid.iss,
// `sub` and `aud` not currently tracked on `LastUsedIdentity`;
// see #3795. Falls back to issuer-only matching in findConfig —
// correct for direct providers, imprecise for SSO until that's
// fixed.
identity.authMethod.openid.sub,
undefined,
identity.authMethod.openid.metadata,
)
: undefined,
Expand Down
3 changes: 3 additions & 0 deletions src/frontend/src/lib/components/ui/ManageIdentities.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
identity.authMethod.openid.metadata !== undefined
? openIdName(
identity.authMethod.openid.iss,
identity.authMethod.openid.sub,
// `aud` not tracked on `LastUsedIdentity`; see #3795.
undefined,
identity.authMethod.openid.metadata,
)
: undefined}
Expand Down
34 changes: 33 additions & 1 deletion src/frontend/src/lib/components/utils/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import type {
OpenIdCredentialAddError,
OpenIdCredentialRemoveError,
} from "$lib/generated/internet_identity_types";
import { isOpenIdCancelError } from "$lib/utils/openID";
import {
OAuthProviderError,
OpenIdCredentialAlreadyLinkedHereError,
isOpenIdCancelError,
} from "$lib/utils/openID";
import {
AuthenticationV2Events,
authenticationV2Funnel,
Expand All @@ -28,6 +32,34 @@ export const handleError = (error: unknown) => {
return;
}

// Specialization of the generic `OpenIdCredentialAlreadyRegistered` error
// below: the credential is already on THIS identity, not some other one.
if (error instanceof OpenIdCredentialAlreadyLinkedHereError) {
toaster.error({
title: "This account is already linked to this identity",
});
return;
}

// OAuth provider returned `error=…` in the callback fragment (RFC 6749
// §4.1.2.1 / 4.2.2.1). Surface the provider's own description so a
// misconfigured SSO app (e.g. Okta set to `response_types=[code]` only)
// doesn't look like an II bug. The SSO view's `mapSubmitError` gives
// more specific guidance when the error hits inside `SignInWithSso`;
// this branch covers callers (direct-OpenID entry points) that route
// through `handleError` instead.
if (error instanceof OAuthProviderError) {
toaster.error({
title: `SSO provider returned "${error.error}"`,
description:
error.errorDescription !== undefined &&
error.errorDescription.length > 0
? error.errorDescription
: "Ask your SSO admin to check the app's OAuth configuration.",
});
return;
}

// Handle canister errors
if (
isCanisterError<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
} from "$lib/generated/internet_identity_types";
import AddAccessMethod from "$lib/components/wizards/addAccessMethod/views/AddAccessMethod.svelte";
import AddPasskey from "$lib/components/wizards/addAccessMethod/views/AddPasskey.svelte";
import SignInWithSso from "$lib/components/wizards/auth/views/SignInWithSso.svelte";
import { ConfirmAccessMethodWizard } from "$lib/components/wizards/confirmAccessMethod";
import { isOpenIdCancelError } from "$lib/utils/openID";
import { isWebAuthnCancelError } from "$lib/utils/webAuthnErrorUtils";
import type { SsoDiscoveryResult } from "$lib/utils/ssoDiscovery";

interface Props {
onOpenIdLinked: (credential: OpenIdCredential) => void;
Expand Down Expand Up @@ -48,6 +50,16 @@
onError(error); // Propagate unhandled errors to parent component
}
};
const handleContinueWithSso = async (ssoResult: SsoDiscoveryResult) => {
try {
onOpenIdLinked(await addAccessMethodFlow.linkSsoAccount(ssoResult));
} catch (error) {
if (isOpenIdCancelError(error)) {
return "cancelled";
}
onError(error);
}
};
const handleCreatePasskey = async () => {
try {
onPasskeyRegistered(
Expand All @@ -74,6 +86,7 @@
<AddAccessMethod
continueWithPasskey={addAccessMethodFlow.continueWithPasskey}
linkOpenIdAccount={handleContinueWithOpenId}
signInWithSso={addAccessMethodFlow.signInWithSso}
{maxPasskeysReached}
{openIdCredentials}
/>
Expand All @@ -83,6 +96,11 @@
continueOnAnotherDevice={() => (isContinueOnAnotherDeviceVisible = true)}
{isUsingPasskeys}
/>
{:else if addAccessMethodFlow.view === "signInWithSso"}
<SignInWithSso
continueWithSso={handleContinueWithSso}
goBack={addAccessMethodFlow.chooseMethod}
/>
{/if}

{#if addAccessMethodFlow.isSystemOverlayVisible}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import ShieldIllustration from "$lib/components/illustrations/ShieldIllustration.svelte";
import PasskeyIcon from "$lib/components/icons/PasskeyIcon.svelte";
import SsoIcon from "$lib/components/icons/SsoIcon.svelte";
import ProgressRing from "$lib/components/ui/ProgressRing.svelte";
import Alert from "$lib/components/ui/Alert.svelte";
import Button from "$lib/components/ui/Button.svelte";
Expand All @@ -18,13 +19,15 @@
interface Props {
linkOpenIdAccount: (config: OpenIdConfig) => Promise<"cancelled" | void>;
continueWithPasskey: () => void;
signInWithSso: () => void;
openIdCredentials?: OpenIdCredential[];
maxPasskeysReached?: boolean;
}

const {
linkOpenIdAccount,
continueWithPasskey,
signInWithSso,
openIdCredentials = [],
maxPasskeysReached,
}: Props = $props();
Expand Down Expand Up @@ -111,6 +114,21 @@
</Tooltip>
</Tooltip>
{/each}
<!--
SSO entry is always rendered alongside the named providers. The SSO
screen calls `add_discoverable_oidc_config` on submit; domains not on
the backend canary allowlist are rejected there.
-->
<Button
onclick={signInWithSso}
variant="secondary"
disabled={authenticatingProviderId !== undefined}
size="xl"
class="flex-1"
aria-label={$t`Sign in with SSO`}
>
<SsoIcon class="size-6" />
</Button>
</div>
<Tooltip
label={$t`You have reached the maximum number of passkeys`}
Expand Down
31 changes: 31 additions & 0 deletions src/frontend/src/lib/components/wizards/auth/AuthWizard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import { isOpenIdCancelError } from "$lib/utils/openID";
import type { OpenIdConfig } from "$lib/generated/internet_identity_types";
import CreateIdentity from "$lib/components/wizards/auth/views/CreateIdentity.svelte";
import SignInWithSso from "$lib/components/wizards/auth/views/SignInWithSso.svelte";
import type { SsoDiscoveryResult } from "$lib/utils/ssoDiscovery";

interface Props {
onSignIn: (identityNumber: bigint) => Promise<void>;
Expand Down Expand Up @@ -93,6 +95,29 @@
}
};

const handleContinueWithSso = async (
result: SsoDiscoveryResult,
): Promise<void | "cancelled"> => {
try {
isAuthenticating = true;
const authResult = await authFlow.continueWithSso(result);
if (authResult.type === "signIn") {
await onSignIn(authResult.identityNumber);
} else if (authResult.name !== undefined) {
await onSignUp(
await authFlow.completeOpenIdRegistration(authResult.name),
);
}
} catch (error) {
if (isOpenIdCancelError(error)) {
return "cancelled";
}
onError(error);
} finally {
isAuthenticating = false;
}
};

const handleCompleteOpenIdRegistration = async (
name: string,
): Promise<void> => {
Expand Down Expand Up @@ -122,6 +147,11 @@
<CreatePasskey create={handleCreatePasskey} />
{:else if authFlow.view === "setupNewIdentity"}
<CreateIdentity create={handleCompleteOpenIdRegistration} />
{:else if authFlow.view === "signInWithSso"}
<SignInWithSso
continueWithSso={handleContinueWithSso}
goBack={authFlow.chooseMethod}
/>
{/if}
{/snippet}

Expand Down Expand Up @@ -149,6 +179,7 @@
<PickAuthenticationMethod
setupOrUseExistingPasskey={authFlow.setupOrUseExistingPasskey}
continueWithOpenId={handleContinueWithOpenId}
signInWithSso={authFlow.signInWithSso}
/>
{/if}
{#if authFlow.view !== "chooseMethod"}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import Button from "$lib/components/ui/Button.svelte";
import PasskeyIcon from "$lib/components/icons/PasskeyIcon.svelte";
import SsoIcon from "$lib/components/icons/SsoIcon.svelte";
import Alert from "$lib/components/ui/Alert.svelte";
import ProgressRing from "$lib/components/ui/ProgressRing.svelte";
import { backendCanisterConfig } from "$lib/globals";
Expand All @@ -12,9 +13,14 @@
interface Props {
setupOrUseExistingPasskey: () => void;
continueWithOpenId: (config: OpenIdConfig) => Promise<void | "cancelled">;
signInWithSso: () => void;
}

const { setupOrUseExistingPasskey, continueWithOpenId }: Props = $props();
const {
setupOrUseExistingPasskey,
continueWithOpenId,
signInWithSso,
}: Props = $props();

let authenticatingProviderId = $state<string>();
let cancelledProviderId = $state<string>();
Expand Down Expand Up @@ -43,6 +49,15 @@
/>
{/if}
<div class="flex flex-col items-stretch gap-3">
<Button
onclick={setupOrUseExistingPasskey}
disabled={!supportsPasskeys || authenticatingProviderId !== undefined}
size="xl"
variant={"secondary"}
>
<PasskeyIcon />
{$t`Continue with passkey`}
</Button>
<div class="flex flex-row flex-nowrap justify-stretch gap-3">
{#each openIdProviders as provider}
{@const name = provider.name}
Expand All @@ -69,16 +84,25 @@
</Button>
</Tooltip>
{/each}
<!--
SSO entry is always rendered. Registration is enforced on the
backend (via the `ALLOWED_DISCOVERY_DOMAINS` canary allowlist on
`add_discoverable_oidc_config`), so unregistered domains surface as
an error inside the SignInWithSso screen rather than being gated
here — we keep this option visible so users know the mechanism
exists.
-->
<Button
onclick={signInWithSso}
variant="secondary"
disabled={authenticatingProviderId !== undefined}
size="xl"
class="flex-1"
aria-label={$t`Sign in with SSO`}
Comment thread
aterga marked this conversation as resolved.
>
<SsoIcon class="size-6" />
</Button>
</div>
<Button
onclick={setupOrUseExistingPasskey}
disabled={!supportsPasskeys || authenticatingProviderId !== undefined}
size="xl"
variant={"secondary"}
>
<PasskeyIcon />
{$t`Continue with passkey`}
</Button>
</div>
<div class="border-border-tertiary border-t"></div>
<div class="flex flex-row items-center justify-between gap-4">
Expand Down
Loading
Loading