diff --git a/packages/console/src/assets/docs/tutorial/integrate-sdk/express.mdx b/packages/console/src/assets/docs/tutorial/integrate-sdk/express.mdx new file mode 100644 index 000000000..5c641b225 --- /dev/null +++ b/packages/console/src/assets/docs/tutorial/integrate-sdk/express.mdx @@ -0,0 +1,386 @@ +import UriInputField from '@mdx/components/UriInputField'; +import Step from '@mdx/components/Step'; +import Tabs from '@mdx/components/Tabs'; +import TabItem from '@mdx/components/TabItem'; +import Alert from '@/components/Alert'; + + props.onNext(1)} +> + +The express demo app will need 4 dependencies: + +1. **@logto/js**: Logto's core SDK for JavaScript. +2. **node-fetch**: Minimal code for a `window.fetch` compatible API on Node.js runtime. +3. **express-session**: A session middleware, we'll use the session to store user tokens. +4. **js-base64**: Yet another Base64 transcoder. + + + + +```bash +npm i @logto/js node-fetch@v2 express-session js-base64 +``` + + + + +```bash +yarn add @logto/js node-fetch@v2 express-session js-base64 +``` + + + + +```bash +pnpm add @logto/js node-fetch@v2 express-session js-base64 +``` + + + + + + props.onNext(2)} +> + +When users are signed in, they will get a set of tokens (Access Token, ID Token, Refresh Token) and interaction data, and the session is an excellent place to store them. + +We have installed [express-session](https://github.com/expressjs/session) in the previous step, so now let's simply add the following code to set it up: + +```js +// app.js + +const session = require('express-session'); + +app.use( + session({ + secret: 'keyboard cat', // Change to your own secret key + cookie: { maxAge: 86400 }, + }) +); +``` + + + + props.onNext(3)} +> + + + In the following steps, we assume your app is running on http://localhost:3000. + + +### Configure Redirect URI + +First, let’s enter your redirect URI. E.g. `http://localhost:3000/callback`. + + + +Go back to your IDE/editor, we need to implement the following authenticate functions. + +### Implement authenticate functions + +1. `getSignInUrl`: builds and returns a complete URL of the Logto Authorization Server to which users will be redirected. +2. `handleSignIn`: parses the callback URL after the authentication process completes, gets the code query parameter, and then fetches tokens (an access token, the refresh token, and an ID token) to complete the sign in process. +3. `refreshTokens`: exchanges a new access token using the refresh token. + +
+
+{`// logto.js
+
+const {
+  withReservedScopes,
+  fetchOidcConfig,
+  discoveryPath,
+  createRequester,
+  generateSignInUri,
+  verifyAndParseCodeFromCallbackUri,
+  fetchTokenByAuthorizationCode,
+  fetchTokenByRefreshToken,
+} = require('@logto/js');
+const fetch = require('node-fetch');
+const { randomFillSync, createHash } = require('crypto');
+const { fromUint8Array } = require('js-base64');
+
+const config = {
+  endpoint: '${props.endpoint}',
+  appId: '${props.appId}',
+  redirectUri: 'http://localhost:3000/callback', // Configured in the previous step
+  scopes: withReservedScopes().split(' '),
+};
+
+const requester = createRequester(fetch);
+
+const generateRandomString = (length = 64) => {
+  return fromUint8Array(randomFillSync(new Uint8Array(length)), true);
+};
+
+const generateCodeChallenge = async (codeVerifier) => {
+  const encodedCodeVerifier = new TextEncoder().encode(codeVerifier);
+  const hash = createHash('sha256');
+  hash.update(encodedCodeVerifier);
+  const codeChallenge = hash.digest();
+  return fromUint8Array(codeChallenge, true);
+};
+
+const getOidcConfig = async () => {
+  return fetchOidcConfig(new URL(discoveryPath, config.endpoint).toString(), requester);
+};
+
+exports.getSignInUrl = async () => {
+  const { authorizationEndpoint } = await getOidcConfig();
+  const codeVerifier = generateRandomString();
+  const codeChallenge = await generateCodeChallenge(codeVerifier);
+  const state = generateRandomString();
+
+  const { redirectUri, scopes, appId: clientId } = config;
+
+  const signInUri = generateSignInUri({
+    authorizationEndpoint,
+    clientId,
+    redirectUri: redirectUri,
+    codeChallenge,
+    state,
+    scopes,
+  });
+
+  return { redirectUri, codeVerifier, state, signInUri };
+};
+
+exports.handleSignIn = async (signInSession, callbackUri) => {
+  const { redirectUri, state, codeVerifier } = signInSession;
+  const code = verifyAndParseCodeFromCallbackUri(callbackUri, redirectUri, state);
+
+  const { appId: clientId } = config;
+  const { tokenEndpoint } = await getOidcConfig();
+  const codeTokenResponse = await fetchTokenByAuthorizationCode(
+    {
+      clientId,
+      tokenEndpoint,
+      redirectUri,
+      codeVerifier,
+      code,
+    },
+    requester
+  );
+
+  return codeTokenResponse;
+};
+
+exports.refreshTokens = async (refreshToken) => {
+  const { appId: clientId, scopes } = config;
+  const { tokenEndpoint } = await getOidcConfig();
+  const tokenResponse = await fetchTokenByRefreshToken(
+    {
+      clientId,
+      tokenEndpoint,
+      refreshToken,
+      scopes,
+    },
+    requester
+  );
+
+  return tokenResponse;
+};`}
+
+
+ +
+ + props.onNext(4)} +> + +### Create a route in Express to sign in: + +```js +const { getSignInUrl } = require('./logto'); + +app.get('/sign-in', async (req, res) => { + const { redirectUri, codeVerifier, state, signInUri } = await getSignInUrl(); + req.session.signIn = { codeVerifier, state, redirectUri }; + res.redirect(signInUri); +}); +``` + +### Create a route to handle callback: + +```js +app.get('/callback', async (req, res) => { + if (!req.session.signIn) { + res.send('Bad request.'); + return; + } + + const response = await handleSignIn( + req.session.signIn, + `${req.protocol}://${req.get('host')}${req.originalUrl}` + ); + req.session.tokens = { + ...response, + expiresAt: response.expiresIn + Date.now(), + idToken: decodeIdToken(response.idToken), + }; + req.session.signIn = null; + + res.redirect('/'); +}); +``` + + + + props.onNext(5)} +> + +TODO: link to the "session & cookies" chapter in users reference. + +You can clear tokens in session to sign out a user from this application. And check this link to read more about "sign out". + +```js +app.get('/sign-out', (req, res) => { + req.session.tokens = null; + res.send('Sign out successfully'); +}); +``` + + + + props.onNext(6)} +> + +### Middleware + +Create a middleware named `withAuth` to attach an `auth` object to `req`, and verify if a user is signed in. + +```js +// auth.js + +const { decodeIdToken } = require('@logto/js'); +const { refreshTokens } = require('./logto'); + +const withAuth = + ({ requireAuth } = { requireAuth: true }) => + async (req, res, next) => { + if (requireAuth && !req.session.tokens) { + res.redirect('/sign-in'); + return; + } + + if (req.session.tokens) { + if (req.session.tokens.expiresAt >= Date.now()) { + // Access token expired, refresh for new tokens + try { + const response = await refreshTokens(req.session.tokens.refreshToken); + req.session.tokens = { + ...response, + expiresAt: response.expiresIn + Date.now(), + idToken: decodeIdToken(response.idToken), + }; + } catch { + // Exchange failed, redirect to sign in + res.redirect('/sign-in'); + return; + } + } + + req.auth = req.session.tokens.idToken.sub; + } + + next(); + }; + +module.exports = withAuth; +``` + +### Implement index page + +In this page, we will show a sign-in link for guests, and a go-to-profile link for users that already signed in: + +```js +// routes/index.js + +router.get('/', withAuth({ requireAuth: false }), function (req, res, next) { + res.render('index', { auth: Boolean(req.auth) }); +}); +``` + +```pug +// views/index.jade + +extends layout + +block content + h1 Hello logto + if auth + p: a(href="/user") Go to profile + else + p: a(href="/sign-in") Click here to sign in +``` + +### Implement user page + +In the user page, we will fetch the protected resource `userId` (`subject`): + +```js +// routes/user.js + +app.get('/user', withAuth(), (req, res, next) => { + res.render('user', { userId: req.auth }); +}); +``` + +```pug +// views/index.jade + +extends layout + +block content + h1 Hello logto + p userId: #{userId} +``` + + + + + +- [Customize sign-in experience](https://docs.logto.io/docs/recipes/customize-sie) +- [Enable SMS or email passcode sign-in](https://docs.logto.io/docs/tutorials/get-started/enable-passcode-sign-in) +- [Enable social sign-in](https://docs.logto.io/docs/tutorials/get-started/enable-social-sign-in) +- [Protect your API](https://docs.logto.io/docs/recipes/protect-your-api) + + diff --git a/packages/console/src/assets/docs/tutorial/integrate-sdk/express_zh-cn.mdx b/packages/console/src/assets/docs/tutorial/integrate-sdk/express_zh-cn.mdx new file mode 100644 index 000000000..9c8adfcd7 --- /dev/null +++ b/packages/console/src/assets/docs/tutorial/integrate-sdk/express_zh-cn.mdx @@ -0,0 +1,382 @@ +import UriInputField from '@mdx/components/UriInputField'; +import Step from '@mdx/components/Step'; +import Tabs from '@mdx/components/Tabs'; +import TabItem from '@mdx/components/TabItem'; +import Alert from '@/components/Alert'; + + props.onNext(1)} +> + +本演示项目需要安装 4 个依赖包: + +1. **@logto/js**: Logto 核心 JavaScript SDK。 +2. **node-fetch**: 最小代码在 Node.js 运行环境中实现 `window.fetch` 兼容。 +3. **express-session**: session 中间件, 用于存储用户 token。 +4. **js-base64**: Base64 转换工具。 + + + + +```bash +npm i @logto/js node-fetch@v2 express-session js-base64 +``` + + + + +```bash +yarn add @logto/js node-fetch@v2 express-session js-base64 +``` + + + + +```bash +pnpm add @logto/js node-fetch@v2 express-session js-base64 +``` + + + + + + props.onNext(2)} +> + +用户完成登录后,将会得到一系列的 token(Access Token, ID Token, Refresh Token)和交互数据,我们将这些数据保存到 session 中。 + +在上一个步骤中已经安装了 [express-session](https://github.com/expressjs/session),在 `app.js` 中完成初始化: + +```js +const session = require('express-session'); + +app.use( + session({ + secret: 'keyboard cat', // 改为你自己的密钥 + cookie: { maxAge: 86400 }, + }) +); +``` + + + + props.onNext(3)} +> + + + 在如下代码示例中, 我们均先假设你的 React 应用运行在 http://localhost:3000 上。 + + +### 配置 Redirect URI + +首先,我们来添加 Redirect URI,如:`http://localhost:3000/callback`。 + + + +返回你的 IDE 或编辑器,我们将会实现如下几个用户认证所需函数。 + +### 实现用户认证的函数 + +1. `getSignInUrl`: 构建并返回完整的用于 Logto 认证服务的 URL,用户将被重定向到这个 URL 以完成登录。 +2. `handleSignIn`: 解析回调 URL, 从 query 参数中获取 code, 并用它获取其他 token (an access token, the refresh token, and an ID token),完成整个登录流程。 +3. `refreshTokens`: 使用 refresh token 获取新的 access token。 + +
+
+{`// logto.js
+
+const {
+  withReservedScopes,
+  fetchOidcConfig,
+  discoveryPath,
+  createRequester,
+  generateSignInUri,
+  verifyAndParseCodeFromCallbackUri,
+  fetchTokenByAuthorizationCode,
+  fetchTokenByRefreshToken,
+} = require('@logto/js');
+const fetch = require('node-fetch');
+const { randomFillSync, createHash } = require('crypto');
+const { fromUint8Array } = require('js-base64');
+
+const config = {
+  endpoint: '${props.endpoint}',
+  appId: '${props.appId}',
+  redirectUri: 'http://localhost:3000/callback', // 上一步配置过的 Redirect URI
+  scopes: withReservedScopes().split(' '),
+};
+
+const requester = createRequester(fetch);
+
+const generateRandomString = (length = 64) => {
+  return fromUint8Array(randomFillSync(new Uint8Array(length)), true);
+};
+
+const generateCodeChallenge = async (codeVerifier) => {
+  const encodedCodeVerifier = new TextEncoder().encode(codeVerifier);
+  const hash = createHash('sha256');
+  hash.update(encodedCodeVerifier);
+  const codeChallenge = hash.digest();
+  return fromUint8Array(codeChallenge, true);
+};
+
+const getOidcConfig = async () => {
+  return fetchOidcConfig(new URL(discoveryPath, config.endpoint).toString(), requester);
+};
+
+exports.getSignInUrl = async () => {
+  const { authorizationEndpoint } = await getOidcConfig();
+  const codeVerifier = generateRandomString();
+  const codeChallenge = await generateCodeChallenge(codeVerifier);
+  const state = generateRandomString();
+
+  const { redirectUri, scopes, appId: clientId } = config;
+
+  const signInUri = generateSignInUri({
+    authorizationEndpoint,
+    clientId,
+    redirectUri: redirectUri,
+    codeChallenge,
+    state,
+    scopes,
+  });
+
+  return { redirectUri, codeVerifier, state, signInUri };
+};
+
+exports.handleSignIn = async (signInSession, callbackUri) => {
+  const { redirectUri, state, codeVerifier } = signInSession;
+  const code = verifyAndParseCodeFromCallbackUri(callbackUri, redirectUri, state);
+
+  const { appId: clientId } = config;
+  const { tokenEndpoint } = await getOidcConfig();
+  const codeTokenResponse = await fetchTokenByAuthorizationCode(
+    {
+      clientId,
+      tokenEndpoint,
+      redirectUri,
+      codeVerifier,
+      code,
+    },
+    requester
+  );
+
+  return codeTokenResponse;
+};
+
+exports.refreshTokens = async (refreshToken) => {
+  const { appId: clientId, scopes } = config;
+  const { tokenEndpoint } = await getOidcConfig();
+  const tokenResponse = await fetchTokenByRefreshToken(
+    {
+      clientId,
+      tokenEndpoint,
+      refreshToken,
+      scopes,
+    },
+    requester
+  );
+
+  return tokenResponse;
+};`}
+
+
+ +
+ + props.onNext(4)} +> + +### 在 Express 里创建一个用于登录的路由: + +```js +const { getSignInUrl } = require('./logto'); + +app.get('/sign-in', async (req, res) => { + const { redirectUri, codeVerifier, state, signInUri } = await getSignInUrl(); + req.session.signIn = { codeVerifier, state, redirectUri }; + res.redirect(signInUri); +}); +``` + +### 创建用于处理登录回调的路由: + +```js +app.get('/callback', async (req, res) => { + if (!req.session.signIn) { + res.send('Bad request.'); + return; + } + + const response = await handleSignIn( + req.session.signIn, + `${req.protocol}://${req.get('host')}${req.originalUrl}` + ); + req.session.tokens = { + ...response, + expiresAt: response.expiresIn + Date.now(), + idToken: decodeIdToken(response.idToken), + }; + req.session.signIn = null; + + res.redirect('/'); +}); +``` + + + + props.onNext(5)} +> + +TODO: link to the "session & cookies" chapter in users reference. + +清空 session 里的 token 信息即可实现退出当前 App。参阅本链接查看更多关于「退出登录」的说明。 + +```js +app.get('/sign-out', (req, res) => { + req.session.tokens = null; + res.send('成功退出登录'); +}); +``` + + + + props.onNext(6)} +> + +### 中间件 + +创建中间件 `withAuth`,用于验证用户是否登录,并在 `req` 里添加 `auth`。 + +```js +// auth.js + +const { decodeIdToken } = require('@logto/js'); +const { refreshTokens } = require('./logto'); + +const withAuth = + ({ requireAuth } = { requireAuth: true }) => + async (req, res, next) => { + if (requireAuth && !req.session.tokens) { + res.redirect('/sign-in'); + return; + } + + if (req.session.tokens) { + if (req.session.tokens.expiresAt >= Date.now()) { + // Access token 已过期, 刷新 token + try { + const response = await refreshTokens(req.session.tokens.refreshToken); + req.session.tokens = { + ...response, + expiresAt: response.expiresIn + Date.now(), + idToken: decodeIdToken(response.idToken), + }; + } catch { + // 发生错误,重定向到登录页面 + res.redirect('/sign-in'); + return; + } + } + + req.auth = req.session.tokens.idToken.sub; + } + + next(); + }; + +module.exports = withAuth; +``` + +### 实现 index 页面 + +在这个页面中,我们将为游客展示一个登录链接, 为已登录用户展示查看用户信息的链接: + +```js +// routes/index.js + +router.get('/', withAuth({ requireAuth: false }), function (req, res, next) { + res.render('index', { auth: Boolean(req.auth) }); +}); +``` + +```pug +// views/index.jade + +extends layout + +block content + h1 Hello logto + if auth + p: a(href="/user") 查看用户信息 + else + p: a(href="/sign-in") 点击此处登录 +``` + +### 实现用户信息页面 + +在用户信息页面, 我们将获取受保护的资源 `userId` (`subject`): + +```js +app.get('/user', withAuth(), (req, res, next) => { + res.render('user', { userId: req.auth }); +}); +``` + +```pug +// views/user.jade + +extends layout + +block content + h1 Hello logto + p userId: #{userId} +``` + + + + + +- [自定义登录体验](https://docs.logto.io/zh-cn/docs/recipes/customize-sie) +- [启用短信或邮件验证码登录](https://docs.logto.io/zh-cn/docs/tutorials/get-started/enable-passcode-sign-in) +- [启用社交登录](https://docs.logto.io/zh-cn/docs/tutorials/get-started/enable-social-sign-in) +- [保护你的 API](https://docs.logto.io/zh-cn/docs/recipes/protect-your-api) + + diff --git a/packages/console/src/pages/Applications/components/Guide/index.tsx b/packages/console/src/pages/Applications/components/Guide/index.tsx index fa8ac75ed..d21c35ae0 100644 --- a/packages/console/src/pages/Applications/components/Guide/index.tsx +++ b/packages/console/src/pages/Applications/components/Guide/index.tsx @@ -25,6 +25,7 @@ const Guides: Record JSX.Elemen react: lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/react.mdx')), vue: lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/vue.mdx')), vanilla: lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/vanilla.mdx')), + express: lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/express.mdx')), 'ios_zh-cn': lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/ios_zh-cn.mdx')), 'android_zh-cn': lazy( async () => import('@/assets/docs/tutorial/integrate-sdk/android_zh-cn.mdx') @@ -34,6 +35,9 @@ const Guides: Record JSX.Elemen 'vanilla_zh-cn': lazy( async () => import('@/assets/docs/tutorial/integrate-sdk/vanilla_zh-cn.mdx') ), + 'express_zh-cn': lazy( + async () => import('@/assets/docs/tutorial/integrate-sdk/express_zh-cn.mdx') + ), }; const Guide = ({ app, isCompact, onClose }: Props) => { diff --git a/packages/console/src/pages/Applications/components/GuideHeader/index.tsx b/packages/console/src/pages/Applications/components/GuideHeader/index.tsx index 962706ec4..4d0c6ea29 100644 --- a/packages/console/src/pages/Applications/components/GuideHeader/index.tsx +++ b/packages/console/src/pages/Applications/components/GuideHeader/index.tsx @@ -34,7 +34,7 @@ const getSampleProjectUrl = (sdk: SupportedSdk) => { return `${githubUrlPrefix}/js/tree/master/packages/vue-sample`; case SupportedSdk.Vanilla: return `${githubUrlPrefix}/js/tree/master/packages/browser-sample`; - case SupportedSdk.Traditional: + case SupportedSdk.Express: return `${githubUrlPrefix}/express-example`; default: return ''; diff --git a/packages/console/src/types/applications.ts b/packages/console/src/types/applications.ts index c8c4fafc0..f5b88ee04 100644 --- a/packages/console/src/types/applications.ts +++ b/packages/console/src/types/applications.ts @@ -12,11 +12,11 @@ export enum SupportedSdk { React = 'React', Vue = 'Vue', Vanilla = 'Vanilla', - Traditional = 'Traditional', + Express = 'Express', } export const applicationTypeAndSdkTypeMappings = Object.freeze({ [ApplicationType.Native]: [SupportedSdk.iOS, SupportedSdk.Android], [ApplicationType.SPA]: [SupportedSdk.React, SupportedSdk.Vue, SupportedSdk.Vanilla], - [ApplicationType.Traditional]: [SupportedSdk.Traditional], + [ApplicationType.Traditional]: [SupportedSdk.Express], } as const);