跳至主要內容

為你的 Next.js (App Router) 應用程式新增驗證 (Authentication)

提示:

先決條件

安裝

透過你喜愛的套件管理器安裝 Logto SDK:

npm i @logto/next

整合

準備設定檔

為 Logto client 準備設定檔:

app/logto.ts
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

在進入細節之前,這裡先快速說明一下終端使用者的體驗。登入流程可簡化如下:

  1. 你的應用程式呼叫登入方法。
  2. 使用者被重新導向至 Logto 登入頁面。對於原生應用程式,會開啟系統瀏覽器。
  3. 使用者登入後,會被重新導向回你的應用程式(設定為 redirect URI)。

關於基於重導的登入

  1. 此驗證流程遵循 OpenID Connect (OIDC) 協議,Logto 強制執行嚴格的安全措施以保護使用者登入。
  2. 如果你有多個應用程式,可以使用相同的身分提供者 (IdP, Identity provider)(Logto)。一旦使用者登入其中一個應用程式,Logto 將在使用者訪問另一個應用程式時自動完成登入流程。

欲了解更多關於基於重導登入的原理和優勢,請參閱 Logto 登入體驗解析


備註:

在以下的程式碼片段中,我們假設你的應用程式運行在 http://localhost:3000/

配置重定向 URI

切換到 Logto Console 的應用程式詳細資訊頁面。新增一個重定向 URI http://localhost:3000/callback

Logto Console 中的重定向 URI

就像登入一樣,使用者應被重定向到 Logto 以登出共享會話。完成後,將使用者重定向回你的網站會很不錯。例如,將 http://localhost:3000/ 新增為登出後重定向 URI 區段。

然後點擊「儲存」以保存更改。

處理 callback

使用者登入後,Logto 會將使用者導回上方設定的 redirect URI。但此時仍需進行一些處理,才能讓你的應用程式正常運作。

我們提供 handleSignIn 輔助函式來處理登入 callback:

app/callback/route.ts
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 處理,因此我們需先建立兩個元件:SignInSignOut

app/sign-in.tsx
'use client';

type Props = {
onSignIn: () => Promise<void>;
};

const SignIn = ({ onSignIn }: Props) => {
return (
<button
onClick={() => {
onSignIn();
}}
>
Sign In
</button>
);
};

export default SignIn;
app/sign-out.tsx
'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) 狀態。

app/page.tsx
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>
);
}

檢查點:測試你的應用程式

現在,你可以測試你的應用程式:

  1. 執行你的應用程式,你會看到登入按鈕。
  2. 點擊登入按鈕,SDK 會初始化登入流程並將你重定向到 Logto 登入頁面。
  3. 登入後,你將被重定向回應用程式並看到登出按鈕。
  4. 點擊登出按鈕以清除權杖存儲並登出。

取得使用者資訊

顯示使用者資訊

當使用者登入後,getLogtoContext() 的返回值將是一個包含使用者資訊的物件。你可以在應用程式中顯示這些資訊:

app/page.tsx
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 路由處理器中獲取使用者資訊:

app/api/profile/route.ts
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 將始終請求三個權限範圍:openidprofileoffline_access

若要請求額外的權限範圍 (Scopes),可以在初始化 Logto 客戶端時配置參數:

app/logto.ts
import { UserScope, LogtoNextConfig } from '@logto/next';

export const logtoConfig: LogtoNextConfig = {
scopes: [UserScope.Email, UserScope.Phone], // 如有需要可新增更多權限範圍
// ...其他配置
});

然後你可以在上下文響應中訪問額外的宣告 (Claims):

app/page.tsx
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 選項

app/page.tsx
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

Claim nameTypeDescriptionNeeds userinfo?
substring使用者的唯一識別符No

profile

Claim nameTypeDescriptionNeeds userinfo?
namestring使用者的全名No
usernamestring使用者的使用者名稱No
picturestring終端使用者大頭貼的 URL。此 URL 必須指向圖片檔案(例如 PNG、JPEG 或 GIF),而非包含圖片的網頁。請注意,此 URL 應明確指向適合描述終端使用者的個人照片,而非終端使用者隨意拍攝的照片。No
created_atnumber終端使用者建立的時間。時間以自 Unix 紀元(1970-01-01T00:00:00Z)以來的毫秒數表示。No
updated_atnumber終端使用者資訊最後更新的時間。時間以自 Unix 紀元(1970-01-01T00:00:00Z)以來的毫秒數表示。No

其他 標準宣告 (Standard claims) 包含 family_namegiven_namemiddle_namenicknamepreferred_usernameprofilewebsitegenderbirthdatezoneinfolocale 也會包含在 profile 權限範圍內,無需額外請求 userinfo endpoint。與上表宣告不同的是,這些宣告僅在其值不為空時才會返回,而上表宣告若值為空則會返回 null

備註:

與標準宣告不同,created_atupdated_at 宣告使用毫秒而非秒數。

email

Claim nameTypeDescriptionNeeds userinfo?
emailstring使用者的電子郵件地址No
email_verifiedboolean電子郵件地址是否已驗證No

phone

Claim nameTypeDescriptionNeeds userinfo?
phone_numberstring使用者的手機號碼No
phone_number_verifiedboolean手機號碼是否已驗證No

address

請參閱 OpenID Connect Core 1.0 以瞭解 address 宣告的詳細資訊。

custom_data

Claim nameTypeDescriptionNeeds userinfo?
custom_dataobject使用者的自訂資料Yes

identities

Claim nameTypeDescriptionNeeds userinfo?
identitiesobject使用者的連結身分Yes
sso_identitiesarray使用者的連結 SSO 身分Yes

roles

Claim nameTypeDescriptionNeeds userinfo?
rolesstring[]使用者的角色 (Roles)No

urn:logto:scope:organizations

Claim nameTypeDescriptionNeeds userinfo?
organizationsstring[]使用者所屬的組織 (Organizations) IDNo
organization_dataobject[]使用者所屬的組織 (Organizations) 資料Yes
備註:

這些組織 (Organizations) 宣告也可透過 userinfo endpoint 取得(當使用 不透明權杖 (Opaque token) 時)。但不透明權杖 (Opaque tokens) 無法作為組織權杖 (Organization tokens) 存取組織專屬資源。詳情請見 不透明權杖 (Opaque token) 與組織 (Organizations)

urn:logto:scope:organization_roles

Claim nameTypeDescriptionNeeds userinfo?
organization_rolesstring[]使用者所屬的組織 (Organizations) 角色 (Roles),格式為 <organization_id>:<role_name>No

考量效能與資料大小,若 "Needs userinfo?" 為 "Yes",表示該宣告 (Claim) 不會出現在 ID 權杖 (ID token) 中,而會在 userinfo endpoint 回應中返回。

API 資源 (API resources)

我們建議先閱讀 🔐 角色型存取控制 (RBAC, Role-Based Access Control),以瞭解 Logto RBAC 的基本概念以及如何正確設定 API 資源。

設定 Logto client

一旦你設定了 API 資源,就可以在應用程式中配置 Logto 時新增它們:

app/logto.ts
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:readshopping:write 權限,而 https://store.your-app.com/api 資源具有 store:readstore:write 權限。

要請求這些權限,你可以在應用程式中配置 Logto 時新增它們:

app/logto.ts
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 配置中:

app/logto.ts
export const logtoConfig = {
// ...other configs
scopes: ['read', 'write'],
resources: ['https://shopping.your-app.com/api', 'https://store.your-app.com/api'],
};

對於每個 API 資源,它將請求 readwrite 權限範圍。

備註:

請求未在 API 資源中定義的權限範圍是可以的。例如,即使 API 資源中沒有可用的 email 權限範圍,你也可以請求 email 權限範圍。不可用的權限範圍將被安全地忽略。

成功登入後,Logto 將根據使用者的角色向 API 資源發出適當的權限範圍。

取得 API 資源的存取權杖 (Access token)

要獲取特定 API 資源的存取權杖 (Access token),你可以使用 getAccessToken 方法:

備註:

在客戶端元件中不允許定義內嵌的 "use server" 註解的伺服器操作。我們必須透過從伺服器元件傳遞屬性來實現。

