Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement OpenID add/remove accounts in identity management #2762

Merged
merged 14 commits into from
Jan 3, 2025
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
"private": true,
"license": "SEE LICENSE IN LICENSE.md",
"scripts": {
"dev": "II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=1 vite",
"host": "II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=1 vite --host",
"dev": "II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=1 II_OPENID_GOOGLE_CLIENT_ID=45431994619-cbbfgtn7o0pp0dpfcg2l66bc4rcg7qbu.apps.googleusercontent.com vite",
lmuntaner marked this conversation as resolved.
Show resolved Hide resolved
"host": "II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=1 II_OPENID_GOOGLE_CLIENT_ID=45431994619-cbbfgtn7o0pp0dpfcg2l66bc4rcg7qbu.apps.googleusercontent.com vite --host",
"showcase": "astro dev --root ./src/showcase",
"build": "tsc --noEmit && vite build",
"check": "tsc --project ./tsconfig.all.json --noEmit",
Expand Down
26 changes: 26 additions & 0 deletions src/frontend/src/components/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,3 +445,29 @@ export const cypherIcon = html`
/>
</svg>
`;

export const googleIcon = html`
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
`;
2 changes: 2 additions & 0 deletions src/frontend/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export const VERSION = import.meta.env.II_VERSION ?? "";
export const FETCH_ROOT_KEY = import.meta.env.II_FETCH_ROOT_KEY === "1";
export const DUMMY_AUTH = import.meta.env.II_DUMMY_AUTH === "1";
export const DUMMY_CAPTCHA = import.meta.env.II_DUMMY_CAPTCHA === "1";
export const II_OPENID_GOOGLE_CLIENT_ID = import.meta.env
.II_OPENID_GOOGLE_CLIENT_ID;
4 changes: 3 additions & 1 deletion src/frontend/src/featureFlags/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Feature flags with default values
const FEATURE_FLAGS_WITH_DEFAULTS = {
DOMAIN_COMPATIBILITY: false,
OPENID_AUTHENTICATION: false,
lmuntaner marked this conversation as resolved.
Show resolved Hide resolved
} as const satisfies Record<string, boolean>;

const LOCALSTORAGE_FEATURE_FLAGS_PREFIX = "ii-localstorage-feature-flags__";
Expand Down Expand Up @@ -63,4 +64,5 @@ const initializedFeatureFlags = Object.fromEntries(
window.__featureFlags = initializedFeatureFlags;

// Export initialized feature flags as named exports
export const { DOMAIN_COMPATIBILITY } = initializedFeatureFlags;
export const { DOMAIN_COMPATIBILITY, OPENID_AUTHENTICATION } =
initializedFeatureFlags;
75 changes: 70 additions & 5 deletions src/frontend/src/flows/manage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import { logoutSection } from "$src/components/logout";
import { mainWindow } from "$src/components/mainWindow";
import { toast } from "$src/components/toast";
import { ENABLE_PIN_QUERY_PARAM_KEY, LEGACY_II_URL } from "$src/config";
import { OPENID_AUTHENTICATION } from "$src/featureFlags";
import { addDevice } from "$src/flows/addDevice/manage/addDevice";
import { dappsExplorer } from "$src/flows/dappsExplorer";
import { KnownDapp, getDapps } from "$src/flows/dappsExplorer/dapps";
import { dappsHeader, dappsTeaser } from "$src/flows/dappsExplorer/teaser";
import { linkedAccountsSection } from "$src/flows/manage/linkedAccountsSection";
import {
TempKeyWarningAction,
tempKeyWarningBox,
Expand All @@ -29,13 +31,21 @@ import { setupKey, setupPhrase } from "$src/flows/recovery/setupRecovery";
import { I18n } from "$src/i18n";
import { AuthenticatedConnection, Connection } from "$src/utils/iiConnection";
import { TemplateElement, renderPage } from "$src/utils/lit-html";
import { OpenIDCredential } from "$src/utils/mockOpenID";
import {
GOOGLE_REQUEST_CONFIG,
createRequestJWT,
decodeJWT,
getMetadataString,
} from "$src/utils/openID";
import { PreLoadImage } from "$src/utils/preLoadImage";
import {
isProtected,
isRecoveryDevice,
isRecoveryPhrase,
} from "$src/utils/recoveryDevice";
import { OmitParams, shuffleArray, unreachable } from "$src/utils/utils";
import { ECDSAKeyIdentity } from "@dfinity/identity";
import { Principal } from "@dfinity/principal";
import { isNullish, nonNullish } from "@dfinity/utils";
import { TemplateResult, html } from "lit-html";
Expand Down Expand Up @@ -147,6 +157,9 @@ const displayManageTemplate = ({
onAddDevice,
addRecoveryPhrase,
addRecoveryKey,
credentials,
onLinkAccount,
onUnlinkAccount,
dapps,
exploreDapps,
identityBackground,
Expand All @@ -157,6 +170,9 @@ const displayManageTemplate = ({
onAddDevice: () => void;
addRecoveryPhrase: () => void;
addRecoveryKey: () => void;
credentials: OpenIDCredential[];
onLinkAccount: () => void;
onUnlinkAccount: (credential: OpenIDCredential) => void;
dapps: KnownDapp[];
exploreDapps: () => void;
identityBackground: PreLoadImage;
Expand All @@ -182,6 +198,14 @@ const displayManageTemplate = ({
onAddDevice,
warnNoPasskeys,
})}
${OPENID_AUTHENTICATION.isEnabled()
? linkedAccountsSection({
credentials,
onLinkAccount,
onUnlinkAccount,
hasOtherAuthMethods: authenticators.length > 0,
})
: ""}
${recoveryMethodsSection({ recoveries, addRecoveryPhrase, addRecoveryKey })}
<aside class="l-stack">
${dappsTeaser({
Expand Down Expand Up @@ -245,7 +269,7 @@ export const renderManage = async ({
// There's nowhere to go from here (i.e. all flows lead to/start from this page), so we
// loop forever
for (;;) {
let anchorInfo: IdentityAnchorInfo;
let anchorInfo: IdentityAnchorInfo & { credentials: OpenIDCredential[] };
sea-snake marked this conversation as resolved.
Show resolved Hide resolved
try {
// Ignore the `commitMetadata` response, it's not critical for the application.
void connection.commitMetadata();
Expand All @@ -267,6 +291,7 @@ export const renderManage = async ({
userNumber,
connection,
anchorInfo.devices,
anchorInfo.credentials,
identityBackground
);
connection = newConnection ?? connection;
Expand All @@ -291,21 +316,31 @@ function isPinAuthenticated(
);
}

export const displayManage = (
export const displayManage = async (
userNumber: bigint,
connection: AuthenticatedConnection,
devices_: DeviceWithUsage[],
credentials: OpenIDCredential[],
identityBackground: PreLoadImage
): Promise<void | AuthenticatedConnection> => {
// Fetch the dapps used in the teaser & explorer
// (dapps are suffled to encourage discovery of new dapps)
const dapps = shuffleArray(getDapps());

// Create method to initiate JWT request
const identity = await ECDSAKeyIdentity.generate();
lmuntaner marked this conversation as resolved.
Show resolved Hide resolved
const requestJWT = await createRequestJWT(GOOGLE_REQUEST_CONFIG, {
principal: identity.getPrincipal(),
mediation: "required",
});

return new Promise((resolve) => {
const devices = devicesFromDevicesWithUsage({
devices: devices_,
userNumber,
connection,
reload: resolve,
hasOtherAuthMethods: credentials.length > 0,
});

if (devices.dupPhrase) {
Expand Down Expand Up @@ -334,6 +369,30 @@ export const displayManage = (
resolve();
};

const onLinkAccount = async () => {
const { jwt, salt } = await requestJWT();
const { iss, sub } = decodeJWT(jwt);
if (
credentials.find(
(credential) => credential.iss === iss && credential.sub === sub
)
) {
toast.error("This account has already been linked");
sea-snake marked this conversation as resolved.
Show resolved Hide resolved
return;
lmuntaner marked this conversation as resolved.
Show resolved Hide resolved
}
await connection.addJWT(jwt, new Uint8Array(salt));
resolve();
};
const onUnlinkAccount = async (credential: OpenIDCredential) => {
const name =
getMetadataString(credential.metadata, "name") ?? credential.sub;
if (!confirm(`Do you really want to unlink the account "${name}"?`)) {
lmuntaner marked this conversation as resolved.
Show resolved Hide resolved
return;
}
await connection.removeJWT(credential.iss, credential.sub);
resolve();
};

// Function to figure out what temp keys warning should be shown, if any.
const determineTempKeysWarning = (): TempKeyWarningAction | undefined => {
if (!isPinAuthenticated(devices_, connection)) {
Expand Down Expand Up @@ -369,6 +428,9 @@ export const displayManage = (
await setupKey({ connection });
resolve();
},
credentials,
onLinkAccount,
onUnlinkAccount,
dapps,
exploreDapps: async () => {
await dappsExplorer({ dapps });
Expand Down Expand Up @@ -445,11 +507,13 @@ export const devicesFromDevicesWithUsage = ({
reload,
connection,
userNumber,
hasOtherAuthMethods,
}: {
devices: DeviceWithUsage[];
reload: (connection?: AuthenticatedConnection) => void;
connection: AuthenticatedConnection;
userNumber: bigint;
hasOtherAuthMethods: boolean;
}): Devices & { dupPhrase: boolean; dupKey: boolean } => {
const hasSingleDevice = devices_.length <= 1;

Expand Down Expand Up @@ -478,9 +542,10 @@ export const devicesFromDevicesWithUsage = ({
last_usage: device.last_usage,
warn: domainWarning(device),
rename: () => renameDevice({ connection, device, reload }),
remove: hasSingleDevice
? undefined
: () => deleteDevice({ connection, device, reload }),
remove:
hasSingleDevice && !hasOtherAuthMethods
? undefined
: () => deleteDevice({ connection, device, reload }),
};

if ("browser_storage_key" in device.key_type) {
Expand Down
132 changes: 132 additions & 0 deletions src/frontend/src/flows/manage/linkedAccountsSection.ts
sea-snake marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { googleIcon } from "$src/components/icons";
import { OpenIDCredential } from "$src/utils/mockOpenID";
import { getMetadataString } from "$src/utils/openID";
import { formatLastUsage } from "$src/utils/time";
import { nonNullish } from "@dfinity/utils";
import { TemplateResult, html } from "lit-html";
import { settingsDropdown } from "./settingsDropdown";

// The maximum number of linked accounts we allow.
// The canister limits the _total_ number of linked accounts to 100,
// and we (the frontend) won't show a counter since this number is intentionally
// high to avoid showing a usage counter while still having an actual limit.
const MAX_CREDENTIALS = 100;

export const linkedAccountsSection = ({
credentials,
onLinkAccount,
onUnlinkAccount,
hasOtherAuthMethods,
}: {
credentials: OpenIDCredential[];
onLinkAccount: () => void;
onUnlinkAccount: (credential: OpenIDCredential) => void;
hasOtherAuthMethods: boolean;
}): TemplateResult => {
const unlinkAvailable = credentials.length > 1 || hasOtherAuthMethods;

return html` <aside
class="l-stack c-card c-card--narrow"
data-role="linked-accounts"
>
<h2 class="t-title">Linked Accounts</h2>
<p class="t-paragraph t-lead">
${credentials.length === 0
? "Link your online account to hold assets and securely sign into dapps."
sea-snake marked this conversation as resolved.
Show resolved Hide resolved
: "Use your online accounts to hold assets and securely sign into dapps."}
sea-snake marked this conversation as resolved.
Show resolved Hide resolved
</p>
<div class="c-action-list">
<ul>
${credentials.map((credential, index) =>
accountItem({
credential,
index,
unlink: onUnlinkAccount,
unlinkAvailable,
})
)}
</ul>
<div class="c-action-list__actions">
<button
?disabled=${credentials.length >= MAX_CREDENTIALS}
class="c-button c-button--primary c-tooltip c-tooltip--onDisabled c-tooltip--left"
@click="${() => onLinkAccount()}"
id="linkAdditionalAccount"
>
<span class="c-tooltip__message c-card c-card--tight"
>You can link up to ${MAX_CREDENTIALS} online accounts. You must
sea-snake marked this conversation as resolved.
Show resolved Hide resolved
unlink an account before you can link another.</span
>
<span
>${credentials.length === 0
? "Link account"
sea-snake marked this conversation as resolved.
Show resolved Hide resolved
: "Link additional account"}</span
sea-snake marked this conversation as resolved.
Show resolved Hide resolved
>
</button>
</div>
</div>
</aside>`;
};

export const accountItem = ({
credential,
index,
unlink,
unlinkAvailable,
}: {
credential: OpenIDCredential;
index: number;
unlink: (credential: OpenIDCredential) => void;
unlinkAvailable: boolean;
}) => {
const settings = [
{
action: "unlink",
caption: "Unlink",
fn: () => unlink(credential),
},
];

const lastUsageTimeStamp = new Date(
Number(credential.last_usage_timestamp / BigInt(1000000))
);
const lastUsageFormattedString = formatLastUsage(lastUsageTimeStamp);
const name = getMetadataString(credential.metadata, "name");
const email = getMetadataString(credential.metadata, "email");
const picture = getMetadataString(credential.metadata, "picture");

return html`
<li class="c-action-list__item" data-account=${credential.sub}>
${
nonNullish(picture)
? html`<div class="c-action-list__avatar">
<img src="${picture}" alt="" aria-hidden="true" loading="lazy" />
<div class="c-action-list__avatar--badge">${googleIcon}</div>
</div>`
: ""
}
<div class="c-action-list__label--stacked c-action-list__label">
<div class="c-action-list__label c-action-list__label--spacer">
<div class="c-action-list__label">
<span class="c-tooltip" tabindex="0">
<span class="c-tooltip__message c-card c-card--tight t-nowrap">${email}</span
<span>${name}</span>
</span>
</div>
${
unlinkAvailable
? settingsDropdown({
alias: credential.sub,
id: `account-${index}`,
settings,
})
: ""
}
</div>
<div>
<div class="t-muted">Last used: ${lastUsageFormattedString}</div>
sea-snake marked this conversation as resolved.
Show resolved Hide resolved
</div>
</div>
</li>
`;
};
Loading
Loading