Kiwi-User Authentication Integration
This skill defines how to integrate with the kiwi-user authentication service from both frontend (Next.js/React) and backend (Go/Gin) applications.
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).
JWT Token Structure
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).
Environment Variables
Frontend (Next.js)
| Variable | Purpose | Example |
|---|
NEXT_PUBLIC_KIWI_USER_API_BASE_URL
| Kiwi-user API base URL | |
NEXT_PUBLIC_APPLICATION_NAME
| Application name for login requests | |
NEXT_PUBLIC_GOOGLE_CLIENT_ID
| Google OAuth client ID | xxx.apps.googleusercontent.com
|
Backend (Go)
| Config Field | Purpose | Example |
|---|
| Kiwi-user API base URL | |
Frontend Integration
See references/frontend-auth.md for complete code patterns.
Login Flows
Kiwi-user supports multiple login methods. All return the same
:
typescript
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) | POST /v1/login/google/web
| Redirect to Google -> callback with code -> exchange for tokens |
| Email | | Send verification code -> POST /v1/login/email/verify_code
|
| Phone | | Send verification code -> POST /v1/login/phone/verify_code
|
| WeChat Mini Program | POST /v1/login/wechat/miniprogram
| WeChat SDK login |
| WeChat Web | POST /v1/login/wechat/web
| WeChat OAuth flow |
| Password | | Direct username/password |
Token Storage
Store all auth data in
with these keys:
| Key | Value |
|---|
| JWT access token string |
| Refresh token string |
kiwi_access_token_expires_at
| Expiration (unix seconds, string) |
kiwi_refresh_token_expires_at
| Expiration (unix seconds, string) |
| User ID string |
| Device type string |
| Device identifier string |
Token Refresh Strategy
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:
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
{user_id, refresh_token, device}
. Both tokens are replaced.
API Client Pattern
Every authenticated API request MUST use
and set the
header:
typescript
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;
}
React Auth Hook
Use a
hook and
context to manage auth state:
typescript
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 };
}
User Info Fetching
with Bearer token. Implement request deduplication and caching:
typescript
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.
Backend Integration
See references/backend-auth.md for complete code patterns.
Method 1: Token Verification via API (Recommended)
Call kiwi-user's
endpoint:
go
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
}
Method 2: Public Key Verification (Self-Contained)
Fetch the public key from
and verify the JWT locally:
go
// 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 ID
This avoids a network call per request but requires managing key rotation.
Auth Middleware Pattern (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()
}
}
Extracting User ID in Handlers
Use the
pattern to get a type-safe user ID:
go
// 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)
}
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))
}
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
}
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();
}
API Reference
See references/kiwi-user-api.md for complete endpoint documentation with request/response types.
Checklist
Before shipping auth integration, verify: