kiwi-user
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseKiwi-User Authentication Integration
Kiwi-User 认证集成方案
This skill defines how to integrate with the kiwi-user authentication service from both frontend (Next.js/React) and backend (Go/Gin) applications.
本方案定义了如何在前端(Next.js/React)与后端(Go/Gin)应用中集成 Kiwi-user 认证服务。
Architecture Overview
架构概览
Frontend (Next.js) Kiwi-User Service Backend (Go/Gin)
| | |
|---(1) OAuth/login ------------------>| |
|<--(2) {access_token, refresh_token}--| |
| | |
| [Store tokens in localStorage] | |
| | |
|---(3) API request (Bearer token) -----|-------------------------------------->|
| |<---(4) POST /v1/token/verify --------|
| |----(5) {success, user_info} -------->|
| | |
|---(6) POST /v1/token/refresh ------->| [When access token near expiry] |
|<--(7) New tokens --------------------| |Kiwi-user is the central auth service. Frontends authenticate users and store tokens. Backends verify tokens on every request via kiwi-user's API (or optionally via public key).
Frontend (Next.js) Kiwi-User Service Backend (Go/Gin)
| | |
|---(1) OAuth/login ------------------>| |
|<--(2) {access_token, refresh_token}--| |
| | |
| [Store tokens in localStorage] | |
| | |
|---(3) API request (Bearer token) -----|-------------------------------------->|
| |<---(4) POST /v1/token/verify --------|
| |----(5) {success, user_info} -------->|
| | |
|---(6) POST /v1/token/refresh ------->| [When access token near expiry] |
|<--(7) New tokens --------------------| |Kiwi-user 是核心认证服务。前端负责用户认证与 Token 存储,后端则通过 Kiwi-user 的 API(或可选的公钥方式)在每次请求时验证 Token。
JWT Token Structure
JWT Token 结构
Kiwi-user issues RS256-signed JWTs. The access token payload contains:
| Field | JWT Claim | Type | Description |
|---|---|---|---|
| UserID | | string | User's unique ID |
| Application | | string | Application name (issuer) |
| PersonalRole | | string | User's role |
| Scopes | | []string | Permission scopes |
| DeviceType | | string | Device type |
| DeviceID | | string | Device identifier |
| OrganizationID | | string | Current org ID |
| Create | | int64 | Issued at (unix seconds) |
| Expire | | int64 | Expiration (unix seconds) |
Default expiration: access token = 600s (10 min), refresh token = 86400s (24 hours).
Kiwi-user 签发基于 RS256 签名的 JWT。Access Token 的载荷包含以下字段:
| 字段 | JWT 声明 | 类型 | 描述 |
|---|---|---|---|
| UserID | | string | 用户唯一ID |
| Application | | string | 应用名称(签发方) |
| PersonalRole | | string | 用户角色 |
| Scopes | | []string | 权限范围 |
| DeviceType | | string | 设备类型 |
| DeviceID | | string | 设备标识 |
| OrganizationID | | string | 当前组织ID |
| Create | | int64 | 签发时间(Unix 时间戳,秒) |
| Expire | | int64 | 过期时间(Unix 时间戳,秒) |
默认过期时间:Access Token = 600秒(10分钟),Refresh Token = 86400秒(24小时)。
Environment Variables
环境变量
Frontend (Next.js)
前端(Next.js)
| Variable | Purpose | Example |
|---|---|---|
| Kiwi-user API base URL | |
| Application name for login requests | |
| Google OAuth client ID | |
| 变量 | 用途 | 示例 |
|---|---|---|
| Kiwi-user API 基础地址 | |
| 登录请求使用的应用名称 | |
| Google OAuth 客户端ID | |
Backend (Go)
后端(Go)
| Config Field | Purpose | Example |
|---|---|---|
| Kiwi-user API base URL | |
| 配置字段 | 用途 | 示例 |
|---|---|---|
| Kiwi-user API 基础地址 | |
Frontend Integration
前端集成
See references/frontend-auth.md for complete code patterns.
完整代码示例请参考 references/frontend-auth.md。
Login Flows
登录流程
Kiwi-user supports multiple login methods. All return the same :
LoginResponsetypescript
interface LoginResponse {
refresh_token: string;
refresh_token_expires_at: number; // unix seconds
access_token: string;
access_token_expires_at: number; // unix seconds
type: string;
device_type: string;
device_id: string;
user_id: string;
}| Method | Endpoint | Flow |
|---|---|---|
| Google OAuth (Web) | | Redirect to Google -> callback with code -> exchange for tokens |
| Send verification code -> | |
| Phone | | Send verification code -> |
| WeChat Mini Program | | WeChat SDK login |
| WeChat Web | | WeChat OAuth flow |
| Password | | Direct username/password |
Kiwi-user 支持多种登录方式,所有方式均返回相同的 结构:
LoginResponsetypescript
interface LoginResponse {
refresh_token: string;
refresh_token_expires_at: number; // unix seconds
access_token: string;
access_token_expires_at: number; // unix seconds
type: string;
device_type: string;
device_id: string;
user_id: string;
}| 方式 | 接口 | 流程 |
|---|---|---|
| Google OAuth(网页端) | | 重定向至 Google -> 携带授权码回调 -> 兑换 Token |
| 邮箱登录 | | 发送验证码 -> 调用 |
| 手机号登录 | | 发送验证码 -> 调用 |
| 微信小程序登录 | | 通过微信 SDK 登录 |
| 微信网页登录 | | 微信 OAuth 流程 |
| 密码登录 | | 直接提交用户名/密码 |
Token Storage
Token 存储
Store all auth data in with these keys:
localStorage| Key | Value |
|---|---|
| JWT access token string |
| Refresh token string |
| Expiration (unix seconds, string) |
| Expiration (unix seconds, string) |
| User ID string |
| Device type string |
| Device identifier string |
将所有认证数据存储在 中,使用以下键名:
localStorage| 键名 | 对应值 |
|---|---|
| JWT Access Token 字符串 |
| Refresh Token 字符串 |
| 过期时间(Unix 时间戳,字符串格式) |
| 过期时间(Unix 时间戳,字符串格式) |
| 用户ID字符串 |
| 设备类型字符串 |
| 设备标识字符串 |
Token Refresh Strategy
Token 刷新策略
Access tokens expire quickly (default 10 min). Refresh BEFORE expiration using a buffer:
typescript
function isAccessTokenExpired(): boolean {
const expiresAt = localStorage.getItem("kiwi_access_token_expires_at");
if (!expiresAt) return true;
const bufferSeconds = 60; // 1-minute buffer
return Date.now() / 1000 >= parseInt(expiresAt) - bufferSeconds;
}The function auto-refreshes transparently:
getAccessToken()typescript
async function getAccessToken(): Promise<string | null> {
if (!isAccessTokenExpired()) {
return localStorage.getItem("kiwi_access_token");
}
// Refresh the token
const refreshed = await refreshAccessToken();
if (!refreshed) {
clearAuthData(); // Refresh token also expired
return null;
}
return localStorage.getItem("kiwi_access_token");
}Refresh calls with . Both tokens are replaced.
POST /v1/token/refresh{user_id, refresh_token, device}Access Token 过期较快(默认10分钟),需在过期前提前刷新,建议设置缓冲时间:
typescript
function isAccessTokenExpired(): boolean {
const expiresAt = localStorage.getItem("kiwi_access_token_expires_at");
if (!expiresAt) return true;
const bufferSeconds = 60; // 1分钟缓冲
return Date.now() / 1000 >= parseInt(expiresAt) - bufferSeconds;
}getAccessToken()typescript
async function getAccessToken(): Promise<string | null> {
if (!isAccessTokenExpired()) {
return localStorage.getItem("kiwi_access_token");
}
// 刷新Token
const refreshed = await refreshAccessToken();
if (!refreshed) {
clearAuthData(); // Refresh Token 也已过期
return null;
}
return localStorage.getItem("kiwi_access_token");
}刷新操作调用 接口,携带参数 ,刷新后新旧Token会被替换。
POST /v1/token/refresh{user_id, refresh_token, device}API Client Pattern
API 客户端实现模式
Every authenticated API request MUST use and set the header:
getAccessToken()Authorizationtypescript
async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = await getAccessToken();
if (!token) {
window.location.href = "/"; // Redirect to login
throw new Error("Not authenticated");
}
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...options.headers,
},
});
if (response.status === 401) {
// Token rejected by backend - clear and redirect
clearAuthData();
window.location.href = "/";
throw new Error("Unauthorized");
}
const data = await response.json();
return data.data as T;
}所有需要认证的API请求必须调用 并设置 请求头:
getAccessToken()Authorizationtypescript
async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = await getAccessToken();
if (!token) {
window.location.href = "/"; // 重定向至登录页
throw new Error("Not authenticated");
}
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...options.headers,
},
});
if (response.status === 401) {
// Token 被后端拒绝 - 清除认证数据并重定向
clearAuthData();
window.location.href = "/";
throw new Error("Unauthorized");
}
const data = await response.json();
return data.data as T;
}React Auth Hook
React 认证 Hook
Use a hook and context to manage auth state:
useAuthAuthProvidertypescript
function useAuth() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState<UserInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function checkAuth() {
const authed = isAuthenticated(); // checks refresh token validity
setIsAuthenticated(authed);
if (authed) {
const userInfo = await getUserInfo();
setUser(userInfo);
}
setIsLoading(false);
}
checkAuth();
}, []);
return { isAuthenticated, isLoading, user, logout };
}使用 Hook 和 上下文管理认证状态:
useAuthAuthProvidertypescript
function useAuth() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState<UserInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function checkAuth() {
const authed = isAuthenticated(); // 检查Refresh Token有效性
setIsAuthenticated(authed);
if (authed) {
const userInfo = await getUserInfo();
setUser(userInfo);
}
setIsLoading(false);
}
checkAuth();
}, []);
return { isAuthenticated, isLoading, user, logout };
}User Info Fetching
用户信息获取
GET /v1/user/infotypescript
let userInfoPromise: Promise<UserInfo> | null = null;
let cachedUserInfo: { data: UserInfo; timestamp: number } | null = null;
const CACHE_DURATION = 5000; // 5 seconds
async function getUserInfo(): Promise<UserInfo> {
// Return cached if fresh
if (cachedUserInfo && Date.now() - cachedUserInfo.timestamp < CACHE_DURATION) {
return cachedUserInfo.data;
}
// Deduplicate concurrent requests
if (userInfoPromise) return userInfoPromise;
userInfoPromise = fetchUserInfo();
try {
const info = await userInfoPromise;
cachedUserInfo = { data: info, timestamp: Date.now() };
return info;
} finally {
userInfoPromise = null;
}
}On 401 response: retry once after refreshing access token. If still 401, logout.
调用 接口并携带Bearer Token,需实现请求去重与缓存机制:
GET /v1/user/infotypescript
let userInfoPromise: Promise<UserInfo> | null = null;
let cachedUserInfo: { data: UserInfo; timestamp: number } | null = null;
const CACHE_DURATION = 5000; // 5秒
async function getUserInfo(): Promise<UserInfo> {
// 如果缓存未过期则返回缓存
if (cachedUserInfo && Date.now() - cachedUserInfo.timestamp < CACHE_DURATION) {
return cachedUserInfo.data;
}
// 去重并发请求
if (userInfoPromise) return userInfoPromise;
userInfoPromise = fetchUserInfo();
try {
const info = await userInfoPromise;
cachedUserInfo = { data: info, timestamp: Date.now() };
return info;
} finally {
userInfoPromise = null;
}
}如果返回401响应:先尝试刷新Access Token后重试一次,若仍返回401则执行登出操作。
Backend Integration
后端集成
See references/backend-auth.md for complete code patterns.
完整代码示例请参考 references/backend-auth.md。
Method 1: Token Verification via API (Recommended)
方式1:通过API验证Token(推荐)
Call kiwi-user's endpoint:
POST /v1/token/verifygo
type Client struct {
baseURL string
httpClient *http.Client
}
func (c *Client) VerifyToken(ctx context.Context, token string) (string, error) {
reqBody := map[string]string{"access_token": token}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequestWithContext(ctx, "POST",
c.baseURL+"/v1/token/verify", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
// ... error handling, parse response ...
// Response: {status: "success", data: {success: true, user_info: {id, name, ...}}}
return response.Data.UserInfo.ID, nil
}调用Kiwi-user的 接口:
POST /v1/token/verifygo
type Client struct {
baseURL string
httpClient *http.Client
}
func (c *Client) VerifyToken(ctx context.Context, token string) (string, error) {
reqBody := map[string]string{"access_token": token}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequestWithContext(ctx, "POST",
c.baseURL+"/v1/token/verify", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
// ... 错误处理、响应解析 ...
// 响应格式: {status: "success", data: {success: true, user_info: {id, name, ...}}}
return response.Data.UserInfo.ID, nil
}Method 2: Public Key Verification (Self-Contained)
方式2:公钥验证(独立验证)
Fetch the public key from and verify the JWT locally:
GET /v1/token/publickeygo
// 1. Fetch public key (cache it - it rarely changes)
// GET /v1/token/publickey -> {public_key: "-----BEGIN PUBLIC KEY-----\n..."}
// 2. Parse and verify the JWT
// Split token into head.payload.signature
// Verify signature with RSA public key + SHA256
// Decode payload: base64url -> JSON -> AccessPayload
// Check exp > now
// 3. Extract user info from payload
// payload.sub = user ID
// payload.iss = application
// payload.roles = personal role
// payload.organization_id = org IDThis avoids a network call per request but requires managing key rotation.
从 接口获取公钥,然后本地验证JWT:
GET /v1/token/publickeygo
// 1. 获取公钥(缓存该公钥 - 极少变更)
// GET /v1/token/publickey -> {public_key: "-----BEGIN PUBLIC KEY-----\n..."}
// 2. 解析并验证JWT
// 将Token拆分为head.payload.signature三部分
// 使用RSA公钥 + SHA256验证签名
// 解析载荷: base64url解码 -> JSON解析 -> AccessPayload
// 检查exp字段是否大于当前时间
// 3. 从载荷中提取用户信息
// payload.sub = 用户ID
// payload.iss = 应用名称
// payload.roles = 用户角色
// payload.organization_id = 组织ID该方式避免了每次请求的网络调用,但需要自行管理公钥轮换。
Auth Middleware Pattern (Gin)
Gin 认证中间件实现模式
go
const UserIDKey = "user_id"
func AuthMiddleware(kiwiUserClient *kiwiuser.Client) gin.HandlerFunc {
return func(c *gin.Context) {
auth := c.GetHeader("Authorization")
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.AbortWithStatusJSON(401, BaseResponse{Status: "error", Error: "missing token"})
return
}
userID, err := kiwiUserClient.VerifyToken(c.Request.Context(), parts[1])
if err != nil {
c.AbortWithStatusJSON(401, BaseResponse{Status: "error", Error: err})
return
}
c.Set(UserIDKey, userID)
c.Next()
}
}go
const UserIDKey = "user_id"
func AuthMiddleware(kiwiUserClient *kiwiuser.Client) gin.HandlerFunc {
return func(c *gin.Context) {
auth := c.GetHeader("Authorization")
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.AbortWithStatusJSON(401, BaseResponse{Status: "error", Error: "missing token"})
return
}
userID, err := kiwiUserClient.VerifyToken(c.Request.Context(), parts[1])
if err != nil {
c.AbortWithStatusJSON(401, BaseResponse{Status: "error", Error: err})
return
}
c.Set(UserIDKey, userID)
c.Next()
}
}Extracting User ID in Handlers
处理器中提取用户ID
Use the pattern to get a type-safe user ID:
RequireUserHandlergo
// Handler wrapper that extracts user ID
func RequireUserHandler[T any](f func(*gin.Context, string) (T, *facade.Error)) gin.HandlerFunc {
return func(c *gin.Context) {
userID, exists := c.Get(middleware.UserIDKey)
if !exists {
c.AbortWithStatusJSON(403, BaseResponse{Status: "error", Error: "user ID not found"})
return
}
userIDStr := userID.(string)
data, err := f(c, userIDStr)
if err != nil {
responseError(c, err)
return
}
c.JSON(200, BaseResponse{Status: "success", Data: data})
}
}
// Usage in route registration
router.GET("/v1/profile", RequireUserHandler(handler.GetProfile))
// Handler receives userID directly
func (h *Handler) GetProfile(c *gin.Context, userID string) (*Profile, *facade.Error) {
return h.service.GetProfile(c, userID)
}使用 模式获取类型安全的用户ID:
RequireUserHandlergo
// 处理器包装函数,用于提取用户ID
func RequireUserHandler[T any](f func(*gin.Context, string) (T, *facade.Error)) gin.HandlerFunc {
return func(c *gin.Context) {
userID, exists := c.Get(middleware.UserIDKey)
if !exists {
c.AbortWithStatusJSON(403, BaseResponse{Status: "error", Error: "user ID not found"})
return
}
userIDStr := userID.(string)
data, err := f(c, userIDStr)
if err != nil {
responseError(c, err)
return
}
c.JSON(200, BaseResponse{Status: "success", Data: data})
}
}
// 路由注册时的用法
router.GET("/v1/profile", RequireUserHandler(handler.GetProfile))
// 处理器直接接收userID参数
func (h *Handler) GetProfile(c *gin.Context, userID string) (*Profile, *facade.Error) {
return h.service.GetProfile(c, userID)
}Route Registration Pattern
路由注册模式
Apply auth middleware to route groups:
go
func RegisterRoutes(router *gin.Engine, authMW gin.HandlerFunc, handler *Handler) {
v1 := router.Group("/v1")
// Public routes (no auth)
public := v1.Group("/public")
public.GET("/health", handler.Health)
// Authenticated routes
authed := v1.Group("")
authed.Use(authMW)
authed.GET("/profile", RequireUserHandler(handler.GetProfile))
authed.POST("/settings", RequireUserHandler(handler.UpdateSettings))
}将认证中间件应用到路由组:
go
func RegisterRoutes(router *gin.Engine, authMW gin.HandlerFunc, handler *Handler) {
v1 := router.Group("/v1")
// 公开路由(无需认证)
public := v1.Group("/public")
public.GET("/health", handler.Health)
// 需认证路由
authed := v1.Group("")
authed.Use(authMW)
authed.GET("/profile", RequireUserHandler(handler.GetProfile))
authed.POST("/settings", RequireUserHandler(handler.UpdateSettings))
}Local Development
本地开发环境配置
In local environment, skip token verification and use a test user:
go
if cfg.Server.Env == "local" {
c.Set(UserIDKey, "test_user")
c.Next()
return
}在本地开发环境中,可以跳过Token验证,使用测试用户ID:
go
if cfg.Server.Env == "local" {
c.Set(UserIDKey, "test_user")
c.Next()
return
}Logout Flow
登出流程
Frontend
前端
typescript
async function logout(): Promise<void> {
const userId = localStorage.getItem("kiwi_user_id");
const refreshToken = localStorage.getItem("kiwi_refresh_token");
// Notify kiwi-user to invalidate tokens
await fetch(`${KIWI_USER_API_BASE_URL}/v1/user/logout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: userId,
refresh_token: refreshToken,
device: { device_type: getDeviceType(), device_id: getDeviceId() },
}),
});
// Clear all local auth data
clearAuthData();
}typescript
async function logout(): Promise<void> {
const userId = localStorage.getItem("kiwi_user_id");
const refreshToken = localStorage.getItem("kiwi_refresh_token");
// 通知Kiwi-user失效Token
await fetch(`${KIWI_USER_API_BASE_URL}/v1/user/logout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: userId,
refresh_token: refreshToken,
device: { device_type: getDeviceType(), device_id: getDeviceId() },
}),
});
// 清除本地所有认证数据
clearAuthData();
}API Reference
API 参考文档
See references/kiwi-user-api.md for complete endpoint documentation with request/response types.
完整接口文档(含请求/响应类型定义)请参考 references/kiwi-user-api.md。
Checklist
检查清单
Before shipping auth integration, verify:
- and
NEXT_PUBLIC_KIWI_USER_API_BASE_URLare configuredNEXT_PUBLIC_APPLICATION_NAME - Tokens stored in localStorage with correct keys (, etc.)
kiwi_access_token - Access token refresh uses 1-minute buffer before actual expiration
- called before every API request (auto-refreshes)
getAccessToken() - API client sets header
Authorization: Bearer <token> - 401 responses trigger token refresh retry, then logout on second failure
- has request deduplication and short-lived cache
getUserInfo() - Backend auth middleware extracts Bearer token from Authorization header
- Backend calls (or uses public key verification)
POST /v1/token/verify - User ID set in gin context with
c.Set("user_id", userID) - Handlers use pattern for type-safe user ID access
RequireUserHandler - Local dev mode skips verification with
test_user - Logout calls AND clears all localStorage keys
/v1/user/logout
在发布认证集成功能前,请验证以下内容:
- 已配置 和
NEXT_PUBLIC_KIWI_USER_API_BASE_URL环境变量NEXT_PUBLIC_APPLICATION_NAME - Token 已使用正确的键名存储在 localStorage 中(如 等)
kiwi_access_token - Access Token 刷新逻辑在过期前1分钟缓冲时间触发
- 所有API请求前均调用 (自动完成刷新)
getAccessToken() - API客户端已设置 请求头
Authorization: Bearer <token> - 401响应会触发Token刷新重试,若再次失败则执行登出
- 已实现请求去重与短时间缓存
getUserInfo() - 后端认证中间件已从Authorization头中提取Bearer Token
- 后端已调用 (或使用公钥验证方式)
POST /v1/token/verify - 用户ID已通过 存入Gin上下文
c.Set("user_id", userID) - 处理器使用 模式获取类型安全的用户ID
RequireUserHandler - 本地开发模式已跳过验证,使用 作为用户ID
test_user - 登出操作已调用 接口并清除所有localStorage键值对
/v1/user/logout