Skip to content

Commit

Permalink
Implement OpenID add/remove accounts in identity management (#2762)
Browse files Browse the repository at this point in the history
* Implement mock openID actor methods

* Implement (un)link account on manage page.

* Move Google client id to env variable.

* Move Google client id to env variable.

* Update CSP in test.

* Update CSP in test.

* Update CSP in test.

* Add OpenID to showcase

* Add OpenID to showcase

* Apply prettier to json

* Fix showcase

* Fix showcase

* Move callback path to shared constant.
  • Loading branch information
sea-snake authored Jan 3, 2025
1 parent 771294c commit 0d59af9
Show file tree
Hide file tree
Showing 24 changed files with 747 additions and 38 deletions.
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",
"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
2 changes: 1 addition & 1 deletion src/canister_tests/src/framework.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ xr-spatial-tracking=()",
let rgx = Regex::new(
"^default-src 'none';\
connect-src 'self' https:;\
img-src 'self' data:;\
img-src 'self' data: https://\\*.googleusercontent.com;\
script-src 'strict-dynamic' ('[^']+' )*'unsafe-inline' 'unsafe-eval' https:;\
base-uri 'none';\
form-action 'none';\
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,
} 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;
82 changes: 77 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,13 @@ 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 copyJson from "$src/flows/manage/linkedAccountsSection.json";
import {
TempKeyWarningAction,
tempKeyWarningBox,
Expand All @@ -29,6 +32,14 @@ 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,
createAnonymousNonce,
decodeJWT,
isPermissionError,
requestJWT,
} from "$src/utils/openID";
import { PreLoadImage } from "$src/utils/preLoadImage";
import {
isProtected,
Expand Down Expand Up @@ -147,6 +158,9 @@ const displayManageTemplate = ({
onAddDevice,
addRecoveryPhrase,
addRecoveryKey,
credentials,
onLinkAccount,
onUnlinkAccount,
dapps,
exploreDapps,
identityBackground,
Expand All @@ -157,6 +171,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 +199,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 +270,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[] };
try {
// Ignore the `commitMetadata` response, it's not critical for the application.
void connection.commitMetadata();
Expand All @@ -267,6 +292,7 @@ export const renderManage = async ({
userNumber,
connection,
anchorInfo.devices,
anchorInfo.credentials,
identityBackground
);
connection = newConnection ?? connection;
Expand All @@ -291,21 +317,32 @@ function isPinAuthenticated(
);
}

export const displayManage = (
export const displayManage = async (
userNumber: bigint,
connection: AuthenticatedConnection,
devices_: DeviceWithUsage[],
credentials: OpenIDCredential[],
identityBackground: PreLoadImage
): Promise<void | AuthenticatedConnection> => {
const i18n = new I18n();
const copy = i18n.i18n(copyJson);

// Fetch the dapps used in the teaser & explorer
// (dapps are suffled to encourage discovery of new dapps)
const dapps = shuffleArray(getDapps());

// Create anonymous nonce and salt for connection principal
const { nonce, salt } = await createAnonymousNonce(
connection.identity.getPrincipal()
);

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 +371,35 @@ export const displayManage = (
resolve();
};

const onLinkAccount = async () => {
try {
const jwt = await withLoader(() =>
requestJWT(GOOGLE_REQUEST_CONFIG, {
mediation: "required",
nonce,
})
);
const { iss, sub } = decodeJWT(jwt);
if (credentials.find((c) => c.iss === iss && c.sub === sub)) {
toast.error(copy.account_already_linked);
return;
}
await connection.addJWT(jwt, salt);
resolve();
} catch (error) {
if (isPermissionError(error)) {
toast.error(copy.third_party_sign_in_permission_required);
}
}
};
const onUnlinkAccount = async (credential: OpenIDCredential) => {
if (!confirm(copy.unlink_account_confirmation.toString())) {
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 +435,9 @@ export const displayManage = (
await setupKey({ connection });
resolve();
},
credentials,
onLinkAccount,
onUnlinkAccount,
dapps,
exploreDapps: async () => {
await dappsExplorer({ dapps });
Expand Down Expand Up @@ -445,11 +514,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 +549,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
14 changes: 14 additions & 0 deletions src/frontend/src/flows/manage/linkedAccountsSection.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"en": {
"account_already_linked": "This account has already been linked",
"unlink_account_confirmation": "Do you want to unlink this account?",
"third_party_sign_in_permission_required": "You need to enable the \"Third-party sign-in\" browser permission for this site",
"link_your_account_to_hold_assets_and_sign_into_dapps": "Link your online account to hold assets and securely sign into dapps.",
"use_your_accounts_to_hold_assets_and_sign_into_dapps": "Use your online accounts to hold assets and securely sign into dapps.",
"link_account": "Link account",
"link_additional_account": "Link additional account",
"max_linked_accounts_reached": "You can link up to 100 online accounts. You must unlink an account before you can link another.",
"unlink": "Unlink",
"last_used": "Last used"
}
}
Loading

0 comments on commit 0d59af9

Please sign in to comment.