app/page.ts
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>
);
}
app/get-access-token.ts
'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 函式:

app/page.tsx
// 取得 API 資源的存取權杖 (Access token)
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>
);
}
RSC 權杖快取限制:

React Server Components 無法寫入 cookies(Next.js 限制)。雖然 getAccessTokenRSC 仍會利用重新整理權杖 (Refresh token) 來刷新過期權杖,但新的存取權杖 (Access token) 不會被寫入 session cookie。這代表每次 RSC 請求若快取的權杖已過期,都可能觸發權杖刷新。

解決方案:

  1. 搭配 Server Actions 使用 client component — 透過 Server Actions 從 client component 呼叫 getAccessToken,即可更新 cookies。
  2. 使用外部 session 儲存 — 配置 sessionWrapper 搭配 Redis / KV 儲存。cookie 僅儲存固定 session ID,權杖資料存於外部儲存,讓 RSC 能持久化刷新後的權杖。詳見下方 使用外部 session 儲存

取得組織權杖 (Organization tokens)

如果你對組織 (Organization) 不熟悉,請閱讀 🏢 組織(多租戶,Multi-tenancy) 以開始了解。

在配置 Logto client 時,你需要新增 UserScope.Organizations 權限範圍 (scope):

app/logto.ts
import { UserScope } from '@logto/next';

export const logtoConfig = {
// ...other configs
scopes: [UserScope.Organizations],
};

使用者登入後,你可以為使用者獲取組織權杖 (organization token):

備註:

在客戶端元件中不允許定義內嵌的 "use server" 註解的伺服器操作。我們必須透過從伺服器元件傳遞屬性來實現。

app/page.ts
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>
);
}
app/get-organization-token.ts
'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 函式:

app/page.tsx
// 取得組織權杖 (Organization token)
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>
);
}
RSC 權杖快取限制:

getAccessTokenRSC 相同,刷新後的組織權杖 (Organization token) 不會在 RSC 中持久化。請參考上方解決方案

使用外部 session 儲存

SDK 預設使用 Cookie 儲存加密的工作階段資料。這種方式安全、無需額外基礎設施,且特別適合在如 Vercel 這類無伺服器(serverless)環境中運作。

然而,有時你可能需要將工作階段資料儲存在外部。例如,當你的工作階段資料過大,不適合存放於 Cookie,特別是當你需要同時維護多個活躍的組織 (Organization) 工作階段時。在這些情境下,你可以透過 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>();
private currentSessionId?: string;

async wrap(data: unknown, _key: string): Promise<string> {
// 若已有現有的 session ID 則重複使用,僅在首次使用者時產生新 ID。
// 這對於無法更新 Cookie 的環境(例如 React Server Components)很重要,
// 因為 Cookie 中的 session ID 必須保持穩定,而外部儲存中的資料則可更新。
const sessionId = this.currentSessionId ?? randomUUID();
this.currentSessionId = sessionId;
this.storage.set(sessionId, data);
return sessionId;
}

async unwrap(value: string, _key: string): Promise<SessionData> {
if (!value) {
return {};
}

// 儲存 session ID 以便 wrap() 可能重複使用
this.currentSessionId = value;
const data = this.storage.get(value);
return data ?? {};
}
}

上述實作採用簡單的記憶體內部儲存。在生產環境中,你可能會想使用更持久的儲存方案,例如 Redis 或資料庫。

未授權時自動導向登入頁

提示:

signIn 輔助函式會修改 cookies 以建立登入 session,因此無法直接在 React Server Component (RSC) 中呼叫。若要在 RSC 偵測到未授權使用者時自動觸發登入,請在專用路由處理器中呼叫 signIn 並導向該路由。

app/sign-in/route.ts
// 自動導向登入頁
import { signIn } from '@logto/next/server-actions';
import { logtoConfig } from '../../logto';

export async function GET() {
await signIn(logtoConfig);
}
app/protected/page.tsx
// 保護頁面,未授權時自動導向登入
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>;
}

延伸閱讀

終端使用者流程:驗證流程、帳號流程與組織流程 (End-user flows: authentication flows, account flows, and organization flows) 設定連接器 (Configure connectors) 授權 (Authorization)