為你的 Next.js (App Router) 應用程式新增驗證 (Authentication)
- 範例專案可在我們的 SDK repository 中找到。
- 此範例基於 Next.js 的 App Router。
- 教學影片可在我們的 YouTube 頻道 上觀看。
先決條件
- 一個 Logto Cloud 帳號或 自行架設 Logto。
- 已建立一個 Logto 傳統應用程式。
安裝
透過你喜愛的套件管理器安裝 Logto SDK:
- npm
- pnpm
- yarn
npm i @logto/nextpnpm add @logto/nextyarn add @logto/next整合
準備設定檔
為 Logto client 準備設定檔:
import { LogtoNextConfig } from '@logto/next';
export const logtoConfig: LogtoNextConfig = {
appId: '<your-application-id>',
appSecret: '<your-app-secret-copied-from-console>',
endpoint: '<your-logto-endpoint>', // 例如 http://localhost:3001
baseUrl: '<your-nextjs-app-base-url>', // 例如 http://localhost:3000
cookieSecret: 'complex_password_at_least_32_characters_long',
cookieSecure: process.env.NODE_ENV === 'production',
};
注意:
如果你使用環境變數作為 cookieSecret(例如 process.env.LOGTO_COOKIE_SECRET),請確保其值至少為 32 個字元。若未達此要求,Logto 會在建置或執行時拋出以下錯誤:
TypeError: Either sessionWrapper or encryptionKey must be provided for CookieStorage
為避免此錯誤,請確保環境變數正確設置,或提供一個長度至少 32 字元的預設值。
設定 redirect URI
在進入細節之前,這裡先快速說明一下終端使用者的體驗。登入流程可簡化如下:
- 你的應用程式呼叫登入方法。
- 使用者被重新導向至 Logto 登入頁面。對於原生應用程式,會開啟系統瀏覽器。
- 使用者登入後,會被重新導向回你的應用程式(設定為 redirect URI)。
關於基於重導的登入
- 此驗證流程遵循 OpenID Connect (OIDC) 協議,Logto 強制執行嚴格的安全措施以保護使用者登入。
- 如果你有多個應用程式,可以使用相同的身分提供者 (IdP, Identity provider)(Logto)。一旦使用者登入其中一個應用程式,Logto 將在使用者訪問另一個應用程式時自動完成登入流程。
欲了解更多關於基於重導登入的原理和優勢,請參閱 Logto 登入體驗解析。
在以下的程式碼片段中,我們假設你的應用程式運行在 http://localhost:3000/。
配置重定向 URI
切換到 Logto Console 的應用程式詳細資訊頁面。新增一個重定向 URI http://localhost:3000/callback。
就像登入一樣,使用者應被重定向到 Logto 以登出共享會話。完成後,將使用者重定向回你的網站會很不錯。例如,將 http://localhost:3000/ 新增為登出後重定向 URI 區段。
然後點擊「儲存」以保存更改。
處理 callback
使用者登入後,Logto 會將使用者導回上方設定的 redirect URI。但此時仍需進行一些處理,才能讓你的應用程式正常運作。
我們提供 handleSignIn 輔助函式來處理登入 callback:
import { handleSignIn } from '@logto/next/server-actions';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
import { logtoConfig } from '../logto';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
await handleSignIn(logtoConfig, searchParams);
redirect('/');
}
實作登入與登出
實作登入與登出按鈕
在 Next.js App Router 中,事件需於 client component 處理,因此我們需先建立兩個元件:SignIn 與 SignOut。
'use client';
type Props = {
onSignIn: () => Promise<void>;
};
const SignIn = ({ onSignIn }: Props) => {
return (
<button
onClick={() => {
onSignIn();
}}
>
Sign In
</button>
);
};
export default SignIn;
'use client';
type Props = {
onSignOut: () => Promise<void>;
};
const SignOut = ({ onSignOut }: Props) => {
return (
<button
onClick={() => {
onSignOut();
}}
>
Sign Out
</button>
);
};
export default SignOut;
請記得在檔案最上方加上 'use client',以標示這些元件為 client component。
將按鈕加入首頁
在客戶端元件中不允許定義內嵌的 "use server" 註解的伺服器操作。我們必須透過從伺服器元件傳遞屬性來實現。
現在讓我們將登入與登出按鈕加入首頁。當需要時,需呼叫 SDK 中的 server actions。為協助此流程,可使用 getLogtoContext 取得驗證 (Authentication) 狀態。
import { getLogtoContext, signIn, signOut } from '@logto/next/server-actions';
import SignIn from './sign-in';
import SignOut from './sign-out';
import { logtoConfig } from './logto';
export default async function Home() {
const { isAuthenticated, claims } = await getLogtoContext(logtoConfig);
return (
<nav>
{isAuthenticated ? (
<p>
Hello, {claims?.sub},
<SignOut
onSignOut={async () => {
'use server';
await signOut(logtoConfig);
}}
/>
</p>
) : (
<p>
<SignIn
onSignIn={async () => {
'use server';
await signIn(logtoConfig);
}}
/>
</p>
)}
</nav>
);
}
檢查點:測試你的應用程式
現在,你可以測試你的應用程式:
- 執行你的應用程式,你會看到登入按鈕。
- 點擊登入按鈕,SDK 會初始化登入流程並將你重定向到 Logto 登入頁面。
- 登入後,你將被重定向回應用程式並看到登出按鈕。
- 點擊登出按鈕以清除權杖存儲並登出。
取得使用者資訊
顯示使用者資訊
當使用者登入後,getLogtoContext() 的返回值將是一個包含使用者資訊的物件。你可以在應用程式中顯示這些資訊:
import { getLogtoContext } from '@logto/next/server-actions';
import { logtoConfig } from './logto';
export default async function Home() {
const { claims } = await getLogtoContext(logtoConfig);
return (
<main>
{claims && (
<div>
<h2>宣告 (Claims):</h2>
<table>
<thead>
<tr>
<th>名稱</th>
<th>值</th>
</tr>
</thead>
<tbody>
{Object.entries(claims).map(([key, value]) => (
<tr key={key}>
<td>{key}</td>
<td>{String(value)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
);
}
在 API 路由處理器中獲取使用者資訊
你也可以在 API 路由處理器中獲取使用者資訊:
import { getLogtoContext } from '@logto/next/server-actions';
import { logtoConfig } from '../../logto';
export const dynamic = 'force-dynamic';
export async function GET() {
const { claims } = await getLogtoContext(logtoConfig);
return Response.json({ claims });
}
請求額外的宣告 (Claims)
你可能會發現從 getLogtoContext 返回的物件中缺少一些使用者資訊。這是因為 OAuth 2.0 和 OpenID Connect (OIDC) 的設計遵循最小權限原則 (PoLP, Principle of Least Privilege),而 Logto 是基於這些標準構建的。
預設情況下,僅返回有限的宣告 (Claims)。如果你需要更多資訊,可以請求額外的權限範圍 (Scopes) 以存取更多宣告。
「宣告 (Claim)」是對主體所做的斷言;「權限範圍 (Scope)」是一組宣告。在目前的情況下,宣告是關於使用者的一部分資訊。
以下是權限範圍與宣告關係的非規範性範例:
「sub」宣告表示「主體 (Subject)」,即使用者的唯一識別符(例如使用者 ID)。
Logto SDK 將始終請求三個權限範圍:openid、profile 和 offline_access。
若要請求額外的權限範圍 (Scopes),可以在初始化 Logto 客戶端時配置參數:
import { UserScope, LogtoNextConfig } from '@logto/next';
export const logtoConfig: LogtoNextConfig = {
scopes: [UserScope.Email, UserScope.Phone], // 如有需要可新增更多權限範圍
// ...其他配置
});
然後你可以在上下文響應中訪問額外的宣告 (Claims):
export default async function Home() {
const { claims: { email } = {}, } = await getLogtoContext(logtoConfig);
return (
<div>
{email && <p>電子郵件: {email}</p>}
</div>
);
};
export default Home;
需要網路請求的宣告 (Claims)
為了防止 ID 權杖 (ID token) 膨脹,某些宣告 (Claims) 需要透過網路請求來獲取。例如,即使在權限範圍 (Scopes) 中請求了 custom_data 宣告,它也不會包含在使用者物件中。要存取這些宣告,你可以配置 fetchUserInfo 選項:
export default async function Home() {
const { userInfo } = await getLogtoContext(logtoConfig, { fetchUserInfo: true });
return (
<div>
{userInfo && <p>電子郵件: {userInfo.email}</p>}
</div>
);
};
export default Home;
fetchUserInfo,SDK 將在使用者登入後透過請求 userinfo 端點 來獲取使用者資訊,並且一旦請求完成,userInfo 將可用。
權限範圍 (Scopes) 和宣告 (Claims)
Logto 使用 OIDC 權限範圍 (Scopes) 和宣告 (Claims) 慣例 來定義從 ID 權杖 (ID token) 和 OIDC 使用者資訊端點 (userinfo endpoint) 獲取使用者資訊的權限範圍和宣告。無論是「權限範圍 (Scope)」還是「宣告 (Claim)」,都是 OAuth 2.0 和 OpenID Connect (OIDC) 規範中的術語。
以下是支援的權限範圍 (Scopes) 及其對應的宣告 (Claims):
openid
| 宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
|---|---|---|---|
| sub | string | 使用者的唯一識別符 | 否 |
profile
| 宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
|---|---|---|---|
| name | string | 使用者的全名 | 否 |
| username | string | 使用者的用戶名 | 否 |
| picture | string | 使用者個人資料圖片的 URL。此 URL 必須指向圖像文件(例如 PNG、JPEG 或 GIF 圖像文件),而不是包含圖像的網頁。請注意,此 URL 應特別參考適合在描述使用者時顯示的個人資料照片,而不是使用者拍攝的任意照片。 | 否 |
| created_at | number | 使用者創建的時間。時間以自 Unix epoch(1970-01-01T00:00:00Z)以來的毫秒數表示。 | 否 |
| updated_at | number | 使用者資訊最後更新的時間。時間以自 Unix epoch(1970-01-01T00:00:00Z)以來的毫秒數表示。 | 否 |
其他 標準宣告 包括 family_name、given_name、middle_name、nickname、preferred_username、profile、website、gender、birthdate、zoneinfo 和 locale 也將包含在 profile 權限範圍中,無需請求使用者資訊端點。與上述宣告的不同之處在於,這些宣告僅在其值不為空時返回,而上述宣告在值為空時將返回 null。
與標準宣告不同,created_at 和 updated_at 宣告使用毫秒而非秒。
email
| 宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
|---|---|---|---|
string | 使用者的電子郵件地址 | 否 | |
| email_verified | boolean | 電子郵件地址是否已驗證 | 否 |
phone
| 宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
|---|---|---|---|
| phone_number | string | 使用者的電話號碼 | 否 |
| phone_number_verified | boolean | 電話號碼是否已驗證 | 否 |
address
請參閱 OpenID Connect Core 1.0 以獲取地址宣告的詳細資訊。
custom_data
| 宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
|---|---|---|---|
| custom_data | object | 使用者的自訂資料 | 是 |
identities
| 宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
|---|---|---|---|
| identities | object | 使用者的連結身分 | 是 |
| sso_identities | array | 使用者的連結 SSO 身分 | 是 |
roles
| 宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
|---|---|---|---|
| roles | string[] | 使用者的角色 | 否 |
urn:logto:scope:organizations
| 宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
|---|---|---|---|
| organizations | string[] | 使用者所屬的組織 ID | 否 |
| organization_data | object[] | 使用者所屬的組織資料 | 是 |
urn:logto:scope:organization_roles
| 宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
|---|---|---|---|
| organization_roles | string[] | 使用者所屬的組織角色,格式為 <organization_id>:<role_name> | 否 |
考慮到效能和資料大小,如果「需要使用者資訊嗎?」為「是」,則表示該宣告不會顯示在 ID 權杖中,而會在 使用者資訊端點 回應中返回。
API 資源 (API resources)
我們建議先閱讀 🔐 角色型存取控制 (RBAC, Role-Based Access Control),以瞭解 Logto RBAC 的基本概念以及如何正確設定 API 資源。
設定 Logto client
一旦你設定了 API 資源,就可以在應用程式中配置 Logto 時新增它們:
export const logtoConfig = {
// ...other configs
resources: ['https://shopping.your-app.com/api', 'https://store.your-app.com/api'], // 新增 API 資源 (API resources)
};
每個 API 資源都有其自身的權限(權限範圍)。
例如,https://shopping.your-app.com/api 資源具有 shopping:read 和 shopping:write 權限,而 https://store.your-app.com/api 資源具有 store:read 和 store:write 權限。
要請求這些權限,你可以在應用程式中配置 Logto 時新增它們:
export const logtoConfig = {
// ...other configs
scopes: ['shopping:read', 'shopping:write', 'store:read', 'store:write'],
resources: ['https://shopping.your-app.com/api', 'https://store.your-app.com/api'],
};
你可能會注意到權限範圍是獨立於 API 資源定義的。這是因為 OAuth 2.0 的資源標示符 (Resource Indicators) 指定請求的最終權限範圍將是所有目標服務中所有權限範圍的笛卡兒積。
因此,在上述情況中,權限範圍可以從 Logto 的定義中簡化,兩個 API 資源都可以擁有 read 和 write 權限範圍而不需要前綴。然後,在 Logto 配置中:
export const logtoConfig = {
// ...other configs
scopes: ['read', 'write'],
resources: ['https://shopping.your-app.com/api', 'https://store.your-app.com/api'],
};
對於每個 API 資源,它將請求 read 和 write 權限範圍。
請求未在 API 資源中定義的權限範圍是可以的。例如,即使 API 資源中沒有可用的 email 權限範圍,你也可以請求 email 權限範圍。不可用的權限範圍將被安全地忽略。
成功登入後,Logto 將根據使用者的角色向 API 資源發出適當的權限範圍。
取得 API 資源 (API resource) 的存取權杖 (Access token)
要獲取特定 API 資源的存取權杖 (Access token),你可以使用 getAccessToken 方法:
在客戶端元件中不允許定義內嵌的 "use server" 註解的伺服器操作。我們必須透過從伺服器元件傳遞屬性來實現。
import { getAccessToken } from '@logto/next/server-actions';
import { logtoConfig } from './logto';
import GetAccessToken from './get-access-token';
export default async function Home() {
return (
<main>
<GetAccessToken
onGetAccessToken={async () => {
'use server';
return getAccessToken(logtoConfig, 'https://shopping.your-app.com/api');
}}
/>
</main>
);
}
'use client';
type Props = {
onGetAccessToken: () => Promise<string>;
};
const GetAccessToken = ({ onGetAccessToken }: Props) => {
return (
<button
onClick={async () => {
const token = await onGetAccessToken();
console.log(token);
}}
>
取得存取權杖 (查看控制台日誌)
</button>
);
};
export default GetAccessToken;
此方法將返回一個 JWT 存取權杖 (Access token),當使用者擁有相關權限時,可以用來存取 API 資源。如果當前快取的存取權杖 (Access token) 已過期,此方法將自動嘗試使用重新整理權杖 (Refresh token) 獲取新的存取權杖 (Access token)。
如果你需要在伺服器元件中取得存取權杖 (Access token),可以使用 getAccessTokenRSC 函式:
import { getAccessTokenRSC } from '@logto/next/server-actions';
import { logtoConfig } from './logto';
export default async function Home() {
const accessToken = await getAccessTokenRSC(logtoConfig, 'https://shopping.your-app.com/api');
return (
<main>
<p>Access token: {accessToken}</p>
</main>
);
}
HTTP 在串流開始後無法設定 cookie,getAccessTokenRSC 也無法更新 cookie 值,因此若存取權杖 (Access token) 被重新整理,將不會被保存在 session 中。建議在 client 端或 route handler 使用 getAccessToken 函式。
取得組織權杖 (Organization tokens)
如果你對組織 (Organization) 不熟悉,請閱讀 🏢 組織(多租戶,Multi-tenancy) 以開始了解。
在配置 Logto client 時,你需要新增 UserScope.Organizations 權限範圍 (scope):
import { UserScope } from '@logto/next';
export const logtoConfig = {
// ...other configs
scopes: [UserScope.Organizations],
};
使用者登入後,你可以為使用者獲取組織權杖 (organization token):
在客戶端元件中不允許定義內嵌的 "use server" 註解的伺服器操作。我們必須透過從伺服器元件傳遞屬性來實現。
import { getOrganizationToken } from '@logto/next/server-actions';
import { logtoConfig } from './logto';
import GetOrganizationToken from './get-organization-token';
export default async function Home() {
return (
<main>
<GetOrganizationToken
onGetOrganizationToken={async () => {
'use server';
return getOrganizationToken(logtoConfig, 'organization-id');
}}
/>
</main>
);
}
'use client';
type Props = {
onGetOrganizationToken: () => Promise<string>;
};
const GetOrganizationToken = ({ onGetOrganizationToken }: Props) => {
return (
<button
onClick={async () => {
const token = await onGetOrganizationToken();
console.log(token);
}}
>
取得組織權杖 (查看控制台日誌)
</button>
);
};
export default GetOrganizationToken;
如果你需要在伺服器元件中取得組織權杖 (Organization token),可以使用 getOrganizationTokenRSC 函式:
import { getOrganizationTokenRSC } from '@logto/next/server-actions';
import { logtoConfig } from './logto';
export default async function Home() {
const token = await getOrganizationTokenRSC(logtoConfig, 'organization-id');
return (
<main>
<p>Organization token: {token}</p>
</main>
);
}
HTTP 在串流開始後無法設定 cookie,getOrganizationTokenRSC 也無法更新 cookie 值,因此若存取權杖 (Access token) 被重新整理,將不會被保存在 session 中。建議在 client 端或 route handler 使用 getOrganizationToken 函式。
使用外部 session 儲存空間
SDK 預設使用 cookies 來儲存加密的會話資料。這種方法安全且不需要額外的基礎設施,特別適合在像 Vercel 這樣的無伺服器環境中運作。
然而,有時你可能需要將會話資料儲存在外部。例如,當你的會話資料對 cookies 來說過大時,特別是當你需要同時維持多個活躍的組織會話時。在這些情況下,你可以使用 sessionWrapper 選項來實現外部會話儲存:
import { MemorySessionWrapper } from './storage';
export const config = {
// ...
sessionWrapper: new MemorySessionWrapper(),
};
import { randomUUID } from 'node:crypto';
import { type SessionWrapper, type SessionData } from '@logto/next';
export class MemorySessionWrapper implements SessionWrapper {
private readonly storage = new Map<string, unknown>();
async wrap(data: unknown, _key: string): Promise<string> {
const sessionId = randomUUID();
this.storage.set(sessionId, data);
return sessionId;
}
async unwrap(value: string, _key: string): Promise<SessionData> {
if (!value) {
return {};
}
const data = this.storage.get(value);
return data ?? {};
}
}
上述實作使用簡單的記憶體內儲存。在生產環境中,你可能會想使用更持久的儲存解決方案,例如 Redis 或資料庫。
未授權時自動導向登入頁
signIn 輔助函式會修改 cookie 以建立登入 session,因此不能直接在 React Server Component (RSC) 中呼叫。若要在 RSC 偵測到未授權使用者時自動觸發登入,請在專用的 route handler 中呼叫 signIn 並導向該路由。
import { signIn } from '@logto/next/server-actions';
import { logtoConfig } from '../../logto';
export async function GET() {
await signIn(logtoConfig);
}
import { getLogtoContext } from '@logto/next/server-actions';
import { redirect } from 'next/navigation';
import { logtoConfig } from '../logto';
export default async function ProtectedPage() {
const { isAuthenticated } = await getLogtoContext(logtoConfig);
if (!isAuthenticated) {
redirect('/sign-in');
}
return <div>Protected content</div>;
}