- Introduce `LoginWithOptions` method to allow for customizable login flows, enabling overrides for scopes, token expiration, and refresh token issuance. - Add `LoginOptions` struct to encapsulate optional parameters for login, improving flexibility in user authentication. - Update token handling in `issueTokens` to accommodate new options, ensuring proper management of access and refresh tokens based on user preferences. - Integrate OTP service initialization into the OpenAPI server setup for enhanced authentication capabilities.
10 KiB
OTP Passwordless Authentication
Overview
OTP (One-Time Password) provides passwordless authentication via magic links.
AI or system generates a short link https://host/<prefix>/v/<code>, user clicks it,
CUI verifies the code against the backend, backend issues tokens, and frontend redirects.
Architecture
AI/System Yao Backend CUI Frontend
| | |
|-- otp.Create(params) ------->| |
|<---- code (nanoid 12) -------| |
| | |
| (compose link, send to user) | |
| | |
| | GET /<prefix>/v/<code> |
| |<-------------------------------|
| | |
| | POST /api/otp/login {code} |
| |<-------------------------------|
| | |
| |-- otp.Login(code) ------------>|
| | ├─ Verify code in store |
| | ├─ Resolve identity |
| | ├─ LoginByTeamID → tokens |
| | └─ SendLoginCookies |
| | |
| | {redirect} ----------------->|
| | |
| | window.location = redirect
Design Principles
- Four clean APIs: Create, Verify, Login, Revoke — each with a single responsibility.
- Verify is pure: returns stored payload, no side effects. Login is the full flow: verify + identity resolution + token issuance.
- Package location
openapi/otp/: can freely importuserandoauth. Dependency chain is one-directional:openapi/otp → user → oauth(no cycles). - Shared store, unified key namespace: reuses OAuth's store.Store,
keys under
{prefix}oauth:otp:{code}alongside refresh_token/access_token.
Package Structure
openapi/
otp/
DESIGN.md ← this file
otp.go ← Service, Payload, NewService
generate.go ← Create (nanoid + collision check + store.Set)
verify.go ← Verify (store.Get + type coercion)
revoke.go ← Revoke (store.Del)
login.go ← Login (Verify + resolve identity + issue tokens)
handler.go ← GinOTPCreate + GinOTPLogin HTTP handlers + Attach(group, oauth)
process.go ← Yao processors: otp.Create, otp.Verify, otp.Login, otp.Revoke
openapi.go ← init OTP service in Load(), register route in Attach()
cui/packages/cui/
openapi/user/auth.ts ← add OTPLogin method
pages/auth/v/$.tsx ← OTP verification page (route: /v/<code>)
Dependency Graph
openapi/otp
├── imports user (LoginByTeamID, LoginWithOptions, SendLoginCookies)
├── imports oauth (OAuth.GetUserProvider, OAuth.GetStore for identity resolution)
└── imports store (store.Store for code persistence)
user
└── imports oauth (existing, unchanged)
One-directional: openapi/otp → user → oauth. No cycles.
Data Structures
Payload (stored in store)
type Payload struct {
TeamID string `json:"team_id,omitempty"`
MemberID string `json:"member_id,omitempty"`
UserID string `json:"user_id,omitempty"`
Redirect string `json:"redirect"`
Scope string `json:"scope,omitempty"`
}
GenerateParams
type GenerateParams struct {
TeamID string
MemberID string
UserID string
ExpiresIn int // seconds, default 24h
Redirect string // required
Scope string // optional, space-separated
}
Store Key Format
{prefix}oauth:otp:{code}
Example: yao_:oauth:otp:abc123def456
Consistent with existing OAuth keys:
{prefix}oauth:refresh_token:{token}{prefix}oauth:access_token:{token}{prefix}oauth:otp:{code}
NanoID: 12 chars, alphabet 23456789abcdefghjkmnpqrstuvwxyz (no ambiguous chars).
Collision check: retry up to 5 times.
Processor Interface
Four processors, CRUD-style naming.
otp.Create
code = Process("otp.Create", {
"user_id": "user_xxx", // required (or member_id)
"team_id": "team_xxx", // optional
"member_id": "member_xxx", // optional; when set, team_id is required
"expires_in": 86400, // optional; seconds, default 24h
"redirect": "/chat", // required; target path after login
"scope": "read write" // optional; space-separated scopes
})
// returns: "abc123def456" (string)
// developer composes the full link: `${host}/${prefix}/v/${code}`
Single map argument. Returns code string.
otp.Verify
payload = Process("otp.Verify", "abc123def456")
// returns: {
// "team_id": "team_xxx",
// "member_id": "member_xxx",
// "user_id": "user_xxx",
// "redirect": "/chat",
// "scope": "read write"
// }
Pure validation. Returns stored Payload. Does NOT consume code (valid within TTL). Use case: inspect payload before login, or use OTP for non-login purposes.
otp.Login
result = Process("otp.Login", "abc123def456", "zh-CN")
// returns: {
// "access_token": "Bearer ...",
// "id_token": "eyJ...",
// "redirect": "/chat",
// "expires_in": 3600,
// ...
// }
Full login flow: verify code -> resolve identity -> issue tokens.
- args[0]: code (string, required)
- args[1]: locale (string, optional)
Internally:
- Verify(code) -> Payload
- Resolve identity (member_id -> user_id if needed)
- LoginByTeamID or LoginWithOptions (when scope override)
- Return LoginResponse + redirect
Does NOT set HTTP cookies (no gin.Context). The HTTP handler wraps this and additionally calls SendLoginCookies.
otp.Revoke
Process("otp.Revoke", "abc123def456")
// returns: null
Immediately removes code from store. Silent on missing/expired.
HTTP APIs
POST /api/otp/create (protected)
Requires authentication (OpenAPI Guard). Permission managed by Scope/ACL.
The caller's team_id is forced from the authenticated identity; request body team_id is ignored.
The member_id must belong to the caller's team.
Request:
{
"member_id": "member_xxx",
"user_id": "user_xxx",
"expires_in": 86400,
"redirect": "/chat",
"scope": "read write"
}
Success (200):
{ "code": "abc123def456" }
Errors: 400 (missing fields), 403 (member not in team), 500 (internal).
POST /api/otp/login (public)
Public endpoint (no auth guard). The OTP code itself is the credential.
Request:
{ "code": "abc123def456", "locale": "zh-CN" }
Success (200):
{ "redirect": "/chat" }
Cookies set: access_token, refresh_token, session_id.
Errors: 400 (missing code), 401 (invalid/expired), 500 (internal).
Handler Flows (handler.go)
GinOTPCreate
1. authorized.GetInfo(c) -> authInfo (teamID, userID)
2. Bind JSON -> request body
3. Force teamID from authInfo
4. Validate member belongs to team
5. service.Create(params) -> code
6. Respond {code}
GinOTPLogin
1. Bind JSON -> {code, locale}
2. service.Login(code, locale) -> LoginResponse + Payload
3. sessionID = utils.GetSessionID(c) or generateSessionID()
4. user.SendLoginCookies(c, loginResp, sessionID)
5. Respond {redirect: payload.Redirect}
The handlers are thin — business logic lives in the Service methods.
Scope Override (user package change)
When payload.Scope is non-empty, LoginByTeamID cannot be used directly
because it resolves scopes internally. Add one function to user package:
// user/types.go
type LoginOptions struct {
Scopes []string
}
// user/login.go
func LoginWithOptions(userid, teamID string, loginCtx *LoginContext, opts *LoginOptions) (*LoginResponse, error)
Same logic as LoginByTeamID, uses opts.Scopes when non-nil.
This is the only change to user package.
Initialization (openapi.go)
In Load():
otp.NewService(oauth.OAuth.GetStore(), oauth.OAuth.GetPrefix())
Note: Since oauth.Service.prefix is private, the OTP service constructs
the prefix independently using share.App.Prefix for consistency.
In Attach():
otp.Attach(group.Group("/otp"), openapi.OAuth)
CUI Page (pages/auth/v/$.tsx)
1. Extract code from URL path: /v/<code>
2. Call userClient.auth.OTPLogin(code, locale)
3. On success:
- Call GetProfile() to get UserInfo (cookies already set by backend)
- AfterLogin(global, { user: profileData, entry: redirect })
- window.location.href = redirect
4. On error: show error UI with "Go Back" button
auth.ts Addition
async OTPLogin(code: string, locale?: string): Promise<ApiResponse<{ redirect: string }>> {
return this.api.Post<{ redirect: string }>('/otp/login', { code, locale: locale || '' })
}
Test Plan
- Unit tests (openapi/otp package):
- Create: produces 12-char code, stores payload, respects TTL
- Create: validates required fields (user_id/member_id, redirect)
- Create: handles collision retry
- Verify: returns payload, rejects expired/invalid/empty
- Verify: does NOT consume code (multi-verify within TTL)
- Revoke: removes code, silent on missing
- Login: full flow with valid code returns tokens
- Login: rejects invalid code
- Handler tests:
- POST /api/otp/create with valid auth -> 200 + code
- POST /api/otp/create without auth -> 401
- POST /api/otp/create with cross-team member -> 403
- POST /api/otp/login with valid code -> 200 + cookies + redirect
- POST /api/otp/login with invalid code -> 401
- POST /api/otp/login with missing code -> 400