yao/openapi/otp/DESIGN.md
Max 37ab2bc84b Enhance user login functionality with options for customization
- 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.
2026-02-21 21:38:39 +08:00

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

  1. Four clean APIs: Create, Verify, Login, Revoke — each with a single responsibility.
  2. Verify is pure: returns stored payload, no side effects. Login is the full flow: verify + identity resolution + token issuance.
  3. Package location openapi/otp/: can freely import user and oauth. Dependency chain is one-directional: openapi/otp → user → oauth (no cycles).
  4. 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:

  1. Verify(code) -> Payload
  2. Resolve identity (member_id -> user_id if needed)
  3. LoginByTeamID or LoginWithOptions (when scope override)
  4. 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