diff --git a/.changeset/long-worms-refuse.md b/.changeset/long-worms-refuse.md new file mode 100644 index 00000000000..6d0454c6973 --- /dev/null +++ b/.changeset/long-worms-refuse.md @@ -0,0 +1,5 @@ +--- +"@logto/console": patch +--- + +add Chrome extension guide diff --git a/packages/console/src/assets/docs/guides/index.tsx b/packages/console/src/assets/docs/guides/index.tsx index 5582af3ee99..d83d09817d4 100644 --- a/packages/console/src/assets/docs/guides/index.tsx +++ b/packages/console/src/assets/docs/guides/index.tsx @@ -12,6 +12,7 @@ import nativeExpo from './native-expo/index'; import nativeFlutter from './native-flutter/index'; import nativeIosSwift from './native-ios-swift/index'; import spaAngular from './spa-angular/index'; +import spaChromeExtension from './spa-chrome-extension/index'; import spaReact from './spa-react/index'; import spaVanilla from './spa-vanilla/index'; import spaVue from './spa-vue/index'; @@ -60,6 +61,13 @@ export const guides: Readonly = Object.freeze([ Component: lazy(async () => import('./spa-angular/README.mdx')), metadata: spaAngular, }, + { + order: 1.1, + id: 'spa-chrome-extension', + Logo: lazy(async () => import('./spa-chrome-extension/logo.svg')), + Component: lazy(async () => import('./spa-chrome-extension/README.mdx')), + metadata: spaChromeExtension, + }, { order: 1.1, id: 'spa-react', diff --git a/packages/console/src/assets/docs/guides/spa-chrome-extension/README.mdx b/packages/console/src/assets/docs/guides/spa-chrome-extension/README.mdx new file mode 100644 index 00000000000..98d22ecee13 --- /dev/null +++ b/packages/console/src/assets/docs/guides/spa-chrome-extension/README.mdx @@ -0,0 +1,238 @@ +import UriInputField from '@/mdx-components/UriInputField'; +import InlineNotification from '@/ds-components/InlineNotification'; +import Steps from '@/mdx-components/Steps'; +import Step from '@/mdx-components/Step'; +import NpmLikeInstallation from '@/mdx-components/NpmLikeInstallation'; + +import RegardingRedirectBasedSignIn from '../../fragments/_regarding-redirect-based-sign-in.md'; + +import extensionPopup from './extension-popup.webp'; + + + + + + + + + + + +Assuming you put a "Sign in" button in your Chrome extension's popup, the authentication flow will look like this: + +```mermaid +sequenceDiagram + participant A as Extension popup + participant B as Extension service worker + participant C as Logto sign-in experience + + A->>B: Invokes sign-in + B->>C: Redirects to Logto + C->>C: User signs in + C->>B: Redirects back to extension + B->>A: Notifies the popup +``` + +For other interactive pages in your extension, you just need to replace the `Extension popup` participant with the page's name. In this tutorial, we will focus on the popup page. + + + + + + + +### Update the `manifest.json` + +Logto SDK requires the following permissions in the `manifest.json`: + +```json title="manifest.json" +{ + "permissions": ["identity", "storage"], + "host_permissions": ["https://*.logto.app/*"] +} +``` + +- `permissions.identity`: Required for the Chrome Identity API, which is used to sign in and sign out. +- `permissions.storage`: Required for storing the user's session. +- `host_permissions`: Required for the Logto SDK to communicate with the Logto APIs. + + +If you are using a custom domain on Logto Cloud, you need to update the `host_permissions` to match your domain. + + +### Set up a background script (service worker) + +In your Chrome extension's background script, initialize the Logto SDK: + +```js title="service-worker.js" +import LogtoClient from '@logto/chrome-extension'; + +export const logtoClient = new LogtoClient({ + endpoint: '' + appId: '', +}); +``` + +Replace `` and `` with the actual values. You can find these values in the application page you just created in the Logto Console. + +If you don't have a background script, you can follow the [official guide](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/basics) to create one. + + +**Why do we need a background script?** + +Normal extension pages like the popup or options page can't run in the background, and they have the possibility to be closed during the authentication process. A background script ensures the authentication process can be properly handled. + + +Then, we need to listen to the message from other extension pages and handle the authentication process: + +```js title="service-worker.js" +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // In the below code, since we return `true` for each action, we need to call `sendResponse` + // to notify the sender. You can also handle errors here, or use other ways to notify the sender. + + if (message.action === 'signIn') { + const redirectUri = chrome.identity.getRedirectURL('/callback'); + logtoClient.signIn(redirectUri).finally(sendResponse); + return true; + } + + if (message.action === 'signOut') { + const redirectUri = chrome.identity.getRedirectURL(); + logtoClient.signOut(redirectUri).finally(sendResponse); + return true; + } + + return false; +}); +``` + +You may notice there are two redirect URIs used in the code above. They are both created by `chrome.identity.getRedirectURL`, which is a [built-in Chrome API](https://developer.chrome.com/docs/extensions/reference/api/identity#method-getRedirectURL) to generate a redirect URL for auth flows. The two URIs will be: + +- `https://.chromiumapp.org/callback` for sign-in. +- `https://.chromiumapp.org/` for sign-out. + +Note that these URIs are not accessible, and they are only used for Chrome to trigger specific actions for the authentication process. + + + + + +As we mentioned in the previous step, we need to update the Logto application settings to allow the redirect URIs we just created (`https://.chromiumapp.org/callback`): + + + +And the post sign-out redirect URI (`https://.chromiumapp.org/`): + + + +Finally, the CORS allowed origins should include the extension's origin (`chrome-extension://`). The SDK in Chrome extension will use this origin to communicate with the Logto APIs. + + + +Don't forget to replace `` with your actual extension ID and click the "Save" button. + + + + + +We're almost there! Let's add the sign-in and sign-out buttons and other necessary logic to the popup page. + +In the `popup.html` file: + +```html title="popup.html" + +``` + +In the `popup.js` file (assuming `popup.js` is included in the `popup.html`): + +```js title="popup.js" +document.getElementById('sign-in').addEventListener('click', async () => { + await chrome.runtime.sendMessage({ action: 'signIn' }); + // Sign-in completed (or failed), you can update the UI here. +}); + +document.getElementById('sign-out').addEventListener('click', async () => { + await chrome.runtime.sendMessage({ action: 'signOut' }); + // Sign-out completed (or failed), you can update the UI here. +}); +``` + + + + + +Now you can test the authentication flow in your Chrome extension: + +1. Open the extension popup. +2. Click on the "Sign in" button. +3. You will be redirected to the Logto sign-in page. +4. Sign in with your Logto account. +5. You will be redirected back to the Chrome. + + + + + +Since Chrome provide unified storage APIs, rather than the sign-in and sign-out flow, all other Logto SDK methods can be used in the popup page directly. + +In your `popup.js`, you can reuse the `LogtoClient` instance created in the background script, or create a new one with the same configuration: + +```js title="popup.js" +import LogtoClient from '@logto/chrome-extension'; + +const logtoClient = new LogtoClient({ + endpoint: '' + appId: '', +}); + +// Or reuse the logtoClient instance created in the background script +import { logtoClient } from './service-worker.js'; +``` + +Then you can create a function to load the authentication state and user's profile: + +```js title="popup.js" +const loadAuthenticationState = async () => { + const isAuthenticated = await logtoClient.isAuthenticated(); + // Update the UI based on the authentication state + + if (isAuthenticated) { + const user = await logtoClient.getIdTokenClaims(); // { sub: '...', email: '...', ... } + // Update the UI with the user's profile + } +}; +``` + +You can also combine the `loadAuthenticationState` function with the sign-in and sign-out logic: + +```js title="popup.js" +document.getElementById('sign-in').addEventListener('click', async () => { + await chrome.runtime.sendMessage({ action: 'signIn' }); + await loadAuthenticationState(); +}); + +document.getElementById('sign-out').addEventListener('click', async () => { + await chrome.runtime.sendMessage({ action: 'signOut' }); + await loadAuthenticationState(); +}); +``` + +Here's an example of the popup page with the authentication state: + +Popup page + + + + + +- **Service worker bundling**: If you use a bundler like Webpack or Rollup, you need to explicitly set the target to `browser` or similar to avoid unnecessary bundling of Node.js modules. +- **Module resolution**: Logto Chrome extension SDK is an ESM-only module. + +See our [sample project](https://github.com/logto-io/js/tree/HEAD/packages/chrome-extension-sample) for a complete example with TypeScript, Rollup, and other configurations. + + + + diff --git a/packages/console/src/assets/docs/guides/spa-chrome-extension/config.json b/packages/console/src/assets/docs/guides/spa-chrome-extension/config.json new file mode 100644 index 00000000000..4721ad2f793 --- /dev/null +++ b/packages/console/src/assets/docs/guides/spa-chrome-extension/config.json @@ -0,0 +1,3 @@ +{ + "order": 1.1 +} diff --git a/packages/console/src/assets/docs/guides/spa-chrome-extension/extension-popup.webp b/packages/console/src/assets/docs/guides/spa-chrome-extension/extension-popup.webp new file mode 100644 index 00000000000..9e136c06e25 Binary files /dev/null and b/packages/console/src/assets/docs/guides/spa-chrome-extension/extension-popup.webp differ diff --git a/packages/console/src/assets/docs/guides/spa-chrome-extension/index.ts b/packages/console/src/assets/docs/guides/spa-chrome-extension/index.ts new file mode 100644 index 00000000000..90ad8eb36eb --- /dev/null +++ b/packages/console/src/assets/docs/guides/spa-chrome-extension/index.ts @@ -0,0 +1,16 @@ +import { ApplicationType } from '@logto/schemas'; + +import { type GuideMetadata } from '../types'; + +const metadata: Readonly = Object.freeze({ + name: 'Chrome extension', + description: 'Build a Chrome extension with Logto.', + target: ApplicationType.SPA, + sample: { + repo: 'js', + path: 'packages/chrome-extension-sample', + }, + fullGuide: 'chrome-extension', +}); + +export default metadata; diff --git a/packages/console/src/assets/docs/guides/spa-chrome-extension/logo.svg b/packages/console/src/assets/docs/guides/spa-chrome-extension/logo.svg new file mode 100644 index 00000000000..4fc53255e9c --- /dev/null +++ b/packages/console/src/assets/docs/guides/spa-chrome-extension/logo.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/console/src/mdx-components/UriInputField/index.tsx b/packages/console/src/mdx-components/UriInputField/index.tsx index 677b8a5a222..36cd2d3000d 100644 --- a/packages/console/src/mdx-components/UriInputField/index.tsx +++ b/packages/console/src/mdx-components/UriInputField/index.tsx @@ -17,19 +17,41 @@ import { } from '@/ds-components/MultiTextInput/utils'; import type { RequestError } from '@/hooks/use-api'; import useApi from '@/hooks/use-api'; -import type { GuideForm } from '@/types/guide'; +import type { + CustomClientMetadataKey, + GuideForm, + Name, + OidcClientMetadataKey, +} from '@/types/guide'; import { trySubmitSafe } from '@/utils/form'; import { uriValidator } from '@/utils/validator'; import * as styles from './index.module.scss'; -type Props = { - readonly name: 'redirectUris' | 'postLogoutRedirectUris'; - /** The default value of the input field when there's no data. */ - readonly defaultValue?: string; -}; +const nameToKey: Record = Object.freeze({ + redirectUris: 'application_details.redirect_uri', + postLogoutRedirectUris: 'application_details.post_sign_out_redirect_uri', + corsAllowedOrigins: 'application_details.cors_allowed_origins', +}); + +type Props = + | { + readonly name: OidcClientMetadataKey; + readonly type?: 'oidcClientMetadata'; + /** The default value of the input field when there's no data. */ + readonly defaultValue?: string; + } + | { + readonly name: CustomClientMetadataKey; + readonly type: 'customClientMetadata'; + /** The default value of the input field when there's no data. */ + readonly defaultValue?: string; + }; + +function UriInputField(props: Props) { + const { name, defaultValue } = props; + const type = props.type ?? 'oidcClientMetadata'; -function UriInputField({ name, defaultValue }: Props) { const methods = useForm>(); const { control, @@ -45,10 +67,7 @@ function UriInputField({ name, defaultValue }: Props) { const ref = useRef(null); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const api = useApi(); - const title: AdminConsoleKey = - name === 'redirectUris' - ? 'application_details.redirect_uri' - : 'application_details.post_sign_out_redirect_uri'; + const title: AdminConsoleKey = nameToKey[name]; const onSubmit = trySubmitSafe(async (value: string[]) => { if (!appId) { @@ -57,7 +76,7 @@ function UriInputField({ name, defaultValue }: Props) { const updatedApp = await api .patch(`api/applications/${appId}`, { json: { - oidcClientMetadata: { + [type]: { [name]: value.filter(Boolean), }, }, @@ -77,9 +96,13 @@ function UriInputField({ name, defaultValue }: Props) { } }; - const clientMetadata = data?.oidcClientMetadata[name]; - const defaultValueArray = clientMetadata?.length - ? clientMetadata + const dataValue = + props.type === 'customClientMetadata' + ? data?.customClientMetadata[props.name] + : data?.oidcClientMetadata[props.name]; + + const defaultValueArray = dataValue?.length + ? dataValue : conditional(defaultValue && [defaultValue]); return ( diff --git a/packages/console/src/types/guide.ts b/packages/console/src/types/guide.ts index 9a22384dc8a..df47b51949d 100644 --- a/packages/console/src/types/guide.ts +++ b/packages/console/src/types/guide.ts @@ -1,4 +1,6 @@ -export type GuideForm = { - redirectUris: string[]; - postLogoutRedirectUris: string[]; -}; +export type OidcClientMetadataKey = 'redirectUris' | 'postLogoutRedirectUris'; +export type CustomClientMetadataKey = 'corsAllowedOrigins'; + +export type Name = OidcClientMetadataKey | CustomClientMetadataKey; + +export type GuideForm = Record;