From 48e9af4aa122f3414ea856815cae02d31a09f6d6 Mon Sep 17 00:00:00 2001 From: AmisKwok Date: Tue, 22 Sep 2026 02:56:12 +0800 Subject: [PATCH 1/7] feat(core): harden the session lifecycle and add clock-skew tolerance - restore() keeps the persisted session when a renewal fails transiently, instead of clearing the store and signing the user out - login/register/loginWith/updateSession bump the epoch, so an in-flight refresh can never overwrite the session they install - single-flight refresh is scoped to the epoch it started in - a renewal carries userId/displayName/claims over when the backend returns tokens only (preserveSessionDetails) - clockSkew (default 30s) plus validAccessToken(leeway:) stop handing out tokens that die in flight - an already-due proactive renewal is throttled by autoRefreshMinInterval instead of spinning - mapAuthFailure reads the code of a bare AuthFail; claims compare structurally; AuthSession.tryFromJson never throws - a TokenStore failing on save/clear can no longer strand the refresh future or leak a raw error - AuthState.session, AuthManagerGroup tuning pass-through, activeIdChanges and restoreAll that keeps going - onStateChanged errors are contained; the group forgets a manager disposed outside it BREAKING CHANGE: AuthTokenSource gained validAccessToken({Duration? leeway}). Implementations that only expose accessToken must add: @override Future validAccessToken({Duration? leeway}) async => accessToken; --- docs/404.html | 2 +- docs/404/index.html | 2 +- docs/Auth-State-Machine/index.html | 22 +- docs/Backend-Strategy/index.html | 4 +- docs/Configuration/index.html | 30 +- docs/Errors/index.html | 22 +- docs/FAQ/index.html | 17 +- docs/Getting-Started/index.html | 4 +- docs/Installation/index.html | 8 +- docs/Multi-Account/index.html | 19 +- docs/Network-Integration/index.html | 27 +- docs/Persistence/index.html | 12 +- docs/Third-Party-Login/index.html | 4 +- docs/Token-Store/index.html | 8 +- docs/Usage/index.html | 4 +- .../static/chunks/nextra-data-en-US.json | 2 +- .../Auth-State-Machine-482df521c021e389.js | 1 - .../Auth-State-Machine-df4af2f2f62580ee.js | 1 + .../pages/Configuration-6e0bc428c276681c.js | 1 + .../pages/Configuration-e1e78c409860f3d5.js | 1 - .../chunks/pages/Errors-ccf26da76701ec77.js | 1 - .../chunks/pages/Errors-d214a21e984d4641.js | 1 + .../chunks/pages/FAQ-2cda6ba11483a6c4.js | 1 + .../chunks/pages/FAQ-66e28fdd0cce1e18.js | 1 - ...af.js => Installation-dd2c3a4ed8ae2991.js} | 2 +- .../pages/Multi-Account-2b08a3f8b85100c0.js | 1 - .../pages/Multi-Account-bd727b2ad9f2f451.js | 1 + .../Network-Integration-402a7d4df93d13c8.js | 1 + .../Network-Integration-ef3ead93a51929f6.js | 1 - .../pages/Persistence-621169678679dd35.js | 1 + .../pages/Persistence-6ffb0f37f033307f.js | 1 - ...931.js => Token-Store-1f37005b28989149.js} | 2 +- .../chunks/pages/index-255afb63bd37772c.js | 1 - .../chunks/pages/index-baf7c42074f1ade3.js | 1 + .../_buildManifest.js | 2 +- .../_ssgManifest.js | 0 docs/index.html | 6 +- example/lib/dio_interceptor.dart | 24 +- lib/src/auth_manager.dart | 306 ++++++- lib/src/auth_manager_group.dart | 151 +++- lib/src/auth_session.dart | 112 ++- lib/src/auth_state.dart | 18 + lib/src/auth_token_source.dart | 22 + lib/src/exceptions.dart | 72 +- test/auth_manager_v1_test.dart | 847 ++++++++++++++++++ 45 files changed, 1612 insertions(+), 155 deletions(-) delete mode 100644 docs/_next/static/chunks/pages/Auth-State-Machine-482df521c021e389.js create mode 100644 docs/_next/static/chunks/pages/Auth-State-Machine-df4af2f2f62580ee.js create mode 100644 docs/_next/static/chunks/pages/Configuration-6e0bc428c276681c.js delete mode 100644 docs/_next/static/chunks/pages/Configuration-e1e78c409860f3d5.js delete mode 100644 docs/_next/static/chunks/pages/Errors-ccf26da76701ec77.js create mode 100644 docs/_next/static/chunks/pages/Errors-d214a21e984d4641.js create mode 100644 docs/_next/static/chunks/pages/FAQ-2cda6ba11483a6c4.js delete mode 100644 docs/_next/static/chunks/pages/FAQ-66e28fdd0cce1e18.js rename docs/_next/static/chunks/pages/{Installation-44254819243c14af.js => Installation-dd2c3a4ed8ae2991.js} (98%) delete mode 100644 docs/_next/static/chunks/pages/Multi-Account-2b08a3f8b85100c0.js create mode 100644 docs/_next/static/chunks/pages/Multi-Account-bd727b2ad9f2f451.js create mode 100644 docs/_next/static/chunks/pages/Network-Integration-402a7d4df93d13c8.js delete mode 100644 docs/_next/static/chunks/pages/Network-Integration-ef3ead93a51929f6.js create mode 100644 docs/_next/static/chunks/pages/Persistence-621169678679dd35.js delete mode 100644 docs/_next/static/chunks/pages/Persistence-6ffb0f37f033307f.js rename docs/_next/static/chunks/pages/{Token-Store-afbc8ed380153931.js => Token-Store-1f37005b28989149.js} (63%) delete mode 100644 docs/_next/static/chunks/pages/index-255afb63bd37772c.js create mode 100644 docs/_next/static/chunks/pages/index-baf7c42074f1ade3.js rename docs/_next/static/{7D0eXxPH8unHa8VZ9ZDfm => mMoJzqLtuvXxc8MM2_6u7}/_buildManifest.js (56%) rename docs/_next/static/{7D0eXxPH8unHa8VZ9ZDfm => mMoJzqLtuvXxc8MM2_6u7}/_ssgManifest.js (100%) create mode 100644 test/auth_manager_v1_test.dart diff --git a/docs/404.html b/docs/404.html index a011fd9..13ae361 100644 --- a/docs/404.html +++ b/docs/404.html @@ -1 +1 @@ -404: This page could not be found

404

This page could not be found.

\ No newline at end of file +404: This page could not be found

404

This page could not be found.

\ No newline at end of file diff --git a/docs/404/index.html b/docs/404/index.html index a011fd9..13ae361 100644 --- a/docs/404/index.html +++ b/docs/404/index.html @@ -1 +1 @@ -404: This page could not be found

404

This page could not be found.

\ No newline at end of file +404: This page could not be found

404

This page could not be found.

\ No newline at end of file diff --git a/docs/Auth-State-Machine/index.html b/docs/Auth-State-Machine/index.html index 1f08d6f..3f661fa 100644 --- a/docs/Auth-State-Machine/index.html +++ b/docs/Auth-State-Machine/index.html @@ -1,4 +1,4 @@ -
🧭 Auth State Machine

Auth State Machine / 认证状态机

+
🧭 Auth State Machine

Auth State Machine / 认证状态机

States / 状态

AuthState is a sealed class with six subtypes:

AuthState 是一个密封类,包含六个子类型:

@@ -51,6 +51,10 @@

true,续期不会卸载已登录界面。
  • state.isBusy — true while Authenticating, Refreshing or LoggingOut. / 在 Authenticating、Refreshing、LoggingOut 期间为 true。
  • +
  • state.session — the session the state carries (Authenticated / Refreshing / +LoggingOut), or null; no pattern-matching needed for the common case. / +该状态携带的会话(Authenticated / Refreshing / LoggingOut)或 null, +常见场景无需再做模式匹配。
  • The stream / 状态流

    AuthManager.state is a Stream<AuthState> that replays the latest value to every new listener (BehaviorSubject-like). This means:

    @@ -87,10 +91,11 @@

    restore() restores a live session → Authenticated.

  • -

    restore() restores an expired session → attempts Refreshing; if it -cannot be renewed (or there is no refresh token) the store is cleared and it -lands on Unauthenticated. restore(refreshIfExpired: false) restores it -verbatim instead.

    +

    restore() restores an expired session → attempts Refreshing; if the +renewal fails transiently (network, 5xx) the persisted session is kept and +activated as-is, so the next request can retry. Only a terminal failure — or the +absence of a refresh token — clears the store and lands on Unauthenticated. +restore(refreshIfExpired: false) restores it verbatim instead.

  • A failed login/register → AuthError, and the future rethrows.

    @@ -108,8 +113,9 @@

    restore() 恢复未过期会话 → Authenticated。

  • -

    restore() 恢复已过期会话 → 先走 Refreshing;若无法续期(或没有刷新令牌), -则清空存储并落到 Unauthenticated。restore(refreshIfExpired: false) 则原样恢复。

    +

    restore() 恢复已过期会话 → 先走 Refreshing;若续期瞬时失败(网络、5xx), +则保留持久化会话并按原样激活,下次请求可再试。只有终局失败(或没有刷新令牌) +才会清空存储并落到 Unauthenticated。restore(refreshIfExpired: false) 则原样恢复。

  • login/register 失败 → AuthError,同时 future 再次抛出错误。

    @@ -126,4 +132,4 @@

  • Usage — How to drive the UI from the stream / 如何用状态流驱动界面
  • Backend Strategy — What each operation calls / 各操作调用什么
  • -


  • Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Backend-Strategy/index.html b/docs/Backend-Strategy/index.html index 07a738d..d7d43a9 100644 --- a/docs/Backend-Strategy/index.html +++ b/docs/Backend-Strategy/index.html @@ -1,4 +1,4 @@ -
    🔌 Backend Strategy

    Backend Strategy / 后端策略

    +
    🔌 Backend Strategy

    Backend Strategy / 后端策略

    AuthStrategy is the only boundary the core needs from a backend. Implement four methods and zero_auth works with REST, gRPC, Firebase, or a private RPC.

    AuthStrategy 是内核向后台要求的唯一边界。实现四个方法后,zero_auth 即可对接 REST、gRPC、Firebase 或私有 RPC。

    The contract / 契约

    @@ -125,4 +125,4 @@

  • Token Store — Where sessions are persisted / 会话持久化到何处
  • Network Integration — Attaching the bearer token / 附加 Bearer 令牌
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Configuration/index.html b/docs/Configuration/index.html index 9586d1c..f8bfe54 100644 --- a/docs/Configuration/index.html +++ b/docs/Configuration/index.html @@ -1,4 +1,4 @@ -
    ⚙️ Configuration

    Configuration / 配置

    +
    ⚙️ Configuration

    Configuration / 配置

    zero_auth is deliberately minimal: there is no global config object. Behavior is composed from the two boundaries you pass to AuthManager.

    zero_auth 刻意保持精简:没有全局配置对象。行为由你传给 AuthManager 的两个边界组合而成。

    Composing AuthManager / 组合管理器

    @@ -7,8 +7,12 @@

    tokenStore: SecureTokenStore(), // where to persist tokens / 令牌持久化位置 autoRefreshAhead: const Duration(minutes: 5), // proactive renewal / 主动续期 autoRefreshRetryDelay: const Duration(seconds: 30), // re-arm after a failed renewal / 续期失败后重新排程 + autoRefreshMaxRetries: 3, // cap those retries / 重试上限 + autoRefreshMinInterval: const Duration(seconds: 5), // floor for an already-due renewal / 已到期续期的下限 refreshFailurePolicy: defaultRefreshFailurePolicy, // sign-out rule / 登出规则 clock: () => DateTime.now(), // time source (tests, skew) / 时间源(测试、时钟偏移) + clockSkew: const Duration(seconds: 30), // treat tokens as expired this much earlier / 提前多久视为过期 + preserveSessionDetails: true, // keep identity across a tokens-only renewal / 只换令牌的续期保留身份 onStateChanged: (state) => debugPrint('$state'), // observe every emission / 观察每次状态 );

    @@ -61,7 +65,27 @@

    KnobWhereEffectBackend endpoints & auth schemeAuthStrategyWhat login/refresh/… actually do / login/refresh 等的实际行为Token persistenceTokenStoreDisk / secure storage / in-memory / 磁盘/安全存储/内存Refresh timingAuthSession.expiresAt + autoRefreshAheadProactive renewal is scheduled that far before expiry / 在过期前该时长调度主动续期Renewal retryautoRefreshRetryDelayAfter a proactive renewal fails, it is re-armed this much later (default 30s) while a session still exists / 主动续期失败后按此时长重新排程(默认 30 秒),会话仍在才重试ObservationonStateChangedOptional callback for every emitted state, for logging or analytics / 可选回调,每次发出状态时触发,便于日志或埋点Refresh failure handlingrefreshFailurePolicyWhether a failed refresh signs the user out / 刷新失败是否让用户登出Time sourceclockDrives expiry maths and proactive scheduling; inject one for deterministic tests or to tolerate device clock skew / 驱动过期计算与主动刷新调度;注入时钟可实现确定性测试或容忍设备时钟偏移Token injectionAuthTokenSource / validAccessToken()How the bearer token reaches HTTP clients, optionally renewing an expired token first / 令牌如何到达 HTTP 客户端,必要时先续期过期令牌 + + + + + + + + + + + + + + + + + + + + +
    KnobWhereEffect
    Backend endpoints & auth schemeAuthStrategyWhat login/refresh/… actually do / login/refresh 等的实际行为
    Token persistenceTokenStoreDisk / secure storage / in-memory / 磁盘/安全存储/内存
    Refresh timingAuthSession.expiresAt + autoRefreshAheadProactive renewal is scheduled that far before expiry / 在过期前该时长调度主动续期
    Renewal retryautoRefreshRetryDelayAfter a proactive renewal fails, it is re-armed this much later (default 30s) while a session still exists / 主动续期失败后按此时长重新排程(默认 30 秒),会话仍在才重试
    Renewal retry capautoRefreshMaxRetriesHow many failed proactive renewals to retry before giving up (default 3) / 主动续期失败最多重试几次(默认 3)
    Renewal floorautoRefreshMinIntervalMinimum wait for a renewal that is already due (default 5s), so very short-lived tokens cannot cause a tight loop / 「已到期」续期的最小等待(默认 5 秒),极短寿命令牌不会造成紧密循环
    Clock skewclockSkewHow much earlier a token counts as expired (default 30s); absorbs a device clock that runs ahead and the latency of the request itself. If your access tokens live under a minute, lower it (e.g. 5s) or almost every read renews first / 提前多久把令牌视为过期(默认 30 秒),用于吸收设备时钟偏快与请求自身的延迟。若访问令牌寿命不足一分钟,请调小(如 5 秒),否则几乎每次读取都会先续期
    Identity on renewalpreserveSessionDetailsCarry userId / displayName / claims over a renewal that returns tokens only (default true) / 只返回令牌的续期是否保留身份字段(默认 true)
    ObservationonStateChangedOptional callback for every emitted state, for logging or analytics / 可选回调,每次发出状态时触发,便于日志或埋点
    Refresh failure handlingrefreshFailurePolicyWhether a failed refresh signs the user out / 刷新失败是否让用户登出
    Time sourceclockDrives expiry maths and proactive scheduling; inject one for deterministic tests or to tolerate device clock skew / 驱动过期计算与主动刷新调度;注入时钟可实现确定性测试或容忍设备时钟偏移
    Token injectionAuthTokenSource / validAccessToken()How the bearer token reaches HTTP clients, optionally renewing an expired token first / 令牌如何到达 HTTP 客户端,必要时先续期过期令牌

    Refresh strategy / 刷新策略

    refresh() is single-flight by construction: concurrent callers share one in-flight request. You decide when to refresh:

    refresh() 天生单飞:并发调用方共享同一次进行中的请求。何时刷新由你决定:

    @@ -115,4 +139,4 @@

  • Usage — Putting it together / 综合使用
  • FAQ — Common questions / 常见问题
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Errors/index.html b/docs/Errors/index.html index c4a7258..1095b47 100644 --- a/docs/Errors/index.html +++ b/docs/Errors/index.html @@ -1,4 +1,4 @@ -
    ⚠️ Errors

    Errors / 错误

    +
    ⚠️ Errors

    Errors / 错误

    zero_auth never lets a raw Exception cross its public surface. Every failure is expressed through one of two mechanisms.

    zero_auth 绝不允许裸 Exception 越过公共边界。每个失败都通过下面两种机制之一表达。

    AppException / 异常类型

    @@ -22,10 +22,14 @@

    final class RefreshTokenMissingException extends AuthException {} // refresh_token_missing / 缺少刷新令牌 final class UnexpectedAuthException extends AuthException {} // unexpected_auth_failure / 意外失败

    AuthStrategy authors opt into these subtypes simply by throwing an -AuthException carrying the matching code; mapAuthFailure performs the -mapping, preserving any vocabulary it does not recognise.

    -

    AuthStrategy 实现者只需抛出携带对应 code 的 AuthException 即可参与映射; -mapAuthFailure 负责转换,并保留它无法识别的自定义错误类型。

    +AuthException — or the bare AuthFail domain type — carrying the matching +code; mapAuthFailure performs the mapping, preserving any vocabulary it does +not recognise.

    +

    AuthStrategy 实现者只需抛出携带对应 code 的 AuthException —— 或裸的领域类型 +AuthFail —— 即可参与映射;mapAuthFailure 负责转换,并保留它无法识别的自定义错误类型。

    +
    // Both of these map to InvalidCredentialsException / 两者都会映射为 InvalidCredentialsException
    +throw AuthException('Wrong password', code: 'invalid_credentials');
    +throw const AuthFail('Wrong password', code: 'invalid_credentials');
    @@ -122,9 +126,13 @@

    SituationSurfaced aslogin/register failsAuthState becomes AuthError(error) and the future throws AppException / 状态变为 AuthError,且 future 抛出 AppExceptionrefresh failsAuthError(error) emitted, then Unauthenticated (unrecoverable) or back to Authenticated (transient) per the failure policy / 发出 AuthError,随后按失败策略转为 Unauthenticated(不可恢复)或回到 Authenticated(瞬时)restore() finds nothingStays Unauthenticated (not an error) / 保持 Unauthenticated(不算错误)TokenStore.load() throwsMapped to AppException, treated as “no session” / 映射为 AppException,按”无会话”处理 + + + + +
    SituationSurfaced as
    login/register failsAuthState becomes AuthError(error) and the future throws AppException / 状态变为 AuthError,且 future 抛出 AppException
    refresh failsAuthError(error) emitted, then Unauthenticated (unrecoverable) or back to Authenticated (transient) per the failure policy / 发出 AuthError,随后按失败策略转为 Unauthenticated(不可恢复)或回到 Authenticated(瞬时)
    restore() finds nothingStays Unauthenticated (not an error) / 保持 Unauthenticated(不算错误)
    restore() renewal fails transientlySession kept (the next validAccessToken() retries); only a terminal failure clears the store / 保留会话(下次 validAccessToken() 会重试);只有终局失败才清空存储
    TokenStore.load() throwsMapped to AppException, treated as “no session” / 映射为 AppException,按”无会话”处理

    Next Steps / 下一步


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/FAQ/index.html b/docs/FAQ/index.html index cd64c53..0f6c34a 100644 --- a/docs/FAQ/index.html +++ b/docs/FAQ/index.html @@ -1,4 +1,4 @@ -
    ❓ FAQ

    FAQ / 常见问题

    +
    ❓ FAQ

    FAQ / 常见问题

    Does zero_auth ship an HTTP client? / zero_auth 内置 HTTP 客户端吗?

    No. The core is backend-agnostic and ships no HTTP, SDK or native code. You implement AuthStrategy to call your backend.

    不。内核后端无关,不含任何 HTTP、SDK 或原生代码。你通过实现 AuthStrategy 来调用自己的后端。

    @@ -22,8 +22,17 @@

    Yes — refresh() is single-flight: many concurrent callers share one in-flight request and its result.

    是的——refresh() 为单飞:多个并发调用方共享同一次请求及其结果。

    How do I attach the bearer token to requests? / 如何给请求附加 Bearer 令牌?

    -

    AuthManager is an AuthTokenSource. Use the Dio interceptor in example/lib/dio_interceptor.dart, or read accessToken directly.

    -

    AuthManager 即 AuthTokenSource。可用 example/lib/dio_interceptor.dart 中的 Dio 拦截器,或直接读取 accessToken。

    +

    AuthManager is an AuthTokenSource. Use the Dio interceptor in example/lib/dio_interceptor.dart, or read accessToken directly — but prefer validAccessToken() (or RefreshingAuthInterceptor) whenever the token reaches a server, since accessToken may already be expired.

    +

    AuthManager 即 AuthTokenSource。可用 example/lib/dio_interceptor.dart 中的 Dio 拦截器,或直接读取 accessToken —— 但只要令牌要发到服务端,就请优先用 validAccessToken()(或 RefreshingAuthInterceptor),因为 accessToken 可能已经过期。

    +

    Does it sync sessions across devices? / 它会在多设备间同步会话吗?

    +

    No — and by design. A session is local state plus whatever your backend decides; +pushing “signed out elsewhere” to a device needs server push (or polling), which +is outside a headless state machine. Model it with your backend revoking the +grant: the next refresh fails, refreshFailurePolicy signs the device out, and +AuthError explains why.

    +

    不会 —— 这是有意的设计。会话是本地状态加上你后端的决定;把「在别处已登出」推送到某台 +设备需要服务端推送(或轮询),那超出了无头状态机的职责。可由后端吊销授权来实现:下一次 +刷新失败,refreshFailurePolicy 会让该设备登出,并由 AuthError 说明原因。

    Which platforms are supported? / 支持哪些平台?

    Any platform where Dart or Flutter runs (Android, iOS, Web, Windows, macOS, Linux).

    -

    任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。


    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Getting-Started/index.html b/docs/Getting-Started/index.html index 36b0832..75c1530 100644 --- a/docs/Getting-Started/index.html +++ b/docs/Getting-Started/index.html @@ -1,4 +1,4 @@ -
    🚀 Getting Started

    Getting Started / 快速开始

    +
    🚀 Getting Started

    Getting Started / 快速开始

    Quick Start / 快速开始

    zero_auth is a headless core: you bring the backend (AuthStrategy) and the persistence (TokenStore), and AuthManager drives the session lifecycle.

    zero_auth 是一个无头内核:你提供后端(AuthStrategy)与持久化(TokenStore),AuthManager 负责驱动会话生命周期。

    @@ -88,4 +88,4 @@

    Installation — Detailed installation methods / 详细安装方式
  • Usage — Full usage guide / 完整使用指南
  • Backend Strategy — Implement AuthStrategy / 实现后端边界
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Installation/index.html b/docs/Installation/index.html index 1cd03a6..ed5da6d 100644 --- a/docs/Installation/index.html +++ b/docs/Installation/index.html @@ -1,9 +1,9 @@ -
    📦 Installation

    Installation / 安装

    +
    📦 Installation

    Installation / 安装

    Add the following to your pubspec.yaml:

    在 pubspec.yaml 中添加以下依赖:

    dependencies:
    -  zero_auth: ^0.5.0
    + zero_auth: ^1.0.0

    Then run:

    然后运行:

    dart pub get
    @@ -14,7 +14,7 @@

    zero_auth: git: url: https://github.com/zero-labsco/zero_auth.git - ref: release/v0.5.0

    + ref: release/v1.0.0

    Import / 导入

    import 'package:zero_auth/zero_auth.dart';

    Requirements / 环境要求

    @@ -42,4 +42,4 @@

  • Getting Started — Quick start guide / 快速开始
  • Usage — Full usage guide / 完整使用指南
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Multi-Account/index.html b/docs/Multi-Account/index.html index bc7669a..573562c 100644 --- a/docs/Multi-Account/index.html +++ b/docs/Multi-Account/index.html @@ -1,4 +1,4 @@ -
    👥 Multiple Accounts

    Multiple Accounts / 多账号

    +
    👥 Multiple Accounts

    Multiple Accounts / 多账号

    First: which kind do you need? / 先分清你需要哪一种

    “Multiple accounts” means two very different things, and they do not need the same solution:

    @@ -50,6 +50,13 @@

    // CRITICAL: one store per account, so persisted sessions stay isolated. // 关键:每个账号一个存储,持久化会话才不会互相覆盖。 storeFactory: (accountId) => SecureTokenStore(key: 'auth_$accountId'), + // Optional: every AuthManager knob is forwarded to the managers it creates. + // 可选:所有 AuthManager 调参都会转发给它创建的管理器。 + autoRefreshAhead: const Duration(minutes: 5), + clockSkew: const Duration(seconds: 30), + onStateChanged: (accountId, state) => debugPrint('$accountId → $state'), + // Or build them yourself / 也可自行构建: + // managerFactory: (id, strategy, store) => AuthManager(...), ); // Sign in (or restore) each account independently. @@ -71,12 +78,18 @@

    group.currentSession // active account's session / 激活账号的会话 group.accessToken // active account's token / 激活账号的令牌 group.state // stream that follows the active account / 跟随激活账号的流 +group.activeIdChanges // stream of the active account id / 激活账号 id 的流 -dio.interceptors.add(AuthInterceptor(group)); // reads the active token / 读激活账号令牌

    +await group.validAccessToken(); // renewed token of the active account / 激活账号续期后的令牌 + +dio.interceptors.add(RefreshingAuthInterceptor(group)); // renews then attaches / 先续期再附加

    Restoring on startup / 启动时恢复

    // `knownIds` comes from your own saved-account list.
     // `knownIds` 来自你自己保存的账号列表。
     await group.restoreAll(knownIds, activeId: lastUsedId);
    +

    One account failing to restore does not abandon the rest: every account is +attempted and the first error is thrown at the end.

    +

    某个账号恢复失败不会连累其余账号:所有账号都会被尝试,最后统一抛出第一个错误。

    Adding accounts explicitly / 显式新增账号

    forAccount creates on first use; addAccount is the same call with a name that reads better when you mean “register an account here”:

    @@ -116,4 +129,4 @@

    Auth State Machine — what each manager emits / 各管理器会发出什么
  • Token Store — keying persisted sessions per account / 按账号隔离持久化会话
  • Backend Strategy — what the contract fixes / 契约固定了什么
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Network-Integration/index.html b/docs/Network-Integration/index.html index 0664a6f..5b14876 100644 --- a/docs/Network-Integration/index.html +++ b/docs/Network-Integration/index.html @@ -1,26 +1,39 @@ -
    🌐 Network Integration

    Network Integration / 网络集成

    +
    🌐 Network Integration

    Network Integration / 网络集成

    AuthManager implements AuthTokenSource, so HTTP clients can read the current access token without depending on the manager object. This keeps your Dio setup decoupled from zero_auth.

    AuthManager 实现了 AuthTokenSource,因此 HTTP 客户端可在不依赖管理器对象的前提下读取当前访问令牌,使 Dio 配置与 zero_auth 解耦。

    AuthTokenSource / 令牌源

    abstract class AuthTokenSource {
    -  Future<String?> get accessToken;
    +  String? get accessToken;                                // may be expired / 可能已过期
    +  Future<String?> validAccessToken({Duration? leeway});    // renewed first / 先续期
     }
    -

    AuthManager.accessToken returns the current token, or null when unauthenticated. It can also trigger a transparent refresh when the token is near expiry (implementation-dependent).

    -

    AuthManager.accessToken 返回当前令牌;未认证时返回 null。在令牌接近过期时,它还可触发一次透明的刷新(取决于实现)。

    +

    AuthManager.accessToken returns the current token, or null when unauthenticated — and it may already be expired.

    +

    AuthManager.accessToken 返回当前令牌;未认证时返回 null —— 而且它可能已经过期。

    +

    validAccessToken() is the one to use before a request: it renews first (reusing +the single-flight refresh) and returns null only when there is nothing to send. +leeway is how long the token must stay valid for, defaulting to the manager’s +clockSkew (30s), so a token that would die mid-request is renewed first.

    +

    请求之前应当用 validAccessToken():它会在必要时先续期(复用单飞刷新),只有真的无令牌 +可发时才返回 null。leeway 表示令牌必须还能维持有效的时长,默认取管理器的 +clockSkew(30 秒),因此会在请求途中失效的令牌会被提前续期。

    Dio interceptor / Dio 拦截器

    example/lib/dio_interceptor.dart ships a ready-to-use interceptor:

    example/lib/dio_interceptor.dart 提供了一个开箱即用的拦截器:

    class AuthInterceptor extends Interceptor {
    -  AuthInterceptor(this.tokens); // an AuthTokenSource
    +  AuthInterceptor(this.tokens); // an AuthTokenSource / 一个令牌源
       final AuthTokenSource tokens;
      
       @override
       void onRequest(RequestOptions o, RequestInterceptorHandler h) async {
    -    final t = await tokens.accessToken;
    +    final t = await tokens.validAccessToken(); // renews first / 先续期
         if (t != null) o.headers['Authorization'] = 'Bearer $t';
         h.next(o);
       }
     }
    +
    +

    If you only want the synchronous read, use tokens.accessToken — but remember +it may hand you a token that has already expired. +若只需要同步读取,可用 tokens.accessToken —— 但请记住它可能返回一个已过期的令牌。

    +
    final dio = Dio()
       ..interceptors.add(AuthInterceptor(authManager));

    When a 401 is returned, refresh the session and retry; if refresh fails, the manager emits AuthError and your app routes back to login.

    @@ -47,4 +60,4 @@

  • Usage — Full integration walkthrough / 完整集成讲解
  • Errors — Handling 401 and refresh failures / 处理 401 与刷新失败
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Persistence/index.html b/docs/Persistence/index.html index 3665622..827a310 100644 --- a/docs/Persistence/index.html +++ b/docs/Persistence/index.html @@ -1,11 +1,17 @@ -
    💽 Session Persistence

    Session Persistence / 会话持久化

    +
    💽 Session Persistence

    Session Persistence / 会话持久化

    InMemoryTokenStore loses the session on every restart. For a real app you must persist it — and zero_auth now ships the primitives that make this a one-liner: session (de)serialization plus two ready-to-copy reference stores.

    InMemoryTokenStore 会在每次重启时丢失会话。真实应用必须把它持久化——而 zero_auth 现在提供了让这件事变成「一行代码」的原语:会话(反)序列化,外加两个开箱即用的参考存储。

    1. Serialize the session / 序列化会话

    AuthSession is JSON-safe out of the box. null fields are omitted, and claims round-trips as a plain map.

    AuthSession 天生就是 JSON 安全的:null 字段会被省略,claims 作为普通映射原样往返。

    final json = session.toJson();            // Map<String, Object?>
    -final restored = AuthSession.fromJson(json);
    +final restored = AuthSession.fromJson(json); + +// Prefer this inside TokenStore.load(): malformed data yields null ("not signed +// in") instead of a FormatException / TypeError. +// TokenStore.load() 里请优先用它:畸形数据返回 null(即「未登录」),而不是抛 +// FormatException / TypeError。 +final safe = AuthSession.tryFromJson(json);

    RefreshToken serializes to its raw string too, so a persisted session rehydrates losslessly.

    RefreshToken 也序列化为其原始字符串,因此持久化的会话可无损恢复。

    2. Flutter — encrypted secure storage / Flutter —— 加密安全存储

    @@ -42,4 +48,4 @@

  • Token Store — the save / load / clear contract / save / load / clear 契约
  • Configuration — enable proactive auto-refresh / 开启主动自动刷新
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Third-Party-Login/index.html b/docs/Third-Party-Login/index.html index fa1a6b6..0a244c9 100644 --- a/docs/Third-Party-Login/index.html +++ b/docs/Third-Party-Login/index.html @@ -1,4 +1,4 @@ -
    🔑 Third-Party Login

    Third-Party Login / 第三方登录

    +
    🔑 Third-Party Login

    Third-Party Login / 第三方登录

    The key idea / 核心思路

    zero_auth does not run the OAuth handshake — and that is deliberate. It is a pure-Dart package, so it ships no platform code, no browser plumbing and no @@ -108,4 +108,4 @@

    Backend Strategy — what your strategy must implement / 策略需要实现什么
  • Auth State Machine — the states this flow emits / 该流程发出的状态
  • Errors — typed exceptions and codes / 类型化异常与 code
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Token-Store/index.html b/docs/Token-Store/index.html index e0bc3cd..920e269 100644 --- a/docs/Token-Store/index.html +++ b/docs/Token-Store/index.html @@ -1,4 +1,4 @@ -
    💾 Token Store

    Token Store / 令牌存储

    +
    💾 Token Store

    Token Store / 令牌存储

    TokenStore is the only persistence surface. The core never touches disk, secure storage, or SharedPreferences directly — you decide where tokens live.

    TokenStore 是唯一的持久化接口。内核绝不直接访问磁盘、安全存储或 SharedPreferences——由你决定令牌存放位置。

    The contract / 契约

    @@ -24,7 +24,9 @@

    @override Future<AuthSession?> load() async { final raw = await _box.read(key: 'zero_auth'); - return raw == null ? null : AuthSession.fromJson(jsonDecode(raw)); + // tryFromJson: corrupt data means "not signed in", not a crash. + // tryFromJson:数据损坏代表「未登录」,而不是崩溃。 + return AuthSession.tryFromJson(raw == null ? null : jsonDecode(raw)); } @override Future<void> clear() => _box.delete(key: 'zero_auth'); @@ -38,4 +40,4 @@

    Session Persistence — persist sessions across restarts (serialization + reference stores) / 跨重启持久化会话(序列化与参考存储)
  • Backend Strategy — The other boundary / 另一个边界
  • Errors — What load() failures become / load() 失败会变成什么
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Usage/index.html b/docs/Usage/index.html index ae95f12..392e4b7 100644 --- a/docs/Usage/index.html +++ b/docs/Usage/index.html @@ -1,4 +1,4 @@ -
    📖 Usage

    Usage / 使用指南

    +

    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/_next/static/chunks/nextra-data-en-US.json b/docs/_next/static/chunks/nextra-data-en-US.json index 641058a..dc3aff3 100644 --- a/docs/_next/static/chunks/nextra-data-en-US.json +++ b/docs/_next/static/chunks/nextra-data-en-US.json @@ -1 +1 @@ -{"/Auth-State-Machine":{"title":"Auth State Machine / 认证状态机","data":{"states--状态#States / 状态":"AuthState is a sealed class with six subtypes:AuthState 是一个密封类,包含六个子类型:\nState\tFields\tMeaning\tUnauthenticated\t—\tNo session / 无会话\tAuthenticating\t—\tlogin/register in flight / 登录/注册进行中\tAuthenticated\tsession: AuthSession\tA valid session / 有效会话\tRefreshing\tsession: AuthSession\tRenewal in flight; the previous session is still usable / 续期进行中,旧会话仍可用\tLoggingOut\tsession: AuthSession\tlogout() in flight; that session is being discarded / 登出进行中,该会话即将被丢弃\tAuthError\terror: AppException\tLast operation failed / 上一次操作失败\t\nTwo helpers save you from spelling out every case:两个辅助属性可避免你手写全部分支:\nstate.isAuthenticated — true for Authenticated and Refreshing, so a\nrenewal never unmounts your signed-in UI. / 在 Authenticated 与 Refreshing\n下均为 true,续期不会卸载已登录界面。\nstate.isBusy — true while Authenticating, Refreshing or LoggingOut. /\n在 Authenticating、Refreshing、LoggingOut 期间为 true。","the-stream--状态流#The stream / 状态流":"AuthManager.state is a Stream that replays the latest value to every new listener (BehaviorSubject-like). This means:AuthManager.state 是一个 Stream,会向每个新订阅者重放最近的值(类似于 BehaviorSubject)。这意味着:\nA freshly mounted widget renders the correct screen on its first frame / 新挂载的 widget 首帧即渲染正确界面。\nYou never need to read a separate \"current state\" field / 无需另读一个\"当前状态\"字段。\nauth.state.listen((state) {\n switch (state) {\n case Authenticated(:final session):\n /* ... */\n case AuthError(:final error):\n /* ... */\n default:\n }\n});","transitions--状态转移#Transitions / 状态转移":"login/register ──► Authenticating ──► Authenticated\n │ ▲\n refresh │ │ success\n ─────────► ▼ │\n Refreshing\n │\n failure │\n ▼\n AuthError ──policy: sign out──► Unauthenticated\n └──policy: keep session─► Authenticated (old session)\n Authenticated ──logout──► LoggingOut ──► Unauthenticated\n Unauthenticated ──restore() finds nothing / nothing persisted──► Unauthenticated\nrestore() restores a live session → Authenticated.\nrestore() restores an expired session → attempts Refreshing; if it\ncannot be renewed (or there is no refresh token) the store is cleared and it\nlands on Unauthenticated. restore(refreshIfExpired: false) restores it\nverbatim instead.\nA failed login/register → AuthError, and the future rethrows.\nA failed refresh → AuthError, followed by Unauthenticated when the\nfailure policy considers the grant unrecoverable,\nor by Authenticated (previous session) when it looks transient.\nDuplicate consecutive emissions are suppressed, so listeners only rebuild on a\nreal change.\nrestore() 恢复未过期会话 → Authenticated。\nrestore() 恢复已过期会话 → 先走 Refreshing;若无法续期(或没有刷新令牌),\n则清空存储并落到 Unauthenticated。restore(refreshIfExpired: false) 则原样恢复。\nlogin/register 失败 → AuthError,同时 future 再次抛出错误。\nrefresh 失败 → AuthError;随后由失败策略决定:\n认为授权不可恢复则转 Unauthenticated,认为只是瞬时故障则回到 Authenticated。\n连续重复的状态会被抑制,监听器只会在真正变化时重建。","next-steps--下一步#Next Steps / 下一步":"Usage — How to drive the UI from the stream / 如何用状态流驱动界面\nBackend Strategy — What each operation calls / 各操作调用什么"}},"/Backend-Strategy":{"title":"Backend Strategy / 后端策略","data":{"":"AuthStrategy is the only boundary the core needs from a backend. Implement four methods and zero_auth works with REST, gRPC, Firebase, or a private RPC.AuthStrategy 是内核向后台要求的唯一边界。实现四个方法后,zero_auth 即可对接 REST、gRPC、Firebase 或私有 RPC。","the-contract--契约#The contract / 契约":"abstract class AuthStrategy {\n Future login(Credentials credentials);\n Future register(RegistrationInput input);\n Future logout(SessionHandle handle);\n Future refresh(RefreshToken token);\n}\nMethod\tWhen called\tReceives\tMust return\tlogin\tAuthManager.login\tCredentials(username, password)\tA fresh AuthSession\tregister\tAuthManager.register\tRegistrationInput(username, password, displayName?, email?)\tA fresh AuthSession\tlogout\tAuthManager.logout\tSessionHandle(userId, refreshToken?)\tvoid (revoke server-side if applicable)\trefresh\tAuthManager.refresh\tRefreshToken(value)\tA new AuthSession (new tokens)\t\nusername is just a name — it is an opaque identifier, and the core never\nvalidates or interprets it. Put an email or a phone number in it and map it to\nwhatever field your API expects.username 只是字段名,它是个不透明标识符,内核从不校验或解析它。把邮箱或手机号放进去,\n再映射成你接口需要的字段即可。","what-is-fixed-and-where-the-escape-hatches-are--哪些是固定的逃生口在哪#What is fixed, and where the escape hatches are / 哪些是固定的,逃生口在哪":"The four signatures above are the only thing the contract fixes. Transport, URLs,\ntoken format and rotation are entirely yours.上面这四个签名是契约唯一固定的东西。传输方式、URL、令牌格式与轮换策略完全由你决定。\nYou need\tDo this\tLogin that is not username + password (SMS code, OAuth, passkey)\tAuthManager.loginWith — see Third-Party Login\tExtra fixed context (tenant id, app id, device id)\tHold it as a field on your strategy; do not squeeze it into Credentials\tExtra data returned by the backend (roles, tenant, permissions)\tPut it in AuthSession.claims\tSignalling why a call failed\tThrow AuthException with a code — see Errors\t\nGotchas / 注意点:\nAlways set userId when building sessions. logout receives\nuserId: session.userId ?? '', so a session without one hands your backend an\nempty string (the refresh token is passed too, when present).\n务必设置 userId:登出时收到的是 session.userId ?? '',没有它后端会拿到空\n字符串(有刷新令牌时会一并传入)。\nLogging in again does not sign the previous session out. The new session\nsimply replaces it locally; revoking the old one server-side is your call — or\nuse Multiple Accounts if both must survive.\n再次登录不会登出上一个会话:新会话只是本地替换;是否在服务端吊销旧的由你决定;\n若两者都要保留,请用多账号。","mapping-failures--映射失败#Mapping failures / 映射失败":"Every thrown error should be mapped to AppException (or AuthException for auth-specific cases). Raw Exceptions must never cross the public surface — the manager wraps unknowns.所有抛出的错误都应映射为 AppException(认证相关场景用 AuthException)。裸 Exception 不得越过公共边界——管理器会包裹未知异常。\n@override\nFuture login(Credentials credentials) async {\n try {\n final json = await myApi.post('/login', {\n 'account': credentials.username, // email, phone, whatever you accept\n 'password': credentials.password,\n });\n return AuthSession.fromJson(json as Map);\n } on MyApiAuthError catch (e) {\n throw AuthException(code: 'invalid_credentials', message: e.message);\n }\n}","building-the-session--构造会话#Building the session / 构造会话":"Return an AuthSession carrying at least the access token, the refresh token, and an expiry:返回 AuthSession,至少包含访问令牌、刷新令牌与过期时间:\nAuthSession(\n accessToken: json['access'],\n refreshToken: json['refresh'],\n expiresAt: DateTime.parse(json['expiresAt']),\n userId: json['userId'],\n displayName: json['displayName'],\n claims: json, // optional raw claims / 可选的原始 claims\n)","example-backend--演示后端#Example backend / 演示后端":"server/ is a layered dart:io demo backend exercising /login, /refresh, /logout, /me and /health. Run it with dart run bin/server.dart on port 8080. It issues real HMAC-SHA256 JWTs with refresh-token rotation and replay detection, and logs every request with its status and duration. The demo account is user / user.server/ 是一个分层的 dart:io 演示后端,提供 /login、/refresh、/logout、/me 与 /health。用 dart run bin/server.dart 在 8080 端口启动。它签发真实的 HMAC-SHA256 JWT,带刷新令牌轮换与重放检测,并记录每条请求的状态码与耗时。演示账号为 user / user。","next-steps--下一步#Next Steps / 下一步":"Token Store — Where sessions are persisted / 会话持久化到何处\nNetwork Integration — Attaching the bearer token / 附加 Bearer 令牌"}},"/Configuration":{"title":"Configuration / 配置","data":{"":"zero_auth is deliberately minimal: there is no global config object. Behavior is composed from the two boundaries you pass to AuthManager.zero_auth 刻意保持精简:没有全局配置对象。行为由你传给 AuthManager 的两个边界组合而成。","composing-authmanager--组合管理器#Composing AuthManager / 组合管理器":"final auth = AuthManager(\n strategy: MyAuthStrategy(), // how to talk to the backend / 如何与后端通信\n tokenStore: SecureTokenStore(), // where to persist tokens / 令牌持久化位置\n autoRefreshAhead: const Duration(minutes: 5), // proactive renewal / 主动续期\n autoRefreshRetryDelay: const Duration(seconds: 30), // re-arm after a failed renewal / 续期失败后重新排程\n refreshFailurePolicy: defaultRefreshFailurePolicy, // sign-out rule / 登出规则\n clock: () => DateTime.now(), // time source (tests, skew) / 时间源(测试、时钟偏移)\n onStateChanged: (state) => debugPrint('$state'), // observe every emission / 观察每次状态\n);\nKnob\tWhere\tEffect\tBackend endpoints & auth scheme\tAuthStrategy\tWhat login/refresh/… actually do / login/refresh 等的实际行为\tToken persistence\tTokenStore\tDisk / secure storage / in-memory / 磁盘/安全存储/内存\tRefresh timing\tAuthSession.expiresAt + autoRefreshAhead\tProactive renewal is scheduled that far before expiry / 在过期前该时长调度主动续期\tRenewal retry\tautoRefreshRetryDelay\tAfter a proactive renewal fails, it is re-armed this much later (default 30s) while a session still exists / 主动续期失败后按此时长重新排程(默认 30 秒),会话仍在才重试\tObservation\tonStateChanged\tOptional callback for every emitted state, for logging or analytics / 可选回调,每次发出状态时触发,便于日志或埋点\tRefresh failure handling\trefreshFailurePolicy\tWhether a failed refresh signs the user out / 刷新失败是否让用户登出\tTime source\tclock\tDrives expiry maths and proactive scheduling; inject one for deterministic tests or to tolerate device clock skew / 驱动过期计算与主动刷新调度;注入时钟可实现确定性测试或容忍设备时钟偏移\tToken injection\tAuthTokenSource / validAccessToken()\tHow the bearer token reaches HTTP clients, optionally renewing an expired token first / 令牌如何到达 HTTP 客户端,必要时先续期过期令牌","refresh-strategy--刷新策略#Refresh strategy / 刷新策略":"refresh() is single-flight by construction: concurrent callers share one in-flight request. You decide when to refresh:refresh() 天生单飞:并发调用方共享同一次进行中的请求。何时刷新由你决定:\nProactively, when session.isExpired is approaching, before a request. / 在请求前、当 session.isExpired 临近时主动刷新。\nReactively, on a 401 from your API (see Network Integration). / 响应式地,在 API 返回 401 时刷新。\nA failed refresh is always reported through AuthError (and the rethrown future),\nbut whether it ends the session is decided by refreshFailurePolicy. See\nRefresh failure policy below.刷新失败总会通过 AuthError(以及重新抛出的 future)上报,但是否终止会话由\nrefreshFailurePolicy 决定,详见下方「刷新失败策略」。","refresh-failure-policy--刷新失败策略#Refresh failure policy / 刷新失败策略":"The default policy signs the user out only for failures that can never succeed\nagain, keeping the session when the failure looks transient (so a network blip\ndoes not log people out):默认策略仅在失败「注定无法重试成功」时让用户登出;看起来像瞬时故障时保留会话(避免一次\n网络抖动就把人踢下线):\nFailure / 失败类型\tDefault outcome / 默认结果\tSessionExpiredException, InvalidCredentialsException\tStore cleared → Unauthenticated / 清空存储 → Unauthenticated\tAnything else (network, 5xx…) / 其它(网络、5xx 等)\tSession kept → back to Authenticated / 保留会话 → 回到 Authenticated\t\n// Never sign out on a failed refresh — useful when refresh tokens are\n// long-lived and your backend is occasionally flaky.\n// 刷新失败也绝不登出——适用于刷新令牌长期有效、后端偶尔不稳定的场景。\nfinal auth = AuthManager(\n strategy: strategy,\n refreshFailurePolicy: (AppException error) => false,\n);\nProactive (background) refresh failures are handled internally, so a scheduled\nrenewal can never surface as an unhandled async error.主动(后台)刷新的失败会在内部处理,因此定时续期永远不会以未处理异步错误的形式泄漏。","no-global-singletons--没有全局单例#No global singletons / 没有全局单例":"AuthManager is a plain object — create one per app, hold it in your DI container or a top-level variable. The core has no static mutable state.AuthManager 是一个普通对象——每个应用创建一个,放在 DI 容器或顶层变量中即可。内核没有静态可变状态。","next-steps--下一步#Next Steps / 下一步":"Usage — Putting it together / 综合使用\nFAQ — Common questions / 常见问题"}},"/Errors":{"title":"Errors / 错误","data":{"":"zero_auth never lets a raw Exception cross its public surface. Every failure is expressed through one of two mechanisms.zero_auth 绝不允许裸 Exception 越过公共边界。每个失败都通过下面两种机制之一表达。","appexception--异常类型#AppException / 异常类型":"All domain failures extend AppException:所有领域失败都继承 AppException:\nsealed class AppException implements Exception {\n final String code; // stable, machine-readable / 稳定、机器可读\n final String message; // human-readable / 人类可读\n final Object? cause; // original error, if any / 原始错误\n}\nfinal class AuthException extends AppException {\n // Base type for every auth failure / 所有认证失败的基类型\n}\n// Concrete subtypes — catch these to branch on what actually went wrong.\n// 具体子类型——捕获它们即可按实际错误分支处理。\nfinal class InvalidCredentialsException extends AuthException {} // invalid_credentials / 凭据无效\nfinal class SessionExpiredException extends AuthException {} // session_expired / 会话过期\nfinal class NoActiveSessionException extends AuthException {} // no_active_session / 无活动会话\nfinal class RefreshTokenMissingException extends AuthException {} // refresh_token_missing / 缺少刷新令牌\nfinal class UnexpectedAuthException extends AuthException {} // unexpected_auth_failure / 意外失败\nAuthStrategy authors opt into these subtypes simply by throwing an\nAuthException carrying the matching code; mapAuthFailure performs the\nmapping, preserving any vocabulary it does not recognise.AuthStrategy 实现者只需抛出携带对应 code 的 AuthException 即可参与映射;\nmapAuthFailure 负责转换,并保留它无法识别的自定义错误类型。\ncode\tMapped type / 映射结果\tinvalid_credentials\tInvalidCredentialsException\tinvalid_grant, invalid_refresh_token, token_expired, session_expired\tSessionExpiredException\tno_active_session\tNoActiveSessionException\trefresh_token_missing\tRefreshTokenMissingException\tanything else / 其它\tpreserved as-is, or UnexpectedAuthException / 原样保留或包装为 UnexpectedAuthException\t\nCodes raised by the manager itself (not your strategy) / 由管理器自身产生的 code:\ncode\tRaised when / 何时触发\tauth_flow_in_progress\tAnother login / register / loginWith is already running / 已有登录流程在执行\tmanager_disposed\tAn operation is called after dispose() / dispose() 后又调用操作\t\nOverlapping authentication flows are rejected rather than racing: a second\nlogin() while one is in flight throws auth_flow_in_progress.重叠的登录流程会被拒绝而非争抢:进行中再次 login() 会抛 auth_flow_in_progress。\nAuthStrategy implementations should map transport/API errors into AuthException (or a custom AppException subclass) so callers get a stable code.AuthStrategy 实现方应将传输层/API 错误映射为 AuthException(或自定义 AppException 子类),让调用方拿到稳定的 code。","resultt--结果类型#Result / 结果类型":"For explicit, exception-free handling, use Result:如需显式、无异常的处理,可使用 Result:\nsealed class Result {\n const factory Result.ok(T value) = Ok;\n const factory Result.err(AppException error) = Err;\n}\nfinal result = await auth.tryLogin(user, pass); // returns Result\nswitch (result) {\n case Ok(:final value):\n /* authenticated / 已认证 */\n case Err(:final error):\n /* inspect error.code / 读取 error.code */\n}","how-failures-surface--失败如何呈现#How failures surface / 失败如何呈现":"Situation\tSurfaced as\tlogin/register fails\tAuthState becomes AuthError(error) and the future throws AppException / 状态变为 AuthError,且 future 抛出 AppException\trefresh fails\tAuthError(error) emitted, then Unauthenticated (unrecoverable) or back to Authenticated (transient) per the failure policy / 发出 AuthError,随后按失败策略转为 Unauthenticated(不可恢复)或回到 Authenticated(瞬时)\trestore() finds nothing\tStays Unauthenticated (not an error) / 保持 Unauthenticated(不算错误)\tTokenStore.load() throws\tMapped to AppException, treated as \"no session\" / 映射为 AppException,按\"无会话\"处理","next-steps--下一步#Next Steps / 下一步":"Backend Strategy — Map your API errors / 映射你的 API 错误\nConfiguration — Tuning refresh & retry / 调优刷新与重试"}},"/Getting-Started":{"title":"Getting Started / 快速开始","data":{"quick-start--快速开始#Quick Start / 快速开始":"zero_auth is a headless core: you bring the backend (AuthStrategy) and the persistence (TokenStore), and AuthManager drives the session lifecycle.zero_auth 是一个无头内核:你提供后端(AuthStrategy)与持久化(TokenStore),AuthManager 负责驱动会话生命周期。\nimport 'package:zero_auth/zero_auth.dart';\n// 1. Implement your backend (four methods only).\n// 1. 实现你的后端(只需四个方法)。\nfinal strategy = MyAuthStrategy();\n// 2. Pick a token store. InMemoryTokenStore ships in-core; for production use\n// the flutter_secure_storage reference in example/lib/secure_token_store.dart.\n// 2. 选择令牌存储。内核自带 InMemoryTokenStore;生产环境请用\n// example/lib/secure_token_store.dart 中的 flutter_secure_storage 实现。\nfinal store = InMemoryTokenStore();\n// 3. Create the manager and restore any persisted session.\n// 3. 创建管理器并恢复已持久化的会话。\nfinal auth = AuthManager(strategy: strategy, tokenStore: store);\nawait auth.restore();\nThen drive the UI from the state stream:随后用状态流驱动界面:\nauth.state.listen((state) {\n switch (state) {\n case Unauthenticated():\n showLoginScreen();\n case Authenticating():\n case LoggingOut():\n showSpinner();\n case Authenticated(:final session):\n showHome(session.userId);\n case Refreshing(:final session):\n // Still signed in: only the token is being renewed.\n // 仍处于已登录状态:只是令牌在续期。\n showHome(session.userId, renewing: true);\n case AuthError(:final error):\n showError(error);\n }\n});\nIf you prefer if/else over exhaustiveness, use state.isAuthenticated and\nstate.isBusy instead of state is Authenticated — isAuthenticated stays\ntrue during Refreshing, so your UI never bounces back to the login screen\nmid-refresh.若不想用穷举匹配,请用 state.isAuthenticated / state.isBusy 代替\nstate is Authenticated——isAuthenticated 在 Refreshing 期间仍为 true,\n界面不会在刷新途中退回登录页。","the-lifecycle--生命周期#The Lifecycle / 生命周期":"AuthManager broadcasts an explicit, sealed state machine:AuthManager 广播一个显式、密封的状态机:\nState\tMeaning\tUnauthenticated\tNo session / 无会话\tAuthenticating\tlogin/register in flight / 登录/注册进行中\tAuthenticated\tA valid session is present / 存在有效会话\tRefreshing\tSession renewal in flight; the previous session stays usable / 续期进行中,旧会话仍可用\tLoggingOut\tlogout() in flight / 登出进行中\tAuthError\tThe last operation failed / 上一次操作失败\t\nThe stream replays the latest value to new listeners, so a widget renders the correct screen on its first frame.状态流会向新订阅者重放最近的值,因此 widget 在首帧即可渲染正确界面。","next-steps--下一步#Next Steps / 下一步":"Installation — Detailed installation methods / 详细安装方式\nUsage — Full usage guide / 完整使用指南\nBackend Strategy — Implement AuthStrategy / 实现后端边界"}},"/Multi-Account":{"title":"Multiple Accounts / 多账号","data":{"first-which-kind-do-you-need--先分清你需要哪一种#First: which kind do you need? / 先分清你需要哪一种":"\"Multiple accounts\" means two very different things, and they do not need the\r\nsame solution:「多账号」其实指两种截然不同的需求,它们的解法不一样:\nShape\tDescription\tRecommendation\tSwitching / 账号切换\tSeveral accounts are remembered, but only one is used at a time (like Gmail's account picker) / 记住多个账号,但同时只用一个\tPlain AuthManager: logout() then login(). No new API. / 普通 AuthManager:登出再登录,不需要新 API\tConcurrent / 账号并存\tSeveral accounts are signed in at the same time and all can make requests / 多个账号同时登录,且都能发请求\tAuthManagerGroup — one AuthManager per account\t\nMost apps only need the first. Reach for AuthManagerGroup only when an account\r\nmust keep working while another one is in the foreground.多数应用只需要第一种。只有当「某个账号在后台也要继续工作」时,才需要 AuthManagerGroup。","shape-1-switching--形态一切换#Shape 1: switching / 形态一:切换":"AuthManager models one session. Switching accounts is just logging out and back\r\nin — the state machine stays honest, and there is no second source of truth:\n// Keep your own list of saved account identifiers (email, phone, …).\r\n// 自己维护已保存账号标识(邮箱、手机号等)的列表。\r\nfinal savedAccounts = ['alice@example.com', 'bob@example.com'];\r\n\r\nFuture switchTo(String account, String password) async {\r\n if (auth.current.isAuthenticated) await auth.logout();\r\n await auth.login(Credentials(username: account, password: password));\r\n}\nBecause username is just an opaque identifier, this works for email or phone\r\nlogins with no extra plumbing — see\r\nBackend Strategy for what the contract fixes.","shape-2-concurrent--形态二并存#Shape 2: concurrent / 形态二:并存":"AuthManagerGroup owns one AuthManager per account and tracks which is active.\nfinal group = AuthManagerGroup(\r\n // Both factories receive the account id; sharing one strategy instance is fine.\r\n // 两个工厂都会收到账号 id;共用一个策略实例也没问题。\r\n strategyFactory: (accountId) => MyAuthStrategy(),\r\n // CRITICAL: one store per account, so persisted sessions stay isolated.\r\n // 关键:每个账号一个存储,持久化会话才不会互相覆盖。\r\n storeFactory: (accountId) => SecureTokenStore(key: 'auth_$accountId'),\r\n);\r\n\r\n// Sign in (or restore) each account independently.\r\n// 各账号独立登录(或恢复)。\r\nawait group.forAccount('alice').login(\r\n Credentials(username: 'alice@example.com', password: pw),\r\n);\r\nawait group.forAccount('bob').login(\r\n Credentials(username: 'bob@example.com', password: pw),\r\n);\r\n\r\n// Pick the active one — nothing is signed out by this call.\r\n// 选择激活账号 —— 此调用不会登出任何账号。\r\ngroup.switchTo('bob');","reading-the-active-account--读取激活账号#Reading the active account / 读取激活账号":"The group mirrors the active account and implements AuthTokenSource, so\r\ninterceptors keep depending on the narrow interface:\ngroup.current // active account's AuthState / 激活账号的状态\r\ngroup.currentSession // active account's session / 激活账号的会话\r\ngroup.accessToken // active account's token / 激活账号的令牌\r\ngroup.state // stream that follows the active account / 跟随激活账号的流\r\n\r\ndio.interceptors.add(AuthInterceptor(group)); // reads the active token / 读激活账号令牌","restoring-on-startup--启动时恢复#Restoring on startup / 启动时恢复":"// `knownIds` comes from your own saved-account list.\r\n// `knownIds` 来自你自己保存的账号列表。\r\nawait group.restoreAll(knownIds, activeId: lastUsedId);","adding-accounts-explicitly--显式新增账号#Adding accounts explicitly / 显式新增账号":"forAccount creates on first use; addAccount is the same call with a name that\r\nreads better when you mean \"register an account here\":forAccount 首次使用时创建;addAccount 是同一个调用,只是名字在「这里注册账号」的\r\n语境下更好读:\nfinal alice = group.addAccount('alice');","removing-an-account--移除账号#Removing an account / 移除账号":"await group.remove('alice'); // logs out, disposes, forgets it","signing-everything-out--全部登出#Signing everything out / 全部登出":"await group.logoutAll(); // every account, in turn\nIf the removed account was active, the group becomes inactive and emits\r\nUnauthenticated.","teardown--释放#Teardown / 释放":"await group.disposeAll(); // disposes every manager and closes the stream","why-the-core-stays-single-session--为什么核心仍是单会话#Why the core stays single-session / 为什么核心仍是单会话":"AuthManager answers a singular question: who is logged in? Multi-account asks\r\na different one: of these signed-in identities, which is active?Folding the second into the first would mean a Map of sessions inside every\r\nstate, a Refreshing that needs an account id, and a breaking change for every\r\nexisting user. Keeping AuthManager single-session and adding an opt-in\r\ncoordinator gives you both without muddying either.把第二个问题塞进第一个,会导致每个状态里都带一个会话 Map、Refreshing 需要携带账号\r\nid,并且对所有现有用户构成破坏性变更。让 AuthManager 保持单会话、再提供一个可选的\r\n协调层,两者都能得到,且互不污染。","gotchas--注意点#Gotchas / 注意点":"Never share one TokenStore between accounts — the second save() would\r\noverwrite the first session. / 绝不要在多个账号间共用一个 TokenStore,第二次\r\nsave() 会覆盖第一个会话。\nSwitching does not sign anyone out; all managers keep refreshing in the\r\nbackground. / 切换不会登出任何账号,所有管理器仍会在后台续期。\nremove() also disposes that manager — do not use it afterwards. /\r\nremove() 会同时释放该管理器,之后不要再使用它。","next-steps--下一步#Next Steps / 下一步":"Auth State Machine — what each manager emits / 各管理器会发出什么\nToken Store — keying persisted sessions per account / 按账号隔离持久化会话\nBackend Strategy — what the contract fixes / 契约固定了什么"}},"/Network-Integration":{"title":"Network Integration / 网络集成","data":{"":"AuthManager implements AuthTokenSource, so HTTP clients can read the current access token without depending on the manager object. This keeps your Dio setup decoupled from zero_auth.AuthManager 实现了 AuthTokenSource,因此 HTTP 客户端可在不依赖管理器对象的前提下读取当前访问令牌,使 Dio 配置与 zero_auth 解耦。","authtokensource--令牌源#AuthTokenSource / 令牌源":"abstract class AuthTokenSource {\n Future get accessToken;\n}\nAuthManager.accessToken returns the current token, or null when unauthenticated. It can also trigger a transparent refresh when the token is near expiry (implementation-dependent).AuthManager.accessToken 返回当前令牌;未认证时返回 null。在令牌接近过期时,它还可触发一次透明的刷新(取决于实现)。","dio-interceptor--dio-拦截器#Dio interceptor / Dio 拦截器":"example/lib/dio_interceptor.dart ships a ready-to-use interceptor:example/lib/dio_interceptor.dart 提供了一个开箱即用的拦截器:\nclass AuthInterceptor extends Interceptor {\n AuthInterceptor(this.tokens); // an AuthTokenSource\n final AuthTokenSource tokens;\n @override\n void onRequest(RequestOptions o, RequestInterceptorHandler h) async {\n final t = await tokens.accessToken;\n if (t != null) o.headers['Authorization'] = 'Bearer $t';\n h.next(o);\n }\n}\nfinal dio = Dio()\n ..interceptors.add(AuthInterceptor(authManager));\nWhen a 401 is returned, refresh the session and retry; if refresh fails, the manager emits AuthError and your app routes back to login.当返回 401 时,刷新会话并重试;若刷新失败,管理器会发出 AuthError,应用随之跳转登录。example/lib/dio_interceptor.dart also ships AuthRetryInterceptor, which does\nexactly that — refreshes through the manager (single-flight) and replays the\nrequest once, so a retry loop cannot form:example/lib/dio_interceptor.dart 里还提供了 AuthRetryInterceptor,正是做这件事 ——\n通过管理器刷新(单飞)并把请求重试一次,因此不会形成重试风暴:\nfinal dio = Dio()\n ..interceptors.add(\n AuthRetryInterceptor(manager: authManager, dio: dio),\n );\nTwo more interceptor variants there / 那里还有另外两种拦截器:\nRefreshingAuthInterceptor — never sends an expired token: it renews first via\nvalidAccessToken() / 绝不发送过期令牌,先用 validAccessToken() 续期。\nAuthInterceptor — the plain AuthTokenSource version / 基础的令牌源版本。","other-http-clients--其它客户端#Other HTTP clients / 其它客户端":"Because AuthTokenSource is a plain interface, the same pattern works for package:http, chopper, or any client that lets you mutate request headers.由于 AuthTokenSource 是普通接口,同样的模式适用于 package:http、chopper 或任何允许修改请求头的客户端。","next-steps--下一步#Next Steps / 下一步":"Usage — Full integration walkthrough / 完整集成讲解\nErrors — Handling 401 and refresh failures / 处理 401 与刷新失败"}},"/Persistence":{"title":"Session Persistence / 会话持久化","data":{"":"InMemoryTokenStore loses the session on every restart. For a real app you must persist it — and zero_auth now ships the primitives that make this a one-liner: session (de)serialization plus two ready-to-copy reference stores.InMemoryTokenStore 会在每次重启时丢失会话。真实应用必须把它持久化——而 zero_auth 现在提供了让这件事变成「一行代码」的原语:会话(反)序列化,外加两个开箱即用的参考存储。","1-serialize-the-session--序列化会话#1. Serialize the session / 序列化会话":"AuthSession is JSON-safe out of the box. null fields are omitted, and claims round-trips as a plain map.AuthSession 天生就是 JSON 安全的:null 字段会被省略,claims 作为普通映射原样往返。\nfinal json = session.toJson(); // Map\nfinal restored = AuthSession.fromJson(json);\nRefreshToken serializes to its raw string too, so a persisted session rehydrates losslessly.RefreshToken 也序列化为其原始字符串,因此持久化的会话可无损恢复。","2-flutter--encrypted-secure-storage--flutter--加密安全存储#2. Flutter — encrypted secure storage / Flutter —— 加密安全存储":"For mobile, persist into the OS keychain / keystore via flutter_secure_storage. A reference implementation lives at example/lib/secure_token_store.dart:在移动端,通过 flutter_secure_storage 把令牌存入系统钥匙串 / Keystore。参考实现位于 example/lib/secure_token_store.dart:\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: SecureTokenStore(), // from example/\n);\nawait auth.restore(); // rehydrate before the first frame\nNever store refresh tokens in plain SharedPreferences. Use OS-backed secure storage only.切勿把刷新令牌明文存入 SharedPreferences,只使用操作系统级安全存储。","3-server--cli--desktop--json-file--服务端--cli--桌面--json-文件#3. Server / CLI / desktop — JSON file / 服务端 / CLI / 桌面 —— JSON 文件":"On non-mobile Dart targets you can write the serialized session to a file. A reference implementation lives at example/lib/json_token_store.dart:在非移动端 Dart 目标上,可以把序列化后的会话写入文件。参考实现位于 example/lib/json_token_store.dart:\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: FileTokenStore(File('.zero_auth_session.json')),\n);\nThis is ideal for a backend that keeps a logged-in user across process restarts, or a CLI that stays authenticated between invocations.这非常适合「重启后仍保持登录」的后端,或「多次调用之间保持认证」的 CLI。","4-combine-with-auto-refresh--结合自动刷新#4. Combine with auto-refresh / 结合自动刷新":"When you enable proactive auto-refresh, the manager renews the token before it expires — and every renewal is persisted through save(), so the on-disk session always carries a fresh access token.当你开启主动自动刷新时,管理器会在令牌过期前续期——而每次续期都会通过 save() 落盘,因此磁盘上的会话始终持有崭新的访问令牌。\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: SecureTokenStore(),\n autoRefreshAhead: const Duration(minutes: 5),\n);","next-steps--下一步#Next Steps / 下一步":"Token Store — the save / load / clear contract / save / load / clear 契约\nConfiguration — enable proactive auto-refresh / 开启主动自动刷新"}},"/Third-Party-Login":{"title":"Third-Party Login / 第三方登录","data":{"the-key-idea--核心思路#The key idea / 核心思路":"zero_auth does not run the OAuth handshake — and that is deliberate. It is a\r\npure-Dart package, so it ships no platform code, no browser plumbing and no\r\nredirect URL handling (those live outside lib/).Instead, zero_auth manages what comes after the handshake: the session your\r\nown backend issues.zero_auth 不负责 OAuth 握手环节,这是刻意为之:它是纯 Dart 包,不含任何平台\r\n代码、浏览器通道或重定向回调处理(这些只能在 lib/ 之外)。它负责的是握手之后的事:你自己后端所签发的会话。\nprovider consent ──► code / id_token ──► YOUR BACKEND ──► your access + refresh tokens ──► zero_auth\r\n (outside) (any language) (this package)\nBecause the login method is invisible to the manager, password login and\r\nGoogle/Apple login end up sharing one token lifecycle. That is the whole\r\npoint.登录方式对管理器是透明的,因此密码登录与 Google / Apple 登录最终共享同一套\r\n令牌生命周期机制。这正是关键所在。","when-this-fits--适用场景#When this fits / 适用场景":"This pattern fits when your backend brokers the provider — you have a\r\nbackend, it verifies the provider token, then mints your own tokens.下面的模式适用于你的后端作为经纪方:你有后端,它校验提供方令牌,然后签发你自己的\r\n令牌。\nScenario\tFit\tOwn backend + Google/Apple/GitHub sign-in / 自有后端 + 第三方登录\t✅ Ideal / 理想\tOwn backend + password AND social logins / 自有后端,密码与社交登录并存\t✅ One shared session model / 共用一套会话模型\tFirebase/Supabase issues the tokens directly / 由 Firebase/Supabase 直接签发令牌\t⚠️ Use their SDK instead / 请改用其 SDK","wiring-it-up--接线步骤#Wiring it up / 接线步骤":"1. Run the handshake outside this package.\r\nUse whichever OAuth client fits your targets (flutter_web_auth, AppAuth, or a\r\nserver-side flow). This code lives in your app or a sibling Flutter package, never\r\nin lib/.1. 在本包之外完成握手。\r\n使用适合目标平台的 OAuth 客户端(flutter_web_auth、AppAuth 或服务端流程)。这部分\r\n代码放在你的应用或兄弟 Flutter 包里,绝不放在 lib/。2. Trade the provider credential for your tokens on your backend.2. 用提供方凭据到你的后端换取自有令牌。\nFuture signInWithGoogle(BuildContext context) async {\r\n // 1) Provider consent (outside zero_auth) → authorization code / id_token.\r\n // 提供方授权(零依赖,在 zero_auth 之外)→ 授权码 / id_token。\r\n final code = await myOAuthClient.authenticate();\r\n\r\n // 2) Your backend verifies it with Google and returns YOUR tokens.\r\n // 你的后端用它与 Google 校验,并返回「你自己的」令牌。\r\n final res = await myApi.post('/auth/google', data: {'code': code});\r\n\r\n return AuthSession(\r\n accessToken: res['accessToken'] as String,\r\n refreshToken: RefreshToken(res['refreshToken'] as String),\r\n expiresAt: DateTime.now().add(Duration(seconds: res['expiresIn'] as int)),\r\n userId: res['userId'] as String,\r\n );\r\n}\n3. Hand the session to the manager with loginWith.\r\nloginWith is the escape hatch for flows that are not login / register: the\r\nmanager still owns persistence, the state machine and proactive refresh.3. 用 loginWith 把会话交给管理器。\r\nloginWith 是为那些不符合 login / register 形态的流程准备的逃生口:持久化、状态机\r\n与主动刷新仍由管理器负责。\nfinal result = await auth.loginWith((strategy) => signInWithGoogle(context));\r\n// emits Authenticating → Authenticated, or AuthError + rethrow.\r\n// 发出 Authenticating → Authenticated;失败则发 AuthError 并重新抛出。\nKeep signInWithGoogle throwing AuthException with a meaningful code\r\n('invalid_credentials', 'session_expired', …) so failures map to the typed\r\nexceptions in Errors.让 signInWithGoogle 抛出带有实用 code('invalid_credentials'、\r\n'session_expired' 等)的 AuthException,这样失败就能映射为\r\nErrors 里的具体异常类型。","same-model-for-every-method--各登录方式共用一套模型#Same model for every method / 各登录方式共用一套模型":"// Password users and Google users end up with the same AuthSession shape.\r\n// 密码用户与 Google 用户最终得到的都是同一种 AuthSession。\r\nawait auth.login(const Credentials(username: user, password: pass));\r\nawait auth.loginWith((strategy) => signInWithGoogle(context));\nYour UI only ever reads state.isAuthenticated, and your network layer only ever\r\nreads auth.validAccessToken() — regardless of how the user signed in.你的界面只需要读 state.isAuthenticated,网络层只需要读 auth.validAccessToken()\r\n——无需关心用户是哪种方式登录的。","also-works-for--同样适用于#Also works for / 同样适用于":"loginWith covers any flow you drive yourself:loginWith 适用于任何由你驱动的流程:\nMagic links / email OTP / 魔法链接、邮箱验证码\nPasskeys / WebAuthn\nBiometric re-unlock (load a locked session and re-mint tokens) / 生物识别解锁\nMigrating from another auth SDK / 从其它认证 SDK 迁移","next-steps--下一步#Next Steps / 下一步":"Backend Strategy — what your strategy must implement / 策略需要实现什么\nAuth State Machine — the states this flow emits / 该流程发出的状态\nErrors — typed exceptions and codes / 类型化异常与 code"}},"/Token-Store":{"title":"Token Store / 令牌存储","data":{"":"TokenStore is the only persistence surface. The core never touches disk, secure storage, or SharedPreferences directly — you decide where tokens live.TokenStore 是唯一的持久化接口。内核绝不直接访问磁盘、安全存储或 SharedPreferences——由你决定令牌存放位置。","the-contract--契约#The contract / 契约":"abstract class TokenStore {\n Future save(AuthSession session);\n Future load();\n Future clear();\n}\nAuthManager calls load() during restore(), save() after every successful login/refresh, and clear() on logout or a failed refresh.AuthManager 在 restore() 时调用 load(),每次登录/刷新成功后调用 save(),在登出或刷新失败失败时调用 clear()。","in-memory-ships-in-core--内存实现内核自带#In-memory (ships in-core) / 内存实现(内核自带)":"InMemoryTokenStore is the default. It survives process restarts? No — it is in-memory only, so the session is lost on restart. Use it for tests and quick prototypes.InMemoryTokenStore 是默认实现,仅存在于内存,进程重启后会话丢失。适用于测试与原型。\nfinal auth = AuthManager(strategy: s, tokenStore: InMemoryTokenStore());","secure-storage-production--安全存储生产#Secure storage (production) / 安全存储(生产)":"example/lib/secure_token_store.dart shows a flutter_secure_storage-backed reference implementation. Adapt it for your app:example/lib/secure_token_store.dart 提供了基于 flutter_secure_storage 的参考实现,可据此改造:\nclass SecureTokenStore implements TokenStore {\n final _box = FlutterSecureStorage();\n @override\n Future save(AuthSession s) =>\n _box.write(key: 'zero_auth', value: jsonEncode(s.toJson()));\n @override\n Future load() async {\n final raw = await _box.read(key: 'zero_auth');\n return raw == null ? null : AuthSession.fromJson(jsonDecode(raw));\n }\n @override\n Future clear() => _box.delete(key: 'zero_auth');\n}\nPersist tokens only in OS-backed secure storage (Keychain / Keystore). Never store refresh tokens in plain SharedPreferences.令牌只应存放在操作系统级安全存储(Keychain / Keystore)中,切勿把刷新令牌明文存入 SharedPreferences。","next-steps--下一步#Next Steps / 下一步":"Session Persistence — persist sessions across restarts (serialization + reference stores) / 跨重启持久化会话(序列化与参考存储)\nBackend Strategy — The other boundary / 另一个边界\nErrors — What load() failures become / load() 失败会变成什么"}},"/Usage":{"title":"Usage / 使用指南","data":{"three-step-integration--三步集成#Three-step integration / 三步集成":"","1-implement-authstrategy--实现后端边界#1. Implement AuthStrategy / 实现后端边界":"class MyAuthStrategy implements AuthStrategy {\n @override\n Future login(Credentials credentials) => myApi.login(\n credentials.username, // an email or phone works too\n credentials.password,\n );\n @override\n Future register(RegistrationInput input) =>\n myApi.register(input.username, input.password);\n @override\n Future logout(SessionHandle handle) => myApi.logout(handle.userId);\n @override\n Future refresh(RefreshToken token) =>\n myApi.refresh(token.value);\n}","2-create-authmanager-and-restore--创建管理器并恢复#2. Create AuthManager and restore / 创建管理器并恢复":"final auth = AuthManager(\n strategy: MyAuthStrategy(),\n tokenStore: InMemoryTokenStore(),\n);\nawait auth.restore(); // rehydrate a persisted session / 恢复持久化会话","3-drive-the-ui-from-the-state-stream--用状态流驱动界面#3. Drive the UI from the state stream / 用状态流驱动界面":"await for (final state in auth.state) {\n // render based on state / 根据状态渲染\n}","operations--操作#Operations / 操作":"Method\tDescription\trestore()\tRehydrate the persisted session at startup / 启动时恢复会话\tlogin(username, password)\tAuthenticate and enter Authenticated / 登录并进入已认证\tregister(username, password)\tRegister and authenticate / 注册并认证\tlogout()\tClear the session / 清除会话\trefresh()\tSilent token refresh — concurrent calls share one flight / 静默刷新,并发共享单飞\ttokenSource\tAn AuthTokenSource for HTTP clients / 供 HTTP 客户端使用的令牌源","token-refresh--expiry--令牌刷新与过期#Token refresh & expiry / 令牌刷新与过期":"AuthSession exposes isExpired so callers can refresh proactively. refresh() is single-flight: if several callers request a refresh at once, only one network call is made and its result is shared.AuthSession 提供 isExpired,调用方可主动刷新。refresh() 为单飞:多个调用方同时请求刷新时,只发起一次网络调用并共享结果。 A failed refresh clears the session and emits AuthError.刷新失败会清空会话并发出 AuthError。","attaching-the-bearer-token--附加-bearer-令牌#Attaching the bearer token / 附加 Bearer 令牌":"AuthManager implements AuthTokenSource, so a Dio interceptor can read the current token without depending on the manager. See Network Integration.AuthManager 实现了 AuthTokenSource,Dio 拦截器可在不依赖管理器的情况下读取当前令牌。详见网络集成。","next-steps--下一步#Next Steps / 下一步":"Auth State Machine — State lifecycle details / 状态机细节\nBackend Strategy — Full AuthStrategy contract / 完整后端契约\nErrors — Exception & Result model / 异常与结果模型"}},"/":{"title":"Zero Auth","data":{"":"A backend-agnostic auth state machine & session lifecycle core for Dart/Flutter. Pure Dart, headless, no HTTP/SDK/native code. Wire your own backend via AuthStrategy and your own persistence via TokenStore.一个后端无关的认证状态机与会话生命周期内核,面向 Dart/Flutter。纯 Dart、无头(headless),不含 HTTP、SDK 或原生代码。通过 AuthStrategy 接入自有后端,通过 TokenStore 接入自有持久化层。","-features--功能特性#✨ Features / 功能特性":"Feature\tDescription\tBackend-agnostic\tImplement only login/register/logout/refresh; REST, gRPC, Firebase or a private RPC are equally valid targets / 只需实现四个方法,REST、gRPC、Firebase 或私有 RPC 均可接入\tAuth State Machine\tSealed Unauthenticated / Authenticating / Authenticated / Refreshing / LoggingOut / AuthError on a Stream that replays the latest value to new listeners; use isAuthenticated / isBusy / 密封六态状态机 + 重放最近值的状态流,可用 isAuthenticated / isBusy\tSilent Restore\trestore() rehydrates the persisted session at startup / 启动时静默恢复持久化会话\tSingle-flight Refresh\tConcurrent refresh() calls share one in-flight request instead of stampeding the backend / 并发刷新共享同一次请求,避免冲击后端\tPluggable Persistence\tThe only persistence surface is TokenStore.save/load/clear; InMemoryTokenStore ships in-core / 唯一的持久化接口,内核自带内存实现\tTyped Session\tAuthSession carries access/refresh tokens, expiry (isExpired), user id, display name and raw claims / 强类型会话,无需手工解析令牌\tUnified Errors\tEvery failure maps to AppException (AuthException for auth cases) or a Result wrapper; raw exceptions never cross the public surface / 统一异常与结果,裸异常不越界\tNetwork Integration\tAuthManager itself is an AuthTokenSource, so a Dio interceptor can attach Authorization: Bearer without depending on the manager / 管理器即令牌源,Dio 拦截器零依赖附加令牌\tSession Serialization\tAuthSession.toJson / fromJson make persistence a one-liner; a file-based reference store ships for server/CLI / AuthSession.toJson / fromJson 让持久化一行搞定,并附带面向服务端 / CLI 的文件参考存储\tProactive Auto-refresh\tPass autoRefreshAhead to renew tokens before expiry (single-flight) / 传入 autoRefreshAhead 在过期前自动续期(单飞)\tNever an Expired Token\tvalidAccessToken() renews first when the token has expired, so interceptors never send a dead bearer token / 令牌过期时先续期,拦截器不会发出失效令牌\tBring Your Own Login\tloginWith adopts a session from any flow you drive: third-party OAuth, magic links, passkeys / loginWith 可接纳第三方 OAuth、魔法链接、Passkey 等自定义流程\tTyped Auth Exceptions\tInvalidCredentialsException, SessionExpiredException and friends, mapped from your strategy's code / InvalidCredentialsException、SessionExpiredException 等,由策略的 code 映射而来\tConfigurable Failure Policy\trefreshFailurePolicy decides whether a failed refresh signs the user out / refreshFailurePolicy 决定刷新失败是否登出\tRunnable Example\tA full Flutter demo app (Android/iOS/Web/Windows) plus a layered dart:io demo backend that issues real JWTs / 完整 Flutter 示例与一个签发真实 JWT 的分层演示后端\tMultiple Accounts\tOptional AuthManagerGroup keeps one manager per account / 可选 AuthManagerGroup,每账号一个管理器\tCross-platform\tPure Dart — runs anywhere Dart or Flutter runs / 纯 Dart,跨平台","-table-of-contents--目录#📚 Table of Contents / 目录":"Page\tDescription\tGetting Started\tQuick start guide / 快速开始\tInstallation\tHow to install / 安装方式\tUsage\tDetailed usage / 详细使用\tAuth State Machine\tState lifecycle & stream / 状态机与状态流\tBackend Strategy\tImplement AuthStrategy / 实现后端边界\tToken Store\tPersistence boundary / 持久化边界\tNetwork Integration\tDio interceptor & token source / 网络集成与拦截器\tErrors\tException & Result model / 异常与结果模型\tConfiguration\tConfiguration options / 配置说明\tSession Persistence\tRestoring and renewing a saved session / 会话持久化与恢复\tThird-Party Login\tOAuth / magic links via loginWith / 用 loginWith 接入第三方登录\tMultiple Accounts\tSwitching vs concurrent accounts / 账号切换与多账号并存\tFAQ\tFrequently asked questions / 常见问题","-links--链接#🔗 Links / 链接":"GitHub\nOfficial Website\npub.dev","-license--许可证#📄 License / 许可证":"This project is licensed under the Mozilla Public License 2.0 (MPL-2.0).本项目采用 Mozilla Public License 2.0 (MPL-2.0) 许可证。This package is provided \"as is\", without warranty of any kind. The author assumes no responsibility or liability for the functionality, security, or any consequences arising from the use of modified versions or derivative projects.本包按\"原样\"提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。"}},"/FAQ":{"title":"FAQ / 常见问题","data":{"does-zero_auth-ship-an-http-client--zero_auth-内置-http-客户端吗#Does zero_auth ship an HTTP client? / zero_auth 内置 HTTP 客户端吗?":"No. The core is backend-agnostic and ships no HTTP, SDK or native code. You implement AuthStrategy to call your backend.不。内核后端无关,不含任何 HTTP、SDK 或原生代码。你通过实现 AuthStrategy 来调用自己的后端。","does-it-depend-on-flutter--它依赖-flutter-吗#Does it depend on Flutter? / 它依赖 Flutter 吗?":"No. It is a pure-Dart package; flutter: is only a declared constraint because the example app uses Flutter. The runtime has no Flutter dependency.不。它是纯 Dart 包;仅因示例 App 用到 Flutter 才声明 flutter: 约束,运行时不依赖 Flutter。","where-are-tokens-stored--令牌存在哪里#Where are tokens stored? / 令牌存在哪里?":"Wherever your TokenStore puts them. The core ships InMemoryTokenStore (lost on restart). For production, use the flutter_secure_storage-backed reference in example/lib/secure_token_store.dart.取决于你的 TokenStore。内核自带 InMemoryTokenStore(重启即丢失)。生产请用 example/lib/secure_token_store.dart 中基于 flutter_secure_storage 的参考实现。","what-happens-on-a-failed-refresh--刷新失败会怎样#What happens on a failed refresh? / 刷新失败会怎样?":"The failure is always reported as AuthError and rethrown. What happens next\ndepends on refreshFailurePolicy: an unrecoverable failure (SessionExpiredException,\nInvalidCredentialsException) clears the store and lands on Unauthenticated,\nwhile a transient one (network, 5xx) keeps the previous session so a retry can\nsucceed. See Configuration.失败总会以 AuthError 上报并重新抛出。之后如何取决于 refreshFailurePolicy:\n不可恢复的失败(SessionExpiredException、InvalidCredentialsException)会清空存储\n并落到 Unauthenticated;瞬时故障(网络、5xx)保留上一个会话以便重试成功。参见\n配置。","is-token-refresh-concurrent-safe--令牌刷新是否并发安全#Is token refresh concurrent-safe? / 令牌刷新是否并发安全?":"Yes — refresh() is single-flight: many concurrent callers share one in-flight request and its result.是的——refresh() 为单飞:多个并发调用方共享同一次请求及其结果。","how-do-i-attach-the-bearer-token-to-requests--如何给请求附加-bearer-令牌#How do I attach the bearer token to requests? / 如何给请求附加 Bearer 令牌?":"AuthManager is an AuthTokenSource. Use the Dio interceptor in example/lib/dio_interceptor.dart, or read accessToken directly.AuthManager 即 AuthTokenSource。可用 example/lib/dio_interceptor.dart 中的 Dio 拦截器,或直接读取 accessToken。","which-platforms-are-supported--支持哪些平台#Which platforms are supported? / 支持哪些平台?":"Any platform where Dart or Flutter runs (Android, iOS, Web, Windows, macOS, Linux).任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。"}},"/Installation":{"title":"Installation / 安装","data":{"from-pubdev-recommended--从-pubdev-安装推荐#From pub.dev (Recommended) / 从 pub.dev 安装(推荐)":"Add the following to your pubspec.yaml:在 pubspec.yaml 中添加以下依赖:\ndependencies:\n zero_auth: ^0.5.0\nThen run:然后运行:\ndart pub get","from-github--从-github-安装#From GitHub / 从 GitHub 安装":"Alternatively, install from GitHub:或者从 GitHub 安装:\ndependencies:\n zero_auth:\n git:\n url: https://github.com/zero-labsco/zero_auth.git\n ref: release/v0.5.0","import--导入#Import / 导入":"import 'package:zero_auth/zero_auth.dart';","requirements--环境要求#Requirements / 环境要求":"Requirement\tVersion\tDart SDK\t>= 3.4.0\tFlutter\t>= 3.0.0 (only a constraint; no Flutter runtime dependency)\t\nzero_auth is a pure-Dart package. Flutter is only listed because the example app uses it; the core has no Flutter dependency.zero_auth 是纯 Dart 包,仅因示例 App 用到 Flutter 才列出;内核本身不依赖 Flutter。","next-steps--下一步#Next Steps / 下一步":"Getting Started — Quick start guide / 快速开始\nUsage — Full usage guide / 完整使用指南"}}} \ No newline at end of file +{"/Auth-State-Machine":{"title":"Auth State Machine / 认证状态机","data":{"states--状态#States / 状态":"AuthState is a sealed class with six subtypes:AuthState 是一个密封类,包含六个子类型:\nState\tFields\tMeaning\tUnauthenticated\t—\tNo session / 无会话\tAuthenticating\t—\tlogin/register in flight / 登录/注册进行中\tAuthenticated\tsession: AuthSession\tA valid session / 有效会话\tRefreshing\tsession: AuthSession\tRenewal in flight; the previous session is still usable / 续期进行中,旧会话仍可用\tLoggingOut\tsession: AuthSession\tlogout() in flight; that session is being discarded / 登出进行中,该会话即将被丢弃\tAuthError\terror: AppException\tLast operation failed / 上一次操作失败\t\nTwo helpers save you from spelling out every case:两个辅助属性可避免你手写全部分支:\nstate.isAuthenticated — true for Authenticated and Refreshing, so a\nrenewal never unmounts your signed-in UI. / 在 Authenticated 与 Refreshing\n下均为 true,续期不会卸载已登录界面。\nstate.isBusy — true while Authenticating, Refreshing or LoggingOut. /\n在 Authenticating、Refreshing、LoggingOut 期间为 true。\nstate.session — the session the state carries (Authenticated / Refreshing /\nLoggingOut), or null; no pattern-matching needed for the common case. /\n该状态携带的会话(Authenticated / Refreshing / LoggingOut)或 null,\n常见场景无需再做模式匹配。","the-stream--状态流#The stream / 状态流":"AuthManager.state is a Stream that replays the latest value to every new listener (BehaviorSubject-like). This means:AuthManager.state 是一个 Stream,会向每个新订阅者重放最近的值(类似于 BehaviorSubject)。这意味着:\nA freshly mounted widget renders the correct screen on its first frame / 新挂载的 widget 首帧即渲染正确界面。\nYou never need to read a separate \"current state\" field / 无需另读一个\"当前状态\"字段。\nauth.state.listen((state) {\n switch (state) {\n case Authenticated(:final session):\n /* ... */\n case AuthError(:final error):\n /* ... */\n default:\n }\n});","transitions--状态转移#Transitions / 状态转移":"login/register ──► Authenticating ──► Authenticated\n │ ▲\n refresh │ │ success\n ─────────► ▼ │\n Refreshing\n │\n failure │\n ▼\n AuthError ──policy: sign out──► Unauthenticated\n └──policy: keep session─► Authenticated (old session)\n Authenticated ──logout──► LoggingOut ──► Unauthenticated\n Unauthenticated ──restore() finds nothing / nothing persisted──► Unauthenticated\nrestore() restores a live session → Authenticated.\nrestore() restores an expired session → attempts Refreshing; if the\nrenewal fails transiently (network, 5xx) the persisted session is kept and\nactivated as-is, so the next request can retry. Only a terminal failure — or the\nabsence of a refresh token — clears the store and lands on Unauthenticated.\nrestore(refreshIfExpired: false) restores it verbatim instead.\nA failed login/register → AuthError, and the future rethrows.\nA failed refresh → AuthError, followed by Unauthenticated when the\nfailure policy considers the grant unrecoverable,\nor by Authenticated (previous session) when it looks transient.\nDuplicate consecutive emissions are suppressed, so listeners only rebuild on a\nreal change.\nrestore() 恢复未过期会话 → Authenticated。\nrestore() 恢复已过期会话 → 先走 Refreshing;若续期瞬时失败(网络、5xx),\n则保留持久化会话并按原样激活,下次请求可再试。只有终局失败(或没有刷新令牌)\n才会清空存储并落到 Unauthenticated。restore(refreshIfExpired: false) 则原样恢复。\nlogin/register 失败 → AuthError,同时 future 再次抛出错误。\nrefresh 失败 → AuthError;随后由失败策略决定:\n认为授权不可恢复则转 Unauthenticated,认为只是瞬时故障则回到 Authenticated。\n连续重复的状态会被抑制,监听器只会在真正变化时重建。","next-steps--下一步#Next Steps / 下一步":"Usage — How to drive the UI from the stream / 如何用状态流驱动界面\nBackend Strategy — What each operation calls / 各操作调用什么"}},"/Backend-Strategy":{"title":"Backend Strategy / 后端策略","data":{"":"AuthStrategy is the only boundary the core needs from a backend. Implement four methods and zero_auth works with REST, gRPC, Firebase, or a private RPC.AuthStrategy 是内核向后台要求的唯一边界。实现四个方法后,zero_auth 即可对接 REST、gRPC、Firebase 或私有 RPC。","the-contract--契约#The contract / 契约":"abstract class AuthStrategy {\n Future login(Credentials credentials);\n Future register(RegistrationInput input);\n Future logout(SessionHandle handle);\n Future refresh(RefreshToken token);\n}\nMethod\tWhen called\tReceives\tMust return\tlogin\tAuthManager.login\tCredentials(username, password)\tA fresh AuthSession\tregister\tAuthManager.register\tRegistrationInput(username, password, displayName?, email?)\tA fresh AuthSession\tlogout\tAuthManager.logout\tSessionHandle(userId, refreshToken?)\tvoid (revoke server-side if applicable)\trefresh\tAuthManager.refresh\tRefreshToken(value)\tA new AuthSession (new tokens)\t\nusername is just a name — it is an opaque identifier, and the core never\nvalidates or interprets it. Put an email or a phone number in it and map it to\nwhatever field your API expects.username 只是字段名,它是个不透明标识符,内核从不校验或解析它。把邮箱或手机号放进去,\n再映射成你接口需要的字段即可。","what-is-fixed-and-where-the-escape-hatches-are--哪些是固定的逃生口在哪#What is fixed, and where the escape hatches are / 哪些是固定的,逃生口在哪":"The four signatures above are the only thing the contract fixes. Transport, URLs,\ntoken format and rotation are entirely yours.上面这四个签名是契约唯一固定的东西。传输方式、URL、令牌格式与轮换策略完全由你决定。\nYou need\tDo this\tLogin that is not username + password (SMS code, OAuth, passkey)\tAuthManager.loginWith — see Third-Party Login\tExtra fixed context (tenant id, app id, device id)\tHold it as a field on your strategy; do not squeeze it into Credentials\tExtra data returned by the backend (roles, tenant, permissions)\tPut it in AuthSession.claims\tSignalling why a call failed\tThrow AuthException with a code — see Errors\t\nGotchas / 注意点:\nAlways set userId when building sessions. logout receives\nuserId: session.userId ?? '', so a session without one hands your backend an\nempty string (the refresh token is passed too, when present).\n务必设置 userId:登出时收到的是 session.userId ?? '',没有它后端会拿到空\n字符串(有刷新令牌时会一并传入)。\nLogging in again does not sign the previous session out. The new session\nsimply replaces it locally; revoking the old one server-side is your call — or\nuse Multiple Accounts if both must survive.\n再次登录不会登出上一个会话:新会话只是本地替换;是否在服务端吊销旧的由你决定;\n若两者都要保留,请用多账号。","mapping-failures--映射失败#Mapping failures / 映射失败":"Every thrown error should be mapped to AppException (or AuthException for auth-specific cases). Raw Exceptions must never cross the public surface — the manager wraps unknowns.所有抛出的错误都应映射为 AppException(认证相关场景用 AuthException)。裸 Exception 不得越过公共边界——管理器会包裹未知异常。\n@override\nFuture login(Credentials credentials) async {\n try {\n final json = await myApi.post('/login', {\n 'account': credentials.username, // email, phone, whatever you accept\n 'password': credentials.password,\n });\n return AuthSession.fromJson(json as Map);\n } on MyApiAuthError catch (e) {\n throw AuthException(code: 'invalid_credentials', message: e.message);\n }\n}","building-the-session--构造会话#Building the session / 构造会话":"Return an AuthSession carrying at least the access token, the refresh token, and an expiry:返回 AuthSession,至少包含访问令牌、刷新令牌与过期时间:\nAuthSession(\n accessToken: json['access'],\n refreshToken: json['refresh'],\n expiresAt: DateTime.parse(json['expiresAt']),\n userId: json['userId'],\n displayName: json['displayName'],\n claims: json, // optional raw claims / 可选的原始 claims\n)","example-backend--演示后端#Example backend / 演示后端":"server/ is a layered dart:io demo backend exercising /login, /refresh, /logout, /me and /health. Run it with dart run bin/server.dart on port 8080. It issues real HMAC-SHA256 JWTs with refresh-token rotation and replay detection, and logs every request with its status and duration. The demo account is user / user.server/ 是一个分层的 dart:io 演示后端,提供 /login、/refresh、/logout、/me 与 /health。用 dart run bin/server.dart 在 8080 端口启动。它签发真实的 HMAC-SHA256 JWT,带刷新令牌轮换与重放检测,并记录每条请求的状态码与耗时。演示账号为 user / user。","next-steps--下一步#Next Steps / 下一步":"Token Store — Where sessions are persisted / 会话持久化到何处\nNetwork Integration — Attaching the bearer token / 附加 Bearer 令牌"}},"/Errors":{"title":"Errors / 错误","data":{"":"zero_auth never lets a raw Exception cross its public surface. Every failure is expressed through one of two mechanisms.zero_auth 绝不允许裸 Exception 越过公共边界。每个失败都通过下面两种机制之一表达。","appexception--异常类型#AppException / 异常类型":"All domain failures extend AppException:所有领域失败都继承 AppException:\nsealed class AppException implements Exception {\n final String code; // stable, machine-readable / 稳定、机器可读\n final String message; // human-readable / 人类可读\n final Object? cause; // original error, if any / 原始错误\n}\nfinal class AuthException extends AppException {\n // Base type for every auth failure / 所有认证失败的基类型\n}\n// Concrete subtypes — catch these to branch on what actually went wrong.\n// 具体子类型——捕获它们即可按实际错误分支处理。\nfinal class InvalidCredentialsException extends AuthException {} // invalid_credentials / 凭据无效\nfinal class SessionExpiredException extends AuthException {} // session_expired / 会话过期\nfinal class NoActiveSessionException extends AuthException {} // no_active_session / 无活动会话\nfinal class RefreshTokenMissingException extends AuthException {} // refresh_token_missing / 缺少刷新令牌\nfinal class UnexpectedAuthException extends AuthException {} // unexpected_auth_failure / 意外失败\nAuthStrategy authors opt into these subtypes simply by throwing an\nAuthException — or the bare AuthFail domain type — carrying the matching\ncode; mapAuthFailure performs the mapping, preserving any vocabulary it does\nnot recognise.AuthStrategy 实现者只需抛出携带对应 code 的 AuthException —— 或裸的领域类型\nAuthFail —— 即可参与映射;mapAuthFailure 负责转换,并保留它无法识别的自定义错误类型。\n// Both of these map to InvalidCredentialsException / 两者都会映射为 InvalidCredentialsException\nthrow AuthException('Wrong password', code: 'invalid_credentials');\nthrow const AuthFail('Wrong password', code: 'invalid_credentials');\ncode\tMapped type / 映射结果\tinvalid_credentials\tInvalidCredentialsException\tinvalid_grant, invalid_refresh_token, token_expired, session_expired\tSessionExpiredException\tno_active_session\tNoActiveSessionException\trefresh_token_missing\tRefreshTokenMissingException\tanything else / 其它\tpreserved as-is, or UnexpectedAuthException / 原样保留或包装为 UnexpectedAuthException\t\nCodes raised by the manager itself (not your strategy) / 由管理器自身产生的 code:\ncode\tRaised when / 何时触发\tauth_flow_in_progress\tAnother login / register / loginWith is already running / 已有登录流程在执行\tmanager_disposed\tAn operation is called after dispose() / dispose() 后又调用操作\t\nOverlapping authentication flows are rejected rather than racing: a second\nlogin() while one is in flight throws auth_flow_in_progress.重叠的登录流程会被拒绝而非争抢:进行中再次 login() 会抛 auth_flow_in_progress。\nAuthStrategy implementations should map transport/API errors into AuthException (or a custom AppException subclass) so callers get a stable code.AuthStrategy 实现方应将传输层/API 错误映射为 AuthException(或自定义 AppException 子类),让调用方拿到稳定的 code。","resultt--结果类型#Result / 结果类型":"For explicit, exception-free handling, use Result:如需显式、无异常的处理,可使用 Result:\nsealed class Result {\n const factory Result.ok(T value) = Ok;\n const factory Result.err(AppException error) = Err;\n}\nfinal result = await auth.tryLogin(user, pass); // returns Result\nswitch (result) {\n case Ok(:final value):\n /* authenticated / 已认证 */\n case Err(:final error):\n /* inspect error.code / 读取 error.code */\n}","how-failures-surface--失败如何呈现#How failures surface / 失败如何呈现":"Situation\tSurfaced as\tlogin/register fails\tAuthState becomes AuthError(error) and the future throws AppException / 状态变为 AuthError,且 future 抛出 AppException\trefresh fails\tAuthError(error) emitted, then Unauthenticated (unrecoverable) or back to Authenticated (transient) per the failure policy / 发出 AuthError,随后按失败策略转为 Unauthenticated(不可恢复)或回到 Authenticated(瞬时)\trestore() finds nothing\tStays Unauthenticated (not an error) / 保持 Unauthenticated(不算错误)\trestore() renewal fails transiently\tSession kept (the next validAccessToken() retries); only a terminal failure clears the store / 保留会话(下次 validAccessToken() 会重试);只有终局失败才清空存储\tTokenStore.load() throws\tMapped to AppException, treated as \"no session\" / 映射为 AppException,按\"无会话\"处理","next-steps--下一步#Next Steps / 下一步":"Backend Strategy — Map your API errors / 映射你的 API 错误\nConfiguration — Tuning refresh & retry / 调优刷新与重试"}},"/Configuration":{"title":"Configuration / 配置","data":{"":"zero_auth is deliberately minimal: there is no global config object. Behavior is composed from the two boundaries you pass to AuthManager.zero_auth 刻意保持精简:没有全局配置对象。行为由你传给 AuthManager 的两个边界组合而成。","composing-authmanager--组合管理器#Composing AuthManager / 组合管理器":"final auth = AuthManager(\n strategy: MyAuthStrategy(), // how to talk to the backend / 如何与后端通信\n tokenStore: SecureTokenStore(), // where to persist tokens / 令牌持久化位置\n autoRefreshAhead: const Duration(minutes: 5), // proactive renewal / 主动续期\n autoRefreshRetryDelay: const Duration(seconds: 30), // re-arm after a failed renewal / 续期失败后重新排程\n autoRefreshMaxRetries: 3, // cap those retries / 重试上限\n autoRefreshMinInterval: const Duration(seconds: 5), // floor for an already-due renewal / 已到期续期的下限\n refreshFailurePolicy: defaultRefreshFailurePolicy, // sign-out rule / 登出规则\n clock: () => DateTime.now(), // time source (tests, skew) / 时间源(测试、时钟偏移)\n clockSkew: const Duration(seconds: 30), // treat tokens as expired this much earlier / 提前多久视为过期\n preserveSessionDetails: true, // keep identity across a tokens-only renewal / 只换令牌的续期保留身份\n onStateChanged: (state) => debugPrint('$state'), // observe every emission / 观察每次状态\n);\nKnob\tWhere\tEffect\tBackend endpoints & auth scheme\tAuthStrategy\tWhat login/refresh/… actually do / login/refresh 等的实际行为\tToken persistence\tTokenStore\tDisk / secure storage / in-memory / 磁盘/安全存储/内存\tRefresh timing\tAuthSession.expiresAt + autoRefreshAhead\tProactive renewal is scheduled that far before expiry / 在过期前该时长调度主动续期\tRenewal retry\tautoRefreshRetryDelay\tAfter a proactive renewal fails, it is re-armed this much later (default 30s) while a session still exists / 主动续期失败后按此时长重新排程(默认 30 秒),会话仍在才重试\tRenewal retry cap\tautoRefreshMaxRetries\tHow many failed proactive renewals to retry before giving up (default 3) / 主动续期失败最多重试几次(默认 3)\tRenewal floor\tautoRefreshMinInterval\tMinimum wait for a renewal that is already due (default 5s), so very short-lived tokens cannot cause a tight loop / 「已到期」续期的最小等待(默认 5 秒),极短寿命令牌不会造成紧密循环\tClock skew\tclockSkew\tHow much earlier a token counts as expired (default 30s); absorbs a device clock that runs ahead and the latency of the request itself. If your access tokens live under a minute, lower it (e.g. 5s) or almost every read renews first / 提前多久把令牌视为过期(默认 30 秒),用于吸收设备时钟偏快与请求自身的延迟。若访问令牌寿命不足一分钟,请调小(如 5 秒),否则几乎每次读取都会先续期\tIdentity on renewal\tpreserveSessionDetails\tCarry userId / displayName / claims over a renewal that returns tokens only (default true) / 只返回令牌的续期是否保留身份字段(默认 true)\tObservation\tonStateChanged\tOptional callback for every emitted state, for logging or analytics / 可选回调,每次发出状态时触发,便于日志或埋点\tRefresh failure handling\trefreshFailurePolicy\tWhether a failed refresh signs the user out / 刷新失败是否让用户登出\tTime source\tclock\tDrives expiry maths and proactive scheduling; inject one for deterministic tests or to tolerate device clock skew / 驱动过期计算与主动刷新调度;注入时钟可实现确定性测试或容忍设备时钟偏移\tToken injection\tAuthTokenSource / validAccessToken()\tHow the bearer token reaches HTTP clients, optionally renewing an expired token first / 令牌如何到达 HTTP 客户端,必要时先续期过期令牌","refresh-strategy--刷新策略#Refresh strategy / 刷新策略":"refresh() is single-flight by construction: concurrent callers share one in-flight request. You decide when to refresh:refresh() 天生单飞:并发调用方共享同一次进行中的请求。何时刷新由你决定:\nProactively, when session.isExpired is approaching, before a request. / 在请求前、当 session.isExpired 临近时主动刷新。\nReactively, on a 401 from your API (see Network Integration). / 响应式地,在 API 返回 401 时刷新。\nA failed refresh is always reported through AuthError (and the rethrown future),\nbut whether it ends the session is decided by refreshFailurePolicy. See\nRefresh failure policy below.刷新失败总会通过 AuthError(以及重新抛出的 future)上报,但是否终止会话由\nrefreshFailurePolicy 决定,详见下方「刷新失败策略」。","refresh-failure-policy--刷新失败策略#Refresh failure policy / 刷新失败策略":"The default policy signs the user out only for failures that can never succeed\nagain, keeping the session when the failure looks transient (so a network blip\ndoes not log people out):默认策略仅在失败「注定无法重试成功」时让用户登出;看起来像瞬时故障时保留会话(避免一次\n网络抖动就把人踢下线):\nFailure / 失败类型\tDefault outcome / 默认结果\tSessionExpiredException, InvalidCredentialsException\tStore cleared → Unauthenticated / 清空存储 → Unauthenticated\tAnything else (network, 5xx…) / 其它(网络、5xx 等)\tSession kept → back to Authenticated / 保留会话 → 回到 Authenticated\t\n// Never sign out on a failed refresh — useful when refresh tokens are\n// long-lived and your backend is occasionally flaky.\n// 刷新失败也绝不登出——适用于刷新令牌长期有效、后端偶尔不稳定的场景。\nfinal auth = AuthManager(\n strategy: strategy,\n refreshFailurePolicy: (AppException error) => false,\n);\nProactive (background) refresh failures are handled internally, so a scheduled\nrenewal can never surface as an unhandled async error.主动(后台)刷新的失败会在内部处理,因此定时续期永远不会以未处理异步错误的形式泄漏。","no-global-singletons--没有全局单例#No global singletons / 没有全局单例":"AuthManager is a plain object — create one per app, hold it in your DI container or a top-level variable. The core has no static mutable state.AuthManager 是一个普通对象——每个应用创建一个,放在 DI 容器或顶层变量中即可。内核没有静态可变状态。","next-steps--下一步#Next Steps / 下一步":"Usage — Putting it together / 综合使用\nFAQ — Common questions / 常见问题"}},"/Getting-Started":{"title":"Getting Started / 快速开始","data":{"quick-start--快速开始#Quick Start / 快速开始":"zero_auth is a headless core: you bring the backend (AuthStrategy) and the persistence (TokenStore), and AuthManager drives the session lifecycle.zero_auth 是一个无头内核:你提供后端(AuthStrategy)与持久化(TokenStore),AuthManager 负责驱动会话生命周期。\nimport 'package:zero_auth/zero_auth.dart';\n// 1. Implement your backend (four methods only).\n// 1. 实现你的后端(只需四个方法)。\nfinal strategy = MyAuthStrategy();\n// 2. Pick a token store. InMemoryTokenStore ships in-core; for production use\n// the flutter_secure_storage reference in example/lib/secure_token_store.dart.\n// 2. 选择令牌存储。内核自带 InMemoryTokenStore;生产环境请用\n// example/lib/secure_token_store.dart 中的 flutter_secure_storage 实现。\nfinal store = InMemoryTokenStore();\n// 3. Create the manager and restore any persisted session.\n// 3. 创建管理器并恢复已持久化的会话。\nfinal auth = AuthManager(strategy: strategy, tokenStore: store);\nawait auth.restore();\nThen drive the UI from the state stream:随后用状态流驱动界面:\nauth.state.listen((state) {\n switch (state) {\n case Unauthenticated():\n showLoginScreen();\n case Authenticating():\n case LoggingOut():\n showSpinner();\n case Authenticated(:final session):\n showHome(session.userId);\n case Refreshing(:final session):\n // Still signed in: only the token is being renewed.\n // 仍处于已登录状态:只是令牌在续期。\n showHome(session.userId, renewing: true);\n case AuthError(:final error):\n showError(error);\n }\n});\nIf you prefer if/else over exhaustiveness, use state.isAuthenticated and\nstate.isBusy instead of state is Authenticated — isAuthenticated stays\ntrue during Refreshing, so your UI never bounces back to the login screen\nmid-refresh.若不想用穷举匹配,请用 state.isAuthenticated / state.isBusy 代替\nstate is Authenticated——isAuthenticated 在 Refreshing 期间仍为 true,\n界面不会在刷新途中退回登录页。","the-lifecycle--生命周期#The Lifecycle / 生命周期":"AuthManager broadcasts an explicit, sealed state machine:AuthManager 广播一个显式、密封的状态机:\nState\tMeaning\tUnauthenticated\tNo session / 无会话\tAuthenticating\tlogin/register in flight / 登录/注册进行中\tAuthenticated\tA valid session is present / 存在有效会话\tRefreshing\tSession renewal in flight; the previous session stays usable / 续期进行中,旧会话仍可用\tLoggingOut\tlogout() in flight / 登出进行中\tAuthError\tThe last operation failed / 上一次操作失败\t\nThe stream replays the latest value to new listeners, so a widget renders the correct screen on its first frame.状态流会向新订阅者重放最近的值,因此 widget 在首帧即可渲染正确界面。","next-steps--下一步#Next Steps / 下一步":"Installation — Detailed installation methods / 详细安装方式\nUsage — Full usage guide / 完整使用指南\nBackend Strategy — Implement AuthStrategy / 实现后端边界"}},"/Multi-Account":{"title":"Multiple Accounts / 多账号","data":{"first-which-kind-do-you-need--先分清你需要哪一种#First: which kind do you need? / 先分清你需要哪一种":"\"Multiple accounts\" means two very different things, and they do not need the\r\nsame solution:「多账号」其实指两种截然不同的需求,它们的解法不一样:\nShape\tDescription\tRecommendation\tSwitching / 账号切换\tSeveral accounts are remembered, but only one is used at a time (like Gmail's account picker) / 记住多个账号,但同时只用一个\tPlain AuthManager: logout() then login(). No new API. / 普通 AuthManager:登出再登录,不需要新 API\tConcurrent / 账号并存\tSeveral accounts are signed in at the same time and all can make requests / 多个账号同时登录,且都能发请求\tAuthManagerGroup — one AuthManager per account\t\nMost apps only need the first. Reach for AuthManagerGroup only when an account\r\nmust keep working while another one is in the foreground.多数应用只需要第一种。只有当「某个账号在后台也要继续工作」时,才需要 AuthManagerGroup。","shape-1-switching--形态一切换#Shape 1: switching / 形态一:切换":"AuthManager models one session. Switching accounts is just logging out and back\r\nin — the state machine stays honest, and there is no second source of truth:\n// Keep your own list of saved account identifiers (email, phone, …).\r\n// 自己维护已保存账号标识(邮箱、手机号等)的列表。\r\nfinal savedAccounts = ['alice@example.com', 'bob@example.com'];\r\n\r\nFuture switchTo(String account, String password) async {\r\n if (auth.current.isAuthenticated) await auth.logout();\r\n await auth.login(Credentials(username: account, password: password));\r\n}\nBecause username is just an opaque identifier, this works for email or phone\r\nlogins with no extra plumbing — see\r\nBackend Strategy for what the contract fixes.","shape-2-concurrent--形态二并存#Shape 2: concurrent / 形态二:并存":"AuthManagerGroup owns one AuthManager per account and tracks which is active.\nfinal group = AuthManagerGroup(\r\n // Both factories receive the account id; sharing one strategy instance is fine.\r\n // 两个工厂都会收到账号 id;共用一个策略实例也没问题。\r\n strategyFactory: (accountId) => MyAuthStrategy(),\r\n // CRITICAL: one store per account, so persisted sessions stay isolated.\r\n // 关键:每个账号一个存储,持久化会话才不会互相覆盖。\r\n storeFactory: (accountId) => SecureTokenStore(key: 'auth_$accountId'),\r\n // Optional: every AuthManager knob is forwarded to the managers it creates.\r\n // 可选:所有 AuthManager 调参都会转发给它创建的管理器。\r\n autoRefreshAhead: const Duration(minutes: 5),\r\n clockSkew: const Duration(seconds: 30),\r\n onStateChanged: (accountId, state) => debugPrint('$accountId → $state'),\r\n // Or build them yourself / 也可自行构建:\r\n // managerFactory: (id, strategy, store) => AuthManager(...),\r\n);\r\n\r\n// Sign in (or restore) each account independently.\r\n// 各账号独立登录(或恢复)。\r\nawait group.forAccount('alice').login(\r\n Credentials(username: 'alice@example.com', password: pw),\r\n);\r\nawait group.forAccount('bob').login(\r\n Credentials(username: 'bob@example.com', password: pw),\r\n);\r\n\r\n// Pick the active one — nothing is signed out by this call.\r\n// 选择激活账号 —— 此调用不会登出任何账号。\r\ngroup.switchTo('bob');","reading-the-active-account--读取激活账号#Reading the active account / 读取激活账号":"The group mirrors the active account and implements AuthTokenSource, so\r\ninterceptors keep depending on the narrow interface:\ngroup.current // active account's AuthState / 激活账号的状态\r\ngroup.currentSession // active account's session / 激活账号的会话\r\ngroup.accessToken // active account's token / 激活账号的令牌\r\ngroup.state // stream that follows the active account / 跟随激活账号的流\r\ngroup.activeIdChanges // stream of the active account id / 激活账号 id 的流\r\n\r\nawait group.validAccessToken(); // renewed token of the active account / 激活账号续期后的令牌\r\n\r\ndio.interceptors.add(RefreshingAuthInterceptor(group)); // renews then attaches / 先续期再附加","restoring-on-startup--启动时恢复#Restoring on startup / 启动时恢复":"// `knownIds` comes from your own saved-account list.\r\n// `knownIds` 来自你自己保存的账号列表。\r\nawait group.restoreAll(knownIds, activeId: lastUsedId);\nOne account failing to restore does not abandon the rest: every account is\r\nattempted and the first error is thrown at the end.某个账号恢复失败不会连累其余账号:所有账号都会被尝试,最后统一抛出第一个错误。","adding-accounts-explicitly--显式新增账号#Adding accounts explicitly / 显式新增账号":"forAccount creates on first use; addAccount is the same call with a name that\r\nreads better when you mean \"register an account here\":forAccount 首次使用时创建;addAccount 是同一个调用,只是名字在「这里注册账号」的\r\n语境下更好读:\nfinal alice = group.addAccount('alice');","removing-an-account--移除账号#Removing an account / 移除账号":"await group.remove('alice'); // logs out, disposes, forgets it","signing-everything-out--全部登出#Signing everything out / 全部登出":"await group.logoutAll(); // every account, in turn\nIf the removed account was active, the group becomes inactive and emits\r\nUnauthenticated.","teardown--释放#Teardown / 释放":"await group.disposeAll(); // disposes every manager and closes the stream","why-the-core-stays-single-session--为什么核心仍是单会话#Why the core stays single-session / 为什么核心仍是单会话":"AuthManager answers a singular question: who is logged in? Multi-account asks\r\na different one: of these signed-in identities, which is active?Folding the second into the first would mean a Map of sessions inside every\r\nstate, a Refreshing that needs an account id, and a breaking change for every\r\nexisting user. Keeping AuthManager single-session and adding an opt-in\r\ncoordinator gives you both without muddying either.把第二个问题塞进第一个,会导致每个状态里都带一个会话 Map、Refreshing 需要携带账号\r\nid,并且对所有现有用户构成破坏性变更。让 AuthManager 保持单会话、再提供一个可选的\r\n协调层,两者都能得到,且互不污染。","gotchas--注意点#Gotchas / 注意点":"Never share one TokenStore between accounts — the second save() would\r\noverwrite the first session. / 绝不要在多个账号间共用一个 TokenStore,第二次\r\nsave() 会覆盖第一个会话。\nSwitching does not sign anyone out; all managers keep refreshing in the\r\nbackground. / 切换不会登出任何账号,所有管理器仍会在后台续期。\nremove() also disposes that manager — do not use it afterwards. /\r\nremove() 会同时释放该管理器,之后不要再使用它。","next-steps--下一步#Next Steps / 下一步":"Auth State Machine — what each manager emits / 各管理器会发出什么\nToken Store — keying persisted sessions per account / 按账号隔离持久化会话\nBackend Strategy — what the contract fixes / 契约固定了什么"}},"/Network-Integration":{"title":"Network Integration / 网络集成","data":{"":"AuthManager implements AuthTokenSource, so HTTP clients can read the current access token without depending on the manager object. This keeps your Dio setup decoupled from zero_auth.AuthManager 实现了 AuthTokenSource,因此 HTTP 客户端可在不依赖管理器对象的前提下读取当前访问令牌,使 Dio 配置与 zero_auth 解耦。","authtokensource--令牌源#AuthTokenSource / 令牌源":"abstract class AuthTokenSource {\n String? get accessToken; // may be expired / 可能已过期\n Future validAccessToken({Duration? leeway}); // renewed first / 先续期\n}\nAuthManager.accessToken returns the current token, or null when unauthenticated — and it may already be expired.AuthManager.accessToken 返回当前令牌;未认证时返回 null —— 而且它可能已经过期。validAccessToken() is the one to use before a request: it renews first (reusing\nthe single-flight refresh) and returns null only when there is nothing to send.\nleeway is how long the token must stay valid for, defaulting to the manager's\nclockSkew (30s), so a token that would die mid-request is renewed first.请求之前应当用 validAccessToken():它会在必要时先续期(复用单飞刷新),只有真的无令牌\n可发时才返回 null。leeway 表示令牌必须还能维持有效的时长,默认取管理器的\nclockSkew(30 秒),因此会在请求途中失效的令牌会被提前续期。","dio-interceptor--dio-拦截器#Dio interceptor / Dio 拦截器":"example/lib/dio_interceptor.dart ships a ready-to-use interceptor:example/lib/dio_interceptor.dart 提供了一个开箱即用的拦截器:\nclass AuthInterceptor extends Interceptor {\n AuthInterceptor(this.tokens); // an AuthTokenSource / 一个令牌源\n final AuthTokenSource tokens;\n @override\n void onRequest(RequestOptions o, RequestInterceptorHandler h) async {\n final t = await tokens.validAccessToken(); // renews first / 先续期\n if (t != null) o.headers['Authorization'] = 'Bearer $t';\n h.next(o);\n }\n}\nIf you only want the synchronous read, use tokens.accessToken — but remember\nit may hand you a token that has already expired.\n若只需要同步读取,可用 tokens.accessToken —— 但请记住它可能返回一个已过期的令牌。\nfinal dio = Dio()\n ..interceptors.add(AuthInterceptor(authManager));\nWhen a 401 is returned, refresh the session and retry; if refresh fails, the manager emits AuthError and your app routes back to login.当返回 401 时,刷新会话并重试;若刷新失败,管理器会发出 AuthError,应用随之跳转登录。example/lib/dio_interceptor.dart also ships AuthRetryInterceptor, which does\nexactly that — refreshes through the manager (single-flight) and replays the\nrequest once, so a retry loop cannot form:example/lib/dio_interceptor.dart 里还提供了 AuthRetryInterceptor,正是做这件事 ——\n通过管理器刷新(单飞)并把请求重试一次,因此不会形成重试风暴:\nfinal dio = Dio()\n ..interceptors.add(\n AuthRetryInterceptor(manager: authManager, dio: dio),\n );\nTwo more interceptor variants there / 那里还有另外两种拦截器:\nRefreshingAuthInterceptor — never sends an expired token: it renews first via\nvalidAccessToken() / 绝不发送过期令牌,先用 validAccessToken() 续期。\nAuthInterceptor — the plain AuthTokenSource version / 基础的令牌源版本。","other-http-clients--其它客户端#Other HTTP clients / 其它客户端":"Because AuthTokenSource is a plain interface, the same pattern works for package:http, chopper, or any client that lets you mutate request headers.由于 AuthTokenSource 是普通接口,同样的模式适用于 package:http、chopper 或任何允许修改请求头的客户端。","next-steps--下一步#Next Steps / 下一步":"Usage — Full integration walkthrough / 完整集成讲解\nErrors — Handling 401 and refresh failures / 处理 401 与刷新失败"}},"/Persistence":{"title":"Session Persistence / 会话持久化","data":{"":"InMemoryTokenStore loses the session on every restart. For a real app you must persist it — and zero_auth now ships the primitives that make this a one-liner: session (de)serialization plus two ready-to-copy reference stores.InMemoryTokenStore 会在每次重启时丢失会话。真实应用必须把它持久化——而 zero_auth 现在提供了让这件事变成「一行代码」的原语:会话(反)序列化,外加两个开箱即用的参考存储。","1-serialize-the-session--序列化会话#1. Serialize the session / 序列化会话":"AuthSession is JSON-safe out of the box. null fields are omitted, and claims round-trips as a plain map.AuthSession 天生就是 JSON 安全的:null 字段会被省略,claims 作为普通映射原样往返。\nfinal json = session.toJson(); // Map\nfinal restored = AuthSession.fromJson(json);\n// Prefer this inside TokenStore.load(): malformed data yields null (\"not signed\n// in\") instead of a FormatException / TypeError.\n// TokenStore.load() 里请优先用它:畸形数据返回 null(即「未登录」),而不是抛\n// FormatException / TypeError。\nfinal safe = AuthSession.tryFromJson(json);\nRefreshToken serializes to its raw string too, so a persisted session rehydrates losslessly.RefreshToken 也序列化为其原始字符串,因此持久化的会话可无损恢复。","2-flutter--encrypted-secure-storage--flutter--加密安全存储#2. Flutter — encrypted secure storage / Flutter —— 加密安全存储":"For mobile, persist into the OS keychain / keystore via flutter_secure_storage. A reference implementation lives at example/lib/secure_token_store.dart:在移动端,通过 flutter_secure_storage 把令牌存入系统钥匙串 / Keystore。参考实现位于 example/lib/secure_token_store.dart:\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: SecureTokenStore(), // from example/\n);\nawait auth.restore(); // rehydrate before the first frame\nNever store refresh tokens in plain SharedPreferences. Use OS-backed secure storage only.切勿把刷新令牌明文存入 SharedPreferences,只使用操作系统级安全存储。","3-server--cli--desktop--json-file--服务端--cli--桌面--json-文件#3. Server / CLI / desktop — JSON file / 服务端 / CLI / 桌面 —— JSON 文件":"On non-mobile Dart targets you can write the serialized session to a file. A reference implementation lives at example/lib/json_token_store.dart:在非移动端 Dart 目标上,可以把序列化后的会话写入文件。参考实现位于 example/lib/json_token_store.dart:\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: FileTokenStore(File('.zero_auth_session.json')),\n);\nThis is ideal for a backend that keeps a logged-in user across process restarts, or a CLI that stays authenticated between invocations.这非常适合「重启后仍保持登录」的后端,或「多次调用之间保持认证」的 CLI。","4-combine-with-auto-refresh--结合自动刷新#4. Combine with auto-refresh / 结合自动刷新":"When you enable proactive auto-refresh, the manager renews the token before it expires — and every renewal is persisted through save(), so the on-disk session always carries a fresh access token.当你开启主动自动刷新时,管理器会在令牌过期前续期——而每次续期都会通过 save() 落盘,因此磁盘上的会话始终持有崭新的访问令牌。\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: SecureTokenStore(),\n autoRefreshAhead: const Duration(minutes: 5),\n);","next-steps--下一步#Next Steps / 下一步":"Token Store — the save / load / clear contract / save / load / clear 契约\nConfiguration — enable proactive auto-refresh / 开启主动自动刷新"}},"/Third-Party-Login":{"title":"Third-Party Login / 第三方登录","data":{"the-key-idea--核心思路#The key idea / 核心思路":"zero_auth does not run the OAuth handshake — and that is deliberate. It is a\r\npure-Dart package, so it ships no platform code, no browser plumbing and no\r\nredirect URL handling (those live outside lib/).Instead, zero_auth manages what comes after the handshake: the session your\r\nown backend issues.zero_auth 不负责 OAuth 握手环节,这是刻意为之:它是纯 Dart 包,不含任何平台\r\n代码、浏览器通道或重定向回调处理(这些只能在 lib/ 之外)。它负责的是握手之后的事:你自己后端所签发的会话。\nprovider consent ──► code / id_token ──► YOUR BACKEND ──► your access + refresh tokens ──► zero_auth\r\n (outside) (any language) (this package)\nBecause the login method is invisible to the manager, password login and\r\nGoogle/Apple login end up sharing one token lifecycle. That is the whole\r\npoint.登录方式对管理器是透明的,因此密码登录与 Google / Apple 登录最终共享同一套\r\n令牌生命周期机制。这正是关键所在。","when-this-fits--适用场景#When this fits / 适用场景":"This pattern fits when your backend brokers the provider — you have a\r\nbackend, it verifies the provider token, then mints your own tokens.下面的模式适用于你的后端作为经纪方:你有后端,它校验提供方令牌,然后签发你自己的\r\n令牌。\nScenario\tFit\tOwn backend + Google/Apple/GitHub sign-in / 自有后端 + 第三方登录\t✅ Ideal / 理想\tOwn backend + password AND social logins / 自有后端,密码与社交登录并存\t✅ One shared session model / 共用一套会话模型\tFirebase/Supabase issues the tokens directly / 由 Firebase/Supabase 直接签发令牌\t⚠️ Use their SDK instead / 请改用其 SDK","wiring-it-up--接线步骤#Wiring it up / 接线步骤":"1. Run the handshake outside this package.\r\nUse whichever OAuth client fits your targets (flutter_web_auth, AppAuth, or a\r\nserver-side flow). This code lives in your app or a sibling Flutter package, never\r\nin lib/.1. 在本包之外完成握手。\r\n使用适合目标平台的 OAuth 客户端(flutter_web_auth、AppAuth 或服务端流程)。这部分\r\n代码放在你的应用或兄弟 Flutter 包里,绝不放在 lib/。2. Trade the provider credential for your tokens on your backend.2. 用提供方凭据到你的后端换取自有令牌。\nFuture signInWithGoogle(BuildContext context) async {\r\n // 1) Provider consent (outside zero_auth) → authorization code / id_token.\r\n // 提供方授权(零依赖,在 zero_auth 之外)→ 授权码 / id_token。\r\n final code = await myOAuthClient.authenticate();\r\n\r\n // 2) Your backend verifies it with Google and returns YOUR tokens.\r\n // 你的后端用它与 Google 校验,并返回「你自己的」令牌。\r\n final res = await myApi.post('/auth/google', data: {'code': code});\r\n\r\n return AuthSession(\r\n accessToken: res['accessToken'] as String,\r\n refreshToken: RefreshToken(res['refreshToken'] as String),\r\n expiresAt: DateTime.now().add(Duration(seconds: res['expiresIn'] as int)),\r\n userId: res['userId'] as String,\r\n );\r\n}\n3. Hand the session to the manager with loginWith.\r\nloginWith is the escape hatch for flows that are not login / register: the\r\nmanager still owns persistence, the state machine and proactive refresh.3. 用 loginWith 把会话交给管理器。\r\nloginWith 是为那些不符合 login / register 形态的流程准备的逃生口:持久化、状态机\r\n与主动刷新仍由管理器负责。\nfinal result = await auth.loginWith((strategy) => signInWithGoogle(context));\r\n// emits Authenticating → Authenticated, or AuthError + rethrow.\r\n// 发出 Authenticating → Authenticated;失败则发 AuthError 并重新抛出。\nKeep signInWithGoogle throwing AuthException with a meaningful code\r\n('invalid_credentials', 'session_expired', …) so failures map to the typed\r\nexceptions in Errors.让 signInWithGoogle 抛出带有实用 code('invalid_credentials'、\r\n'session_expired' 等)的 AuthException,这样失败就能映射为\r\nErrors 里的具体异常类型。","same-model-for-every-method--各登录方式共用一套模型#Same model for every method / 各登录方式共用一套模型":"// Password users and Google users end up with the same AuthSession shape.\r\n// 密码用户与 Google 用户最终得到的都是同一种 AuthSession。\r\nawait auth.login(const Credentials(username: user, password: pass));\r\nawait auth.loginWith((strategy) => signInWithGoogle(context));\nYour UI only ever reads state.isAuthenticated, and your network layer only ever\r\nreads auth.validAccessToken() — regardless of how the user signed in.你的界面只需要读 state.isAuthenticated,网络层只需要读 auth.validAccessToken()\r\n——无需关心用户是哪种方式登录的。","also-works-for--同样适用于#Also works for / 同样适用于":"loginWith covers any flow you drive yourself:loginWith 适用于任何由你驱动的流程:\nMagic links / email OTP / 魔法链接、邮箱验证码\nPasskeys / WebAuthn\nBiometric re-unlock (load a locked session and re-mint tokens) / 生物识别解锁\nMigrating from another auth SDK / 从其它认证 SDK 迁移","next-steps--下一步#Next Steps / 下一步":"Backend Strategy — what your strategy must implement / 策略需要实现什么\nAuth State Machine — the states this flow emits / 该流程发出的状态\nErrors — typed exceptions and codes / 类型化异常与 code"}},"/Token-Store":{"title":"Token Store / 令牌存储","data":{"":"TokenStore is the only persistence surface. The core never touches disk, secure storage, or SharedPreferences directly — you decide where tokens live.TokenStore 是唯一的持久化接口。内核绝不直接访问磁盘、安全存储或 SharedPreferences——由你决定令牌存放位置。","the-contract--契约#The contract / 契约":"abstract class TokenStore {\n Future save(AuthSession session);\n Future load();\n Future clear();\n}\nAuthManager calls load() during restore(), save() after every successful login/refresh, and clear() on logout or a failed refresh.AuthManager 在 restore() 时调用 load(),每次登录/刷新成功后调用 save(),在登出或刷新失败失败时调用 clear()。","in-memory-ships-in-core--内存实现内核自带#In-memory (ships in-core) / 内存实现(内核自带)":"InMemoryTokenStore is the default. It survives process restarts? No — it is in-memory only, so the session is lost on restart. Use it for tests and quick prototypes.InMemoryTokenStore 是默认实现,仅存在于内存,进程重启后会话丢失。适用于测试与原型。\nfinal auth = AuthManager(strategy: s, tokenStore: InMemoryTokenStore());","secure-storage-production--安全存储生产#Secure storage (production) / 安全存储(生产)":"example/lib/secure_token_store.dart shows a flutter_secure_storage-backed reference implementation. Adapt it for your app:example/lib/secure_token_store.dart 提供了基于 flutter_secure_storage 的参考实现,可据此改造:\nclass SecureTokenStore implements TokenStore {\n final _box = FlutterSecureStorage();\n @override\n Future save(AuthSession s) =>\n _box.write(key: 'zero_auth', value: jsonEncode(s.toJson()));\n @override\n Future load() async {\n final raw = await _box.read(key: 'zero_auth');\n // tryFromJson: corrupt data means \"not signed in\", not a crash.\n // tryFromJson:数据损坏代表「未登录」,而不是崩溃。\n return AuthSession.tryFromJson(raw == null ? null : jsonDecode(raw));\n }\n @override\n Future clear() => _box.delete(key: 'zero_auth');\n}\nPersist tokens only in OS-backed secure storage (Keychain / Keystore). Never store refresh tokens in plain SharedPreferences.令牌只应存放在操作系统级安全存储(Keychain / Keystore)中,切勿把刷新令牌明文存入 SharedPreferences。","next-steps--下一步#Next Steps / 下一步":"Session Persistence — persist sessions across restarts (serialization + reference stores) / 跨重启持久化会话(序列化与参考存储)\nBackend Strategy — The other boundary / 另一个边界\nErrors — What load() failures become / load() 失败会变成什么"}},"/Usage":{"title":"Usage / 使用指南","data":{"three-step-integration--三步集成#Three-step integration / 三步集成":"","1-implement-authstrategy--实现后端边界#1. Implement AuthStrategy / 实现后端边界":"class MyAuthStrategy implements AuthStrategy {\n @override\n Future login(Credentials credentials) => myApi.login(\n credentials.username, // an email or phone works too\n credentials.password,\n );\n @override\n Future register(RegistrationInput input) =>\n myApi.register(input.username, input.password);\n @override\n Future logout(SessionHandle handle) => myApi.logout(handle.userId);\n @override\n Future refresh(RefreshToken token) =>\n myApi.refresh(token.value);\n}","2-create-authmanager-and-restore--创建管理器并恢复#2. Create AuthManager and restore / 创建管理器并恢复":"final auth = AuthManager(\n strategy: MyAuthStrategy(),\n tokenStore: InMemoryTokenStore(),\n);\nawait auth.restore(); // rehydrate a persisted session / 恢复持久化会话","3-drive-the-ui-from-the-state-stream--用状态流驱动界面#3. Drive the UI from the state stream / 用状态流驱动界面":"await for (final state in auth.state) {\n // render based on state / 根据状态渲染\n}","operations--操作#Operations / 操作":"Method\tDescription\trestore()\tRehydrate the persisted session at startup / 启动时恢复会话\tlogin(username, password)\tAuthenticate and enter Authenticated / 登录并进入已认证\tregister(username, password)\tRegister and authenticate / 注册并认证\tlogout()\tClear the session / 清除会话\trefresh()\tSilent token refresh — concurrent calls share one flight / 静默刷新,并发共享单飞\ttokenSource\tAn AuthTokenSource for HTTP clients / 供 HTTP 客户端使用的令牌源","token-refresh--expiry--令牌刷新与过期#Token refresh & expiry / 令牌刷新与过期":"AuthSession exposes isExpired so callers can refresh proactively. refresh() is single-flight: if several callers request a refresh at once, only one network call is made and its result is shared.AuthSession 提供 isExpired,调用方可主动刷新。refresh() 为单飞:多个调用方同时请求刷新时,只发起一次网络调用并共享结果。 A failed refresh clears the session and emits AuthError.刷新失败会清空会话并发出 AuthError。","attaching-the-bearer-token--附加-bearer-令牌#Attaching the bearer token / 附加 Bearer 令牌":"AuthManager implements AuthTokenSource, so a Dio interceptor can read the current token without depending on the manager. See Network Integration.AuthManager 实现了 AuthTokenSource,Dio 拦截器可在不依赖管理器的情况下读取当前令牌。详见网络集成。","next-steps--下一步#Next Steps / 下一步":"Auth State Machine — State lifecycle details / 状态机细节\nBackend Strategy — Full AuthStrategy contract / 完整后端契约\nErrors — Exception & Result model / 异常与结果模型"}},"/":{"title":"Zero Auth","data":{"":"A backend-agnostic auth state machine & session lifecycle core for Dart/Flutter. Pure Dart, headless, no HTTP/SDK/native code. Wire your own backend via AuthStrategy and your own persistence via TokenStore.一个后端无关的认证状态机与会话生命周期内核,面向 Dart/Flutter。纯 Dart、无头(headless),不含 HTTP、SDK 或原生代码。通过 AuthStrategy 接入自有后端,通过 TokenStore 接入自有持久化层。","-features--功能特性#✨ Features / 功能特性":"Feature\tDescription\tBackend-agnostic\tImplement only login/register/logout/refresh; REST, gRPC, Firebase or a private RPC are equally valid targets / 只需实现四个方法,REST、gRPC、Firebase 或私有 RPC 均可接入\tAuth State Machine\tSealed Unauthenticated / Authenticating / Authenticated / Refreshing / LoggingOut / AuthError on a Stream that replays the latest value to new listeners; use isAuthenticated / isBusy / 密封六态状态机 + 重放最近值的状态流,可用 isAuthenticated / isBusy\tSilent Restore\trestore() rehydrates the persisted session at startup / 启动时静默恢复持久化会话\tSingle-flight Refresh\tConcurrent refresh() calls share one in-flight request instead of stampeding the backend / 并发刷新共享同一次请求,避免冲击后端\tPluggable Persistence\tThe only persistence surface is TokenStore.save/load/clear; InMemoryTokenStore ships in-core / 唯一的持久化接口,内核自带内存实现\tTyped Session\tAuthSession carries access/refresh tokens, expiry (isExpired), user id, display name and raw claims / 强类型会话,无需手工解析令牌\tUnified Errors\tEvery failure maps to AppException (AuthException for auth cases) or a Result wrapper; raw exceptions never cross the public surface / 统一异常与结果,裸异常不越界\tNetwork Integration\tAuthManager itself is an AuthTokenSource, so a Dio interceptor can attach Authorization: Bearer without depending on the manager / 管理器即令牌源,Dio 拦截器零依赖附加令牌\tSession Serialization\tAuthSession.toJson / fromJson make persistence a one-liner, and tryFromJson returns null on malformed data; a file-based reference store ships for server/CLI / AuthSession.toJson / fromJson 让持久化一行搞定,tryFromJson 遇畸形数据返回 null,并附带面向服务端 / CLI 的文件参考存储\tProactive Auto-refresh\tPass autoRefreshAhead to renew tokens before expiry (single-flight) / 传入 autoRefreshAhead 在过期前自动续期(单飞)\tNever an Expired Token\tvalidAccessToken() renews first when the token has expired — or is about to, per clockSkew — so interceptors never send a dead bearer token / 令牌过期(或按 clockSkew 即将过期)时先续期,拦截器不会发出失效令牌\tBring Your Own Login\tloginWith adopts a session from any flow you drive: third-party OAuth, magic links, passkeys / loginWith 可接纳第三方 OAuth、魔法链接、Passkey 等自定义流程\tTyped Auth Exceptions\tInvalidCredentialsException, SessionExpiredException and friends, mapped from your strategy's code / InvalidCredentialsException、SessionExpiredException 等,由策略的 code 映射而来\tConfigurable Failure Policy\trefreshFailurePolicy decides whether a failed refresh signs the user out / refreshFailurePolicy 决定刷新失败是否登出\tRunnable Example\tA full Flutter demo app (Android/iOS/Web/Windows) plus a layered dart:io demo backend that issues real JWTs / 完整 Flutter 示例与一个签发真实 JWT 的分层演示后端\tMultiple Accounts\tOptional AuthManagerGroup keeps one manager per account / 可选 AuthManagerGroup,每账号一个管理器\tCross-platform\tPure Dart — runs anywhere Dart or Flutter runs / 纯 Dart,跨平台","-table-of-contents--目录#📚 Table of Contents / 目录":"Page\tDescription\tGetting Started\tQuick start guide / 快速开始\tInstallation\tHow to install / 安装方式\tUsage\tDetailed usage / 详细使用\tAuth State Machine\tState lifecycle & stream / 状态机与状态流\tBackend Strategy\tImplement AuthStrategy / 实现后端边界\tToken Store\tPersistence boundary / 持久化边界\tNetwork Integration\tDio interceptor & token source / 网络集成与拦截器\tErrors\tException & Result model / 异常与结果模型\tConfiguration\tConfiguration options / 配置说明\tSession Persistence\tRestoring and renewing a saved session / 会话持久化与恢复\tThird-Party Login\tOAuth / magic links via loginWith / 用 loginWith 接入第三方登录\tMultiple Accounts\tSwitching vs concurrent accounts / 账号切换与多账号并存\tFAQ\tFrequently asked questions / 常见问题","-links--链接#🔗 Links / 链接":"GitHub\nOfficial Website\npub.dev","-license--许可证#📄 License / 许可证":"This project is licensed under the Mozilla Public License 2.0 (MPL-2.0).本项目采用 Mozilla Public License 2.0 (MPL-2.0) 许可证。This package is provided \"as is\", without warranty of any kind. The author assumes no responsibility or liability for the functionality, security, or any consequences arising from the use of modified versions or derivative projects.本包按\"原样\"提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。"}},"/FAQ":{"title":"FAQ / 常见问题","data":{"does-zero_auth-ship-an-http-client--zero_auth-内置-http-客户端吗#Does zero_auth ship an HTTP client? / zero_auth 内置 HTTP 客户端吗?":"No. The core is backend-agnostic and ships no HTTP, SDK or native code. You implement AuthStrategy to call your backend.不。内核后端无关,不含任何 HTTP、SDK 或原生代码。你通过实现 AuthStrategy 来调用自己的后端。","does-it-depend-on-flutter--它依赖-flutter-吗#Does it depend on Flutter? / 它依赖 Flutter 吗?":"No. It is a pure-Dart package; flutter: is only a declared constraint because the example app uses Flutter. The runtime has no Flutter dependency.不。它是纯 Dart 包;仅因示例 App 用到 Flutter 才声明 flutter: 约束,运行时不依赖 Flutter。","where-are-tokens-stored--令牌存在哪里#Where are tokens stored? / 令牌存在哪里?":"Wherever your TokenStore puts them. The core ships InMemoryTokenStore (lost on restart). For production, use the flutter_secure_storage-backed reference in example/lib/secure_token_store.dart.取决于你的 TokenStore。内核自带 InMemoryTokenStore(重启即丢失)。生产请用 example/lib/secure_token_store.dart 中基于 flutter_secure_storage 的参考实现。","what-happens-on-a-failed-refresh--刷新失败会怎样#What happens on a failed refresh? / 刷新失败会怎样?":"The failure is always reported as AuthError and rethrown. What happens next\ndepends on refreshFailurePolicy: an unrecoverable failure (SessionExpiredException,\nInvalidCredentialsException) clears the store and lands on Unauthenticated,\nwhile a transient one (network, 5xx) keeps the previous session so a retry can\nsucceed. See Configuration.失败总会以 AuthError 上报并重新抛出。之后如何取决于 refreshFailurePolicy:\n不可恢复的失败(SessionExpiredException、InvalidCredentialsException)会清空存储\n并落到 Unauthenticated;瞬时故障(网络、5xx)保留上一个会话以便重试成功。参见\n配置。","is-token-refresh-concurrent-safe--令牌刷新是否并发安全#Is token refresh concurrent-safe? / 令牌刷新是否并发安全?":"Yes — refresh() is single-flight: many concurrent callers share one in-flight request and its result.是的——refresh() 为单飞:多个并发调用方共享同一次请求及其结果。","how-do-i-attach-the-bearer-token-to-requests--如何给请求附加-bearer-令牌#How do I attach the bearer token to requests? / 如何给请求附加 Bearer 令牌?":"AuthManager is an AuthTokenSource. Use the Dio interceptor in example/lib/dio_interceptor.dart, or read accessToken directly — but prefer validAccessToken() (or RefreshingAuthInterceptor) whenever the token reaches a server, since accessToken may already be expired.AuthManager 即 AuthTokenSource。可用 example/lib/dio_interceptor.dart 中的 Dio 拦截器,或直接读取 accessToken —— 但只要令牌要发到服务端,就请优先用 validAccessToken()(或 RefreshingAuthInterceptor),因为 accessToken 可能已经过期。","does-it-sync-sessions-across-devices--它会在多设备间同步会话吗#Does it sync sessions across devices? / 它会在多设备间同步会话吗?":"No — and by design. A session is local state plus whatever your backend decides;\npushing \"signed out elsewhere\" to a device needs server push (or polling), which\nis outside a headless state machine. Model it with your backend revoking the\ngrant: the next refresh fails, refreshFailurePolicy signs the device out, and\nAuthError explains why.不会 —— 这是有意的设计。会话是本地状态加上你后端的决定;把「在别处已登出」推送到某台\n设备需要服务端推送(或轮询),那超出了无头状态机的职责。可由后端吊销授权来实现:下一次\n刷新失败,refreshFailurePolicy 会让该设备登出,并由 AuthError 说明原因。","which-platforms-are-supported--支持哪些平台#Which platforms are supported? / 支持哪些平台?":"Any platform where Dart or Flutter runs (Android, iOS, Web, Windows, macOS, Linux).任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。"}},"/Installation":{"title":"Installation / 安装","data":{"from-pubdev-recommended--从-pubdev-安装推荐#From pub.dev (Recommended) / 从 pub.dev 安装(推荐)":"Add the following to your pubspec.yaml:在 pubspec.yaml 中添加以下依赖:\ndependencies:\n zero_auth: ^1.0.0\nThen run:然后运行:\ndart pub get","from-github--从-github-安装#From GitHub / 从 GitHub 安装":"Alternatively, install from GitHub:或者从 GitHub 安装:\ndependencies:\n zero_auth:\n git:\n url: https://github.com/zero-labsco/zero_auth.git\n ref: release/v1.0.0","import--导入#Import / 导入":"import 'package:zero_auth/zero_auth.dart';","requirements--环境要求#Requirements / 环境要求":"Requirement\tVersion\tDart SDK\t>= 3.4.0\tFlutter\t>= 3.0.0 (only a constraint; no Flutter runtime dependency)\t\nzero_auth is a pure-Dart package. Flutter is only listed because the example app uses it; the core has no Flutter dependency.zero_auth 是纯 Dart 包,仅因示例 App 用到 Flutter 才列出;内核本身不依赖 Flutter。","next-steps--下一步#Next Steps / 下一步":"Getting Started — Quick start guide / 快速开始\nUsage — Full usage guide / 完整使用指南"}}} \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Auth-State-Machine-482df521c021e389.js b/docs/_next/static/chunks/pages/Auth-State-Machine-482df521c021e389.js deleted file mode 100644 index bdb2d6d..0000000 --- a/docs/_next/static/chunks/pages/Auth-State-Machine-482df521c021e389.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[207],{4778:function(e,n,s){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Auth-State-Machine",function(){return s(6503)}])},6503:function(e,n,s){"use strict";s.r(n),s.d(n,{useTOC:function(){return h}});var i=s(5893),t=s(7812),r=s(7080),d=s(8925);function h(e){return[{value:"States / 状态",id:"states--状态",depth:2},{value:"The stream / 状态流",id:"the-stream--状态流",depth:2},{value:"Transitions / 状态转移",id:"transitions--状态转移",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}n.default=(0,t.c)(function(e){let{toc:n=h(e)}=e,s={a:"a",code:"code",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,d.a)(),...e.components};return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)(s.h1,{children:"Auth State Machine / 认证状态机"}),"\n",(0,i.jsx)(s.h2,{id:n[0].id,children:n[0].value}),"\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"AuthState"})," is a sealed class with six subtypes:"]}),"\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"AuthState"})," 是一个密封类,包含六个子类型:"]}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,i.jsxs)(s.table,{children:[(0,i.jsx)(s.thead,{children:(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.th,{children:"State"}),(0,i.jsx)(s.th,{children:"Fields"}),(0,i.jsx)(s.th,{children:"Meaning"})]})}),(0,i.jsxs)(s.tbody,{children:[(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"Unauthenticated"})}),(0,i.jsx)(s.td,{children:"—"}),(0,i.jsx)(s.td,{children:"No session / 无会话"})]}),(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"Authenticating"})}),(0,i.jsx)(s.td,{children:"—"}),(0,i.jsxs)(s.td,{children:[(0,i.jsx)(s.code,{children:"login"}),"/",(0,i.jsx)(s.code,{children:"register"})," in flight / 登录/注册进行中"]})]}),(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"Authenticated"})}),(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"session: AuthSession"})}),(0,i.jsx)(s.td,{children:"A valid session / 有效会话"})]}),(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"Refreshing"})}),(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"session: AuthSession"})}),(0,i.jsx)(s.td,{children:"Renewal in flight; the previous session is still usable / 续期进行中,旧会话仍可用"})]}),(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"LoggingOut"})}),(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"session: AuthSession"})}),(0,i.jsxs)(s.td,{children:[(0,i.jsx)(s.code,{children:"logout()"})," in flight; that session is being discarded / 登出进行中,该会话即将被丢弃"]})]}),(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"AuthError"})}),(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"error: AppException"})}),(0,i.jsx)(s.td,{children:"Last operation failed / 上一次操作失败"})]})]})]}),"\n",(0,i.jsx)(s.p,{children:"Two helpers save you from spelling out every case:"}),"\n",(0,i.jsx)(s.p,{children:"两个辅助属性可避免你手写全部分支:"}),"\n",(0,i.jsxs)(s.ul,{children:["\n",(0,i.jsxs)(s.li,{children:[(0,i.jsx)(s.code,{children:"state.isAuthenticated"})," — ",(0,i.jsx)(s.code,{children:"true"})," for ",(0,i.jsx)(s.code,{children:"Authenticated"})," ",(0,i.jsx)(s.strong,{children:"and"})," ",(0,i.jsx)(s.code,{children:"Refreshing"}),", so a\nrenewal never unmounts your signed-in UI. / 在 ",(0,i.jsx)(s.code,{children:"Authenticated"})," 与 ",(0,i.jsx)(s.code,{children:"Refreshing"}),"\n下均为 ",(0,i.jsx)(s.code,{children:"true"}),",续期不会卸载已登录界面。"]}),"\n",(0,i.jsxs)(s.li,{children:[(0,i.jsx)(s.code,{children:"state.isBusy"})," — ",(0,i.jsx)(s.code,{children:"true"})," while ",(0,i.jsx)(s.code,{children:"Authenticating"}),", ",(0,i.jsx)(s.code,{children:"Refreshing"})," or ",(0,i.jsx)(s.code,{children:"LoggingOut"}),". /\n在 ",(0,i.jsx)(s.code,{children:"Authenticating"}),"、",(0,i.jsx)(s.code,{children:"Refreshing"}),"、",(0,i.jsx)(s.code,{children:"LoggingOut"})," 期间为 ",(0,i.jsx)(s.code,{children:"true"}),"。"]}),"\n"]}),"\n",(0,i.jsx)(s.h2,{id:n[1].id,children:n[1].value}),"\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"AuthManager.state"})," is a ",(0,i.jsx)(s.code,{children:"Stream"})," that ",(0,i.jsx)(s.strong,{children:"replays the latest value"})," to every new listener (",(0,i.jsx)(s.code,{children:"BehaviorSubject"}),"-like). This means:"]}),"\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"AuthManager.state"})," 是一个 ",(0,i.jsx)(s.code,{children:"Stream"}),",会向每个新订阅者",(0,i.jsx)(s.strong,{children:"重放最近的值"}),"(类似于 ",(0,i.jsx)(s.code,{children:"BehaviorSubject"}),")。这意味着:"]}),"\n",(0,i.jsxs)(s.ul,{children:["\n",(0,i.jsx)(s.li,{children:"A freshly mounted widget renders the correct screen on its first frame / 新挂载的 widget 首帧即渲染正确界面。"}),"\n",(0,i.jsx)(s.li,{children:"You never need to read a separate “current state” field / 无需另读一个”当前状态”字段。"}),"\n"]}),"\n",(0,i.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,i.jsxs)(s.code,{children:[(0,i.jsxs)(s.span,{children:[(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"auth.state."}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"listen"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"((state) {"})]}),"\n",(0,i.jsxs)(s.span,{children:[(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" switch"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (state) {"})]}),"\n",(0,i.jsxs)(s.span,{children:[(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" case"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Authenticated"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":final"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" session)"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"})]}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" /* ... */"})}),"\n",(0,i.jsxs)(s.span,{children:[(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" case"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthError"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":final"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" error)"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"})]}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" /* ... */"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" default:"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" }"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"});"})})]})}),"\n",(0,i.jsx)(s.h2,{id:n[2].id,children:n[2].value}),"\n",(0,i.jsx)(s.pre,{tabIndex:"0","data-language":"plaintext","data-word-wrap":"","data-copy":"",children:(0,i.jsxs)(s.code,{children:[(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" login/register ──► Authenticating ──► Authenticated"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" │ ▲"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" refresh │ │ success"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" ─────────► ▼ │"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" Refreshing"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" │"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" failure │"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" ▼"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" AuthError ──policy: sign out──► Unauthenticated"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" └──policy: keep session─► Authenticated (old session)"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" Authenticated ──logout──► LoggingOut ──► Unauthenticated"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" Unauthenticated ──restore() finds nothing / nothing persisted──► Unauthenticated"})})]})}),"\n",(0,i.jsxs)(s.ul,{children:["\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"restore()"})," restores a live session → ",(0,i.jsx)(s.code,{children:"Authenticated"}),"."]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"restore()"})," restores an ",(0,i.jsx)(s.strong,{children:"expired"})," session → attempts ",(0,i.jsx)(s.code,{children:"Refreshing"}),"; if it\ncannot be renewed (or there is no refresh token) the store is cleared and it\nlands on ",(0,i.jsx)(s.code,{children:"Unauthenticated"}),". ",(0,i.jsx)(s.code,{children:"restore(refreshIfExpired: false)"})," restores it\nverbatim instead."]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:["A failed ",(0,i.jsx)(s.code,{children:"login"}),"/",(0,i.jsx)(s.code,{children:"register"})," → ",(0,i.jsx)(s.code,{children:"AuthError"}),", and the future rethrows."]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:["A failed ",(0,i.jsx)(s.code,{children:"refresh"})," → ",(0,i.jsx)(s.code,{children:"AuthError"}),", followed by ",(0,i.jsx)(s.code,{children:"Unauthenticated"})," when the\n",(0,i.jsx)(s.a,{href:"#refresh-failure-policy",children:"failure policy"})," considers the grant unrecoverable,\nor by ",(0,i.jsx)(s.code,{children:"Authenticated"})," (previous session) when it looks transient."]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsx)(s.p,{children:"Duplicate consecutive emissions are suppressed, so listeners only rebuild on a\nreal change."}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"restore()"})," 恢复未过期会话 → ",(0,i.jsx)(s.code,{children:"Authenticated"}),"。"]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"restore()"})," 恢复",(0,i.jsx)(s.strong,{children:"已过期"}),"会话 → 先走 ",(0,i.jsx)(s.code,{children:"Refreshing"}),";若无法续期(或没有刷新令牌),\n则清空存储并落到 ",(0,i.jsx)(s.code,{children:"Unauthenticated"}),"。",(0,i.jsx)(s.code,{children:"restore(refreshIfExpired: false)"})," 则原样恢复。"]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"login"}),"/",(0,i.jsx)(s.code,{children:"register"})," 失败 → ",(0,i.jsx)(s.code,{children:"AuthError"}),",同时 future 再次抛出错误。"]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"refresh"})," 失败 → ",(0,i.jsx)(s.code,{children:"AuthError"}),";随后由",(0,i.jsx)(s.a,{href:"#refresh-failure-policy",children:"失败策略"}),"决定:\n认为授权不可恢复则转 ",(0,i.jsx)(s.code,{children:"Unauthenticated"}),",认为只是瞬时故障则回到 ",(0,i.jsx)(s.code,{children:"Authenticated"}),"。"]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsx)(s.p,{children:"连续重复的状态会被抑制,监听器只会在真正变化时重建。"}),"\n"]}),"\n"]}),"\n",(0,i.jsx)(s.h2,{id:n[3].id,children:n[3].value}),"\n",(0,i.jsxs)(s.ul,{children:["\n",(0,i.jsxs)(s.li,{children:[(0,i.jsx)(s.a,{href:"Usage",children:"Usage"})," — How to drive the UI from the stream / 如何用状态流驱动界面"]}),"\n",(0,i.jsxs)(s.li,{children:[(0,i.jsx)(s.a,{href:"Backend-Strategy",children:"Backend Strategy"})," — What each operation calls / 各操作调用什么"]}),"\n"]})]})},"/Auth-State-Machine",{filePath:"pages/Auth-State-Machine.md",timestamp:178966094e4,pageMap:r.v,frontMatter:{},title:"Auth State Machine / 认证状态机"},"undefined"==typeof RemoteContent?h:RemoteContent.useTOC)},7080:function(e,n,s){"use strict";s.d(n,{v:function(){return i}});let i=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(e){e.O(0,[812,888,774,179],function(){return e(e.s=4778)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Auth-State-Machine-df4af2f2f62580ee.js b/docs/_next/static/chunks/pages/Auth-State-Machine-df4af2f2f62580ee.js new file mode 100644 index 0000000..9c7f46f --- /dev/null +++ b/docs/_next/static/chunks/pages/Auth-State-Machine-df4af2f2f62580ee.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[207],{4778:function(e,n,s){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Auth-State-Machine",function(){return s(6503)}])},6503:function(e,n,s){"use strict";s.r(n),s.d(n,{useTOC:function(){return h}});var i=s(5893),t=s(7812),r=s(7080),d=s(8925);function h(e){return[{value:"States / 状态",id:"states--状态",depth:2},{value:"The stream / 状态流",id:"the-stream--状态流",depth:2},{value:"Transitions / 状态转移",id:"transitions--状态转移",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}n.default=(0,t.c)(function(e){let{toc:n=h(e)}=e,s={a:"a",code:"code",em:"em",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,d.a)(),...e.components};return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)(s.h1,{children:"Auth State Machine / 认证状态机"}),"\n",(0,i.jsx)(s.h2,{id:n[0].id,children:n[0].value}),"\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"AuthState"})," is a sealed class with six subtypes:"]}),"\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"AuthState"})," 是一个密封类,包含六个子类型:"]}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,i.jsxs)(s.table,{children:[(0,i.jsx)(s.thead,{children:(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.th,{children:"State"}),(0,i.jsx)(s.th,{children:"Fields"}),(0,i.jsx)(s.th,{children:"Meaning"})]})}),(0,i.jsxs)(s.tbody,{children:[(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"Unauthenticated"})}),(0,i.jsx)(s.td,{children:"—"}),(0,i.jsx)(s.td,{children:"No session / 无会话"})]}),(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"Authenticating"})}),(0,i.jsx)(s.td,{children:"—"}),(0,i.jsxs)(s.td,{children:[(0,i.jsx)(s.code,{children:"login"}),"/",(0,i.jsx)(s.code,{children:"register"})," in flight / 登录/注册进行中"]})]}),(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"Authenticated"})}),(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"session: AuthSession"})}),(0,i.jsx)(s.td,{children:"A valid session / 有效会话"})]}),(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"Refreshing"})}),(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"session: AuthSession"})}),(0,i.jsx)(s.td,{children:"Renewal in flight; the previous session is still usable / 续期进行中,旧会话仍可用"})]}),(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"LoggingOut"})}),(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"session: AuthSession"})}),(0,i.jsxs)(s.td,{children:[(0,i.jsx)(s.code,{children:"logout()"})," in flight; that session is being discarded / 登出进行中,该会话即将被丢弃"]})]}),(0,i.jsxs)(s.tr,{children:[(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"AuthError"})}),(0,i.jsx)(s.td,{children:(0,i.jsx)(s.code,{children:"error: AppException"})}),(0,i.jsx)(s.td,{children:"Last operation failed / 上一次操作失败"})]})]})]}),"\n",(0,i.jsx)(s.p,{children:"Two helpers save you from spelling out every case:"}),"\n",(0,i.jsx)(s.p,{children:"两个辅助属性可避免你手写全部分支:"}),"\n",(0,i.jsxs)(s.ul,{children:["\n",(0,i.jsxs)(s.li,{children:[(0,i.jsx)(s.code,{children:"state.isAuthenticated"})," — ",(0,i.jsx)(s.code,{children:"true"})," for ",(0,i.jsx)(s.code,{children:"Authenticated"})," ",(0,i.jsx)(s.strong,{children:"and"})," ",(0,i.jsx)(s.code,{children:"Refreshing"}),", so a\nrenewal never unmounts your signed-in UI. / 在 ",(0,i.jsx)(s.code,{children:"Authenticated"})," 与 ",(0,i.jsx)(s.code,{children:"Refreshing"}),"\n下均为 ",(0,i.jsx)(s.code,{children:"true"}),",续期不会卸载已登录界面。"]}),"\n",(0,i.jsxs)(s.li,{children:[(0,i.jsx)(s.code,{children:"state.isBusy"})," — ",(0,i.jsx)(s.code,{children:"true"})," while ",(0,i.jsx)(s.code,{children:"Authenticating"}),", ",(0,i.jsx)(s.code,{children:"Refreshing"})," or ",(0,i.jsx)(s.code,{children:"LoggingOut"}),". /\n在 ",(0,i.jsx)(s.code,{children:"Authenticating"}),"、",(0,i.jsx)(s.code,{children:"Refreshing"}),"、",(0,i.jsx)(s.code,{children:"LoggingOut"})," 期间为 ",(0,i.jsx)(s.code,{children:"true"}),"。"]}),"\n",(0,i.jsxs)(s.li,{children:[(0,i.jsx)(s.code,{children:"state.session"})," — the session the state carries (",(0,i.jsx)(s.code,{children:"Authenticated"})," / ",(0,i.jsx)(s.code,{children:"Refreshing"})," /\n",(0,i.jsx)(s.code,{children:"LoggingOut"}),"), or ",(0,i.jsx)(s.code,{children:"null"}),"; no pattern-matching needed for the common case. /\n该状态携带的会话(",(0,i.jsx)(s.code,{children:"Authenticated"})," / ",(0,i.jsx)(s.code,{children:"Refreshing"})," / ",(0,i.jsx)(s.code,{children:"LoggingOut"}),")或 ",(0,i.jsx)(s.code,{children:"null"}),",\n常见场景无需再做模式匹配。"]}),"\n"]}),"\n",(0,i.jsx)(s.h2,{id:n[1].id,children:n[1].value}),"\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"AuthManager.state"})," is a ",(0,i.jsx)(s.code,{children:"Stream"})," that ",(0,i.jsx)(s.strong,{children:"replays the latest value"})," to every new listener (",(0,i.jsx)(s.code,{children:"BehaviorSubject"}),"-like). This means:"]}),"\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"AuthManager.state"})," 是一个 ",(0,i.jsx)(s.code,{children:"Stream"}),",会向每个新订阅者",(0,i.jsx)(s.strong,{children:"重放最近的值"}),"(类似于 ",(0,i.jsx)(s.code,{children:"BehaviorSubject"}),")。这意味着:"]}),"\n",(0,i.jsxs)(s.ul,{children:["\n",(0,i.jsx)(s.li,{children:"A freshly mounted widget renders the correct screen on its first frame / 新挂载的 widget 首帧即渲染正确界面。"}),"\n",(0,i.jsx)(s.li,{children:"You never need to read a separate “current state” field / 无需另读一个”当前状态”字段。"}),"\n"]}),"\n",(0,i.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,i.jsxs)(s.code,{children:[(0,i.jsxs)(s.span,{children:[(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"auth.state."}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"listen"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"((state) {"})]}),"\n",(0,i.jsxs)(s.span,{children:[(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" switch"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (state) {"})]}),"\n",(0,i.jsxs)(s.span,{children:[(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" case"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Authenticated"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":final"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" session)"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"})]}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" /* ... */"})}),"\n",(0,i.jsxs)(s.span,{children:[(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" case"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthError"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":final"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" error)"}),(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"})]}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" /* ... */"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" default:"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" }"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"});"})})]})}),"\n",(0,i.jsx)(s.h2,{id:n[2].id,children:n[2].value}),"\n",(0,i.jsx)(s.pre,{tabIndex:"0","data-language":"plaintext","data-word-wrap":"","data-copy":"",children:(0,i.jsxs)(s.code,{children:[(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" login/register ──► Authenticating ──► Authenticated"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" │ ▲"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" refresh │ │ success"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" ─────────► ▼ │"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" Refreshing"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" │"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" failure │"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" ▼"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" AuthError ──policy: sign out──► Unauthenticated"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" └──policy: keep session─► Authenticated (old session)"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" Authenticated ──logout──► LoggingOut ──► Unauthenticated"})}),"\n",(0,i.jsx)(s.span,{children:(0,i.jsx)(s.span,{children:" Unauthenticated ──restore() finds nothing / nothing persisted──► Unauthenticated"})})]})}),"\n",(0,i.jsxs)(s.ul,{children:["\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"restore()"})," restores a live session → ",(0,i.jsx)(s.code,{children:"Authenticated"}),"."]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"restore()"})," restores an ",(0,i.jsx)(s.strong,{children:"expired"})," session → attempts ",(0,i.jsx)(s.code,{children:"Refreshing"}),"; if the\nrenewal fails ",(0,i.jsx)(s.em,{children:"transiently"})," (network, 5xx) the persisted session is ",(0,i.jsx)(s.strong,{children:"kept"})," and\nactivated as-is, so the next request can retry. Only a terminal failure — or the\nabsence of a refresh token — clears the store and lands on ",(0,i.jsx)(s.code,{children:"Unauthenticated"}),".\n",(0,i.jsx)(s.code,{children:"restore(refreshIfExpired: false)"})," restores it verbatim instead."]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:["A failed ",(0,i.jsx)(s.code,{children:"login"}),"/",(0,i.jsx)(s.code,{children:"register"})," → ",(0,i.jsx)(s.code,{children:"AuthError"}),", and the future rethrows."]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:["A failed ",(0,i.jsx)(s.code,{children:"refresh"})," → ",(0,i.jsx)(s.code,{children:"AuthError"}),", followed by ",(0,i.jsx)(s.code,{children:"Unauthenticated"})," when the\n",(0,i.jsx)(s.a,{href:"#refresh-failure-policy",children:"failure policy"})," considers the grant unrecoverable,\nor by ",(0,i.jsx)(s.code,{children:"Authenticated"})," (previous session) when it looks transient."]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsx)(s.p,{children:"Duplicate consecutive emissions are suppressed, so listeners only rebuild on a\nreal change."}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"restore()"})," 恢复未过期会话 → ",(0,i.jsx)(s.code,{children:"Authenticated"}),"。"]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"restore()"})," 恢复",(0,i.jsx)(s.strong,{children:"已过期"}),"会话 → 先走 ",(0,i.jsx)(s.code,{children:"Refreshing"}),";若续期",(0,i.jsx)(s.strong,{children:"瞬时"}),"失败(网络、5xx),\n则",(0,i.jsx)(s.strong,{children:"保留"}),"持久化会话并按原样激活,下次请求可再试。只有终局失败(或没有刷新令牌)\n才会清空存储并落到 ",(0,i.jsx)(s.code,{children:"Unauthenticated"}),"。",(0,i.jsx)(s.code,{children:"restore(refreshIfExpired: false)"})," 则原样恢复。"]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"login"}),"/",(0,i.jsx)(s.code,{children:"register"})," 失败 → ",(0,i.jsx)(s.code,{children:"AuthError"}),",同时 future 再次抛出错误。"]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsxs)(s.p,{children:[(0,i.jsx)(s.code,{children:"refresh"})," 失败 → ",(0,i.jsx)(s.code,{children:"AuthError"}),";随后由",(0,i.jsx)(s.a,{href:"#refresh-failure-policy",children:"失败策略"}),"决定:\n认为授权不可恢复则转 ",(0,i.jsx)(s.code,{children:"Unauthenticated"}),",认为只是瞬时故障则回到 ",(0,i.jsx)(s.code,{children:"Authenticated"}),"。"]}),"\n"]}),"\n",(0,i.jsxs)(s.li,{children:["\n",(0,i.jsx)(s.p,{children:"连续重复的状态会被抑制,监听器只会在真正变化时重建。"}),"\n"]}),"\n"]}),"\n",(0,i.jsx)(s.h2,{id:n[3].id,children:n[3].value}),"\n",(0,i.jsxs)(s.ul,{children:["\n",(0,i.jsxs)(s.li,{children:[(0,i.jsx)(s.a,{href:"Usage",children:"Usage"})," — How to drive the UI from the stream / 如何用状态流驱动界面"]}),"\n",(0,i.jsxs)(s.li,{children:[(0,i.jsx)(s.a,{href:"Backend-Strategy",children:"Backend Strategy"})," — What each operation calls / 各操作调用什么"]}),"\n"]})]})},"/Auth-State-Machine",{filePath:"pages/Auth-State-Machine.md",timestamp:178966094e4,pageMap:r.v,frontMatter:{},title:"Auth State Machine / 认证状态机"},"undefined"==typeof RemoteContent?h:RemoteContent.useTOC)},7080:function(e,n,s){"use strict";s.d(n,{v:function(){return i}});let i=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(e){e.O(0,[812,888,774,179],function(){return e(e.s=4778)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Configuration-6e0bc428c276681c.js b/docs/_next/static/chunks/pages/Configuration-6e0bc428c276681c.js new file mode 100644 index 0000000..2cb17b1 --- /dev/null +++ b/docs/_next/static/chunks/pages/Configuration-6e0bc428c276681c.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[717],{9321:function(e,i,s){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Configuration",function(){return s(3223)}])},3223:function(e,i,s){"use strict";s.r(i),s.d(i,{useTOC:function(){return l}});var n=s(5893),r=s(7812),t=s(7080),h=s(8925);function l(e){let i={code:"code",...(0,h.a)()};return[{value:(0,n.jsxs)(n.Fragment,{children:["Composing ",(0,n.jsx)(i.code,{children:"AuthManager"})," / 组合管理器"]}),id:"composing-authmanager--组合管理器",depth:2},{value:"Refresh strategy / 刷新策略",id:"refresh-strategy--刷新策略",depth:2},{value:"Refresh failure policy / 刷新失败策略",id:"refresh-failure-policy--刷新失败策略",depth:2},{value:"No global singletons / 没有全局单例",id:"no-global-singletons--没有全局单例",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}i.default=(0,r.c)(function(e){let{toc:i=l(e)}=e,s={a:"a",code:"code",em:"em",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,h.a)(),...e.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(s.h1,{children:"Configuration / 配置"}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"zero_auth"})," is deliberately minimal: there is no global config object. Behavior is composed from the two boundaries you pass to ",(0,n.jsx)(s.code,{children:"AuthManager"}),"."]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"zero_auth"})," 刻意保持精简:没有全局配置对象。行为由你传给 ",(0,n.jsx)(s.code,{children:"AuthManager"})," 的两个边界组合而成。"]}),"\n",(0,n.jsx)(s.h2,{id:i[0].id,children:i[0].value}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManager"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategy"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" MyAuthStrategy"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// how to talk to the backend / 如何与后端通信"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" tokenStore"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" SecureTokenStore"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// where to persist tokens / 令牌持久化位置"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" autoRefreshAhead"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Duration"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(minutes"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" 5"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// proactive renewal / 主动续期"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" autoRefreshRetryDelay"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Duration"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(seconds"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" 30"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// re-arm after a failed renewal / 续期失败后重新排程"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" autoRefreshMaxRetries"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" 3"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:", "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// cap those retries / 重试上限"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" autoRefreshMinInterval"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Duration"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(seconds"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" 5"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// floor for an already-due renewal / 已到期续期的下限"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" refreshFailurePolicy"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" defaultRefreshFailurePolicy, "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// sign-out rule / 登出规则"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" clock"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" () "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" DateTime"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"."}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"now"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// time source (tests, skew) / 时间源(测试、时钟偏移)"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" clockSkew"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Duration"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(seconds"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" 30"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// treat tokens as expired this much earlier / 提前多久视为过期"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" preserveSessionDetails"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" true"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:", "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// keep identity across a tokens-only renewal / 只换令牌的续期保留身份"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" onStateChanged"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (state) "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:" debugPrint"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"$"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"state"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// observe every emission / 观察每次状态"})]}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})})]})}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(s.table,{children:[(0,n.jsx)(s.thead,{children:(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.th,{children:"Knob"}),(0,n.jsx)(s.th,{children:"Where"}),(0,n.jsx)(s.th,{children:"Effect"})]})}),(0,n.jsxs)(s.tbody,{children:[(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Backend endpoints & auth scheme"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"AuthStrategy"})}),(0,n.jsxs)(s.td,{children:["What ",(0,n.jsx)(s.code,{children:"login"}),"/",(0,n.jsx)(s.code,{children:"refresh"}),"/… actually do / ",(0,n.jsx)(s.code,{children:"login"}),"/",(0,n.jsx)(s.code,{children:"refresh"})," 等的实际行为"]})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Token persistence"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"TokenStore"})}),(0,n.jsx)(s.td,{children:"Disk / secure storage / in-memory / 磁盘/安全存储/内存"})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Refresh timing"}),(0,n.jsxs)(s.td,{children:[(0,n.jsx)(s.code,{children:"AuthSession.expiresAt"})," + ",(0,n.jsx)(s.code,{children:"autoRefreshAhead"})]}),(0,n.jsx)(s.td,{children:"Proactive renewal is scheduled that far before expiry / 在过期前该时长调度主动续期"})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Renewal retry"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"autoRefreshRetryDelay"})}),(0,n.jsxs)(s.td,{children:["After a ",(0,n.jsx)(s.em,{children:"proactive"})," renewal fails, it is re-armed this much later (default 30s) while a session still exists / 主动续期失败后按此时长重新排程(默认 30 秒),会话仍在才重试"]})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Renewal retry cap"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"autoRefreshMaxRetries"})}),(0,n.jsx)(s.td,{children:"How many failed proactive renewals to retry before giving up (default 3) / 主动续期失败最多重试几次(默认 3)"})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Renewal floor"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"autoRefreshMinInterval"})}),(0,n.jsx)(s.td,{children:"Minimum wait for a renewal that is already due (default 5s), so very short-lived tokens cannot cause a tight loop / 「已到期」续期的最小等待(默认 5 秒),极短寿命令牌不会造成紧密循环"})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Clock skew"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"clockSkew"})}),(0,n.jsxs)(s.td,{children:["How much earlier a token counts as expired (default 30s); absorbs a device clock that runs ahead and the latency of the request itself. ",(0,n.jsx)(s.strong,{children:"If your access tokens live under a minute, lower it"})," (e.g. 5s) or almost every read renews first / 提前多久把令牌视为过期(默认 30 秒),用于吸收设备时钟偏快与请求自身的延迟。",(0,n.jsx)(s.strong,{children:"若访问令牌寿命不足一分钟,请调小"}),"(如 5 秒),否则几乎每次读取都会先续期"]})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Identity on renewal"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"preserveSessionDetails"})}),(0,n.jsxs)(s.td,{children:["Carry ",(0,n.jsx)(s.code,{children:"userId"})," / ",(0,n.jsx)(s.code,{children:"displayName"})," / ",(0,n.jsx)(s.code,{children:"claims"})," over a renewal that returns tokens only (default ",(0,n.jsx)(s.code,{children:"true"}),") / 只返回令牌的续期是否保留身份字段(默认 ",(0,n.jsx)(s.code,{children:"true"}),")"]})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Observation"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"onStateChanged"})}),(0,n.jsx)(s.td,{children:"Optional callback for every emitted state, for logging or analytics / 可选回调,每次发出状态时触发,便于日志或埋点"})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Refresh failure handling"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"refreshFailurePolicy"})}),(0,n.jsx)(s.td,{children:"Whether a failed refresh signs the user out / 刷新失败是否让用户登出"})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Time source"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"clock"})}),(0,n.jsx)(s.td,{children:"Drives expiry maths and proactive scheduling; inject one for deterministic tests or to tolerate device clock skew / 驱动过期计算与主动刷新调度;注入时钟可实现确定性测试或容忍设备时钟偏移"})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Token injection"}),(0,n.jsxs)(s.td,{children:[(0,n.jsx)(s.code,{children:"AuthTokenSource"})," / ",(0,n.jsx)(s.code,{children:"validAccessToken()"})]}),(0,n.jsx)(s.td,{children:"How the bearer token reaches HTTP clients, optionally renewing an expired token first / 令牌如何到达 HTTP 客户端,必要时先续期过期令牌"})]})]})]}),"\n",(0,n.jsx)(s.h2,{id:i[1].id,children:i[1].value}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"refresh()"})," is ",(0,n.jsx)(s.strong,{children:"single-flight"})," by construction: concurrent callers share one in-flight request. You decide ",(0,n.jsx)(s.em,{children:"when"})," to refresh:"]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"refresh()"})," 天生",(0,n.jsx)(s.strong,{children:"单飞"}),":并发调用方共享同一次进行中的请求。",(0,n.jsx)(s.em,{children:"何时"}),"刷新由你决定:"]}),"\n",(0,n.jsxs)(s.ul,{children:["\n",(0,n.jsxs)(s.li,{children:["Proactively, when ",(0,n.jsx)(s.code,{children:"session.isExpired"})," is approaching, before a request. / 在请求前、当 ",(0,n.jsx)(s.code,{children:"session.isExpired"})," 临近时主动刷新。"]}),"\n",(0,n.jsxs)(s.li,{children:["Reactively, on a ",(0,n.jsx)(s.code,{children:"401"})," from your API (see ",(0,n.jsx)(s.a,{href:"Network-Integration",children:"Network Integration"}),"). / 响应式地,在 API 返回 ",(0,n.jsx)(s.code,{children:"401"})," 时刷新。"]}),"\n"]}),"\n",(0,n.jsxs)(s.p,{children:["A failed refresh is always reported through ",(0,n.jsx)(s.code,{children:"AuthError"})," (and the rethrown future),\nbut whether it ",(0,n.jsx)(s.strong,{children:"ends the session"})," is decided by ",(0,n.jsx)(s.code,{children:"refreshFailurePolicy"}),". See\n",(0,n.jsx)(s.strong,{children:"Refresh failure policy"})," below."]}),"\n",(0,n.jsxs)(s.p,{children:["刷新失败总会通过 ",(0,n.jsx)(s.code,{children:"AuthError"}),"(以及重新抛出的 future)上报,但是否",(0,n.jsx)(s.strong,{children:"终止会话"}),"由\n",(0,n.jsx)(s.code,{children:"refreshFailurePolicy"})," 决定,详见下方「刷新失败策略」。"]}),"\n",(0,n.jsx)(s.h2,{id:i[2].id,children:i[2].value}),"\n",(0,n.jsx)(s.p,{children:"The default policy signs the user out only for failures that can never succeed\nagain, keeping the session when the failure looks transient (so a network blip\ndoes not log people out):"}),"\n",(0,n.jsx)(s.p,{children:"默认策略仅在失败「注定无法重试成功」时让用户登出;看起来像瞬时故障时保留会话(避免一次\n网络抖动就把人踢下线):"}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(s.table,{children:[(0,n.jsx)(s.thead,{children:(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.th,{children:"Failure / 失败类型"}),(0,n.jsx)(s.th,{children:"Default outcome / 默认结果"})]})}),(0,n.jsxs)(s.tbody,{children:[(0,n.jsxs)(s.tr,{children:[(0,n.jsxs)(s.td,{children:[(0,n.jsx)(s.code,{children:"SessionExpiredException"}),", ",(0,n.jsx)(s.code,{children:"InvalidCredentialsException"})]}),(0,n.jsxs)(s.td,{children:["Store cleared → ",(0,n.jsx)(s.code,{children:"Unauthenticated"})," / 清空存储 → ",(0,n.jsx)(s.code,{children:"Unauthenticated"})]})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Anything else (network, 5xx…) / 其它(网络、5xx 等)"}),(0,n.jsxs)(s.td,{children:["Session kept → back to ",(0,n.jsx)(s.code,{children:"Authenticated"})," / 保留会话 → 回到 ",(0,n.jsx)(s.code,{children:"Authenticated"})]})]})]})]}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// Never sign out on a failed refresh — useful when refresh tokens are"})}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// long-lived and your backend is occasionally flaky."})}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// 刷新失败也绝不登出——适用于刷新令牌长期有效、后端偶尔不稳定的场景。"})}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManager"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategy"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategy,"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" refreshFailurePolicy"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" ("}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AppException"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" error) "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" false"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:","})]}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})})]})}),"\n",(0,n.jsx)(s.p,{children:"Proactive (background) refresh failures are handled internally, so a scheduled\nrenewal can never surface as an unhandled async error."}),"\n",(0,n.jsx)(s.p,{children:"主动(后台)刷新的失败会在内部处理,因此定时续期永远不会以未处理异步错误的形式泄漏。"}),"\n",(0,n.jsx)(s.h2,{id:i[3].id,children:i[3].value}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthManager"})," is a plain object — create one per app, hold it in your DI container or a top-level variable. The core has no static mutable state."]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthManager"})," 是一个普通对象——每个应用创建一个,放在 DI 容器或顶层变量中即可。内核没有静态可变状态。"]}),"\n",(0,n.jsx)(s.h2,{id:i[4].id,children:i[4].value}),"\n",(0,n.jsxs)(s.ul,{children:["\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.a,{href:"Usage",children:"Usage"})," — Putting it together / 综合使用"]}),"\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.a,{href:"FAQ",children:"FAQ"})," — Common questions / 常见问题"]}),"\n"]})]})},"/Configuration",{filePath:"pages/Configuration.md",timestamp:1789873037e3,pageMap:t.v,frontMatter:{},title:"Configuration / 配置"},"undefined"==typeof RemoteContent?l:RemoteContent.useTOC)},7080:function(e,i,s){"use strict";s.d(i,{v:function(){return n}});let n=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(e){e.O(0,[812,888,774,179],function(){return e(e.s=9321)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Configuration-e1e78c409860f3d5.js b/docs/_next/static/chunks/pages/Configuration-e1e78c409860f3d5.js deleted file mode 100644 index 4283993..0000000 --- a/docs/_next/static/chunks/pages/Configuration-e1e78c409860f3d5.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[717],{9321:function(e,i,s){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Configuration",function(){return s(3223)}])},3223:function(e,i,s){"use strict";s.r(i),s.d(i,{useTOC:function(){return l}});var n=s(5893),r=s(7812),t=s(7080),h=s(8925);function l(e){let i={code:"code",...(0,h.a)()};return[{value:(0,n.jsxs)(n.Fragment,{children:["Composing ",(0,n.jsx)(i.code,{children:"AuthManager"})," / 组合管理器"]}),id:"composing-authmanager--组合管理器",depth:2},{value:"Refresh strategy / 刷新策略",id:"refresh-strategy--刷新策略",depth:2},{value:"Refresh failure policy / 刷新失败策略",id:"refresh-failure-policy--刷新失败策略",depth:2},{value:"No global singletons / 没有全局单例",id:"no-global-singletons--没有全局单例",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}i.default=(0,r.c)(function(e){let{toc:i=l(e)}=e,s={a:"a",code:"code",em:"em",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,h.a)(),...e.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(s.h1,{children:"Configuration / 配置"}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"zero_auth"})," is deliberately minimal: there is no global config object. Behavior is composed from the two boundaries you pass to ",(0,n.jsx)(s.code,{children:"AuthManager"}),"."]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"zero_auth"})," 刻意保持精简:没有全局配置对象。行为由你传给 ",(0,n.jsx)(s.code,{children:"AuthManager"})," 的两个边界组合而成。"]}),"\n",(0,n.jsx)(s.h2,{id:i[0].id,children:i[0].value}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManager"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategy"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" MyAuthStrategy"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// how to talk to the backend / 如何与后端通信"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" tokenStore"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" SecureTokenStore"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// where to persist tokens / 令牌持久化位置"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" autoRefreshAhead"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Duration"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(minutes"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" 5"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// proactive renewal / 主动续期"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" autoRefreshRetryDelay"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Duration"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(seconds"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" 30"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// re-arm after a failed renewal / 续期失败后重新排程"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" refreshFailurePolicy"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" defaultRefreshFailurePolicy, "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// sign-out rule / 登出规则"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" clock"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" () "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" DateTime"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"."}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"now"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// time source (tests, skew) / 时间源(测试、时钟偏移)"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" onStateChanged"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (state) "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:" debugPrint"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"$"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"state"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// observe every emission / 观察每次状态"})]}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})})]})}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(s.table,{children:[(0,n.jsx)(s.thead,{children:(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.th,{children:"Knob"}),(0,n.jsx)(s.th,{children:"Where"}),(0,n.jsx)(s.th,{children:"Effect"})]})}),(0,n.jsxs)(s.tbody,{children:[(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Backend endpoints & auth scheme"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"AuthStrategy"})}),(0,n.jsxs)(s.td,{children:["What ",(0,n.jsx)(s.code,{children:"login"}),"/",(0,n.jsx)(s.code,{children:"refresh"}),"/… actually do / ",(0,n.jsx)(s.code,{children:"login"}),"/",(0,n.jsx)(s.code,{children:"refresh"})," 等的实际行为"]})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Token persistence"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"TokenStore"})}),(0,n.jsx)(s.td,{children:"Disk / secure storage / in-memory / 磁盘/安全存储/内存"})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Refresh timing"}),(0,n.jsxs)(s.td,{children:[(0,n.jsx)(s.code,{children:"AuthSession.expiresAt"})," + ",(0,n.jsx)(s.code,{children:"autoRefreshAhead"})]}),(0,n.jsx)(s.td,{children:"Proactive renewal is scheduled that far before expiry / 在过期前该时长调度主动续期"})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Renewal retry"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"autoRefreshRetryDelay"})}),(0,n.jsxs)(s.td,{children:["After a ",(0,n.jsx)(s.em,{children:"proactive"})," renewal fails, it is re-armed this much later (default 30s) while a session still exists / 主动续期失败后按此时长重新排程(默认 30 秒),会话仍在才重试"]})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Observation"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"onStateChanged"})}),(0,n.jsx)(s.td,{children:"Optional callback for every emitted state, for logging or analytics / 可选回调,每次发出状态时触发,便于日志或埋点"})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Refresh failure handling"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"refreshFailurePolicy"})}),(0,n.jsx)(s.td,{children:"Whether a failed refresh signs the user out / 刷新失败是否让用户登出"})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Time source"}),(0,n.jsx)(s.td,{children:(0,n.jsx)(s.code,{children:"clock"})}),(0,n.jsx)(s.td,{children:"Drives expiry maths and proactive scheduling; inject one for deterministic tests or to tolerate device clock skew / 驱动过期计算与主动刷新调度;注入时钟可实现确定性测试或容忍设备时钟偏移"})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Token injection"}),(0,n.jsxs)(s.td,{children:[(0,n.jsx)(s.code,{children:"AuthTokenSource"})," / ",(0,n.jsx)(s.code,{children:"validAccessToken()"})]}),(0,n.jsx)(s.td,{children:"How the bearer token reaches HTTP clients, optionally renewing an expired token first / 令牌如何到达 HTTP 客户端,必要时先续期过期令牌"})]})]})]}),"\n",(0,n.jsx)(s.h2,{id:i[1].id,children:i[1].value}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"refresh()"})," is ",(0,n.jsx)(s.strong,{children:"single-flight"})," by construction: concurrent callers share one in-flight request. You decide ",(0,n.jsx)(s.em,{children:"when"})," to refresh:"]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"refresh()"})," 天生",(0,n.jsx)(s.strong,{children:"单飞"}),":并发调用方共享同一次进行中的请求。",(0,n.jsx)(s.em,{children:"何时"}),"刷新由你决定:"]}),"\n",(0,n.jsxs)(s.ul,{children:["\n",(0,n.jsxs)(s.li,{children:["Proactively, when ",(0,n.jsx)(s.code,{children:"session.isExpired"})," is approaching, before a request. / 在请求前、当 ",(0,n.jsx)(s.code,{children:"session.isExpired"})," 临近时主动刷新。"]}),"\n",(0,n.jsxs)(s.li,{children:["Reactively, on a ",(0,n.jsx)(s.code,{children:"401"})," from your API (see ",(0,n.jsx)(s.a,{href:"Network-Integration",children:"Network Integration"}),"). / 响应式地,在 API 返回 ",(0,n.jsx)(s.code,{children:"401"})," 时刷新。"]}),"\n"]}),"\n",(0,n.jsxs)(s.p,{children:["A failed refresh is always reported through ",(0,n.jsx)(s.code,{children:"AuthError"})," (and the rethrown future),\nbut whether it ",(0,n.jsx)(s.strong,{children:"ends the session"})," is decided by ",(0,n.jsx)(s.code,{children:"refreshFailurePolicy"}),". See\n",(0,n.jsx)(s.strong,{children:"Refresh failure policy"})," below."]}),"\n",(0,n.jsxs)(s.p,{children:["刷新失败总会通过 ",(0,n.jsx)(s.code,{children:"AuthError"}),"(以及重新抛出的 future)上报,但是否",(0,n.jsx)(s.strong,{children:"终止会话"}),"由\n",(0,n.jsx)(s.code,{children:"refreshFailurePolicy"})," 决定,详见下方「刷新失败策略」。"]}),"\n",(0,n.jsx)(s.h2,{id:i[2].id,children:i[2].value}),"\n",(0,n.jsx)(s.p,{children:"The default policy signs the user out only for failures that can never succeed\nagain, keeping the session when the failure looks transient (so a network blip\ndoes not log people out):"}),"\n",(0,n.jsx)(s.p,{children:"默认策略仅在失败「注定无法重试成功」时让用户登出;看起来像瞬时故障时保留会话(避免一次\n网络抖动就把人踢下线):"}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(s.table,{children:[(0,n.jsx)(s.thead,{children:(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.th,{children:"Failure / 失败类型"}),(0,n.jsx)(s.th,{children:"Default outcome / 默认结果"})]})}),(0,n.jsxs)(s.tbody,{children:[(0,n.jsxs)(s.tr,{children:[(0,n.jsxs)(s.td,{children:[(0,n.jsx)(s.code,{children:"SessionExpiredException"}),", ",(0,n.jsx)(s.code,{children:"InvalidCredentialsException"})]}),(0,n.jsxs)(s.td,{children:["Store cleared → ",(0,n.jsx)(s.code,{children:"Unauthenticated"})," / 清空存储 → ",(0,n.jsx)(s.code,{children:"Unauthenticated"})]})]}),(0,n.jsxs)(s.tr,{children:[(0,n.jsx)(s.td,{children:"Anything else (network, 5xx…) / 其它(网络、5xx 等)"}),(0,n.jsxs)(s.td,{children:["Session kept → back to ",(0,n.jsx)(s.code,{children:"Authenticated"})," / 保留会话 → 回到 ",(0,n.jsx)(s.code,{children:"Authenticated"})]})]})]})]}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// Never sign out on a failed refresh — useful when refresh tokens are"})}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// long-lived and your backend is occasionally flaky."})}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// 刷新失败也绝不登出——适用于刷新令牌长期有效、后端偶尔不稳定的场景。"})}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManager"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategy"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategy,"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" refreshFailurePolicy"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" ("}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AppException"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" error) "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" false"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:","})]}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})})]})}),"\n",(0,n.jsx)(s.p,{children:"Proactive (background) refresh failures are handled internally, so a scheduled\nrenewal can never surface as an unhandled async error."}),"\n",(0,n.jsx)(s.p,{children:"主动(后台)刷新的失败会在内部处理,因此定时续期永远不会以未处理异步错误的形式泄漏。"}),"\n",(0,n.jsx)(s.h2,{id:i[3].id,children:i[3].value}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthManager"})," is a plain object — create one per app, hold it in your DI container or a top-level variable. The core has no static mutable state."]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthManager"})," 是一个普通对象——每个应用创建一个,放在 DI 容器或顶层变量中即可。内核没有静态可变状态。"]}),"\n",(0,n.jsx)(s.h2,{id:i[4].id,children:i[4].value}),"\n",(0,n.jsxs)(s.ul,{children:["\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.a,{href:"Usage",children:"Usage"})," — Putting it together / 综合使用"]}),"\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.a,{href:"FAQ",children:"FAQ"})," — Common questions / 常见问题"]}),"\n"]})]})},"/Configuration",{filePath:"pages/Configuration.md",timestamp:178966094e4,pageMap:t.v,frontMatter:{},title:"Configuration / 配置"},"undefined"==typeof RemoteContent?l:RemoteContent.useTOC)},7080:function(e,i,s){"use strict";s.d(i,{v:function(){return n}});let n=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(e){e.O(0,[812,888,774,179],function(){return e(e.s=9321)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Errors-ccf26da76701ec77.js b/docs/_next/static/chunks/pages/Errors-ccf26da76701ec77.js deleted file mode 100644 index 1fb195d..0000000 --- a/docs/_next/static/chunks/pages/Errors-ccf26da76701ec77.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[302],{7031:function(i,s,e){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Errors",function(){return e(1824)}])},1824:function(i,s,e){"use strict";e.r(s),e.d(s,{useTOC:function(){return l}});var n=e(5893),r=e(7812),h=e(7080),t=e(8925);function l(i){let s={code:"code",...(0,t.a)()};return[{value:(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(s.code,{children:"AppException"})," / 异常类型"]}),id:"appexception--异常类型",depth:2},{value:(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(s.code,{children:"Result"})," / 结果类型"]}),id:"resultt--结果类型",depth:2},{value:"How failures surface / 失败如何呈现",id:"how-failures-surface--失败如何呈现",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}s.default=(0,r.c)(function(i){let{toc:s=l(i)}=i,e={a:"a",blockquote:"blockquote",code:"code",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,t.a)(),...i.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(e.h1,{children:"Errors / 错误"}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"zero_auth"})," never lets a raw ",(0,n.jsx)(e.code,{children:"Exception"})," cross its public surface. Every failure is expressed through one of two mechanisms."]}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"zero_auth"})," 绝不允许裸 ",(0,n.jsx)(e.code,{children:"Exception"})," 越过公共边界。每个失败都通过下面两种机制之一表达。"]}),"\n",(0,n.jsx)(e.h2,{id:s[0].id,children:s[0].value}),"\n",(0,n.jsxs)(e.p,{children:["All domain failures extend ",(0,n.jsx)(e.code,{children:"AppException"}),":"]}),"\n",(0,n.jsxs)(e.p,{children:["所有领域失败都继承 ",(0,n.jsx)(e.code,{children:"AppException"}),":"]}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"sealed"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AppException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" implements"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Exception"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" String"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" code; "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// stable, machine-readable / 稳定、机器可读"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" String"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" message; "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// human-readable / 人类可读"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Object"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"?"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" cause; "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// original error, if any / 原始错误"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})}),"\n",(0,n.jsx)(e.span,{children:" "}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AppException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // Base type for every auth failure / 所有认证失败的基类型"})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})}),"\n",(0,n.jsx)(e.span,{children:" "}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// Concrete subtypes — catch these to branch on what actually went wrong."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// 具体子类型——捕获它们即可按实际错误分支处理。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" InvalidCredentialsException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {} "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// invalid_credentials / 凭据无效"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" SessionExpiredException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {} "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// session_expired / 会话过期"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" NoActiveSessionException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {} "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// no_active_session / 无活动会话"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" RefreshTokenMissingException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {} "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// refresh_token_missing / 缺少刷新令牌"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" UnexpectedAuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {} "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// unexpected_auth_failure / 意外失败"})]})]})}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthStrategy"})," authors opt into these subtypes simply by throwing an\n",(0,n.jsx)(e.code,{children:"AuthException"})," carrying the matching ",(0,n.jsx)(e.code,{children:"code"}),"; ",(0,n.jsx)(e.code,{children:"mapAuthFailure"})," performs the\nmapping, preserving any vocabulary it does not recognise."]}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthStrategy"})," 实现者只需抛出携带对应 ",(0,n.jsx)(e.code,{children:"code"})," 的 ",(0,n.jsx)(e.code,{children:"AuthException"})," 即可参与映射;\n",(0,n.jsx)(e.code,{children:"mapAuthFailure"})," 负责转换,并保留它无法识别的自定义错误类型。"]}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(e.table,{children:[(0,n.jsx)(e.thead,{children:(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.th,{children:"code"}),(0,n.jsx)(e.th,{children:"Mapped type / 映射结果"})]})}),(0,n.jsxs)(e.tbody,{children:[(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"invalid_credentials"})}),(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"InvalidCredentialsException"})})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"invalid_grant"}),", ",(0,n.jsx)(e.code,{children:"invalid_refresh_token"}),", ",(0,n.jsx)(e.code,{children:"token_expired"}),", ",(0,n.jsx)(e.code,{children:"session_expired"})]}),(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"SessionExpiredException"})})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"no_active_session"})}),(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"NoActiveSessionException"})})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"refresh_token_missing"})}),(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"RefreshTokenMissingException"})})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.td,{children:"anything else / 其它"}),(0,n.jsxs)(e.td,{children:["preserved as-is, or ",(0,n.jsx)(e.code,{children:"UnexpectedAuthException"})," / 原样保留或包装为 ",(0,n.jsx)(e.code,{children:"UnexpectedAuthException"})]})]})]})]}),"\n",(0,n.jsx)(e.p,{children:"Codes raised by the manager itself (not your strategy) / 由管理器自身产生的 code:"}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(e.table,{children:[(0,n.jsx)(e.thead,{children:(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.th,{children:"code"}),(0,n.jsx)(e.th,{children:"Raised when / 何时触发"})]})}),(0,n.jsxs)(e.tbody,{children:[(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"auth_flow_in_progress"})}),(0,n.jsxs)(e.td,{children:["Another ",(0,n.jsx)(e.code,{children:"login"})," / ",(0,n.jsx)(e.code,{children:"register"})," / ",(0,n.jsx)(e.code,{children:"loginWith"})," is already running / 已有登录流程在执行"]})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"manager_disposed"})}),(0,n.jsxs)(e.td,{children:["An operation is called after ",(0,n.jsx)(e.code,{children:"dispose()"})," / ",(0,n.jsx)(e.code,{children:"dispose()"})," 后又调用操作"]})]})]})]}),"\n",(0,n.jsxs)(e.blockquote,{children:["\n",(0,n.jsxs)(e.p,{children:["Overlapping authentication flows are rejected rather than racing: a second\n",(0,n.jsx)(e.code,{children:"login()"})," while one is in flight throws ",(0,n.jsx)(e.code,{children:"auth_flow_in_progress"}),"."]}),"\n",(0,n.jsxs)(e.p,{children:["重叠的登录流程会被拒绝而非争抢:进行中再次 ",(0,n.jsx)(e.code,{children:"login()"})," 会抛 ",(0,n.jsx)(e.code,{children:"auth_flow_in_progress"}),"。"]}),"\n"]}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthStrategy"})," implementations should map transport/API errors into ",(0,n.jsx)(e.code,{children:"AuthException"})," (or a custom ",(0,n.jsx)(e.code,{children:"AppException"})," subclass) so callers get a stable ",(0,n.jsx)(e.code,{children:"code"}),"."]}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthStrategy"})," 实现方应将传输层/API 错误映射为 ",(0,n.jsx)(e.code,{children:"AuthException"}),"(或自定义 ",(0,n.jsx)(e.code,{children:"AppException"})," 子类),让调用方拿到稳定的 ",(0,n.jsx)(e.code,{children:"code"}),"。"]}),"\n",(0,n.jsx)(e.h2,{id:s[1].id,children:s[1].value}),"\n",(0,n.jsxs)(e.p,{children:["For explicit, exception-free handling, use ",(0,n.jsx)(e.code,{children:"Result"}),":"]}),"\n",(0,n.jsxs)(e.p,{children:["如需显式、无异常的处理,可使用 ",(0,n.jsx)(e.code,{children:"Result"}),":"]}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"sealed"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Result"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"T"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"> {"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" factory"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Result"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"ok"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"T"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" value) "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Ok"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"T"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:">;"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" factory"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Result"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"err"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AppException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" error) "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Err"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"T"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:">;"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" result "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"tryLogin"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(user, pass); "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// returns Result"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"switch"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (result) {"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" case"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Ok"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" value)"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" /* authenticated / 已认证 */"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" case"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Err"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" error)"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" /* inspect error.code / 读取 error.code */"})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,n.jsx)(e.h2,{id:s[2].id,children:s[2].value}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(e.table,{children:[(0,n.jsx)(e.thead,{children:(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.th,{children:"Situation"}),(0,n.jsx)(e.th,{children:"Surfaced as"})]})}),(0,n.jsxs)(e.tbody,{children:[(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"login"}),"/",(0,n.jsx)(e.code,{children:"register"})," fails"]}),(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"AuthState"})," becomes ",(0,n.jsx)(e.code,{children:"AuthError(error)"})," ",(0,n.jsx)(e.strong,{children:"and"})," the future throws ",(0,n.jsx)(e.code,{children:"AppException"})," / 状态变为 ",(0,n.jsx)(e.code,{children:"AuthError"}),",且 future 抛出 ",(0,n.jsx)(e.code,{children:"AppException"})]})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"refresh"})," fails"]}),(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"AuthError(error)"})," emitted, then ",(0,n.jsx)(e.code,{children:"Unauthenticated"})," (unrecoverable) or back to ",(0,n.jsx)(e.code,{children:"Authenticated"})," (transient) per the ",(0,n.jsx)(e.a,{href:"Configuration#refresh-failure-policy",children:"failure policy"})," / 发出 ",(0,n.jsx)(e.code,{children:"AuthError"}),",随后按",(0,n.jsx)(e.a,{href:"Configuration#refresh-failure-policy",children:"失败策略"}),"转为 ",(0,n.jsx)(e.code,{children:"Unauthenticated"}),"(不可恢复)或回到 ",(0,n.jsx)(e.code,{children:"Authenticated"}),"(瞬时)"]})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"restore()"})," finds nothing"]}),(0,n.jsxs)(e.td,{children:["Stays ",(0,n.jsx)(e.code,{children:"Unauthenticated"})," (not an error) / 保持 ",(0,n.jsx)(e.code,{children:"Unauthenticated"}),"(不算错误)"]})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"TokenStore.load()"})," throws"]}),(0,n.jsxs)(e.td,{children:["Mapped to ",(0,n.jsx)(e.code,{children:"AppException"}),", treated as “no session” / 映射为 ",(0,n.jsx)(e.code,{children:"AppException"}),",按”无会话”处理"]})]})]})]}),"\n",(0,n.jsx)(e.h2,{id:s[3].id,children:s[3].value}),"\n",(0,n.jsxs)(e.ul,{children:["\n",(0,n.jsxs)(e.li,{children:[(0,n.jsx)(e.a,{href:"Backend-Strategy",children:"Backend Strategy"})," — Map your API errors / 映射你的 API 错误"]}),"\n",(0,n.jsxs)(e.li,{children:[(0,n.jsx)(e.a,{href:"Configuration",children:"Configuration"})," — Tuning refresh & retry / 调优刷新与重试"]}),"\n"]})]})},"/Errors",{filePath:"pages/Errors.md",timestamp:178966094e4,pageMap:h.v,frontMatter:{},title:"Errors / 错误"},"undefined"==typeof RemoteContent?l:RemoteContent.useTOC)},7080:function(i,s,e){"use strict";e.d(s,{v:function(){return n}});let n=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(i){i.O(0,[812,888,774,179],function(){return i(i.s=7031)}),_N_E=i.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Errors-d214a21e984d4641.js b/docs/_next/static/chunks/pages/Errors-d214a21e984d4641.js new file mode 100644 index 0000000..9471fb9 --- /dev/null +++ b/docs/_next/static/chunks/pages/Errors-d214a21e984d4641.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[302],{7031:function(i,s,e){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Errors",function(){return e(1824)}])},1824:function(i,s,e){"use strict";e.r(s),e.d(s,{useTOC:function(){return l}});var n=e(5893),h=e(7812),r=e(7080),t=e(8925);function l(i){let s={code:"code",...(0,t.a)()};return[{value:(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(s.code,{children:"AppException"})," / 异常类型"]}),id:"appexception--异常类型",depth:2},{value:(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(s.code,{children:"Result"})," / 结果类型"]}),id:"resultt--结果类型",depth:2},{value:"How failures surface / 失败如何呈现",id:"how-failures-surface--失败如何呈现",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}s.default=(0,h.c)(function(i){let{toc:s=l(i)}=i,e={a:"a",blockquote:"blockquote",code:"code",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,t.a)(),...i.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(e.h1,{children:"Errors / 错误"}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"zero_auth"})," never lets a raw ",(0,n.jsx)(e.code,{children:"Exception"})," cross its public surface. Every failure is expressed through one of two mechanisms."]}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"zero_auth"})," 绝不允许裸 ",(0,n.jsx)(e.code,{children:"Exception"})," 越过公共边界。每个失败都通过下面两种机制之一表达。"]}),"\n",(0,n.jsx)(e.h2,{id:s[0].id,children:s[0].value}),"\n",(0,n.jsxs)(e.p,{children:["All domain failures extend ",(0,n.jsx)(e.code,{children:"AppException"}),":"]}),"\n",(0,n.jsxs)(e.p,{children:["所有领域失败都继承 ",(0,n.jsx)(e.code,{children:"AppException"}),":"]}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"sealed"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AppException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" implements"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Exception"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" String"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" code; "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// stable, machine-readable / 稳定、机器可读"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" String"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" message; "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// human-readable / 人类可读"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Object"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"?"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" cause; "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// original error, if any / 原始错误"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})}),"\n",(0,n.jsx)(e.span,{children:" "}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AppException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // Base type for every auth failure / 所有认证失败的基类型"})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})}),"\n",(0,n.jsx)(e.span,{children:" "}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// Concrete subtypes — catch these to branch on what actually went wrong."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// 具体子类型——捕获它们即可按实际错误分支处理。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" InvalidCredentialsException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {} "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// invalid_credentials / 凭据无效"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" SessionExpiredException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {} "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// session_expired / 会话过期"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" NoActiveSessionException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {} "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// no_active_session / 无活动会话"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" RefreshTokenMissingException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {} "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// refresh_token_missing / 缺少刷新令牌"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" UnexpectedAuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {} "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// unexpected_auth_failure / 意外失败"})]})]})}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthStrategy"})," authors opt into these subtypes simply by throwing an\n",(0,n.jsx)(e.code,{children:"AuthException"})," — or the bare ",(0,n.jsx)(e.code,{children:"AuthFail"})," domain type — carrying the matching\n",(0,n.jsx)(e.code,{children:"code"}),"; ",(0,n.jsx)(e.code,{children:"mapAuthFailure"})," performs the mapping, preserving any vocabulary it does\nnot recognise."]}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthStrategy"})," 实现者只需抛出携带对应 ",(0,n.jsx)(e.code,{children:"code"})," 的 ",(0,n.jsx)(e.code,{children:"AuthException"})," —— 或裸的领域类型\n",(0,n.jsx)(e.code,{children:"AuthFail"})," —— 即可参与映射;",(0,n.jsx)(e.code,{children:"mapAuthFailure"})," 负责转换,并保留它无法识别的自定义错误类型。"]}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// Both of these map to InvalidCredentialsException / 两者都会映射为 InvalidCredentialsException"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"throw"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'Wrong password'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:", code"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'invalid_credentials'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"throw"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthFail"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'Wrong password'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:", code"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'invalid_credentials'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})]})]})}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(e.table,{children:[(0,n.jsx)(e.thead,{children:(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.th,{children:"code"}),(0,n.jsx)(e.th,{children:"Mapped type / 映射结果"})]})}),(0,n.jsxs)(e.tbody,{children:[(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"invalid_credentials"})}),(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"InvalidCredentialsException"})})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"invalid_grant"}),", ",(0,n.jsx)(e.code,{children:"invalid_refresh_token"}),", ",(0,n.jsx)(e.code,{children:"token_expired"}),", ",(0,n.jsx)(e.code,{children:"session_expired"})]}),(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"SessionExpiredException"})})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"no_active_session"})}),(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"NoActiveSessionException"})})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"refresh_token_missing"})}),(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"RefreshTokenMissingException"})})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.td,{children:"anything else / 其它"}),(0,n.jsxs)(e.td,{children:["preserved as-is, or ",(0,n.jsx)(e.code,{children:"UnexpectedAuthException"})," / 原样保留或包装为 ",(0,n.jsx)(e.code,{children:"UnexpectedAuthException"})]})]})]})]}),"\n",(0,n.jsx)(e.p,{children:"Codes raised by the manager itself (not your strategy) / 由管理器自身产生的 code:"}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(e.table,{children:[(0,n.jsx)(e.thead,{children:(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.th,{children:"code"}),(0,n.jsx)(e.th,{children:"Raised when / 何时触发"})]})}),(0,n.jsxs)(e.tbody,{children:[(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"auth_flow_in_progress"})}),(0,n.jsxs)(e.td,{children:["Another ",(0,n.jsx)(e.code,{children:"login"})," / ",(0,n.jsx)(e.code,{children:"register"})," / ",(0,n.jsx)(e.code,{children:"loginWith"})," is already running / 已有登录流程在执行"]})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.td,{children:(0,n.jsx)(e.code,{children:"manager_disposed"})}),(0,n.jsxs)(e.td,{children:["An operation is called after ",(0,n.jsx)(e.code,{children:"dispose()"})," / ",(0,n.jsx)(e.code,{children:"dispose()"})," 后又调用操作"]})]})]})]}),"\n",(0,n.jsxs)(e.blockquote,{children:["\n",(0,n.jsxs)(e.p,{children:["Overlapping authentication flows are rejected rather than racing: a second\n",(0,n.jsx)(e.code,{children:"login()"})," while one is in flight throws ",(0,n.jsx)(e.code,{children:"auth_flow_in_progress"}),"."]}),"\n",(0,n.jsxs)(e.p,{children:["重叠的登录流程会被拒绝而非争抢:进行中再次 ",(0,n.jsx)(e.code,{children:"login()"})," 会抛 ",(0,n.jsx)(e.code,{children:"auth_flow_in_progress"}),"。"]}),"\n"]}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthStrategy"})," implementations should map transport/API errors into ",(0,n.jsx)(e.code,{children:"AuthException"})," (or a custom ",(0,n.jsx)(e.code,{children:"AppException"})," subclass) so callers get a stable ",(0,n.jsx)(e.code,{children:"code"}),"."]}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthStrategy"})," 实现方应将传输层/API 错误映射为 ",(0,n.jsx)(e.code,{children:"AuthException"}),"(或自定义 ",(0,n.jsx)(e.code,{children:"AppException"})," 子类),让调用方拿到稳定的 ",(0,n.jsx)(e.code,{children:"code"}),"。"]}),"\n",(0,n.jsx)(e.h2,{id:s[1].id,children:s[1].value}),"\n",(0,n.jsxs)(e.p,{children:["For explicit, exception-free handling, use ",(0,n.jsx)(e.code,{children:"Result"}),":"]}),"\n",(0,n.jsxs)(e.p,{children:["如需显式、无异常的处理,可使用 ",(0,n.jsx)(e.code,{children:"Result"}),":"]}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"sealed"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Result"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"T"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"> {"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" factory"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Result"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"ok"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"T"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" value) "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Ok"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"T"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:">;"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" factory"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Result"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"err"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AppException"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" error) "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Err"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"T"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:">;"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" result "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"tryLogin"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(user, pass); "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// returns Result"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"switch"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (result) {"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" case"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Ok"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" value)"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" /* authenticated / 已认证 */"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" case"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Err"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" error)"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" /* inspect error.code / 读取 error.code */"})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,n.jsx)(e.h2,{id:s[2].id,children:s[2].value}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(e.table,{children:[(0,n.jsx)(e.thead,{children:(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.th,{children:"Situation"}),(0,n.jsx)(e.th,{children:"Surfaced as"})]})}),(0,n.jsxs)(e.tbody,{children:[(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"login"}),"/",(0,n.jsx)(e.code,{children:"register"})," fails"]}),(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"AuthState"})," becomes ",(0,n.jsx)(e.code,{children:"AuthError(error)"})," ",(0,n.jsx)(e.strong,{children:"and"})," the future throws ",(0,n.jsx)(e.code,{children:"AppException"})," / 状态变为 ",(0,n.jsx)(e.code,{children:"AuthError"}),",且 future 抛出 ",(0,n.jsx)(e.code,{children:"AppException"})]})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"refresh"})," fails"]}),(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"AuthError(error)"})," emitted, then ",(0,n.jsx)(e.code,{children:"Unauthenticated"})," (unrecoverable) or back to ",(0,n.jsx)(e.code,{children:"Authenticated"})," (transient) per the ",(0,n.jsx)(e.a,{href:"Configuration#refresh-failure-policy",children:"failure policy"})," / 发出 ",(0,n.jsx)(e.code,{children:"AuthError"}),",随后按",(0,n.jsx)(e.a,{href:"Configuration#refresh-failure-policy",children:"失败策略"}),"转为 ",(0,n.jsx)(e.code,{children:"Unauthenticated"}),"(不可恢复)或回到 ",(0,n.jsx)(e.code,{children:"Authenticated"}),"(瞬时)"]})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"restore()"})," finds nothing"]}),(0,n.jsxs)(e.td,{children:["Stays ",(0,n.jsx)(e.code,{children:"Unauthenticated"})," (not an error) / 保持 ",(0,n.jsx)(e.code,{children:"Unauthenticated"}),"(不算错误)"]})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"restore()"})," renewal fails transiently"]}),(0,n.jsxs)(e.td,{children:["Session ",(0,n.jsx)(e.strong,{children:"kept"})," (the next ",(0,n.jsx)(e.code,{children:"validAccessToken()"})," retries); only a terminal failure clears the store / ",(0,n.jsx)(e.strong,{children:"保留"}),"会话(下次 ",(0,n.jsx)(e.code,{children:"validAccessToken()"})," 会重试);只有终局失败才清空存储"]})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"TokenStore.load()"})," throws"]}),(0,n.jsxs)(e.td,{children:["Mapped to ",(0,n.jsx)(e.code,{children:"AppException"}),", treated as “no session” / 映射为 ",(0,n.jsx)(e.code,{children:"AppException"}),",按”无会话”处理"]})]})]})]}),"\n",(0,n.jsx)(e.h2,{id:s[3].id,children:s[3].value}),"\n",(0,n.jsxs)(e.ul,{children:["\n",(0,n.jsxs)(e.li,{children:[(0,n.jsx)(e.a,{href:"Backend-Strategy",children:"Backend Strategy"})," — Map your API errors / 映射你的 API 错误"]}),"\n",(0,n.jsxs)(e.li,{children:[(0,n.jsx)(e.a,{href:"Configuration",children:"Configuration"})," — Tuning refresh & retry / 调优刷新与重试"]}),"\n"]})]})},"/Errors",{filePath:"pages/Errors.md",timestamp:1789873037e3,pageMap:r.v,frontMatter:{},title:"Errors / 错误"},"undefined"==typeof RemoteContent?l:RemoteContent.useTOC)},7080:function(i,s,e){"use strict";e.d(s,{v:function(){return n}});let n=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(i){i.O(0,[812,888,774,179],function(){return i(i.s=7031)}),_N_E=i.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/FAQ-2cda6ba11483a6c4.js b/docs/_next/static/chunks/pages/FAQ-2cda6ba11483a6c4.js new file mode 100644 index 0000000..cbdcd10 --- /dev/null +++ b/docs/_next/static/chunks/pages/FAQ-2cda6ba11483a6c4.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[210],{9238:function(e,r,t){(window.__NEXT_P=window.__NEXT_P||[]).push(["/FAQ",function(){return t(5797)}])},5797:function(e,r,t){"use strict";t.r(r),t.d(r,{useTOC:function(){return a}});var n=t(5893),i=t(7812),o=t(7080),s=t(8925);function a(e){let r={code:"code",...(0,s.a)()};return[{value:(0,n.jsxs)(n.Fragment,{children:["Does ",(0,n.jsx)(r.code,{children:"zero_auth"})," ship an HTTP client? / ",(0,n.jsx)(r.code,{children:"zero_auth"})," 内置 HTTP 客户端吗?"]}),id:"does-zero_auth-ship-an-http-client--zero_auth-内置-http-客户端吗",depth:3},{value:"Does it depend on Flutter? / 它依赖 Flutter 吗?",id:"does-it-depend-on-flutter--它依赖-flutter-吗",depth:3},{value:"Where are tokens stored? / 令牌存在哪里?",id:"where-are-tokens-stored--令牌存在哪里",depth:3},{value:"What happens on a failed refresh? / 刷新失败会怎样?",id:"what-happens-on-a-failed-refresh--刷新失败会怎样",depth:3},{value:"Is token refresh concurrent-safe? / 令牌刷新是否并发安全?",id:"is-token-refresh-concurrent-safe--令牌刷新是否并发安全",depth:3},{value:"How do I attach the bearer token to requests? / 如何给请求附加 Bearer 令牌?",id:"how-do-i-attach-the-bearer-token-to-requests--如何给请求附加-bearer-令牌",depth:3},{value:"Does it sync sessions across devices? / 它会在多设备间同步会话吗?",id:"does-it-sync-sessions-across-devices--它会在多设备间同步会话吗",depth:3},{value:"Which platforms are supported? / 支持哪些平台?",id:"which-platforms-are-supported--支持哪些平台",depth:3}]}r.default=(0,i.c)(function(e){let{toc:r=a(e)}=e,t={a:"a",code:"code",em:"em",h1:"h1",h3:"h3",p:"p",...(0,s.a)(),...e.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(t.h1,{children:"FAQ / 常见问题"}),"\n",(0,n.jsx)(t.h3,{id:r[0].id,children:r[0].value}),"\n",(0,n.jsxs)(t.p,{children:["No. The core is backend-agnostic and ships no HTTP, SDK or native code. You implement ",(0,n.jsx)(t.code,{children:"AuthStrategy"})," to call your backend."]}),"\n",(0,n.jsxs)(t.p,{children:["不。内核后端无关,不含任何 HTTP、SDK 或原生代码。你通过实现 ",(0,n.jsx)(t.code,{children:"AuthStrategy"})," 来调用自己的后端。"]}),"\n",(0,n.jsx)(t.h3,{id:r[1].id,children:r[1].value}),"\n",(0,n.jsxs)(t.p,{children:["No. It is a pure-Dart package; ",(0,n.jsx)(t.code,{children:"flutter:"})," is only a declared constraint because the example app uses Flutter. The runtime has no Flutter dependency."]}),"\n",(0,n.jsxs)(t.p,{children:["不。它是纯 Dart 包;仅因示例 App 用到 Flutter 才声明 ",(0,n.jsx)(t.code,{children:"flutter:"})," 约束,运行时不依赖 Flutter。"]}),"\n",(0,n.jsx)(t.h3,{id:r[2].id,children:r[2].value}),"\n",(0,n.jsxs)(t.p,{children:["Wherever your ",(0,n.jsx)(t.code,{children:"TokenStore"})," puts them. The core ships ",(0,n.jsx)(t.code,{children:"InMemoryTokenStore"})," (lost on restart). For production, use the ",(0,n.jsx)(t.code,{children:"flutter_secure_storage"}),"-backed reference in ",(0,n.jsx)(t.code,{children:"example/lib/secure_token_store.dart"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["取决于你的 ",(0,n.jsx)(t.code,{children:"TokenStore"}),"。内核自带 ",(0,n.jsx)(t.code,{children:"InMemoryTokenStore"}),"(重启即丢失)。生产请用 ",(0,n.jsx)(t.code,{children:"example/lib/secure_token_store.dart"})," 中基于 ",(0,n.jsx)(t.code,{children:"flutter_secure_storage"})," 的参考实现。"]}),"\n",(0,n.jsx)(t.h3,{id:r[3].id,children:r[3].value}),"\n",(0,n.jsxs)(t.p,{children:["The failure is always reported as ",(0,n.jsx)(t.code,{children:"AuthError"})," and rethrown. What happens ",(0,n.jsx)(t.em,{children:"next"}),"\ndepends on ",(0,n.jsx)(t.code,{children:"refreshFailurePolicy"}),": an unrecoverable failure (",(0,n.jsx)(t.code,{children:"SessionExpiredException"}),",\n",(0,n.jsx)(t.code,{children:"InvalidCredentialsException"}),") clears the store and lands on ",(0,n.jsx)(t.code,{children:"Unauthenticated"}),",\nwhile a transient one (network, 5xx) keeps the previous session so a retry can\nsucceed. See ",(0,n.jsx)(t.a,{href:"Configuration#refresh-failure-policy",children:"Configuration"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["失败总会以 ",(0,n.jsx)(t.code,{children:"AuthError"})," 上报并重新抛出。",(0,n.jsx)(t.em,{children:"之后"}),"如何取决于 ",(0,n.jsx)(t.code,{children:"refreshFailurePolicy"}),":\n不可恢复的失败(",(0,n.jsx)(t.code,{children:"SessionExpiredException"}),"、",(0,n.jsx)(t.code,{children:"InvalidCredentialsException"}),")会清空存储\n并落到 ",(0,n.jsx)(t.code,{children:"Unauthenticated"}),";瞬时故障(网络、5xx)保留上一个会话以便重试成功。参见\n",(0,n.jsx)(t.a,{href:"Configuration#refresh-failure-policy",children:"配置"}),"。"]}),"\n",(0,n.jsx)(t.h3,{id:r[4].id,children:r[4].value}),"\n",(0,n.jsxs)(t.p,{children:["Yes — ",(0,n.jsx)(t.code,{children:"refresh()"})," is single-flight: many concurrent callers share one in-flight request and its result."]}),"\n",(0,n.jsxs)(t.p,{children:["是的——",(0,n.jsx)(t.code,{children:"refresh()"})," 为单飞:多个并发调用方共享同一次请求及其结果。"]}),"\n",(0,n.jsx)(t.h3,{id:r[5].id,children:r[5].value}),"\n",(0,n.jsxs)(t.p,{children:[(0,n.jsx)(t.code,{children:"AuthManager"})," is an ",(0,n.jsx)(t.code,{children:"AuthTokenSource"}),". Use the Dio interceptor in ",(0,n.jsx)(t.code,{children:"example/lib/dio_interceptor.dart"}),", or read ",(0,n.jsx)(t.code,{children:"accessToken"})," directly — but prefer ",(0,n.jsx)(t.code,{children:"validAccessToken()"})," (or ",(0,n.jsx)(t.code,{children:"RefreshingAuthInterceptor"}),") whenever the token reaches a server, since ",(0,n.jsx)(t.code,{children:"accessToken"})," may already be expired."]}),"\n",(0,n.jsxs)(t.p,{children:[(0,n.jsx)(t.code,{children:"AuthManager"})," 即 ",(0,n.jsx)(t.code,{children:"AuthTokenSource"}),"。可用 ",(0,n.jsx)(t.code,{children:"example/lib/dio_interceptor.dart"})," 中的 Dio 拦截器,或直接读取 ",(0,n.jsx)(t.code,{children:"accessToken"})," —— 但只要令牌要发到服务端,就请优先用 ",(0,n.jsx)(t.code,{children:"validAccessToken()"}),"(或 ",(0,n.jsx)(t.code,{children:"RefreshingAuthInterceptor"}),"),因为 ",(0,n.jsx)(t.code,{children:"accessToken"})," 可能已经过期。"]}),"\n",(0,n.jsx)(t.h3,{id:r[6].id,children:r[6].value}),"\n",(0,n.jsxs)(t.p,{children:["No — and by design. A session is local state plus whatever your backend decides;\npushing “signed out elsewhere” to a device needs server push (or polling), which\nis outside a headless state machine. Model it with your backend revoking the\ngrant: the next refresh fails, ",(0,n.jsx)(t.code,{children:"refreshFailurePolicy"})," signs the device out, and\n",(0,n.jsx)(t.code,{children:"AuthError"})," explains why."]}),"\n",(0,n.jsxs)(t.p,{children:["不会 —— 这是有意的设计。会话是本地状态加上你后端的决定;把「在别处已登出」推送到某台\n设备需要服务端推送(或轮询),那超出了无头状态机的职责。可由后端吊销授权来实现:下一次\n刷新失败,",(0,n.jsx)(t.code,{children:"refreshFailurePolicy"})," 会让该设备登出,并由 ",(0,n.jsx)(t.code,{children:"AuthError"})," 说明原因。"]}),"\n",(0,n.jsx)(t.h3,{id:r[7].id,children:r[7].value}),"\n",(0,n.jsx)(t.p,{children:"Any platform where Dart or Flutter runs (Android, iOS, Web, Windows, macOS, Linux)."}),"\n",(0,n.jsx)(t.p,{children:"任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。"})]})},"/FAQ",{filePath:"pages/FAQ.md",timestamp:1789857834e3,pageMap:o.v,frontMatter:{},title:"FAQ / 常见问题"},"undefined"==typeof RemoteContent?a:RemoteContent.useTOC)},7080:function(e,r,t){"use strict";t.d(r,{v:function(){return n}});let n=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(e){e.O(0,[812,888,774,179],function(){return e(e.s=9238)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/FAQ-66e28fdd0cce1e18.js b/docs/_next/static/chunks/pages/FAQ-66e28fdd0cce1e18.js deleted file mode 100644 index 5e75d3b..0000000 --- a/docs/_next/static/chunks/pages/FAQ-66e28fdd0cce1e18.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[210],{9238:function(e,t,r){(window.__NEXT_P=window.__NEXT_P||[]).push(["/FAQ",function(){return r(5797)}])},5797:function(e,t,r){"use strict";r.r(t),r.d(t,{useTOC:function(){return s}});var n=r(5893),i=r(7812),o=r(7080),a=r(8925);function s(e){let t={code:"code",...(0,a.a)()};return[{value:(0,n.jsxs)(n.Fragment,{children:["Does ",(0,n.jsx)(t.code,{children:"zero_auth"})," ship an HTTP client? / ",(0,n.jsx)(t.code,{children:"zero_auth"})," 内置 HTTP 客户端吗?"]}),id:"does-zero_auth-ship-an-http-client--zero_auth-内置-http-客户端吗",depth:3},{value:"Does it depend on Flutter? / 它依赖 Flutter 吗?",id:"does-it-depend-on-flutter--它依赖-flutter-吗",depth:3},{value:"Where are tokens stored? / 令牌存在哪里?",id:"where-are-tokens-stored--令牌存在哪里",depth:3},{value:"What happens on a failed refresh? / 刷新失败会怎样?",id:"what-happens-on-a-failed-refresh--刷新失败会怎样",depth:3},{value:"Is token refresh concurrent-safe? / 令牌刷新是否并发安全?",id:"is-token-refresh-concurrent-safe--令牌刷新是否并发安全",depth:3},{value:"How do I attach the bearer token to requests? / 如何给请求附加 Bearer 令牌?",id:"how-do-i-attach-the-bearer-token-to-requests--如何给请求附加-bearer-令牌",depth:3},{value:"Which platforms are supported? / 支持哪些平台?",id:"which-platforms-are-supported--支持哪些平台",depth:3}]}t.default=(0,i.c)(function(e){let{toc:t=s(e)}=e,r={a:"a",code:"code",em:"em",h1:"h1",h3:"h3",p:"p",...(0,a.a)(),...e.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(r.h1,{children:"FAQ / 常见问题"}),"\n",(0,n.jsx)(r.h3,{id:t[0].id,children:t[0].value}),"\n",(0,n.jsxs)(r.p,{children:["No. The core is backend-agnostic and ships no HTTP, SDK or native code. You implement ",(0,n.jsx)(r.code,{children:"AuthStrategy"})," to call your backend."]}),"\n",(0,n.jsxs)(r.p,{children:["不。内核后端无关,不含任何 HTTP、SDK 或原生代码。你通过实现 ",(0,n.jsx)(r.code,{children:"AuthStrategy"})," 来调用自己的后端。"]}),"\n",(0,n.jsx)(r.h3,{id:t[1].id,children:t[1].value}),"\n",(0,n.jsxs)(r.p,{children:["No. It is a pure-Dart package; ",(0,n.jsx)(r.code,{children:"flutter:"})," is only a declared constraint because the example app uses Flutter. The runtime has no Flutter dependency."]}),"\n",(0,n.jsxs)(r.p,{children:["不。它是纯 Dart 包;仅因示例 App 用到 Flutter 才声明 ",(0,n.jsx)(r.code,{children:"flutter:"})," 约束,运行时不依赖 Flutter。"]}),"\n",(0,n.jsx)(r.h3,{id:t[2].id,children:t[2].value}),"\n",(0,n.jsxs)(r.p,{children:["Wherever your ",(0,n.jsx)(r.code,{children:"TokenStore"})," puts them. The core ships ",(0,n.jsx)(r.code,{children:"InMemoryTokenStore"})," (lost on restart). For production, use the ",(0,n.jsx)(r.code,{children:"flutter_secure_storage"}),"-backed reference in ",(0,n.jsx)(r.code,{children:"example/lib/secure_token_store.dart"}),"."]}),"\n",(0,n.jsxs)(r.p,{children:["取决于你的 ",(0,n.jsx)(r.code,{children:"TokenStore"}),"。内核自带 ",(0,n.jsx)(r.code,{children:"InMemoryTokenStore"}),"(重启即丢失)。生产请用 ",(0,n.jsx)(r.code,{children:"example/lib/secure_token_store.dart"})," 中基于 ",(0,n.jsx)(r.code,{children:"flutter_secure_storage"})," 的参考实现。"]}),"\n",(0,n.jsx)(r.h3,{id:t[3].id,children:t[3].value}),"\n",(0,n.jsxs)(r.p,{children:["The failure is always reported as ",(0,n.jsx)(r.code,{children:"AuthError"})," and rethrown. What happens ",(0,n.jsx)(r.em,{children:"next"}),"\ndepends on ",(0,n.jsx)(r.code,{children:"refreshFailurePolicy"}),": an unrecoverable failure (",(0,n.jsx)(r.code,{children:"SessionExpiredException"}),",\n",(0,n.jsx)(r.code,{children:"InvalidCredentialsException"}),") clears the store and lands on ",(0,n.jsx)(r.code,{children:"Unauthenticated"}),",\nwhile a transient one (network, 5xx) keeps the previous session so a retry can\nsucceed. See ",(0,n.jsx)(r.a,{href:"Configuration#refresh-failure-policy",children:"Configuration"}),"."]}),"\n",(0,n.jsxs)(r.p,{children:["失败总会以 ",(0,n.jsx)(r.code,{children:"AuthError"})," 上报并重新抛出。",(0,n.jsx)(r.em,{children:"之后"}),"如何取决于 ",(0,n.jsx)(r.code,{children:"refreshFailurePolicy"}),":\n不可恢复的失败(",(0,n.jsx)(r.code,{children:"SessionExpiredException"}),"、",(0,n.jsx)(r.code,{children:"InvalidCredentialsException"}),")会清空存储\n并落到 ",(0,n.jsx)(r.code,{children:"Unauthenticated"}),";瞬时故障(网络、5xx)保留上一个会话以便重试成功。参见\n",(0,n.jsx)(r.a,{href:"Configuration#refresh-failure-policy",children:"配置"}),"。"]}),"\n",(0,n.jsx)(r.h3,{id:t[4].id,children:t[4].value}),"\n",(0,n.jsxs)(r.p,{children:["Yes — ",(0,n.jsx)(r.code,{children:"refresh()"})," is single-flight: many concurrent callers share one in-flight request and its result."]}),"\n",(0,n.jsxs)(r.p,{children:["是的——",(0,n.jsx)(r.code,{children:"refresh()"})," 为单飞:多个并发调用方共享同一次请求及其结果。"]}),"\n",(0,n.jsx)(r.h3,{id:t[5].id,children:t[5].value}),"\n",(0,n.jsxs)(r.p,{children:[(0,n.jsx)(r.code,{children:"AuthManager"})," is an ",(0,n.jsx)(r.code,{children:"AuthTokenSource"}),". Use the Dio interceptor in ",(0,n.jsx)(r.code,{children:"example/lib/dio_interceptor.dart"}),", or read ",(0,n.jsx)(r.code,{children:"accessToken"})," directly."]}),"\n",(0,n.jsxs)(r.p,{children:[(0,n.jsx)(r.code,{children:"AuthManager"})," 即 ",(0,n.jsx)(r.code,{children:"AuthTokenSource"}),"。可用 ",(0,n.jsx)(r.code,{children:"example/lib/dio_interceptor.dart"})," 中的 Dio 拦截器,或直接读取 ",(0,n.jsx)(r.code,{children:"accessToken"}),"。"]}),"\n",(0,n.jsx)(r.h3,{id:t[6].id,children:t[6].value}),"\n",(0,n.jsx)(r.p,{children:"Any platform where Dart or Flutter runs (Android, iOS, Web, Windows, macOS, Linux)."}),"\n",(0,n.jsx)(r.p,{children:"任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。"})]})},"/FAQ",{filePath:"pages/FAQ.md",timestamp:1789598212e3,pageMap:o.v,frontMatter:{},title:"FAQ / 常见问题"},"undefined"==typeof RemoteContent?s:RemoteContent.useTOC)},7080:function(e,t,r){"use strict";r.d(t,{v:function(){return n}});let n=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(e){e.O(0,[812,888,774,179],function(){return e(e.s=9238)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Installation-44254819243c14af.js b/docs/_next/static/chunks/pages/Installation-dd2c3a4ed8ae2991.js similarity index 98% rename from docs/_next/static/chunks/pages/Installation-44254819243c14af.js rename to docs/_next/static/chunks/pages/Installation-dd2c3a4ed8ae2991.js index 7a7d7c8..7135421 100644 --- a/docs/_next/static/chunks/pages/Installation-44254819243c14af.js +++ b/docs/_next/static/chunks/pages/Installation-dd2c3a4ed8ae2991.js @@ -1 +1 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[139],{9720:function(e,t,i){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Installation",function(){return i(1220)}])},1220:function(e,t,i){"use strict";i.r(t),i.d(t,{useTOC:function(){return l}});var n=i(5893),r=i(7812),s=i(7080),a=i(8925),d=i(5192);function l(e){return[{value:"From pub.dev (Recommended) / 从 pub.dev 安装(推荐)",id:"from-pubdev-recommended--从-pubdev-安装推荐",depth:2},{value:"From GitHub / 从 GitHub 安装",id:"from-github--从-github-安装",depth:2},{value:"Import / 导入",id:"import--导入",depth:2},{value:"Requirements / 环境要求",id:"requirements--环境要求",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}t.default=(0,r.c)(function(e){let{toc:t=l(e)}=e,i={a:"a",code:"code",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,a.a)(),...e.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(i.h1,{children:"Installation / 安装"}),"\n",(0,n.jsx)(i.h2,{id:t[0].id,children:t[0].value}),"\n",(0,n.jsxs)(i.p,{children:["Add the following to your ",(0,n.jsx)(i.code,{children:"pubspec.yaml"}),":"]}),"\n",(0,n.jsxs)(i.p,{children:["在 ",(0,n.jsx)(i.code,{children:"pubspec.yaml"})," 中添加以下依赖:"]}),"\n",(0,n.jsx)(i.pre,{tabIndex:"0","data-language":"yaml","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(i.code,{children:[(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:"dependencies"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:":"})]}),"\n",(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:" zero_auth"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:": "}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"^0.5.0"})]})]})}),"\n",(0,n.jsx)(i.p,{children:"Then run:"}),"\n",(0,n.jsx)(i.p,{children:"然后运行:"}),"\n",(0,n.jsx)(i.pre,{icon:d.Fx,tabIndex:"0","data-language":"bash","data-word-wrap":"","data-copy":"",children:(0,n.jsx)(i.code,{children:(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"dart"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" pub"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" get"})]})})}),"\n",(0,n.jsx)(i.h2,{id:t[1].id,children:t[1].value}),"\n",(0,n.jsx)(i.p,{children:"Alternatively, install from GitHub:"}),"\n",(0,n.jsx)(i.p,{children:"或者从 GitHub 安装:"}),"\n",(0,n.jsx)(i.pre,{tabIndex:"0","data-language":"yaml","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(i.code,{children:[(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:"dependencies"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:":"})]}),"\n",(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:" zero_auth"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:":"})]}),"\n",(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:" git"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:":"})]}),"\n",(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:" url"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:": "}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"https://github.com/zero-labsco/zero_auth.git"})]}),"\n",(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:" ref"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:": "}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"release/v0.5.0"})]})]})}),"\n",(0,n.jsx)(i.h2,{id:t[2].id,children:t[2].value}),"\n",(0,n.jsx)(i.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsx)(i.code,{children:(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"import"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'package:zero_auth/zero_auth.dart'"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:";"})]})})}),"\n",(0,n.jsx)(i.h2,{id:t[3].id,children:t[3].value}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(i.table,{children:[(0,n.jsx)(i.thead,{children:(0,n.jsxs)(i.tr,{children:[(0,n.jsx)(i.th,{children:"Requirement"}),(0,n.jsx)(i.th,{children:"Version"})]})}),(0,n.jsxs)(i.tbody,{children:[(0,n.jsxs)(i.tr,{children:[(0,n.jsx)(i.td,{children:"Dart SDK"}),(0,n.jsx)(i.td,{children:">= 3.4.0"})]}),(0,n.jsxs)(i.tr,{children:[(0,n.jsx)(i.td,{children:"Flutter"}),(0,n.jsx)(i.td,{children:">= 3.0.0 (only a constraint; no Flutter runtime dependency)"})]})]})]}),"\n",(0,n.jsxs)(i.p,{children:[(0,n.jsx)(i.code,{children:"zero_auth"})," is a pure-Dart package. Flutter is only listed because the example app uses it; the core has no Flutter dependency."]}),"\n",(0,n.jsxs)(i.p,{children:[(0,n.jsx)(i.code,{children:"zero_auth"})," 是纯 Dart 包,仅因示例 App 用到 Flutter 才列出;内核本身不依赖 Flutter。"]}),"\n",(0,n.jsx)(i.h2,{id:t[4].id,children:t[4].value}),"\n",(0,n.jsxs)(i.ul,{children:["\n",(0,n.jsxs)(i.li,{children:[(0,n.jsx)(i.a,{href:"Getting-Started",children:"Getting Started"})," — Quick start guide / 快速开始"]}),"\n",(0,n.jsxs)(i.li,{children:[(0,n.jsx)(i.a,{href:"Usage",children:"Usage"})," — Full usage guide / 完整使用指南"]}),"\n"]})]})},"/Installation",{filePath:"pages/Installation.md",timestamp:1789598212e3,pageMap:s.v,frontMatter:{},title:"Installation / 安装"},"undefined"==typeof RemoteContent?l:RemoteContent.useTOC)},7080:function(e,t,i){"use strict";i.d(t,{v:function(){return n}});let n=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(e){e.O(0,[812,888,774,179],function(){return e(e.s=9720)}),_N_E=e.O()}]); \ No newline at end of file +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[139],{9720:function(e,t,i){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Installation",function(){return i(1220)}])},1220:function(e,t,i){"use strict";i.r(t),i.d(t,{useTOC:function(){return l}});var n=i(5893),r=i(7812),s=i(7080),a=i(8925),d=i(5192);function l(e){return[{value:"From pub.dev (Recommended) / 从 pub.dev 安装(推荐)",id:"from-pubdev-recommended--从-pubdev-安装推荐",depth:2},{value:"From GitHub / 从 GitHub 安装",id:"from-github--从-github-安装",depth:2},{value:"Import / 导入",id:"import--导入",depth:2},{value:"Requirements / 环境要求",id:"requirements--环境要求",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}t.default=(0,r.c)(function(e){let{toc:t=l(e)}=e,i={a:"a",code:"code",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,a.a)(),...e.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(i.h1,{children:"Installation / 安装"}),"\n",(0,n.jsx)(i.h2,{id:t[0].id,children:t[0].value}),"\n",(0,n.jsxs)(i.p,{children:["Add the following to your ",(0,n.jsx)(i.code,{children:"pubspec.yaml"}),":"]}),"\n",(0,n.jsxs)(i.p,{children:["在 ",(0,n.jsx)(i.code,{children:"pubspec.yaml"})," 中添加以下依赖:"]}),"\n",(0,n.jsx)(i.pre,{tabIndex:"0","data-language":"yaml","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(i.code,{children:[(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:"dependencies"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:":"})]}),"\n",(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:" zero_auth"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:": "}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"^1.0.0"})]})]})}),"\n",(0,n.jsx)(i.p,{children:"Then run:"}),"\n",(0,n.jsx)(i.p,{children:"然后运行:"}),"\n",(0,n.jsx)(i.pre,{icon:d.Fx,tabIndex:"0","data-language":"bash","data-word-wrap":"","data-copy":"",children:(0,n.jsx)(i.code,{children:(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"dart"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" pub"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" get"})]})})}),"\n",(0,n.jsx)(i.h2,{id:t[1].id,children:t[1].value}),"\n",(0,n.jsx)(i.p,{children:"Alternatively, install from GitHub:"}),"\n",(0,n.jsx)(i.p,{children:"或者从 GitHub 安装:"}),"\n",(0,n.jsx)(i.pre,{tabIndex:"0","data-language":"yaml","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(i.code,{children:[(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:"dependencies"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:":"})]}),"\n",(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:" zero_auth"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:":"})]}),"\n",(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:" git"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:":"})]}),"\n",(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:" url"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:": "}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"https://github.com/zero-labsco/zero_auth.git"})]}),"\n",(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#22863A","--shiki-dark":"#85E89D"},children:" ref"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:": "}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"release/v1.0.0"})]})]})}),"\n",(0,n.jsx)(i.h2,{id:t[2].id,children:t[2].value}),"\n",(0,n.jsx)(i.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsx)(i.code,{children:(0,n.jsxs)(i.span,{children:[(0,n.jsx)(i.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"import"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'package:zero_auth/zero_auth.dart'"}),(0,n.jsx)(i.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:";"})]})})}),"\n",(0,n.jsx)(i.h2,{id:t[3].id,children:t[3].value}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(i.table,{children:[(0,n.jsx)(i.thead,{children:(0,n.jsxs)(i.tr,{children:[(0,n.jsx)(i.th,{children:"Requirement"}),(0,n.jsx)(i.th,{children:"Version"})]})}),(0,n.jsxs)(i.tbody,{children:[(0,n.jsxs)(i.tr,{children:[(0,n.jsx)(i.td,{children:"Dart SDK"}),(0,n.jsx)(i.td,{children:">= 3.4.0"})]}),(0,n.jsxs)(i.tr,{children:[(0,n.jsx)(i.td,{children:"Flutter"}),(0,n.jsx)(i.td,{children:">= 3.0.0 (only a constraint; no Flutter runtime dependency)"})]})]})]}),"\n",(0,n.jsxs)(i.p,{children:[(0,n.jsx)(i.code,{children:"zero_auth"})," is a pure-Dart package. Flutter is only listed because the example app uses it; the core has no Flutter dependency."]}),"\n",(0,n.jsxs)(i.p,{children:[(0,n.jsx)(i.code,{children:"zero_auth"})," 是纯 Dart 包,仅因示例 App 用到 Flutter 才列出;内核本身不依赖 Flutter。"]}),"\n",(0,n.jsx)(i.h2,{id:t[4].id,children:t[4].value}),"\n",(0,n.jsxs)(i.ul,{children:["\n",(0,n.jsxs)(i.li,{children:[(0,n.jsx)(i.a,{href:"Getting-Started",children:"Getting Started"})," — Quick start guide / 快速开始"]}),"\n",(0,n.jsxs)(i.li,{children:[(0,n.jsx)(i.a,{href:"Usage",children:"Usage"})," — Full usage guide / 完整使用指南"]}),"\n"]})]})},"/Installation",{filePath:"pages/Installation.md",timestamp:1789598212e3,pageMap:s.v,frontMatter:{},title:"Installation / 安装"},"undefined"==typeof RemoteContent?l:RemoteContent.useTOC)},7080:function(e,t,i){"use strict";i.d(t,{v:function(){return n}});let n=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(e){e.O(0,[812,888,774,179],function(){return e(e.s=9720)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Multi-Account-2b08a3f8b85100c0.js b/docs/_next/static/chunks/pages/Multi-Account-2b08a3f8b85100c0.js deleted file mode 100644 index d47f017..0000000 --- a/docs/_next/static/chunks/pages/Multi-Account-2b08a3f8b85100c0.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[200],{1233:function(i,s,e){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Multi-Account",function(){return e(3606)}])},3606:function(i,s,e){"use strict";e.r(s),e.d(s,{useTOC:function(){return a}});var n=e(5893),h=e(7812),t=e(7080),r=e(8925);function a(i){return[{value:"First: which kind do you need? / 先分清你需要哪一种",id:"first-which-kind-do-you-need--先分清你需要哪一种",depth:2},{value:"Shape 1: switching / 形态一:切换",id:"shape-1-switching--形态一切换",depth:2},{value:"Shape 2: concurrent / 形态二:并存",id:"shape-2-concurrent--形态二并存",depth:2},{value:"Reading the active account / 读取激活账号",id:"reading-the-active-account--读取激活账号",depth:3},{value:"Restoring on startup / 启动时恢复",id:"restoring-on-startup--启动时恢复",depth:3},{value:"Adding accounts explicitly / 显式新增账号",id:"adding-accounts-explicitly--显式新增账号",depth:3},{value:"Removing an account / 移除账号",id:"removing-an-account--移除账号",depth:3},{value:"Signing everything out / 全部登出",id:"signing-everything-out--全部登出",depth:3},{value:"Teardown / 释放",id:"teardown--释放",depth:3},{value:"Why the core stays single-session / 为什么核心仍是单会话",id:"why-the-core-stays-single-session--为什么核心仍是单会话",depth:2},{value:"Gotchas / 注意点",id:"gotchas--注意点",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}s.default=(0,h.c)(function(i){let{toc:s=a(i)}=i,e={a:"a",code:"code",em:"em",h1:"h1",h2:"h2",h3:"h3",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,r.a)(),...i.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(e.h1,{children:"Multiple Accounts / 多账号"}),"\n",(0,n.jsx)(e.h2,{id:s[0].id,children:s[0].value}),"\n",(0,n.jsxs)(e.p,{children:["“Multiple accounts” means two very different things, and they do ",(0,n.jsx)(e.strong,{children:"not"})," need the\r\nsame solution:"]}),"\n",(0,n.jsxs)(e.p,{children:["「多账号」其实指两种截然不同的需求,它们的解法",(0,n.jsx)(e.strong,{children:"不一样"}),":"]}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(e.table,{children:[(0,n.jsx)(e.thead,{children:(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.th,{children:"Shape"}),(0,n.jsx)(e.th,{children:"Description"}),(0,n.jsx)(e.th,{children:"Recommendation"})]})}),(0,n.jsxs)(e.tbody,{children:[(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.strong,{children:"Switching"})," / 账号切换"]}),(0,n.jsx)(e.td,{children:"Several accounts are remembered, but only one is used at a time (like Gmail’s account picker) / 记住多个账号,但同时只用一个"}),(0,n.jsxs)(e.td,{children:["Plain ",(0,n.jsx)(e.code,{children:"AuthManager"}),": ",(0,n.jsx)(e.code,{children:"logout()"})," then ",(0,n.jsx)(e.code,{children:"login()"}),". No new API. / 普通 ",(0,n.jsx)(e.code,{children:"AuthManager"}),":登出再登录,不需要新 API"]})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.strong,{children:"Concurrent"})," / 账号并存"]}),(0,n.jsxs)(e.td,{children:["Several accounts are signed in ",(0,n.jsx)(e.em,{children:"at the same time"})," and all can make requests / 多个账号同时登录,且都能发请求"]}),(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"AuthManagerGroup"})," — one ",(0,n.jsx)(e.code,{children:"AuthManager"})," per account"]})]})]})]}),"\n",(0,n.jsxs)(e.p,{children:["Most apps only need the first. Reach for ",(0,n.jsx)(e.code,{children:"AuthManagerGroup"})," only when an account\r\nmust keep working while another one is in the foreground."]}),"\n",(0,n.jsxs)(e.p,{children:["多数应用只需要第一种。只有当「某个账号在后台也要继续工作」时,才需要 ",(0,n.jsx)(e.code,{children:"AuthManagerGroup"}),"。"]}),"\n",(0,n.jsx)(e.h2,{id:s[1].id,children:s[1].value}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthManager"})," models one session. Switching accounts is just logging out and back\r\nin — the state machine stays honest, and there is no second source of truth:"]}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// Keep your own list of saved account identifiers (email, phone, …)."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// 自己维护已保存账号标识(邮箱、手机号等)的列表。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" savedAccounts "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" <"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"String"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:">"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"["}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'alice@example.com'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:", "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'bob@example.com'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"];"})]}),"\n",(0,n.jsx)(e.span,{children:" "}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"Future"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"void"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"> "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"switchTo"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"String"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" account, "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"String"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" password) "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"async"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" if"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (auth.current.isAuthenticated) "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"logout"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"();"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"login"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"Credentials"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(username"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" account, password"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" password));"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,n.jsxs)(e.p,{children:["Because ",(0,n.jsx)(e.code,{children:"username"})," is just an opaque identifier, this works for email or phone\r\nlogins with no extra plumbing — see\r\n",(0,n.jsx)(e.a,{href:"Backend-Strategy",children:"Backend Strategy"})," for what the contract fixes."]}),"\n",(0,n.jsx)(e.h2,{id:s[2].id,children:s[2].value}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthManagerGroup"})," owns one ",(0,n.jsx)(e.code,{children:"AuthManager"})," per account and tracks which is active."]}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManagerGroup"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // Both factories receive the account id; sharing one strategy instance is fine."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // 两个工厂都会收到账号 id;共用一个策略实例也没问题。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategyFactory"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (accountId) "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" MyAuthStrategy"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(),"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // CRITICAL: one store per account, so persisted sessions stay isolated."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // 关键:每个账号一个存储,持久化会话才不会互相覆盖。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" storeFactory"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (accountId) "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" SecureTokenStore"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(key"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'auth_"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"$"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"accountId"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"),"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})}),"\n",(0,n.jsx)(e.span,{children:" "}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// Sign in (or restore) each account independently."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// 各账号独立登录(或恢复)。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"forAccount"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'alice'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:")."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"login"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Credentials"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(username"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'alice@example.com'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:", password"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" pw),"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"forAccount"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'bob'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:")."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"login"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Credentials"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(username"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'bob@example.com'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:", password"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" pw),"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})}),"\n",(0,n.jsx)(e.span,{children:" "}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// Pick the active one — nothing is signed out by this call."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// 选择激活账号 —— 此调用不会登出任何账号。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"switchTo"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'bob'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})]})]})}),"\n",(0,n.jsx)(e.h3,{id:s[3].id,children:s[3].value}),"\n",(0,n.jsxs)(e.p,{children:["The group mirrors the active account and implements ",(0,n.jsx)(e.code,{children:"AuthTokenSource"}),", so\r\ninterceptors keep depending on the narrow interface:"]}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"group.current "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// active account's AuthState / 激活账号的状态"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"group.currentSession "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// active account's session / 激活账号的会话"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"group.accessToken "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// active account's token / 激活账号的令牌"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"group.state "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// stream that follows the active account / 跟随激活账号的流"})]}),"\n",(0,n.jsx)(e.span,{children:" "}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"dio.interceptors."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"add"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AuthInterceptor"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(group)); "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// reads the active token / 读激活账号令牌"})]})]})}),"\n",(0,n.jsx)(e.h3,{id:s[4].id,children:s[4].value}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// `knownIds` comes from your own saved-account list."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// `knownIds` 来自你自己保存的账号列表。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"restoreAll"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(knownIds, activeId"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" lastUsedId);"})]})]})}),"\n",(0,n.jsx)(e.h3,{id:s[5].id,children:s[5].value}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"forAccount"})," creates on first use; ",(0,n.jsx)(e.code,{children:"addAccount"})," is the same call with a name that\r\nreads better when you mean “register an account here”:"]}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"forAccount"})," 首次使用时创建;",(0,n.jsx)(e.code,{children:"addAccount"})," 是同一个调用,只是名字在「这里注册账号」的\r\n语境下更好读:"]}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsx)(e.code,{children:(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" alice "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"addAccount"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'alice'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})]})})}),"\n",(0,n.jsx)(e.h3,{id:s[6].id,children:s[6].value}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsx)(e.code,{children:(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"remove"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'alice'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"); "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// logs out, disposes, forgets it"})]})})}),"\n",(0,n.jsx)(e.h3,{id:s[7].id,children:s[7].value}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsx)(e.code,{children:(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"logoutAll"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(); "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// every account, in turn"})]})})}),"\n",(0,n.jsxs)(e.p,{children:["If the removed account was active, the group becomes inactive and emits\r\n",(0,n.jsx)(e.code,{children:"Unauthenticated"}),"."]}),"\n",(0,n.jsx)(e.h3,{id:s[8].id,children:s[8].value}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsx)(e.code,{children:(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"disposeAll"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(); "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// disposes every manager and closes the stream"})]})})}),"\n",(0,n.jsx)(e.h2,{id:s[9].id,children:s[9].value}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthManager"})," answers a singular question: ",(0,n.jsx)(e.strong,{children:"who is logged in?"})," Multi-account asks\r\na different one: ",(0,n.jsx)(e.strong,{children:"of these signed-in identities, which is active?"})]}),"\n",(0,n.jsxs)(e.p,{children:["Folding the second into the first would mean a ",(0,n.jsx)(e.code,{children:"Map"})," of sessions inside every\r\nstate, a ",(0,n.jsx)(e.code,{children:"Refreshing"})," that needs an account id, and a breaking change for every\r\nexisting user. Keeping ",(0,n.jsx)(e.code,{children:"AuthManager"})," single-session and adding an opt-in\r\ncoordinator gives you both without muddying either."]}),"\n",(0,n.jsxs)(e.p,{children:["把第二个问题塞进第一个,会导致每个状态里都带一个会话 ",(0,n.jsx)(e.code,{children:"Map"}),"、",(0,n.jsx)(e.code,{children:"Refreshing"})," 需要携带账号\r\nid,并且对所有现有用户构成破坏性变更。让 ",(0,n.jsx)(e.code,{children:"AuthManager"})," 保持单会话、再提供一个可选的\r\n协调层,两者都能得到,且互不污染。"]}),"\n",(0,n.jsx)(e.h2,{id:s[10].id,children:s[10].value}),"\n",(0,n.jsxs)(e.ul,{children:["\n",(0,n.jsxs)(e.li,{children:[(0,n.jsxs)(e.strong,{children:["Never share one ",(0,n.jsx)(e.code,{children:"TokenStore"})," between accounts"]})," — the second ",(0,n.jsx)(e.code,{children:"save()"})," would\r\noverwrite the first session. / ",(0,n.jsxs)(e.strong,{children:["绝不要在多个账号间共用一个 ",(0,n.jsx)(e.code,{children:"TokenStore"})]}),",第二次\r\n",(0,n.jsx)(e.code,{children:"save()"})," 会覆盖第一个会话。"]}),"\n",(0,n.jsx)(e.li,{children:"Switching does not sign anyone out; all managers keep refreshing in the\r\nbackground. / 切换不会登出任何账号,所有管理器仍会在后台续期。"}),"\n",(0,n.jsxs)(e.li,{children:[(0,n.jsx)(e.code,{children:"remove()"})," also disposes that manager — do not use it afterwards. /\r\n",(0,n.jsx)(e.code,{children:"remove()"})," 会同时释放该管理器,之后不要再使用它。"]}),"\n"]}),"\n",(0,n.jsx)(e.h2,{id:s[11].id,children:s[11].value}),"\n",(0,n.jsxs)(e.ul,{children:["\n",(0,n.jsxs)(e.li,{children:[(0,n.jsx)(e.a,{href:"Auth-State-Machine",children:"Auth State Machine"})," — what each manager emits / 各管理器会发出什么"]}),"\n",(0,n.jsxs)(e.li,{children:[(0,n.jsx)(e.a,{href:"Token-Store",children:"Token Store"})," — keying persisted sessions per account / 按账号隔离持久化会话"]}),"\n",(0,n.jsxs)(e.li,{children:[(0,n.jsx)(e.a,{href:"Backend-Strategy",children:"Backend Strategy"})," — what the contract fixes / 契约固定了什么"]}),"\n"]})]})},"/Multi-Account",{filePath:"pages/Multi-Account.md",timestamp:1789871655e3,pageMap:t.v,frontMatter:{},title:"Multiple Accounts / 多账号"},"undefined"==typeof RemoteContent?a:RemoteContent.useTOC)},7080:function(i,s,e){"use strict";e.d(s,{v:function(){return n}});let n=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(i){i.O(0,[812,888,774,179],function(){return i(i.s=1233)}),_N_E=i.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Multi-Account-bd727b2ad9f2f451.js b/docs/_next/static/chunks/pages/Multi-Account-bd727b2ad9f2f451.js new file mode 100644 index 0000000..df190bb --- /dev/null +++ b/docs/_next/static/chunks/pages/Multi-Account-bd727b2ad9f2f451.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[200],{1233:function(i,s,e){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Multi-Account",function(){return e(3606)}])},3606:function(i,s,e){"use strict";e.r(s),e.d(s,{useTOC:function(){return r}});var n=e(5893),h=e(7812),t=e(7080),a=e(8925);function r(i){return[{value:"First: which kind do you need? / 先分清你需要哪一种",id:"first-which-kind-do-you-need--先分清你需要哪一种",depth:2},{value:"Shape 1: switching / 形态一:切换",id:"shape-1-switching--形态一切换",depth:2},{value:"Shape 2: concurrent / 形态二:并存",id:"shape-2-concurrent--形态二并存",depth:2},{value:"Reading the active account / 读取激活账号",id:"reading-the-active-account--读取激活账号",depth:3},{value:"Restoring on startup / 启动时恢复",id:"restoring-on-startup--启动时恢复",depth:3},{value:"Adding accounts explicitly / 显式新增账号",id:"adding-accounts-explicitly--显式新增账号",depth:3},{value:"Removing an account / 移除账号",id:"removing-an-account--移除账号",depth:3},{value:"Signing everything out / 全部登出",id:"signing-everything-out--全部登出",depth:3},{value:"Teardown / 释放",id:"teardown--释放",depth:3},{value:"Why the core stays single-session / 为什么核心仍是单会话",id:"why-the-core-stays-single-session--为什么核心仍是单会话",depth:2},{value:"Gotchas / 注意点",id:"gotchas--注意点",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}s.default=(0,h.c)(function(i){let{toc:s=r(i)}=i,e={a:"a",code:"code",em:"em",h1:"h1",h2:"h2",h3:"h3",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,a.a)(),...i.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(e.h1,{children:"Multiple Accounts / 多账号"}),"\n",(0,n.jsx)(e.h2,{id:s[0].id,children:s[0].value}),"\n",(0,n.jsxs)(e.p,{children:["“Multiple accounts” means two very different things, and they do ",(0,n.jsx)(e.strong,{children:"not"})," need the\r\nsame solution:"]}),"\n",(0,n.jsxs)(e.p,{children:["「多账号」其实指两种截然不同的需求,它们的解法",(0,n.jsx)(e.strong,{children:"不一样"}),":"]}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,n.jsxs)(e.table,{children:[(0,n.jsx)(e.thead,{children:(0,n.jsxs)(e.tr,{children:[(0,n.jsx)(e.th,{children:"Shape"}),(0,n.jsx)(e.th,{children:"Description"}),(0,n.jsx)(e.th,{children:"Recommendation"})]})}),(0,n.jsxs)(e.tbody,{children:[(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.strong,{children:"Switching"})," / 账号切换"]}),(0,n.jsx)(e.td,{children:"Several accounts are remembered, but only one is used at a time (like Gmail’s account picker) / 记住多个账号,但同时只用一个"}),(0,n.jsxs)(e.td,{children:["Plain ",(0,n.jsx)(e.code,{children:"AuthManager"}),": ",(0,n.jsx)(e.code,{children:"logout()"})," then ",(0,n.jsx)(e.code,{children:"login()"}),". No new API. / 普通 ",(0,n.jsx)(e.code,{children:"AuthManager"}),":登出再登录,不需要新 API"]})]}),(0,n.jsxs)(e.tr,{children:[(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.strong,{children:"Concurrent"})," / 账号并存"]}),(0,n.jsxs)(e.td,{children:["Several accounts are signed in ",(0,n.jsx)(e.em,{children:"at the same time"})," and all can make requests / 多个账号同时登录,且都能发请求"]}),(0,n.jsxs)(e.td,{children:[(0,n.jsx)(e.code,{children:"AuthManagerGroup"})," — one ",(0,n.jsx)(e.code,{children:"AuthManager"})," per account"]})]})]})]}),"\n",(0,n.jsxs)(e.p,{children:["Most apps only need the first. Reach for ",(0,n.jsx)(e.code,{children:"AuthManagerGroup"})," only when an account\r\nmust keep working while another one is in the foreground."]}),"\n",(0,n.jsxs)(e.p,{children:["多数应用只需要第一种。只有当「某个账号在后台也要继续工作」时,才需要 ",(0,n.jsx)(e.code,{children:"AuthManagerGroup"}),"。"]}),"\n",(0,n.jsx)(e.h2,{id:s[1].id,children:s[1].value}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthManager"})," models one session. Switching accounts is just logging out and back\r\nin — the state machine stays honest, and there is no second source of truth:"]}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// Keep your own list of saved account identifiers (email, phone, …)."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// 自己维护已保存账号标识(邮箱、手机号等)的列表。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" savedAccounts "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" <"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"String"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:">"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"["}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'alice@example.com'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:", "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'bob@example.com'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"];"})]}),"\n",(0,n.jsx)(e.span,{children:" "}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"Future"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"void"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"> "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"switchTo"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"String"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" account, "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"String"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" password) "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"async"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" if"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (auth.current.isAuthenticated) "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"logout"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"();"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"login"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"Credentials"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(username"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" account, password"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" password));"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,n.jsxs)(e.p,{children:["Because ",(0,n.jsx)(e.code,{children:"username"})," is just an opaque identifier, this works for email or phone\r\nlogins with no extra plumbing — see\r\n",(0,n.jsx)(e.a,{href:"Backend-Strategy",children:"Backend Strategy"})," for what the contract fixes."]}),"\n",(0,n.jsx)(e.h2,{id:s[2].id,children:s[2].value}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthManagerGroup"})," owns one ",(0,n.jsx)(e.code,{children:"AuthManager"})," per account and tracks which is active."]}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManagerGroup"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // Both factories receive the account id; sharing one strategy instance is fine."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // 两个工厂都会收到账号 id;共用一个策略实例也没问题。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategyFactory"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (accountId) "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" MyAuthStrategy"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(),"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // CRITICAL: one store per account, so persisted sessions stay isolated."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // 关键:每个账号一个存储,持久化会话才不会互相覆盖。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" storeFactory"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (accountId) "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" SecureTokenStore"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(key"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'auth_"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"$"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"accountId"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"),"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // Optional: every AuthManager knob is forwarded to the managers it creates."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // 可选:所有 AuthManager 调参都会转发给它创建的管理器。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" autoRefreshAhead"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Duration"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(minutes"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" 5"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"),"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" clockSkew"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Duration"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(seconds"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" 30"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"),"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" onStateChanged"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (accountId, state) "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:" debugPrint"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"$"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"accountId"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" → "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"$"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"state"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"),"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // Or build them yourself / 也可自行构建:"})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // managerFactory: (id, strategy, store) => AuthManager(...),"})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})}),"\n",(0,n.jsx)(e.span,{children:" "}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// Sign in (or restore) each account independently."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// 各账号独立登录(或恢复)。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"forAccount"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'alice'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:")."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"login"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Credentials"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(username"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'alice@example.com'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:", password"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" pw),"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"forAccount"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'bob'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:")."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"login"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Credentials"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(username"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'bob@example.com'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:", password"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" pw),"})]}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})}),"\n",(0,n.jsx)(e.span,{children:" "}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// Pick the active one — nothing is signed out by this call."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// 选择激活账号 —— 此调用不会登出任何账号。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"switchTo"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'bob'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})]})]})}),"\n",(0,n.jsx)(e.h3,{id:s[3].id,children:s[3].value}),"\n",(0,n.jsxs)(e.p,{children:["The group mirrors the active account and implements ",(0,n.jsx)(e.code,{children:"AuthTokenSource"}),", so\r\ninterceptors keep depending on the narrow interface:"]}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"group.current "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// active account's AuthState / 激活账号的状态"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"group.currentSession "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// active account's session / 激活账号的会话"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"group.accessToken "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// active account's token / 激活账号的令牌"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"group.state "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// stream that follows the active account / 跟随激活账号的流"})]}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"group.activeIdChanges "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// stream of the active account id / 激活账号 id 的流"})]}),"\n",(0,n.jsx)(e.span,{children:" "}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"validAccessToken"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(); "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// renewed token of the active account / 激活账号续期后的令牌"})]}),"\n",(0,n.jsx)(e.span,{children:" "}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"dio.interceptors."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"add"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"RefreshingAuthInterceptor"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(group)); "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// renews then attaches / 先续期再附加"})]})]})}),"\n",(0,n.jsx)(e.h3,{id:s[4].id,children:s[4].value}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(e.code,{children:[(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// `knownIds` comes from your own saved-account list."})}),"\n",(0,n.jsx)(e.span,{children:(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// `knownIds` 来自你自己保存的账号列表。"})}),"\n",(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"restoreAll"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(knownIds, activeId"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" lastUsedId);"})]})]})}),"\n",(0,n.jsx)(e.p,{children:"One account failing to restore does not abandon the rest: every account is\r\nattempted and the first error is thrown at the end."}),"\n",(0,n.jsx)(e.p,{children:"某个账号恢复失败不会连累其余账号:所有账号都会被尝试,最后统一抛出第一个错误。"}),"\n",(0,n.jsx)(e.h3,{id:s[5].id,children:s[5].value}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"forAccount"})," creates on first use; ",(0,n.jsx)(e.code,{children:"addAccount"})," is the same call with a name that\r\nreads better when you mean “register an account here”:"]}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"forAccount"})," 首次使用时创建;",(0,n.jsx)(e.code,{children:"addAccount"})," 是同一个调用,只是名字在「这里注册账号」的\r\n语境下更好读:"]}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsx)(e.code,{children:(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" alice "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"addAccount"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'alice'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})]})})}),"\n",(0,n.jsx)(e.h3,{id:s[6].id,children:s[6].value}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsx)(e.code,{children:(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"remove"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'alice'"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"); "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// logs out, disposes, forgets it"})]})})}),"\n",(0,n.jsx)(e.h3,{id:s[7].id,children:s[7].value}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsx)(e.code,{children:(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"logoutAll"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(); "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// every account, in turn"})]})})}),"\n",(0,n.jsxs)(e.p,{children:["If the removed account was active, the group becomes inactive and emits\r\n",(0,n.jsx)(e.code,{children:"Unauthenticated"}),"."]}),"\n",(0,n.jsx)(e.h3,{id:s[8].id,children:s[8].value}),"\n",(0,n.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsx)(e.code,{children:(0,n.jsxs)(e.span,{children:[(0,n.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" group."}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"disposeAll"}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(); "}),(0,n.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// disposes every manager and closes the stream"})]})})}),"\n",(0,n.jsx)(e.h2,{id:s[9].id,children:s[9].value}),"\n",(0,n.jsxs)(e.p,{children:[(0,n.jsx)(e.code,{children:"AuthManager"})," answers a singular question: ",(0,n.jsx)(e.strong,{children:"who is logged in?"})," Multi-account asks\r\na different one: ",(0,n.jsx)(e.strong,{children:"of these signed-in identities, which is active?"})]}),"\n",(0,n.jsxs)(e.p,{children:["Folding the second into the first would mean a ",(0,n.jsx)(e.code,{children:"Map"})," of sessions inside every\r\nstate, a ",(0,n.jsx)(e.code,{children:"Refreshing"})," that needs an account id, and a breaking change for every\r\nexisting user. Keeping ",(0,n.jsx)(e.code,{children:"AuthManager"})," single-session and adding an opt-in\r\ncoordinator gives you both without muddying either."]}),"\n",(0,n.jsxs)(e.p,{children:["把第二个问题塞进第一个,会导致每个状态里都带一个会话 ",(0,n.jsx)(e.code,{children:"Map"}),"、",(0,n.jsx)(e.code,{children:"Refreshing"})," 需要携带账号\r\nid,并且对所有现有用户构成破坏性变更。让 ",(0,n.jsx)(e.code,{children:"AuthManager"})," 保持单会话、再提供一个可选的\r\n协调层,两者都能得到,且互不污染。"]}),"\n",(0,n.jsx)(e.h2,{id:s[10].id,children:s[10].value}),"\n",(0,n.jsxs)(e.ul,{children:["\n",(0,n.jsxs)(e.li,{children:[(0,n.jsxs)(e.strong,{children:["Never share one ",(0,n.jsx)(e.code,{children:"TokenStore"})," between accounts"]})," — the second ",(0,n.jsx)(e.code,{children:"save()"})," would\r\noverwrite the first session. / ",(0,n.jsxs)(e.strong,{children:["绝不要在多个账号间共用一个 ",(0,n.jsx)(e.code,{children:"TokenStore"})]}),",第二次\r\n",(0,n.jsx)(e.code,{children:"save()"})," 会覆盖第一个会话。"]}),"\n",(0,n.jsx)(e.li,{children:"Switching does not sign anyone out; all managers keep refreshing in the\r\nbackground. / 切换不会登出任何账号,所有管理器仍会在后台续期。"}),"\n",(0,n.jsxs)(e.li,{children:[(0,n.jsx)(e.code,{children:"remove()"})," also disposes that manager — do not use it afterwards. /\r\n",(0,n.jsx)(e.code,{children:"remove()"})," 会同时释放该管理器,之后不要再使用它。"]}),"\n"]}),"\n",(0,n.jsx)(e.h2,{id:s[11].id,children:s[11].value}),"\n",(0,n.jsxs)(e.ul,{children:["\n",(0,n.jsxs)(e.li,{children:[(0,n.jsx)(e.a,{href:"Auth-State-Machine",children:"Auth State Machine"})," — what each manager emits / 各管理器会发出什么"]}),"\n",(0,n.jsxs)(e.li,{children:[(0,n.jsx)(e.a,{href:"Token-Store",children:"Token Store"})," — keying persisted sessions per account / 按账号隔离持久化会话"]}),"\n",(0,n.jsxs)(e.li,{children:[(0,n.jsx)(e.a,{href:"Backend-Strategy",children:"Backend Strategy"})," — what the contract fixes / 契约固定了什么"]}),"\n"]})]})},"/Multi-Account",{filePath:"pages/Multi-Account.md",timestamp:1789873037e3,pageMap:t.v,frontMatter:{},title:"Multiple Accounts / 多账号"},"undefined"==typeof RemoteContent?r:RemoteContent.useTOC)},7080:function(i,s,e){"use strict";e.d(s,{v:function(){return n}});let n=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(i){i.O(0,[812,888,774,179],function(){return i(i.s=1233)}),_N_E=i.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Network-Integration-402a7d4df93d13c8.js b/docs/_next/static/chunks/pages/Network-Integration-402a7d4df93d13c8.js new file mode 100644 index 0000000..8d47262 --- /dev/null +++ b/docs/_next/static/chunks/pages/Network-Integration-402a7d4df93d13c8.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[773],{7293:function(i,e,s){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Network-Integration",function(){return s(2063)}])},2063:function(i,e,s){"use strict";s.r(e),s.d(e,{useTOC:function(){return a}});var n=s(5893),t=s(7812),r=s(7080),h=s(8925);function a(i){let e={code:"code",...(0,h.a)()};return[{value:(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(e.code,{children:"AuthTokenSource"})," / 令牌源"]}),id:"authtokensource--令牌源",depth:2},{value:"Dio interceptor / Dio 拦截器",id:"dio-interceptor--dio-拦截器",depth:2},{value:"Other HTTP clients / 其它客户端",id:"other-http-clients--其它客户端",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}e.default=(0,t.c)(function(i){let{toc:e=a(i)}=i,s={a:"a",blockquote:"blockquote",code:"code",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",ul:"ul",...(0,h.a)(),...i.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(s.h1,{children:"Network Integration / 网络集成"}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthManager"})," implements ",(0,n.jsx)(s.code,{children:"AuthTokenSource"}),", so HTTP clients can read the current access token ",(0,n.jsx)(s.strong,{children:"without depending on the manager object"}),". This keeps your Dio setup decoupled from ",(0,n.jsx)(s.code,{children:"zero_auth"}),"."]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthManager"})," 实现了 ",(0,n.jsx)(s.code,{children:"AuthTokenSource"}),",因此 HTTP 客户端可在",(0,n.jsx)(s.strong,{children:"不依赖管理器对象"}),"的前提下读取当前访问令牌,使 Dio 配置与 ",(0,n.jsx)(s.code,{children:"zero_auth"})," 解耦。"]}),"\n",(0,n.jsx)(s.h2,{id:e[0].id,children:e[0].value}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"abstract"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthTokenSource"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" String"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"?"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" get"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" accessToken; "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// may be expired / 可能已过期"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"String"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"?> "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"validAccessToken"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"({"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"Duration"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"?"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" leeway}); "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// renewed first / 先续期"})]}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthManager.accessToken"})," returns the current token, or ",(0,n.jsx)(s.code,{children:"null"})," when unauthenticated — and it may already be expired."]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthManager.accessToken"})," 返回当前令牌;未认证时返回 ",(0,n.jsx)(s.code,{children:"null"})," —— 而且它可能已经过期。"]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"validAccessToken()"})," is the one to use before a request: it renews first (reusing\nthe single-flight refresh) and returns ",(0,n.jsx)(s.code,{children:"null"})," only when there is nothing to send.\n",(0,n.jsx)(s.code,{children:"leeway"})," is how long the token must stay valid for, defaulting to the manager’s\n",(0,n.jsx)(s.code,{children:"clockSkew"})," (30s), so a token that would die mid-request is renewed first."]}),"\n",(0,n.jsxs)(s.p,{children:["请求之前应当用 ",(0,n.jsx)(s.code,{children:"validAccessToken()"}),":它会在必要时先续期(复用单飞刷新),只有真的无令牌\n可发时才返回 ",(0,n.jsx)(s.code,{children:"null"}),"。",(0,n.jsx)(s.code,{children:"leeway"})," 表示令牌必须还能维持有效的时长,默认取管理器的\n",(0,n.jsx)(s.code,{children:"clockSkew"}),"(30 秒),因此会在请求途中失效的令牌会被提前续期。"]}),"\n",(0,n.jsx)(s.h2,{id:e[1].id,children:e[1].value}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"example/lib/dio_interceptor.dart"})," ships a ready-to-use interceptor:"]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"example/lib/dio_interceptor.dart"})," 提供了一个开箱即用的拦截器:"]}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"class"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthInterceptor"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Interceptor"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthInterceptor"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"this"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:".tokens); "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// an AuthTokenSource / 一个令牌源"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthTokenSource"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" tokens;"})]}),"\n",(0,n.jsx)(s.span,{children:" "}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" @override"})}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" void"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:" onRequest"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"RequestOptions"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" o, "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"RequestInterceptorHandler"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" h) "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"async"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" t "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" await"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" tokens."}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"validAccessToken"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(); "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// renews first / 先续期"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" if"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (t "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"!="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" null"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:") o.headers["}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'Authorization'"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"] "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'Bearer "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"$"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"t"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:";"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" h."}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"next"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(o);"})]}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" }"})}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,n.jsxs)(s.blockquote,{children:["\n",(0,n.jsxs)(s.p,{children:["If you only want the synchronous read, use ",(0,n.jsx)(s.code,{children:"tokens.accessToken"})," — but remember\nit may hand you a token that has already expired.\n若只需要同步读取,可用 ",(0,n.jsx)(s.code,{children:"tokens.accessToken"})," —— 但请记住它可能返回一个已过期的令牌。"]}),"\n"]}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" dio "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Dio"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"()"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" ..interceptors."}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"add"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AuthInterceptor"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(authManager));"})]})]})}),"\n",(0,n.jsxs)(s.p,{children:["When a ",(0,n.jsx)(s.code,{children:"401"})," is returned, refresh the session and retry; if refresh fails, the manager emits ",(0,n.jsx)(s.code,{children:"AuthError"})," and your app routes back to login."]}),"\n",(0,n.jsxs)(s.p,{children:["当返回 ",(0,n.jsx)(s.code,{children:"401"})," 时,刷新会话并重试;若刷新失败,管理器会发出 ",(0,n.jsx)(s.code,{children:"AuthError"}),",应用随之跳转登录。"]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"example/lib/dio_interceptor.dart"})," also ships ",(0,n.jsx)(s.code,{children:"AuthRetryInterceptor"}),", which does\nexactly that — refreshes through the manager (single-flight) and replays the\nrequest ",(0,n.jsx)(s.strong,{children:"once"}),", so a retry loop cannot form:"]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"example/lib/dio_interceptor.dart"})," 里还提供了 ",(0,n.jsx)(s.code,{children:"AuthRetryInterceptor"}),",正是做这件事 ——\n通过管理器刷新(单飞)并把请求",(0,n.jsx)(s.strong,{children:"重试一次"}),",因此不会形成重试风暴:"]}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" dio "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Dio"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"()"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" ..interceptors."}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"add"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthRetryInterceptor"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(manager"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" authManager, dio"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" dio),"})]}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" );"})})]})}),"\n",(0,n.jsx)(s.p,{children:"Two more interceptor variants there / 那里还有另外两种拦截器:"}),"\n",(0,n.jsxs)(s.ul,{children:["\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.code,{children:"RefreshingAuthInterceptor"})," — never sends an expired token: it renews first via\n",(0,n.jsx)(s.code,{children:"validAccessToken()"})," / 绝不发送过期令牌,先用 ",(0,n.jsx)(s.code,{children:"validAccessToken()"})," 续期。"]}),"\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.code,{children:"AuthInterceptor"})," — the plain ",(0,n.jsx)(s.code,{children:"AuthTokenSource"})," version / 基础的令牌源版本。"]}),"\n"]}),"\n",(0,n.jsx)(s.h2,{id:e[2].id,children:e[2].value}),"\n",(0,n.jsxs)(s.p,{children:["Because ",(0,n.jsx)(s.code,{children:"AuthTokenSource"})," is a plain interface, the same pattern works for ",(0,n.jsx)(s.code,{children:"package:http"}),", ",(0,n.jsx)(s.code,{children:"chopper"}),", or any client that lets you mutate request headers."]}),"\n",(0,n.jsxs)(s.p,{children:["由于 ",(0,n.jsx)(s.code,{children:"AuthTokenSource"})," 是普通接口,同样的模式适用于 ",(0,n.jsx)(s.code,{children:"package:http"}),"、",(0,n.jsx)(s.code,{children:"chopper"})," 或任何允许修改请求头的客户端。"]}),"\n",(0,n.jsx)(s.h2,{id:e[3].id,children:e[3].value}),"\n",(0,n.jsxs)(s.ul,{children:["\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.a,{href:"Usage",children:"Usage"})," — Full integration walkthrough / 完整集成讲解"]}),"\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.a,{href:"Errors",children:"Errors"})," — Handling ",(0,n.jsx)(s.code,{children:"401"})," and refresh failures / 处理 401 与刷新失败"]}),"\n"]})]})},"/Network-Integration",{filePath:"pages/Network-Integration.md",timestamp:1789873037e3,pageMap:r.v,frontMatter:{},title:"Network Integration / 网络集成"},"undefined"==typeof RemoteContent?a:RemoteContent.useTOC)},7080:function(i,e,s){"use strict";s.d(e,{v:function(){return n}});let n=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(i){i.O(0,[812,888,774,179],function(){return i(i.s=7293)}),_N_E=i.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Network-Integration-ef3ead93a51929f6.js b/docs/_next/static/chunks/pages/Network-Integration-ef3ead93a51929f6.js deleted file mode 100644 index 1a0ab65..0000000 --- a/docs/_next/static/chunks/pages/Network-Integration-ef3ead93a51929f6.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[773],{7293:function(i,e,s){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Network-Integration",function(){return s(2063)}])},2063:function(i,e,s){"use strict";s.r(e),s.d(e,{useTOC:function(){return a}});var n=s(5893),t=s(7812),r=s(7080),h=s(8925);function a(i){let e={code:"code",...(0,h.a)()};return[{value:(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(e.code,{children:"AuthTokenSource"})," / 令牌源"]}),id:"authtokensource--令牌源",depth:2},{value:"Dio interceptor / Dio 拦截器",id:"dio-interceptor--dio-拦截器",depth:2},{value:"Other HTTP clients / 其它客户端",id:"other-http-clients--其它客户端",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}e.default=(0,t.c)(function(i){let{toc:e=a(i)}=i,s={a:"a",code:"code",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",ul:"ul",...(0,h.a)(),...i.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(s.h1,{children:"Network Integration / 网络集成"}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthManager"})," implements ",(0,n.jsx)(s.code,{children:"AuthTokenSource"}),", so HTTP clients can read the current access token ",(0,n.jsx)(s.strong,{children:"without depending on the manager object"}),". This keeps your Dio setup decoupled from ",(0,n.jsx)(s.code,{children:"zero_auth"}),"."]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthManager"})," 实现了 ",(0,n.jsx)(s.code,{children:"AuthTokenSource"}),",因此 HTTP 客户端可在",(0,n.jsx)(s.strong,{children:"不依赖管理器对象"}),"的前提下读取当前访问令牌,使 Dio 配置与 ",(0,n.jsx)(s.code,{children:"zero_auth"})," 解耦。"]}),"\n",(0,n.jsx)(s.h2,{id:e[0].id,children:e[0].value}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"abstract"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthTokenSource"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"String"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"?> "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"get"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" accessToken;"})]}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthManager.accessToken"})," returns the current token, or ",(0,n.jsx)(s.code,{children:"null"})," when unauthenticated. It can also trigger a transparent refresh when the token is near expiry (implementation-dependent)."]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthManager.accessToken"})," 返回当前令牌;未认证时返回 ",(0,n.jsx)(s.code,{children:"null"}),"。在令牌接近过期时,它还可触发一次透明的刷新(取决于实现)。"]}),"\n",(0,n.jsx)(s.h2,{id:e[1].id,children:e[1].value}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"example/lib/dio_interceptor.dart"})," ships a ready-to-use interceptor:"]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"example/lib/dio_interceptor.dart"})," 提供了一个开箱即用的拦截器:"]}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"class"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthInterceptor"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" extends"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Interceptor"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthInterceptor"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"this"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:".tokens); "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// an AuthTokenSource"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthTokenSource"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" tokens;"})]}),"\n",(0,n.jsx)(s.span,{children:" "}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" @override"})}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" void"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:" onRequest"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"RequestOptions"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" o, "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"RequestInterceptorHandler"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" h) "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"async"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" t "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" await"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" tokens.accessToken;"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" if"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" (t "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"!="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" null"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:") o.headers["}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'Authorization'"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"] "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'Bearer "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"$"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"t"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:";"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" h."}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"next"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(o);"})]}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" }"})}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" dio "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Dio"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"()"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" ..interceptors."}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"add"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AuthInterceptor"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(authManager));"})]})]})}),"\n",(0,n.jsxs)(s.p,{children:["When a ",(0,n.jsx)(s.code,{children:"401"})," is returned, refresh the session and retry; if refresh fails, the manager emits ",(0,n.jsx)(s.code,{children:"AuthError"})," and your app routes back to login."]}),"\n",(0,n.jsxs)(s.p,{children:["当返回 ",(0,n.jsx)(s.code,{children:"401"})," 时,刷新会话并重试;若刷新失败,管理器会发出 ",(0,n.jsx)(s.code,{children:"AuthError"}),",应用随之跳转登录。"]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"example/lib/dio_interceptor.dart"})," also ships ",(0,n.jsx)(s.code,{children:"AuthRetryInterceptor"}),", which does\nexactly that — refreshes through the manager (single-flight) and replays the\nrequest ",(0,n.jsx)(s.strong,{children:"once"}),", so a retry loop cannot form:"]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"example/lib/dio_interceptor.dart"})," 里还提供了 ",(0,n.jsx)(s.code,{children:"AuthRetryInterceptor"}),",正是做这件事 ——\n通过管理器刷新(单飞)并把请求",(0,n.jsx)(s.strong,{children:"重试一次"}),",因此不会形成重试风暴:"]}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" dio "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Dio"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"()"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" ..interceptors."}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"add"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthRetryInterceptor"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(manager"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" authManager, dio"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" dio),"})]}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" );"})})]})}),"\n",(0,n.jsx)(s.p,{children:"Two more interceptor variants there / 那里还有另外两种拦截器:"}),"\n",(0,n.jsxs)(s.ul,{children:["\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.code,{children:"RefreshingAuthInterceptor"})," — never sends an expired token: it renews first via\n",(0,n.jsx)(s.code,{children:"validAccessToken()"})," / 绝不发送过期令牌,先用 ",(0,n.jsx)(s.code,{children:"validAccessToken()"})," 续期。"]}),"\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.code,{children:"AuthInterceptor"})," — the plain ",(0,n.jsx)(s.code,{children:"AuthTokenSource"})," version / 基础的令牌源版本。"]}),"\n"]}),"\n",(0,n.jsx)(s.h2,{id:e[2].id,children:e[2].value}),"\n",(0,n.jsxs)(s.p,{children:["Because ",(0,n.jsx)(s.code,{children:"AuthTokenSource"})," is a plain interface, the same pattern works for ",(0,n.jsx)(s.code,{children:"package:http"}),", ",(0,n.jsx)(s.code,{children:"chopper"}),", or any client that lets you mutate request headers."]}),"\n",(0,n.jsxs)(s.p,{children:["由于 ",(0,n.jsx)(s.code,{children:"AuthTokenSource"})," 是普通接口,同样的模式适用于 ",(0,n.jsx)(s.code,{children:"package:http"}),"、",(0,n.jsx)(s.code,{children:"chopper"})," 或任何允许修改请求头的客户端。"]}),"\n",(0,n.jsx)(s.h2,{id:e[3].id,children:e[3].value}),"\n",(0,n.jsxs)(s.ul,{children:["\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.a,{href:"Usage",children:"Usage"})," — Full integration walkthrough / 完整集成讲解"]}),"\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.a,{href:"Errors",children:"Errors"})," — Handling ",(0,n.jsx)(s.code,{children:"401"})," and refresh failures / 处理 401 与刷新失败"]}),"\n"]})]})},"/Network-Integration",{filePath:"pages/Network-Integration.md",timestamp:1789598212e3,pageMap:r.v,frontMatter:{},title:"Network Integration / 网络集成"},"undefined"==typeof RemoteContent?a:RemoteContent.useTOC)},7080:function(i,e,s){"use strict";s.d(e,{v:function(){return n}});let n=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(i){i.O(0,[812,888,774,179],function(){return i(i.s=7293)}),_N_E=i.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Persistence-621169678679dd35.js b/docs/_next/static/chunks/pages/Persistence-621169678679dd35.js new file mode 100644 index 0000000..b2511b4 --- /dev/null +++ b/docs/_next/static/chunks/pages/Persistence-621169678679dd35.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[746],{7423:function(e,i,s){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Persistence",function(){return s(2960)}])},2960:function(e,i,s){"use strict";s.r(i),s.d(i,{useTOC:function(){return h}});var n=s(5893),t=s(7812),r=s(7080),a=s(8925);function h(e){return[{value:"1. Serialize the session / 序列化会话",id:"1-serialize-the-session--序列化会话",depth:2},{value:"2. Flutter — encrypted secure storage / Flutter —— 加密安全存储",id:"2-flutter--encrypted-secure-storage--flutter--加密安全存储",depth:2},{value:"3. Server / CLI / desktop — JSON file / 服务端 / CLI / 桌面 —— JSON 文件",id:"3-server--cli--desktop--json-file--服务端--cli--桌面--json-文件",depth:2},{value:"4. Combine with auto-refresh / 结合自动刷新",id:"4-combine-with-auto-refresh--结合自动刷新",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}i.default=(0,t.c)(function(e){let{toc:i=h(e)}=e,s={a:"a",blockquote:"blockquote",code:"code",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",ul:"ul",...(0,a.a)(),...e.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(s.h1,{children:"Session Persistence / 会话持久化"}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"InMemoryTokenStore"})," loses the session on every restart. For a real app you must persist it — and ",(0,n.jsx)(s.code,{children:"zero_auth"})," now ships the primitives that make this a one-liner: ",(0,n.jsx)(s.strong,{children:"session (de)serialization"})," plus two ready-to-copy reference stores."]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"InMemoryTokenStore"})," 会在每次重启时丢失会话。真实应用必须把它持久化——而 ",(0,n.jsx)(s.code,{children:"zero_auth"})," 现在提供了让这件事变成「一行代码」的原语:",(0,n.jsx)(s.strong,{children:"会话(反)序列化"}),",外加两个开箱即用的参考存储。"]}),"\n",(0,n.jsx)(s.h2,{id:i[0].id,children:i[0].value}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthSession"})," is JSON-safe out of the box. ",(0,n.jsx)(s.code,{children:"null"})," fields are omitted, and ",(0,n.jsx)(s.code,{children:"claims"})," round-trips as a plain map."]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"AuthSession"})," 天生就是 JSON 安全的:",(0,n.jsx)(s.code,{children:"null"})," 字段会被省略,",(0,n.jsx)(s.code,{children:"claims"})," 作为普通映射原样往返。"]}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" json "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" session."}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"toJson"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(); "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// Map"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" restored "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthSession"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"."}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"fromJson"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(json);"})]}),"\n",(0,n.jsx)(s.span,{children:" "}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:'// Prefer this inside TokenStore.load(): malformed data yields null ("not signed'})}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:'// in") instead of a FormatException / TypeError.'})}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// TokenStore.load() 里请优先用它:畸形数据返回 null(即「未登录」),而不是抛"})}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// FormatException / TypeError。"})}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" safe "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthSession"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"."}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"tryFromJson"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(json);"})]})]})}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"RefreshToken"})," serializes to its raw string too, so a persisted session rehydrates losslessly."]}),"\n",(0,n.jsxs)(s.p,{children:[(0,n.jsx)(s.code,{children:"RefreshToken"})," 也序列化为其原始字符串,因此持久化的会话可无损恢复。"]}),"\n",(0,n.jsx)(s.h2,{id:i[1].id,children:i[1].value}),"\n",(0,n.jsxs)(s.p,{children:["For mobile, persist into the OS keychain / keystore via ",(0,n.jsx)(s.code,{children:"flutter_secure_storage"}),". A reference implementation lives at ",(0,n.jsx)(s.code,{children:"example/lib/secure_token_store.dart"}),":"]}),"\n",(0,n.jsxs)(s.p,{children:["在移动端,通过 ",(0,n.jsx)(s.code,{children:"flutter_secure_storage"})," 把令牌存入系统钥匙串 / Keystore。参考实现位于 ",(0,n.jsx)(s.code,{children:"example/lib/secure_token_store.dart"}),":"]}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManager"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategy"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" myStrategy,"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" tokenStore"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" SecureTokenStore"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(), "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// from example/"})]}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})}),"\n",(0,n.jsx)(s.span,{children:" "}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth."}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"restore"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(); "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// rehydrate before the first frame"})]})]})}),"\n",(0,n.jsxs)(s.blockquote,{children:["\n",(0,n.jsxs)(s.p,{children:["Never store refresh tokens in plain ",(0,n.jsx)(s.code,{children:"SharedPreferences"}),". Use OS-backed secure storage only."]}),"\n",(0,n.jsxs)(s.p,{children:["切勿把刷新令牌明文存入 ",(0,n.jsx)(s.code,{children:"SharedPreferences"}),",只使用操作系统级安全存储。"]}),"\n"]}),"\n",(0,n.jsx)(s.h2,{id:i[2].id,children:i[2].value}),"\n",(0,n.jsxs)(s.p,{children:["On non-mobile Dart targets you can write the serialized session to a file. A reference implementation lives at ",(0,n.jsx)(s.code,{children:"example/lib/json_token_store.dart"}),":"]}),"\n",(0,n.jsxs)(s.p,{children:["在非移动端 Dart 目标上,可以把序列化后的会话写入文件。参考实现位于 ",(0,n.jsx)(s.code,{children:"example/lib/json_token_store.dart"}),":"]}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManager"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategy"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" myStrategy,"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" tokenStore"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" FileTokenStore"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"File"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'.zero_auth_session.json'"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:")),"})]}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})})]})}),"\n",(0,n.jsx)(s.p,{children:"This is ideal for a backend that keeps a logged-in user across process restarts, or a CLI that stays authenticated between invocations."}),"\n",(0,n.jsx)(s.p,{children:"这非常适合「重启后仍保持登录」的后端,或「多次调用之间保持认证」的 CLI。"}),"\n",(0,n.jsx)(s.h2,{id:i[3].id,children:i[3].value}),"\n",(0,n.jsxs)(s.p,{children:["When you enable ",(0,n.jsx)(s.a,{href:"Configuration",children:"proactive auto-refresh"}),", the manager renews the token before it expires — and every renewal is persisted through ",(0,n.jsx)(s.code,{children:"save()"}),", so the on-disk session always carries a fresh access token."]}),"\n",(0,n.jsxs)(s.p,{children:["当你开启",(0,n.jsx)(s.a,{href:"Configuration",children:"主动自动刷新"}),"时,管理器会在令牌过期前续期——而每次续期都会通过 ",(0,n.jsx)(s.code,{children:"save()"})," 落盘,因此磁盘上的会话始终持有崭新的访问令牌。"]}),"\n",(0,n.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,n.jsxs)(s.code,{children:[(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth "}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManager"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategy"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" myStrategy,"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" tokenStore"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" SecureTokenStore"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(),"})]}),"\n",(0,n.jsxs)(s.span,{children:[(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" autoRefreshAhead"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Duration"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(minutes"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" 5"}),(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"),"})]}),"\n",(0,n.jsx)(s.span,{children:(0,n.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})})]})}),"\n",(0,n.jsx)(s.h2,{id:i[4].id,children:i[4].value}),"\n",(0,n.jsxs)(s.ul,{children:["\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.a,{href:"Token-Store",children:"Token Store"})," — the ",(0,n.jsx)(s.code,{children:"save"})," / ",(0,n.jsx)(s.code,{children:"load"})," / ",(0,n.jsx)(s.code,{children:"clear"})," contract / ",(0,n.jsx)(s.code,{children:"save"})," / ",(0,n.jsx)(s.code,{children:"load"})," / ",(0,n.jsx)(s.code,{children:"clear"})," 契约"]}),"\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.a,{href:"Configuration",children:"Configuration"})," — enable proactive auto-refresh / 开启主动自动刷新"]}),"\n"]})]})},"/Persistence",{filePath:"pages/Persistence.md",timestamp:1789600805e3,pageMap:r.v,frontMatter:{},title:"Session Persistence / 会话持久化"},"undefined"==typeof RemoteContent?h:RemoteContent.useTOC)},7080:function(e,i,s){"use strict";s.d(i,{v:function(){return n}});let n=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(e){e.O(0,[812,888,774,179],function(){return e(e.s=7423)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Persistence-6ffb0f37f033307f.js b/docs/_next/static/chunks/pages/Persistence-6ffb0f37f033307f.js deleted file mode 100644 index cd71f0e..0000000 --- a/docs/_next/static/chunks/pages/Persistence-6ffb0f37f033307f.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[746],{7423:function(e,i,s){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Persistence",function(){return s(2960)}])},2960:function(e,i,s){"use strict";s.r(i),s.d(i,{useTOC:function(){return h}});var t=s(5893),n=s(7812),r=s(7080),a=s(8925);function h(e){return[{value:"1. Serialize the session / 序列化会话",id:"1-serialize-the-session--序列化会话",depth:2},{value:"2. Flutter — encrypted secure storage / Flutter —— 加密安全存储",id:"2-flutter--encrypted-secure-storage--flutter--加密安全存储",depth:2},{value:"3. Server / CLI / desktop — JSON file / 服务端 / CLI / 桌面 —— JSON 文件",id:"3-server--cli--desktop--json-file--服务端--cli--桌面--json-文件",depth:2},{value:"4. Combine with auto-refresh / 结合自动刷新",id:"4-combine-with-auto-refresh--结合自动刷新",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}i.default=(0,n.c)(function(e){let{toc:i=h(e)}=e,s={a:"a",blockquote:"blockquote",code:"code",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",ul:"ul",...(0,a.a)(),...e.components};return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(s.h1,{children:"Session Persistence / 会话持久化"}),"\n",(0,t.jsxs)(s.p,{children:[(0,t.jsx)(s.code,{children:"InMemoryTokenStore"})," loses the session on every restart. For a real app you must persist it — and ",(0,t.jsx)(s.code,{children:"zero_auth"})," now ships the primitives that make this a one-liner: ",(0,t.jsx)(s.strong,{children:"session (de)serialization"})," plus two ready-to-copy reference stores."]}),"\n",(0,t.jsxs)(s.p,{children:[(0,t.jsx)(s.code,{children:"InMemoryTokenStore"})," 会在每次重启时丢失会话。真实应用必须把它持久化——而 ",(0,t.jsx)(s.code,{children:"zero_auth"})," 现在提供了让这件事变成「一行代码」的原语:",(0,t.jsx)(s.strong,{children:"会话(反)序列化"}),",外加两个开箱即用的参考存储。"]}),"\n",(0,t.jsx)(s.h2,{id:i[0].id,children:i[0].value}),"\n",(0,t.jsxs)(s.p,{children:[(0,t.jsx)(s.code,{children:"AuthSession"})," is JSON-safe out of the box. ",(0,t.jsx)(s.code,{children:"null"})," fields are omitted, and ",(0,t.jsx)(s.code,{children:"claims"})," round-trips as a plain map."]}),"\n",(0,t.jsxs)(s.p,{children:[(0,t.jsx)(s.code,{children:"AuthSession"})," 天生就是 JSON 安全的:",(0,t.jsx)(s.code,{children:"null"})," 字段会被省略,",(0,t.jsx)(s.code,{children:"claims"})," 作为普通映射原样往返。"]}),"\n",(0,t.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,t.jsxs)(s.code,{children:[(0,t.jsxs)(s.span,{children:[(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" json "}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" session."}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"toJson"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(); "}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// Map"})]}),"\n",(0,t.jsxs)(s.span,{children:[(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" restored "}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthSession"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"."}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"fromJson"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(json);"})]})]})}),"\n",(0,t.jsxs)(s.p,{children:[(0,t.jsx)(s.code,{children:"RefreshToken"})," serializes to its raw string too, so a persisted session rehydrates losslessly."]}),"\n",(0,t.jsxs)(s.p,{children:[(0,t.jsx)(s.code,{children:"RefreshToken"})," 也序列化为其原始字符串,因此持久化的会话可无损恢复。"]}),"\n",(0,t.jsx)(s.h2,{id:i[1].id,children:i[1].value}),"\n",(0,t.jsxs)(s.p,{children:["For mobile, persist into the OS keychain / keystore via ",(0,t.jsx)(s.code,{children:"flutter_secure_storage"}),". A reference implementation lives at ",(0,t.jsx)(s.code,{children:"example/lib/secure_token_store.dart"}),":"]}),"\n",(0,t.jsxs)(s.p,{children:["在移动端,通过 ",(0,t.jsx)(s.code,{children:"flutter_secure_storage"})," 把令牌存入系统钥匙串 / Keystore。参考实现位于 ",(0,t.jsx)(s.code,{children:"example/lib/secure_token_store.dart"}),":"]}),"\n",(0,t.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,t.jsxs)(s.code,{children:[(0,t.jsxs)(s.span,{children:[(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth "}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManager"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,t.jsxs)(s.span,{children:[(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategy"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" myStrategy,"})]}),"\n",(0,t.jsxs)(s.span,{children:[(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" tokenStore"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" SecureTokenStore"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(), "}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// from example/"})]}),"\n",(0,t.jsx)(s.span,{children:(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})}),"\n",(0,t.jsx)(s.span,{children:" "}),"\n",(0,t.jsxs)(s.span,{children:[(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"await"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth."}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"restore"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(); "}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:"// rehydrate before the first frame"})]})]})}),"\n",(0,t.jsxs)(s.blockquote,{children:["\n",(0,t.jsxs)(s.p,{children:["Never store refresh tokens in plain ",(0,t.jsx)(s.code,{children:"SharedPreferences"}),". Use OS-backed secure storage only."]}),"\n",(0,t.jsxs)(s.p,{children:["切勿把刷新令牌明文存入 ",(0,t.jsx)(s.code,{children:"SharedPreferences"}),",只使用操作系统级安全存储。"]}),"\n"]}),"\n",(0,t.jsx)(s.h2,{id:i[2].id,children:i[2].value}),"\n",(0,t.jsxs)(s.p,{children:["On non-mobile Dart targets you can write the serialized session to a file. A reference implementation lives at ",(0,t.jsx)(s.code,{children:"example/lib/json_token_store.dart"}),":"]}),"\n",(0,t.jsxs)(s.p,{children:["在非移动端 Dart 目标上,可以把序列化后的会话写入文件。参考实现位于 ",(0,t.jsx)(s.code,{children:"example/lib/json_token_store.dart"}),":"]}),"\n",(0,t.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,t.jsxs)(s.code,{children:[(0,t.jsxs)(s.span,{children:[(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth "}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManager"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,t.jsxs)(s.span,{children:[(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategy"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" myStrategy,"})]}),"\n",(0,t.jsxs)(s.span,{children:[(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" tokenStore"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" FileTokenStore"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"File"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:"'.zero_auth_session.json'"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:")),"})]}),"\n",(0,t.jsx)(s.span,{children:(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})})]})}),"\n",(0,t.jsx)(s.p,{children:"This is ideal for a backend that keeps a logged-in user across process restarts, or a CLI that stays authenticated between invocations."}),"\n",(0,t.jsx)(s.p,{children:"这非常适合「重启后仍保持登录」的后端,或「多次调用之间保持认证」的 CLI。"}),"\n",(0,t.jsx)(s.h2,{id:i[3].id,children:i[3].value}),"\n",(0,t.jsxs)(s.p,{children:["When you enable ",(0,t.jsx)(s.a,{href:"Configuration",children:"proactive auto-refresh"}),", the manager renews the token before it expires — and every renewal is persisted through ",(0,t.jsx)(s.code,{children:"save()"}),", so the on-disk session always carries a fresh access token."]}),"\n",(0,t.jsxs)(s.p,{children:["当你开启",(0,t.jsx)(s.a,{href:"Configuration",children:"主动自动刷新"}),"时,管理器会在令牌过期前续期——而每次续期都会通过 ",(0,t.jsx)(s.code,{children:"save()"})," 落盘,因此磁盘上的会话始终持有崭新的访问令牌。"]}),"\n",(0,t.jsx)(s.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,t.jsxs)(s.code,{children:[(0,t.jsxs)(s.span,{children:[(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth "}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManager"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("})]}),"\n",(0,t.jsxs)(s.span,{children:[(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" strategy"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" myStrategy,"})]}),"\n",(0,t.jsxs)(s.span,{children:[(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" tokenStore"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" SecureTokenStore"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(),"})]}),"\n",(0,t.jsxs)(s.span,{children:[(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" autoRefreshAhead"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" const"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Duration"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(minutes"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" 5"}),(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"),"})]}),"\n",(0,t.jsx)(s.span,{children:(0,t.jsx)(s.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})})]})}),"\n",(0,t.jsx)(s.h2,{id:i[4].id,children:i[4].value}),"\n",(0,t.jsxs)(s.ul,{children:["\n",(0,t.jsxs)(s.li,{children:[(0,t.jsx)(s.a,{href:"Token-Store",children:"Token Store"})," — the ",(0,t.jsx)(s.code,{children:"save"})," / ",(0,t.jsx)(s.code,{children:"load"})," / ",(0,t.jsx)(s.code,{children:"clear"})," contract / ",(0,t.jsx)(s.code,{children:"save"})," / ",(0,t.jsx)(s.code,{children:"load"})," / ",(0,t.jsx)(s.code,{children:"clear"})," 契约"]}),"\n",(0,t.jsxs)(s.li,{children:[(0,t.jsx)(s.a,{href:"Configuration",children:"Configuration"})," — enable proactive auto-refresh / 开启主动自动刷新"]}),"\n"]})]})},"/Persistence",{filePath:"pages/Persistence.md",timestamp:1789600805e3,pageMap:r.v,frontMatter:{},title:"Session Persistence / 会话持久化"},"undefined"==typeof RemoteContent?h:RemoteContent.useTOC)},7080:function(e,i,s){"use strict";s.d(i,{v:function(){return t}});let t=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(e){e.O(0,[812,888,774,179],function(){return e(e.s=7423)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/Token-Store-afbc8ed380153931.js b/docs/_next/static/chunks/pages/Token-Store-1f37005b28989149.js similarity index 63% rename from docs/_next/static/chunks/pages/Token-Store-afbc8ed380153931.js rename to docs/_next/static/chunks/pages/Token-Store-1f37005b28989149.js index 03d94cf..28d6a25 100644 --- a/docs/_next/static/chunks/pages/Token-Store-afbc8ed380153931.js +++ b/docs/_next/static/chunks/pages/Token-Store-1f37005b28989149.js @@ -1 +1 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[909],{8560:function(i,s,e){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Token-Store",function(){return e(9327)}])},9327:function(i,s,e){"use strict";e.r(s),e.d(s,{useTOC:function(){return l}});var r=e(5893),t=e(7812),h=e(7080),n=e(8925);function l(i){return[{value:"The contract / 契约",id:"the-contract--契约",depth:2},{value:"In-memory (ships in-core) / 内存实现(内核自带)",id:"in-memory-ships-in-core--内存实现内核自带",depth:2},{value:"Secure storage (production) / 安全存储(生产)",id:"secure-storage-production--安全存储生产",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}s.default=(0,t.c)(function(i){let{toc:s=l(i)}=i,e={a:"a",blockquote:"blockquote",code:"code",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",ul:"ul",...(0,n.a)(),...i.components};return(0,r.jsxs)(r.Fragment,{children:[(0,r.jsx)(e.h1,{children:"Token Store / 令牌存储"}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"TokenStore"})," is the ",(0,r.jsx)(e.strong,{children:"only"})," persistence surface. The core never touches disk, secure storage, or ",(0,r.jsx)(e.code,{children:"SharedPreferences"})," directly — you decide where tokens live."]}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"TokenStore"})," 是",(0,r.jsx)(e.strong,{children:"唯一"}),"的持久化接口。内核绝不直接访问磁盘、安全存储或 ",(0,r.jsx)(e.code,{children:"SharedPreferences"}),"——由你决定令牌存放位置。"]}),"\n",(0,r.jsx)(e.h2,{id:s[0].id,children:s[0].value}),"\n",(0,r.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,r.jsxs)(e.code,{children:[(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"abstract"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" TokenStore"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"void"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"> "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"save"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AuthSession"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" session);"})]}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AuthSession"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"?> "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"load"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"();"})]}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"void"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"> "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"clear"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"();"})]}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"AuthManager"})," calls ",(0,r.jsx)(e.code,{children:"load()"})," during ",(0,r.jsx)(e.code,{children:"restore()"}),", ",(0,r.jsx)(e.code,{children:"save()"})," after every successful login/refresh, and ",(0,r.jsx)(e.code,{children:"clear()"})," on logout or a failed refresh."]}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"AuthManager"})," 在 ",(0,r.jsx)(e.code,{children:"restore()"})," 时调用 ",(0,r.jsx)(e.code,{children:"load()"}),",每次登录/刷新成功后调用 ",(0,r.jsx)(e.code,{children:"save()"}),",在登出或刷新失败失败时调用 ",(0,r.jsx)(e.code,{children:"clear()"}),"。"]}),"\n",(0,r.jsx)(e.h2,{id:s[1].id,children:s[1].value}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"InMemoryTokenStore"})," is the default. It survives process restarts? No — it is in-memory only, so the session is lost on restart. Use it for tests and quick prototypes."]}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"InMemoryTokenStore"})," 是默认实现,仅存在于内存,进程重启后会话丢失。适用于测试与原型。"]}),"\n",(0,r.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,r.jsx)(e.code,{children:(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManager"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(strategy"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" s, tokenStore"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" InMemoryTokenStore"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"());"})]})})}),"\n",(0,r.jsx)(e.h2,{id:s[2].id,children:s[2].value}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"example/lib/secure_token_store.dart"})," shows a ",(0,r.jsx)(e.code,{children:"flutter_secure_storage"}),"-backed reference implementation. Adapt it for your app:"]}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"example/lib/secure_token_store.dart"})," 提供了基于 ",(0,r.jsx)(e.code,{children:"flutter_secure_storage"})," 的参考实现,可据此改造:"]}),"\n",(0,r.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,r.jsxs)(e.code,{children:[(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"class"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" SecureTokenStore"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" implements"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" TokenStore"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" _box "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" FlutterSecureStorage"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"();"})]}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" @override"})}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"void"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"> "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"save"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AuthSession"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" s) "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"})]}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" _box."}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"write"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(key"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'zero_auth'"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:", value"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:" jsonEncode"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(s."}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"toJson"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"()));"})]}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" @override"})}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AuthSession"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"?> "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"load"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"() "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"async"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" raw "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" await"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" _box."}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"read"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(key"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'zero_auth'"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})]}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" return"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" raw "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=="}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" null"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" ?"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" null"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" :"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthSession"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"."}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"fromJson"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"jsonDecode"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(raw));"})]}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" }"})}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" @override"})}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"void"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"> "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"clear"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"() "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" _box."}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"delete"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(key"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'zero_auth'"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})]}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,r.jsxs)(e.blockquote,{children:["\n",(0,r.jsxs)(e.p,{children:["Persist tokens only in OS-backed secure storage (Keychain / Keystore). Never store refresh tokens in plain ",(0,r.jsx)(e.code,{children:"SharedPreferences"}),"."]}),"\n",(0,r.jsxs)(e.p,{children:["令牌只应存放在操作系统级安全存储(Keychain / Keystore)中,切勿把刷新令牌明文存入 ",(0,r.jsx)(e.code,{children:"SharedPreferences"}),"。"]}),"\n"]}),"\n",(0,r.jsx)(e.h2,{id:s[3].id,children:s[3].value}),"\n",(0,r.jsxs)(e.ul,{children:["\n",(0,r.jsxs)(e.li,{children:[(0,r.jsx)(e.a,{href:"Persistence",children:"Session Persistence"})," — persist sessions across restarts (serialization + reference stores) / 跨重启持久化会话(序列化与参考存储)"]}),"\n",(0,r.jsxs)(e.li,{children:[(0,r.jsx)(e.a,{href:"Backend-Strategy",children:"Backend Strategy"})," — The other boundary / 另一个边界"]}),"\n",(0,r.jsxs)(e.li,{children:[(0,r.jsx)(e.a,{href:"Errors",children:"Errors"})," — What ",(0,r.jsx)(e.code,{children:"load()"})," failures become / ",(0,r.jsx)(e.code,{children:"load()"})," 失败会变成什么"]}),"\n"]})]})},"/Token-Store",{filePath:"pages/Token-Store.md",timestamp:1789600805e3,pageMap:h.v,frontMatter:{},title:"Token Store / 令牌存储"},"undefined"==typeof RemoteContent?l:RemoteContent.useTOC)},7080:function(i,s,e){"use strict";e.d(s,{v:function(){return r}});let r=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(i){i.O(0,[812,888,774,179],function(){return i(i.s=8560)}),_N_E=i.O()}]); \ No newline at end of file +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[909],{8560:function(i,s,e){(window.__NEXT_P=window.__NEXT_P||[]).push(["/Token-Store",function(){return e(9327)}])},9327:function(i,s,e){"use strict";e.r(s),e.d(s,{useTOC:function(){return l}});var r=e(5893),n=e(7812),t=e(7080),h=e(8925);function l(i){return[{value:"The contract / 契约",id:"the-contract--契约",depth:2},{value:"In-memory (ships in-core) / 内存实现(内核自带)",id:"in-memory-ships-in-core--内存实现内核自带",depth:2},{value:"Secure storage (production) / 安全存储(生产)",id:"secure-storage-production--安全存储生产",depth:2},{value:"Next Steps / 下一步",id:"next-steps--下一步",depth:2}]}s.default=(0,n.c)(function(i){let{toc:s=l(i)}=i,e={a:"a",blockquote:"blockquote",code:"code",h1:"h1",h2:"h2",li:"li",p:"p",pre:"pre",span:"span",strong:"strong",ul:"ul",...(0,h.a)(),...i.components};return(0,r.jsxs)(r.Fragment,{children:[(0,r.jsx)(e.h1,{children:"Token Store / 令牌存储"}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"TokenStore"})," is the ",(0,r.jsx)(e.strong,{children:"only"})," persistence surface. The core never touches disk, secure storage, or ",(0,r.jsx)(e.code,{children:"SharedPreferences"})," directly — you decide where tokens live."]}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"TokenStore"})," 是",(0,r.jsx)(e.strong,{children:"唯一"}),"的持久化接口。内核绝不直接访问磁盘、安全存储或 ",(0,r.jsx)(e.code,{children:"SharedPreferences"}),"——由你决定令牌存放位置。"]}),"\n",(0,r.jsx)(e.h2,{id:s[0].id,children:s[0].value}),"\n",(0,r.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,r.jsxs)(e.code,{children:[(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"abstract"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" class"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" TokenStore"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"void"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"> "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"save"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AuthSession"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" session);"})]}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AuthSession"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"?> "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"load"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"();"})]}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"void"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"> "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"clear"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"();"})]}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"AuthManager"})," calls ",(0,r.jsx)(e.code,{children:"load()"})," during ",(0,r.jsx)(e.code,{children:"restore()"}),", ",(0,r.jsx)(e.code,{children:"save()"})," after every successful login/refresh, and ",(0,r.jsx)(e.code,{children:"clear()"})," on logout or a failed refresh."]}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"AuthManager"})," 在 ",(0,r.jsx)(e.code,{children:"restore()"})," 时调用 ",(0,r.jsx)(e.code,{children:"load()"}),",每次登录/刷新成功后调用 ",(0,r.jsx)(e.code,{children:"save()"}),",在登出或刷新失败失败时调用 ",(0,r.jsx)(e.code,{children:"clear()"}),"。"]}),"\n",(0,r.jsx)(e.h2,{id:s[1].id,children:s[1].value}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"InMemoryTokenStore"})," is the default. It survives process restarts? No — it is in-memory only, so the session is lost on restart. Use it for tests and quick prototypes."]}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"InMemoryTokenStore"})," 是默认实现,仅存在于内存,进程重启后会话丢失。适用于测试与原型。"]}),"\n",(0,r.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,r.jsx)(e.code,{children:(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"final"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" auth "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthManager"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(strategy"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" s, tokenStore"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" InMemoryTokenStore"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"());"})]})})}),"\n",(0,r.jsx)(e.h2,{id:s[2].id,children:s[2].value}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"example/lib/secure_token_store.dart"})," shows a ",(0,r.jsx)(e.code,{children:"flutter_secure_storage"}),"-backed reference implementation. Adapt it for your app:"]}),"\n",(0,r.jsxs)(e.p,{children:[(0,r.jsx)(e.code,{children:"example/lib/secure_token_store.dart"})," 提供了基于 ",(0,r.jsx)(e.code,{children:"flutter_secure_storage"})," 的参考实现,可据此改造:"]}),"\n",(0,r.jsx)(e.pre,{tabIndex:"0","data-language":"dart","data-word-wrap":"","data-copy":"",children:(0,r.jsxs)(e.code,{children:[(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"class"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" SecureTokenStore"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" implements"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" TokenStore"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" _box "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" FlutterSecureStorage"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"();"})]}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" @override"})}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"void"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"> "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"save"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"("}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AuthSession"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" s) "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"})]}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" _box."}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"write"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(key"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'zero_auth'"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:", value"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:" jsonEncode"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(s."}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"toJson"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"()));"})]}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" @override"})}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:"AuthSession"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"?> "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"load"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"() "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"async"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" {"})]}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" final"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" raw "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"="}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" await"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" _box."}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"read"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(key"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'zero_auth'"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})]}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:' // tryFromJson: corrupt data means "not signed in", not a crash.'})}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#6A737D","--shiki-dark":"#6A737D"},children:" // tryFromJson:数据损坏代表「未登录」,而不是崩溃。"})}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" return"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" AuthSession"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"."}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"tryFromJson"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(raw "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=="}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" null"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" ?"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" null"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" :"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:" jsonDecode"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(raw));"})]}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" }"})}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:" @override"})}),"\n",(0,r.jsxs)(e.span,{children:[(0,r.jsx)(e.span,{style:{"--shiki-light":"#005CC5","--shiki-dark":"#79B8FF"},children:" Future"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"<"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"void"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"> "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"clear"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"() "}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:"=>"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:" _box."}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#6F42C1","--shiki-dark":"#B392F0"},children:"delete"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"(key"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#D73A49","--shiki-dark":"#F97583"},children:":"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#032F62","--shiki-dark":"#9ECBFF"},children:" 'zero_auth'"}),(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:");"})]}),"\n",(0,r.jsx)(e.span,{children:(0,r.jsx)(e.span,{style:{"--shiki-light":"#24292E","--shiki-dark":"#E1E4E8"},children:"}"})})]})}),"\n",(0,r.jsxs)(e.blockquote,{children:["\n",(0,r.jsxs)(e.p,{children:["Persist tokens only in OS-backed secure storage (Keychain / Keystore). Never store refresh tokens in plain ",(0,r.jsx)(e.code,{children:"SharedPreferences"}),"."]}),"\n",(0,r.jsxs)(e.p,{children:["令牌只应存放在操作系统级安全存储(Keychain / Keystore)中,切勿把刷新令牌明文存入 ",(0,r.jsx)(e.code,{children:"SharedPreferences"}),"。"]}),"\n"]}),"\n",(0,r.jsx)(e.h2,{id:s[3].id,children:s[3].value}),"\n",(0,r.jsxs)(e.ul,{children:["\n",(0,r.jsxs)(e.li,{children:[(0,r.jsx)(e.a,{href:"Persistence",children:"Session Persistence"})," — persist sessions across restarts (serialization + reference stores) / 跨重启持久化会话(序列化与参考存储)"]}),"\n",(0,r.jsxs)(e.li,{children:[(0,r.jsx)(e.a,{href:"Backend-Strategy",children:"Backend Strategy"})," — The other boundary / 另一个边界"]}),"\n",(0,r.jsxs)(e.li,{children:[(0,r.jsx)(e.a,{href:"Errors",children:"Errors"})," — What ",(0,r.jsx)(e.code,{children:"load()"})," failures become / ",(0,r.jsx)(e.code,{children:"load()"})," 失败会变成什么"]}),"\n"]})]})},"/Token-Store",{filePath:"pages/Token-Store.md",timestamp:1789600805e3,pageMap:t.v,frontMatter:{},title:"Token Store / 令牌存储"},"undefined"==typeof RemoteContent?l:RemoteContent.useTOC)},7080:function(i,s,e){"use strict";e.d(s,{v:function(){return r}});let r=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(i){i.O(0,[812,888,774,179],function(){return i(i.s=8560)}),_N_E=i.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/index-255afb63bd37772c.js b/docs/_next/static/chunks/pages/index-255afb63bd37772c.js deleted file mode 100644 index 74fa277..0000000 --- a/docs/_next/static/chunks/pages/index-255afb63bd37772c.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[405],{2725:function(e,n,r){(window.__NEXT_P=window.__NEXT_P||[]).push(["/",function(){return r(4991)}])},4991:function(e,n,r){"use strict";r.r(n),r.d(n,{useTOC:function(){return c}});var t=r(5893),s=r(7812),i=r(7080),d=r(8925);function c(e){return[{value:"✨ Features / 功能特性",id:"-features--功能特性",depth:2},{value:"\uD83D\uDCDA Table of Contents / 目录",id:"-table-of-contents--目录",depth:2},{value:"\uD83D\uDD17 Links / 链接",id:"-links--链接",depth:2},{value:"\uD83D\uDCC4 License / 许可证",id:"-license--许可证",depth:2}]}n.default=(0,s.c)(function(e){let{toc:n=c(e)}=e,r={a:"a",code:"code",h1:"h1",h2:"h2",li:"li",p:"p",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,d.a)(),...e.components};return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(r.h1,{children:"Zero Auth"}),"\n",(0,t.jsxs)(r.p,{children:["A backend-agnostic auth state machine & session lifecycle core for Dart/Flutter. Pure Dart, headless, no HTTP/SDK/native code. Wire your own backend via ",(0,t.jsx)(r.code,{children:"AuthStrategy"})," and your own persistence via ",(0,t.jsx)(r.code,{children:"TokenStore"}),"."]}),"\n",(0,t.jsxs)(r.p,{children:["一个后端无关的认证状态机与会话生命周期内核,面向 Dart/Flutter。纯 Dart、无头(headless),不含 HTTP、SDK 或原生代码。通过 ",(0,t.jsx)(r.code,{children:"AuthStrategy"})," 接入自有后端,通过 ",(0,t.jsx)(r.code,{children:"TokenStore"})," 接入自有持久化层。"]}),"\n",(0,t.jsx)(r.h2,{id:n[0].id,children:n[0].value}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,t.jsxs)(r.table,{children:[(0,t.jsx)(r.thead,{children:(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.th,{children:"Feature"}),(0,t.jsx)(r.th,{children:"Description"})]})}),(0,t.jsxs)(r.tbody,{children:[(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Backend-agnostic"})}),(0,t.jsxs)(r.td,{children:["Implement only ",(0,t.jsx)(r.code,{children:"login"}),"/",(0,t.jsx)(r.code,{children:"register"}),"/",(0,t.jsx)(r.code,{children:"logout"}),"/",(0,t.jsx)(r.code,{children:"refresh"}),"; REST, gRPC, Firebase or a private RPC are equally valid targets / 只需实现四个方法,REST、gRPC、Firebase 或私有 RPC 均可接入"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Auth State Machine"})}),(0,t.jsxs)(r.td,{children:["Sealed ",(0,t.jsx)(r.code,{children:"Unauthenticated"})," / ",(0,t.jsx)(r.code,{children:"Authenticating"})," / ",(0,t.jsx)(r.code,{children:"Authenticated"})," / ",(0,t.jsx)(r.code,{children:"Refreshing"})," / ",(0,t.jsx)(r.code,{children:"LoggingOut"})," / ",(0,t.jsx)(r.code,{children:"AuthError"})," on a ",(0,t.jsx)(r.code,{children:"Stream"})," that replays the latest value to new listeners; use ",(0,t.jsx)(r.code,{children:"isAuthenticated"})," / ",(0,t.jsx)(r.code,{children:"isBusy"})," / 密封六态状态机 + 重放最近值的状态流,可用 ",(0,t.jsx)(r.code,{children:"isAuthenticated"})," / ",(0,t.jsx)(r.code,{children:"isBusy"})]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Silent Restore"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"restore()"})," rehydrates the persisted session at startup / 启动时静默恢复持久化会话"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Single-flight Refresh"})}),(0,t.jsxs)(r.td,{children:["Concurrent ",(0,t.jsx)(r.code,{children:"refresh()"})," calls share one in-flight request instead of stampeding the backend / 并发刷新共享同一次请求,避免冲击后端"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Pluggable Persistence"})}),(0,t.jsxs)(r.td,{children:["The only persistence surface is ",(0,t.jsx)(r.code,{children:"TokenStore.save/load/clear"}),"; ",(0,t.jsx)(r.code,{children:"InMemoryTokenStore"})," ships in-core / 唯一的持久化接口,内核自带内存实现"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Typed Session"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"AuthSession"})," carries access/refresh tokens, expiry (",(0,t.jsx)(r.code,{children:"isExpired"}),"), user id, display name and raw claims / 强类型会话,无需手工解析令牌"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Unified Errors"})}),(0,t.jsxs)(r.td,{children:["Every failure maps to ",(0,t.jsx)(r.code,{children:"AppException"})," (",(0,t.jsx)(r.code,{children:"AuthException"})," for auth cases) or a ",(0,t.jsx)(r.code,{children:"Result"})," wrapper; raw exceptions never cross the public surface / 统一异常与结果,裸异常不越界"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Network Integration"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"AuthManager"})," itself is an ",(0,t.jsx)(r.code,{children:"AuthTokenSource"}),", so a Dio interceptor can attach ",(0,t.jsx)(r.code,{children:"Authorization: Bearer"})," without depending on the manager / 管理器即令牌源,Dio 拦截器零依赖附加令牌"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Session Serialization"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"AuthSession.toJson"})," / ",(0,t.jsx)(r.code,{children:"fromJson"})," make persistence a one-liner; a file-based reference store ships for server/CLI / ",(0,t.jsx)(r.code,{children:"AuthSession.toJson"})," / ",(0,t.jsx)(r.code,{children:"fromJson"})," 让持久化一行搞定,并附带面向服务端 / CLI 的文件参考存储"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Proactive Auto-refresh"})}),(0,t.jsxs)(r.td,{children:["Pass ",(0,t.jsx)(r.code,{children:"autoRefreshAhead"})," to renew tokens before expiry (single-flight) / 传入 ",(0,t.jsx)(r.code,{children:"autoRefreshAhead"})," 在过期前自动续期(单飞)"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Never an Expired Token"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"validAccessToken()"})," renews first when the token has expired, so interceptors never send a dead bearer token / 令牌过期时先续期,拦截器不会发出失效令牌"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Bring Your Own Login"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"loginWith"})," adopts a session from any flow you drive: third-party OAuth, magic links, passkeys / ",(0,t.jsx)(r.code,{children:"loginWith"})," 可接纳第三方 OAuth、魔法链接、Passkey 等自定义流程"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Typed Auth Exceptions"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"InvalidCredentialsException"}),", ",(0,t.jsx)(r.code,{children:"SessionExpiredException"})," and friends, mapped from your strategy’s ",(0,t.jsx)(r.code,{children:"code"})," / ",(0,t.jsx)(r.code,{children:"InvalidCredentialsException"}),"、",(0,t.jsx)(r.code,{children:"SessionExpiredException"})," 等,由策略的 ",(0,t.jsx)(r.code,{children:"code"})," 映射而来"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Configurable Failure Policy"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"refreshFailurePolicy"})," decides whether a failed refresh signs the user out / ",(0,t.jsx)(r.code,{children:"refreshFailurePolicy"})," 决定刷新失败是否登出"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Runnable Example"})}),(0,t.jsxs)(r.td,{children:["A full Flutter demo app (Android/iOS/Web/Windows) plus a layered ",(0,t.jsx)(r.code,{children:"dart:io"})," demo backend that issues real JWTs / 完整 Flutter 示例与一个签发真实 JWT 的分层演示后端"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Multiple Accounts"})}),(0,t.jsxs)(r.td,{children:["Optional ",(0,t.jsx)(r.code,{children:"AuthManagerGroup"})," keeps one manager per account / 可选 ",(0,t.jsx)(r.code,{children:"AuthManagerGroup"}),",每账号一个管理器"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Cross-platform"})}),(0,t.jsx)(r.td,{children:"Pure Dart — runs anywhere Dart or Flutter runs / 纯 Dart,跨平台"})]})]})]}),"\n",(0,t.jsx)(r.h2,{id:n[1].id,children:n[1].value}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,t.jsxs)(r.table,{children:[(0,t.jsx)(r.thead,{children:(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.th,{children:"Page"}),(0,t.jsx)(r.th,{children:"Description"})]})}),(0,t.jsxs)(r.tbody,{children:[(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Getting-Started",children:"Getting Started"})}),(0,t.jsx)(r.td,{children:"Quick start guide / 快速开始"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Installation",children:"Installation"})}),(0,t.jsx)(r.td,{children:"How to install / 安装方式"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Usage",children:"Usage"})}),(0,t.jsx)(r.td,{children:"Detailed usage / 详细使用"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Auth-State-Machine",children:"Auth State Machine"})}),(0,t.jsx)(r.td,{children:"State lifecycle & stream / 状态机与状态流"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Backend-Strategy",children:"Backend Strategy"})}),(0,t.jsxs)(r.td,{children:["Implement ",(0,t.jsx)(r.code,{children:"AuthStrategy"})," / 实现后端边界"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Token-Store",children:"Token Store"})}),(0,t.jsx)(r.td,{children:"Persistence boundary / 持久化边界"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Network-Integration",children:"Network Integration"})}),(0,t.jsx)(r.td,{children:"Dio interceptor & token source / 网络集成与拦截器"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Errors",children:"Errors"})}),(0,t.jsxs)(r.td,{children:["Exception & ",(0,t.jsx)(r.code,{children:"Result"})," model / 异常与结果模型"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Configuration",children:"Configuration"})}),(0,t.jsx)(r.td,{children:"Configuration options / 配置说明"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Persistence",children:"Session Persistence"})}),(0,t.jsx)(r.td,{children:"Restoring and renewing a saved session / 会话持久化与恢复"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Third-Party-Login",children:"Third-Party Login"})}),(0,t.jsxs)(r.td,{children:["OAuth / magic links via ",(0,t.jsx)(r.code,{children:"loginWith"})," / 用 ",(0,t.jsx)(r.code,{children:"loginWith"})," 接入第三方登录"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Multi-Account",children:"Multiple Accounts"})}),(0,t.jsx)(r.td,{children:"Switching vs concurrent accounts / 账号切换与多账号并存"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"FAQ",children:"FAQ"})}),(0,t.jsx)(r.td,{children:"Frequently asked questions / 常见问题"})]})]})]}),"\n",(0,t.jsx)(r.h2,{id:n[2].id,children:n[2].value}),"\n",(0,t.jsxs)(r.ul,{children:["\n",(0,t.jsx)(r.li,{children:(0,t.jsx)(r.a,{href:"https://github.com/zero-labsco/zero_auth",children:"GitHub"})}),"\n",(0,t.jsx)(r.li,{children:(0,t.jsx)(r.a,{href:"https://www.zerolabsco.com/",children:"Official Website"})}),"\n",(0,t.jsx)(r.li,{children:(0,t.jsx)(r.a,{href:"https://pub.dev/packages/zero_auth",children:"pub.dev"})}),"\n"]}),"\n",(0,t.jsx)(r.h2,{id:n[3].id,children:n[3].value}),"\n",(0,t.jsxs)(r.p,{children:["This project is licensed under the ",(0,t.jsx)(r.strong,{children:"Mozilla Public License 2.0 (MPL-2.0)"}),"."]}),"\n",(0,t.jsx)(r.p,{children:"本项目采用 Mozilla Public License 2.0 (MPL-2.0) 许可证。"}),"\n",(0,t.jsx)(r.p,{children:"This package is provided “as is”, without warranty of any kind. The author assumes no responsibility or liability for the functionality, security, or any consequences arising from the use of modified versions or derivative projects."}),"\n",(0,t.jsx)(r.p,{children:"本包按”原样”提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。"})]})},"/",{filePath:"pages/index.md",timestamp:1789857834e3,pageMap:i.v,frontMatter:{},title:"Zero Auth"},"undefined"==typeof RemoteContent?c:RemoteContent.useTOC)},7080:function(e,n,r){"use strict";r.d(n,{v:function(){return t}});let t=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(e){e.O(0,[812,888,774,179],function(){return e(e.s=2725)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/docs/_next/static/chunks/pages/index-baf7c42074f1ade3.js b/docs/_next/static/chunks/pages/index-baf7c42074f1ade3.js new file mode 100644 index 0000000..8c22147 --- /dev/null +++ b/docs/_next/static/chunks/pages/index-baf7c42074f1ade3.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[405],{2725:function(e,n,r){(window.__NEXT_P=window.__NEXT_P||[]).push(["/",function(){return r(4991)}])},4991:function(e,n,r){"use strict";r.r(n),r.d(n,{useTOC:function(){return c}});var t=r(5893),s=r(7812),i=r(7080),d=r(8925);function c(e){return[{value:"✨ Features / 功能特性",id:"-features--功能特性",depth:2},{value:"\uD83D\uDCDA Table of Contents / 目录",id:"-table-of-contents--目录",depth:2},{value:"\uD83D\uDD17 Links / 链接",id:"-links--链接",depth:2},{value:"\uD83D\uDCC4 License / 许可证",id:"-license--许可证",depth:2}]}n.default=(0,s.c)(function(e){let{toc:n=c(e)}=e,r={a:"a",code:"code",h1:"h1",h2:"h2",li:"li",p:"p",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,d.a)(),...e.components};return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(r.h1,{children:"Zero Auth"}),"\n",(0,t.jsxs)(r.p,{children:["A backend-agnostic auth state machine & session lifecycle core for Dart/Flutter. Pure Dart, headless, no HTTP/SDK/native code. Wire your own backend via ",(0,t.jsx)(r.code,{children:"AuthStrategy"})," and your own persistence via ",(0,t.jsx)(r.code,{children:"TokenStore"}),"."]}),"\n",(0,t.jsxs)(r.p,{children:["一个后端无关的认证状态机与会话生命周期内核,面向 Dart/Flutter。纯 Dart、无头(headless),不含 HTTP、SDK 或原生代码。通过 ",(0,t.jsx)(r.code,{children:"AuthStrategy"})," 接入自有后端,通过 ",(0,t.jsx)(r.code,{children:"TokenStore"})," 接入自有持久化层。"]}),"\n",(0,t.jsx)(r.h2,{id:n[0].id,children:n[0].value}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,t.jsxs)(r.table,{children:[(0,t.jsx)(r.thead,{children:(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.th,{children:"Feature"}),(0,t.jsx)(r.th,{children:"Description"})]})}),(0,t.jsxs)(r.tbody,{children:[(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Backend-agnostic"})}),(0,t.jsxs)(r.td,{children:["Implement only ",(0,t.jsx)(r.code,{children:"login"}),"/",(0,t.jsx)(r.code,{children:"register"}),"/",(0,t.jsx)(r.code,{children:"logout"}),"/",(0,t.jsx)(r.code,{children:"refresh"}),"; REST, gRPC, Firebase or a private RPC are equally valid targets / 只需实现四个方法,REST、gRPC、Firebase 或私有 RPC 均可接入"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Auth State Machine"})}),(0,t.jsxs)(r.td,{children:["Sealed ",(0,t.jsx)(r.code,{children:"Unauthenticated"})," / ",(0,t.jsx)(r.code,{children:"Authenticating"})," / ",(0,t.jsx)(r.code,{children:"Authenticated"})," / ",(0,t.jsx)(r.code,{children:"Refreshing"})," / ",(0,t.jsx)(r.code,{children:"LoggingOut"})," / ",(0,t.jsx)(r.code,{children:"AuthError"})," on a ",(0,t.jsx)(r.code,{children:"Stream"})," that replays the latest value to new listeners; use ",(0,t.jsx)(r.code,{children:"isAuthenticated"})," / ",(0,t.jsx)(r.code,{children:"isBusy"})," / 密封六态状态机 + 重放最近值的状态流,可用 ",(0,t.jsx)(r.code,{children:"isAuthenticated"})," / ",(0,t.jsx)(r.code,{children:"isBusy"})]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Silent Restore"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"restore()"})," rehydrates the persisted session at startup / 启动时静默恢复持久化会话"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Single-flight Refresh"})}),(0,t.jsxs)(r.td,{children:["Concurrent ",(0,t.jsx)(r.code,{children:"refresh()"})," calls share one in-flight request instead of stampeding the backend / 并发刷新共享同一次请求,避免冲击后端"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Pluggable Persistence"})}),(0,t.jsxs)(r.td,{children:["The only persistence surface is ",(0,t.jsx)(r.code,{children:"TokenStore.save/load/clear"}),"; ",(0,t.jsx)(r.code,{children:"InMemoryTokenStore"})," ships in-core / 唯一的持久化接口,内核自带内存实现"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Typed Session"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"AuthSession"})," carries access/refresh tokens, expiry (",(0,t.jsx)(r.code,{children:"isExpired"}),"), user id, display name and raw claims / 强类型会话,无需手工解析令牌"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Unified Errors"})}),(0,t.jsxs)(r.td,{children:["Every failure maps to ",(0,t.jsx)(r.code,{children:"AppException"})," (",(0,t.jsx)(r.code,{children:"AuthException"})," for auth cases) or a ",(0,t.jsx)(r.code,{children:"Result"})," wrapper; raw exceptions never cross the public surface / 统一异常与结果,裸异常不越界"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Network Integration"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"AuthManager"})," itself is an ",(0,t.jsx)(r.code,{children:"AuthTokenSource"}),", so a Dio interceptor can attach ",(0,t.jsx)(r.code,{children:"Authorization: Bearer"})," without depending on the manager / 管理器即令牌源,Dio 拦截器零依赖附加令牌"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Session Serialization"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"AuthSession.toJson"})," / ",(0,t.jsx)(r.code,{children:"fromJson"})," make persistence a one-liner, and ",(0,t.jsx)(r.code,{children:"tryFromJson"})," returns ",(0,t.jsx)(r.code,{children:"null"})," on malformed data; a file-based reference store ships for server/CLI / ",(0,t.jsx)(r.code,{children:"AuthSession.toJson"})," / ",(0,t.jsx)(r.code,{children:"fromJson"})," 让持久化一行搞定,",(0,t.jsx)(r.code,{children:"tryFromJson"})," 遇畸形数据返回 ",(0,t.jsx)(r.code,{children:"null"}),",并附带面向服务端 / CLI 的文件参考存储"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Proactive Auto-refresh"})}),(0,t.jsxs)(r.td,{children:["Pass ",(0,t.jsx)(r.code,{children:"autoRefreshAhead"})," to renew tokens before expiry (single-flight) / 传入 ",(0,t.jsx)(r.code,{children:"autoRefreshAhead"})," 在过期前自动续期(单飞)"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Never an Expired Token"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"validAccessToken()"})," renews first when the token has expired — or is about to, per ",(0,t.jsx)(r.code,{children:"clockSkew"})," — so interceptors never send a dead bearer token / 令牌过期(或按 ",(0,t.jsx)(r.code,{children:"clockSkew"})," 即将过期)时先续期,拦截器不会发出失效令牌"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Bring Your Own Login"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"loginWith"})," adopts a session from any flow you drive: third-party OAuth, magic links, passkeys / ",(0,t.jsx)(r.code,{children:"loginWith"})," 可接纳第三方 OAuth、魔法链接、Passkey 等自定义流程"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Typed Auth Exceptions"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"InvalidCredentialsException"}),", ",(0,t.jsx)(r.code,{children:"SessionExpiredException"})," and friends, mapped from your strategy’s ",(0,t.jsx)(r.code,{children:"code"})," / ",(0,t.jsx)(r.code,{children:"InvalidCredentialsException"}),"、",(0,t.jsx)(r.code,{children:"SessionExpiredException"})," 等,由策略的 ",(0,t.jsx)(r.code,{children:"code"})," 映射而来"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Configurable Failure Policy"})}),(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"refreshFailurePolicy"})," decides whether a failed refresh signs the user out / ",(0,t.jsx)(r.code,{children:"refreshFailurePolicy"})," 决定刷新失败是否登出"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Runnable Example"})}),(0,t.jsxs)(r.td,{children:["A full Flutter demo app (Android/iOS/Web/Windows) plus a layered ",(0,t.jsx)(r.code,{children:"dart:io"})," demo backend that issues real JWTs / 完整 Flutter 示例与一个签发真实 JWT 的分层演示后端"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Multiple Accounts"})}),(0,t.jsxs)(r.td,{children:["Optional ",(0,t.jsx)(r.code,{children:"AuthManagerGroup"})," keeps one manager per account / 可选 ",(0,t.jsx)(r.code,{children:"AuthManagerGroup"}),",每账号一个管理器"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.strong,{children:"Cross-platform"})}),(0,t.jsx)(r.td,{children:"Pure Dart — runs anywhere Dart or Flutter runs / 纯 Dart,跨平台"})]})]})]}),"\n",(0,t.jsx)(r.h2,{id:n[1].id,children:n[1].value}),"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",(0,t.jsxs)(r.table,{children:[(0,t.jsx)(r.thead,{children:(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.th,{children:"Page"}),(0,t.jsx)(r.th,{children:"Description"})]})}),(0,t.jsxs)(r.tbody,{children:[(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Getting-Started",children:"Getting Started"})}),(0,t.jsx)(r.td,{children:"Quick start guide / 快速开始"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Installation",children:"Installation"})}),(0,t.jsx)(r.td,{children:"How to install / 安装方式"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Usage",children:"Usage"})}),(0,t.jsx)(r.td,{children:"Detailed usage / 详细使用"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Auth-State-Machine",children:"Auth State Machine"})}),(0,t.jsx)(r.td,{children:"State lifecycle & stream / 状态机与状态流"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Backend-Strategy",children:"Backend Strategy"})}),(0,t.jsxs)(r.td,{children:["Implement ",(0,t.jsx)(r.code,{children:"AuthStrategy"})," / 实现后端边界"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Token-Store",children:"Token Store"})}),(0,t.jsx)(r.td,{children:"Persistence boundary / 持久化边界"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Network-Integration",children:"Network Integration"})}),(0,t.jsx)(r.td,{children:"Dio interceptor & token source / 网络集成与拦截器"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Errors",children:"Errors"})}),(0,t.jsxs)(r.td,{children:["Exception & ",(0,t.jsx)(r.code,{children:"Result"})," model / 异常与结果模型"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Configuration",children:"Configuration"})}),(0,t.jsx)(r.td,{children:"Configuration options / 配置说明"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Persistence",children:"Session Persistence"})}),(0,t.jsx)(r.td,{children:"Restoring and renewing a saved session / 会话持久化与恢复"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Third-Party-Login",children:"Third-Party Login"})}),(0,t.jsxs)(r.td,{children:["OAuth / magic links via ",(0,t.jsx)(r.code,{children:"loginWith"})," / 用 ",(0,t.jsx)(r.code,{children:"loginWith"})," 接入第三方登录"]})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"Multi-Account",children:"Multiple Accounts"})}),(0,t.jsx)(r.td,{children:"Switching vs concurrent accounts / 账号切换与多账号并存"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.a,{href:"FAQ",children:"FAQ"})}),(0,t.jsx)(r.td,{children:"Frequently asked questions / 常见问题"})]})]})]}),"\n",(0,t.jsx)(r.h2,{id:n[2].id,children:n[2].value}),"\n",(0,t.jsxs)(r.ul,{children:["\n",(0,t.jsx)(r.li,{children:(0,t.jsx)(r.a,{href:"https://github.com/zero-labsco/zero_auth",children:"GitHub"})}),"\n",(0,t.jsx)(r.li,{children:(0,t.jsx)(r.a,{href:"https://www.zerolabsco.com/",children:"Official Website"})}),"\n",(0,t.jsx)(r.li,{children:(0,t.jsx)(r.a,{href:"https://pub.dev/packages/zero_auth",children:"pub.dev"})}),"\n"]}),"\n",(0,t.jsx)(r.h2,{id:n[3].id,children:n[3].value}),"\n",(0,t.jsxs)(r.p,{children:["This project is licensed under the ",(0,t.jsx)(r.strong,{children:"Mozilla Public License 2.0 (MPL-2.0)"}),"."]}),"\n",(0,t.jsx)(r.p,{children:"本项目采用 Mozilla Public License 2.0 (MPL-2.0) 许可证。"}),"\n",(0,t.jsx)(r.p,{children:"This package is provided “as is”, without warranty of any kind. The author assumes no responsibility or liability for the functionality, security, or any consequences arising from the use of modified versions or derivative projects."}),"\n",(0,t.jsx)(r.p,{children:"本包按”原样”提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。"})]})},"/",{filePath:"pages/index.md",timestamp:1789871655e3,pageMap:i.v,frontMatter:{},title:"Zero Auth"},"undefined"==typeof RemoteContent?c:RemoteContent.useTOC)},7080:function(e,n,r){"use strict";r.d(n,{v:function(){return t}});let t=[{data:{index:"\uD83C\uDFE0 Home","Getting-Started":"\uD83D\uDE80 Getting Started",Installation:"\uD83D\uDCE6 Installation",Usage:"\uD83D\uDCD6 Usage","--features":{type:"separator",title:"\uD83D\uDD27 Features"},"Auth-State-Machine":"\uD83E\uDDED Auth State Machine","Backend-Strategy":"\uD83D\uDD0C Backend Strategy","Token-Store":"\uD83D\uDCBE Token Store","Network-Integration":"\uD83C\uDF10 Network Integration","--cookbook":{type:"separator",title:"\uD83C\uDF73 Cookbook"},"Third-Party-Login":"\uD83D\uDD11 Third-Party Login","Multi-Account":"\uD83D\uDC65 Multiple Accounts","--advanced":{type:"separator",title:"\uD83D\uDEE0 Advanced"},Errors:"⚠️ Errors",Configuration:"⚙️ Configuration",Persistence:"\uD83D\uDCBD Session Persistence","--info":{type:"separator",title:"ℹ️ Info"},FAQ:"❓ FAQ"}},{name:"Auth-State-Machine",route:"/Auth-State-Machine",frontMatter:{sidebarTitle:"Auth State Machine"}},{name:"Backend-Strategy",route:"/Backend-Strategy",frontMatter:{sidebarTitle:"Backend Strategy"}},{name:"Configuration",route:"/Configuration",frontMatter:{sidebarTitle:"Configuration"}},{name:"Errors",route:"/Errors",frontMatter:{sidebarTitle:"Errors"}},{name:"FAQ",route:"/FAQ",frontMatter:{sidebarTitle:"Faq"}},{name:"Getting-Started",route:"/Getting-Started",frontMatter:{sidebarTitle:"Getting Started"}},{name:"index",route:"/",frontMatter:{sidebarTitle:"Index"}},{name:"Installation",route:"/Installation",frontMatter:{sidebarTitle:"Installation"}},{name:"Multi-Account",route:"/Multi-Account",frontMatter:{sidebarTitle:"Multi Account"}},{name:"Network-Integration",route:"/Network-Integration",frontMatter:{sidebarTitle:"Network Integration"}},{name:"Persistence",route:"/Persistence",frontMatter:{sidebarTitle:"Persistence"}},{name:"Third-Party-Login",route:"/Third-Party-Login",frontMatter:{sidebarTitle:"Third Party Login"}},{name:"Token-Store",route:"/Token-Store",frontMatter:{sidebarTitle:"Token Store"}},{name:"Usage",route:"/Usage",frontMatter:{sidebarTitle:"Usage"}}]}},function(e){e.O(0,[812,888,774,179],function(){return e(e.s=2725)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/docs/_next/static/7D0eXxPH8unHa8VZ9ZDfm/_buildManifest.js b/docs/_next/static/mMoJzqLtuvXxc8MM2_6u7/_buildManifest.js similarity index 56% rename from docs/_next/static/7D0eXxPH8unHa8VZ9ZDfm/_buildManifest.js rename to docs/_next/static/mMoJzqLtuvXxc8MM2_6u7/_buildManifest.js index 6ab9418..61d1541 100644 --- a/docs/_next/static/7D0eXxPH8unHa8VZ9ZDfm/_buildManifest.js +++ b/docs/_next/static/mMoJzqLtuvXxc8MM2_6u7/_buildManifest.js @@ -1 +1 @@ -self.__BUILD_MANIFEST=function(t){return{__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},"/":[t,"static/chunks/pages/index-255afb63bd37772c.js"],"/Auth-State-Machine":[t,"static/chunks/pages/Auth-State-Machine-482df521c021e389.js"],"/Backend-Strategy":[t,"static/chunks/pages/Backend-Strategy-6257d2611446a978.js"],"/Configuration":[t,"static/chunks/pages/Configuration-e1e78c409860f3d5.js"],"/Errors":[t,"static/chunks/pages/Errors-ccf26da76701ec77.js"],"/FAQ":[t,"static/chunks/pages/FAQ-66e28fdd0cce1e18.js"],"/Getting-Started":[t,"static/chunks/pages/Getting-Started-5c397c7a4144d454.js"],"/Installation":[t,"static/chunks/pages/Installation-44254819243c14af.js"],"/Multi-Account":[t,"static/chunks/pages/Multi-Account-2b08a3f8b85100c0.js"],"/Network-Integration":[t,"static/chunks/pages/Network-Integration-ef3ead93a51929f6.js"],"/Persistence":[t,"static/chunks/pages/Persistence-6ffb0f37f033307f.js"],"/Third-Party-Login":[t,"static/chunks/pages/Third-Party-Login-f0a2842214831efd.js"],"/Token-Store":[t,"static/chunks/pages/Token-Store-afbc8ed380153931.js"],"/Usage":[t,"static/chunks/pages/Usage-707440e645e21052.js"],"/_error":["static/chunks/pages/_error-7a92967bea80186d.js"],"/_meta":["static/chunks/pages/_meta-b47e7a01bb6b7929.js"],sortedPages:["/","/Auth-State-Machine","/Backend-Strategy","/Configuration","/Errors","/FAQ","/Getting-Started","/Installation","/Multi-Account","/Network-Integration","/Persistence","/Third-Party-Login","/Token-Store","/Usage","/_app","/_error","/_meta"]}}("static/chunks/812-0b5992150f379e9a.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB(); \ No newline at end of file +self.__BUILD_MANIFEST=function(t){return{__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},"/":[t,"static/chunks/pages/index-baf7c42074f1ade3.js"],"/Auth-State-Machine":[t,"static/chunks/pages/Auth-State-Machine-df4af2f2f62580ee.js"],"/Backend-Strategy":[t,"static/chunks/pages/Backend-Strategy-6257d2611446a978.js"],"/Configuration":[t,"static/chunks/pages/Configuration-6e0bc428c276681c.js"],"/Errors":[t,"static/chunks/pages/Errors-d214a21e984d4641.js"],"/FAQ":[t,"static/chunks/pages/FAQ-2cda6ba11483a6c4.js"],"/Getting-Started":[t,"static/chunks/pages/Getting-Started-5c397c7a4144d454.js"],"/Installation":[t,"static/chunks/pages/Installation-dd2c3a4ed8ae2991.js"],"/Multi-Account":[t,"static/chunks/pages/Multi-Account-bd727b2ad9f2f451.js"],"/Network-Integration":[t,"static/chunks/pages/Network-Integration-402a7d4df93d13c8.js"],"/Persistence":[t,"static/chunks/pages/Persistence-621169678679dd35.js"],"/Third-Party-Login":[t,"static/chunks/pages/Third-Party-Login-f0a2842214831efd.js"],"/Token-Store":[t,"static/chunks/pages/Token-Store-1f37005b28989149.js"],"/Usage":[t,"static/chunks/pages/Usage-707440e645e21052.js"],"/_error":["static/chunks/pages/_error-7a92967bea80186d.js"],"/_meta":["static/chunks/pages/_meta-b47e7a01bb6b7929.js"],sortedPages:["/","/Auth-State-Machine","/Backend-Strategy","/Configuration","/Errors","/FAQ","/Getting-Started","/Installation","/Multi-Account","/Network-Integration","/Persistence","/Third-Party-Login","/Token-Store","/Usage","/_app","/_error","/_meta"]}}("static/chunks/812-0b5992150f379e9a.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB(); \ No newline at end of file diff --git a/docs/_next/static/7D0eXxPH8unHa8VZ9ZDfm/_ssgManifest.js b/docs/_next/static/mMoJzqLtuvXxc8MM2_6u7/_ssgManifest.js similarity index 100% rename from docs/_next/static/7D0eXxPH8unHa8VZ9ZDfm/_ssgManifest.js rename to docs/_next/static/mMoJzqLtuvXxc8MM2_6u7/_ssgManifest.js diff --git a/docs/index.html b/docs/index.html index aac3de4..3e01aee 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,4 +1,4 @@ -
    🏠 Home

    Zero Auth

    +
    🏠 Home

    Zero Auth

    A backend-agnostic auth state machine & session lifecycle core for Dart/Flutter. Pure Dart, headless, no HTTP/SDK/native code. Wire your own backend via AuthStrategy and your own persistence via TokenStore.

    一个后端无关的认证状态机与会话生命周期内核,面向 Dart/Flutter。纯 Dart、无头(headless),不含 HTTP、SDK 或原生代码。通过 AuthStrategy 接入自有后端,通过 TokenStore 接入自有持久化层。

    ✨ Features / 功能特性

    @@ -79,7 +79,7 @@

    FeatureDescriptionBackend-agnosticImplement only login/register/logout/refresh; REST, gRPC, Firebase or a private RPC are equally valid targets / 只需实现四个方法,REST、gRPC、Firebase 或私有 RPC 均可接入Auth State MachineSealed Unauthenticated / Authenticating / Authenticated / Refreshing / LoggingOut / AuthError on a Stream<AuthState> that replays the latest value to new listeners; use isAuthenticated / isBusy / 密封六态状态机 + 重放最近值的状态流,可用 isAuthenticated / isBusySilent Restorerestore() rehydrates the persisted session at startup / 启动时静默恢复持久化会话Single-flight RefreshConcurrent refresh() calls share one in-flight request instead of stampeding the backend / 并发刷新共享同一次请求,避免冲击后端Pluggable PersistenceThe only persistence surface is TokenStore.save/load/clear; InMemoryTokenStore ships in-core / 唯一的持久化接口,内核自带内存实现Typed SessionAuthSession carries access/refresh tokens, expiry (isExpired), user id, display name and raw claims / 强类型会话,无需手工解析令牌Unified ErrorsEvery failure maps to AppException (AuthException for auth cases) or a Result<T> wrapper; raw exceptions never cross the public surface / 统一异常与结果,裸异常不越界Network IntegrationAuthManager itself is an AuthTokenSource, so a Dio interceptor can attach Authorization: Bearer without depending on the manager / 管理器即令牌源,Dio 拦截器零依赖附加令牌Session SerializationAuthSession.toJson / fromJson make persistence a one-liner; a file-based reference store ships for server/CLI / AuthSession.toJson / fromJson 让持久化一行搞定,并附带面向服务端 / CLI 的文件参考存储Proactive Auto-refreshPass autoRefreshAhead to renew tokens before expiry (single-flight) / 传入 autoRefreshAhead 在过期前自动续期(单飞)Never an Expired TokenvalidAccessToken() renews first when the token has expired, so interceptors never send a dead bearer token / 令牌过期时先续期,拦截器不会发出失效令牌Bring Your Own LoginloginWith adopts a session from any flow you drive: third-party OAuth, magic links, passkeys / loginWith 可接纳第三方 OAuth、魔法链接、Passkey 等自定义流程Typed Auth ExceptionsInvalidCredentialsException, SessionExpiredException and friends, mapped from your strategy’s code / InvalidCredentialsException、SessionExpiredException 等,由策略的 code 映射而来Configurable Failure PolicyrefreshFailurePolicy decides whether a failed refresh signs the user out / refreshFailurePolicy 决定刷新失败是否登出Runnable ExampleA full Flutter demo app (Android/iOS/Web/Windows) plus a layered dart:io demo backend that issues real JWTs / 完整 Flutter 示例与一个签发真实 JWT 的分层演示后端Multiple AccountsOptional AuthManagerGroup keeps one manager per account / 可选 AuthManagerGroup,每账号一个管理器Cross-platformPure Dart — runs anywhere Dart or Flutter runs / 纯 Dart,跨平台 +
    FeatureDescription
    Backend-agnosticImplement only login/register/logout/refresh; REST, gRPC, Firebase or a private RPC are equally valid targets / 只需实现四个方法,REST、gRPC、Firebase 或私有 RPC 均可接入
    Auth State MachineSealed Unauthenticated / Authenticating / Authenticated / Refreshing / LoggingOut / AuthError on a Stream<AuthState> that replays the latest value to new listeners; use isAuthenticated / isBusy / 密封六态状态机 + 重放最近值的状态流,可用 isAuthenticated / isBusy
    Silent Restorerestore() rehydrates the persisted session at startup / 启动时静默恢复持久化会话
    Single-flight RefreshConcurrent refresh() calls share one in-flight request instead of stampeding the backend / 并发刷新共享同一次请求,避免冲击后端
    Pluggable PersistenceThe only persistence surface is TokenStore.save/load/clear; InMemoryTokenStore ships in-core / 唯一的持久化接口,内核自带内存实现
    Typed SessionAuthSession carries access/refresh tokens, expiry (isExpired), user id, display name and raw claims / 强类型会话,无需手工解析令牌
    Unified ErrorsEvery failure maps to AppException (AuthException for auth cases) or a Result<T> wrapper; raw exceptions never cross the public surface / 统一异常与结果,裸异常不越界
    Network IntegrationAuthManager itself is an AuthTokenSource, so a Dio interceptor can attach Authorization: Bearer without depending on the manager / 管理器即令牌源,Dio 拦截器零依赖附加令牌
    Session SerializationAuthSession.toJson / fromJson make persistence a one-liner, and tryFromJson returns null on malformed data; a file-based reference store ships for server/CLI / AuthSession.toJson / fromJson 让持久化一行搞定,tryFromJson 遇畸形数据返回 null,并附带面向服务端 / CLI 的文件参考存储
    Proactive Auto-refreshPass autoRefreshAhead to renew tokens before expiry (single-flight) / 传入 autoRefreshAhead 在过期前自动续期(单飞)
    Never an Expired TokenvalidAccessToken() renews first when the token has expired — or is about to, per clockSkew — so interceptors never send a dead bearer token / 令牌过期(或按 clockSkew 即将过期)时先续期,拦截器不会发出失效令牌
    Bring Your Own LoginloginWith adopts a session from any flow you drive: third-party OAuth, magic links, passkeys / loginWith 可接纳第三方 OAuth、魔法链接、Passkey 等自定义流程
    Typed Auth ExceptionsInvalidCredentialsException, SessionExpiredException and friends, mapped from your strategy’s code / InvalidCredentialsException、SessionExpiredException 等,由策略的 code 映射而来
    Configurable Failure PolicyrefreshFailurePolicy decides whether a failed refresh signs the user out / refreshFailurePolicy 决定刷新失败是否登出
    Runnable ExampleA full Flutter demo app (Android/iOS/Web/Windows) plus a layered dart:io demo backend that issues real JWTs / 完整 Flutter 示例与一个签发真实 JWT 的分层演示后端
    Multiple AccountsOptional AuthManagerGroup keeps one manager per account / 可选 AuthManagerGroup,每账号一个管理器
    Cross-platformPure Dart — runs anywhere Dart or Flutter runs / 纯 Dart,跨平台

    📚 Table of Contents / 目录

    @@ -153,4 +153,4 @@

    This project is licensed under the Mozilla Public License 2.0 (MPL-2.0).

    本项目采用 Mozilla Public License 2.0 (MPL-2.0) 许可证。

    This package is provided “as is”, without warranty of any kind. The author assumes no responsibility or liability for the functionality, security, or any consequences arising from the use of modified versions or derivative projects.

    -

    本包按”原样”提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    本包按”原样”提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。


    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/example/lib/dio_interceptor.dart b/example/lib/dio_interceptor.dart index f2b4c1b..003965d 100644 --- a/example/lib/dio_interceptor.dart +++ b/example/lib/dio_interceptor.dart @@ -3,6 +3,12 @@ import 'package:zero_auth/zero_auth.dart'; /// Dio interceptor that attaches `Authorization: Bearer ` when a session /// is active, and skips the header when unauthenticated. +/// +/// NOTE: this reads the synchronous `accessToken`, which may already be expired. +/// Use [RefreshingAuthInterceptor] (or `validAccessToken()`) whenever the token +/// actually reaches a server. +/// 注意:这里读取的是同步的 `accessToken`,它可能已经过期。只要令牌真的要发到服务端, +/// 请用 [RefreshingAuthInterceptor](或 `validAccessToken()`)。 final class AuthInterceptor extends Interceptor { AuthInterceptor(this.source); @@ -60,23 +66,31 @@ final class AuthRetryInterceptor extends QueuedInterceptor { } } -/// Variant that never sends an expired token: when the session has expired it -/// transparently renews it through [AuthManager.validAccessToken] (which reuses +/// Variant that never sends an expired token: it reads +/// [AuthTokenSource.validAccessToken], which renews the session first (reusing /// the single-flight refresh) before attaching the header. /// +/// It accepts the interface rather than an [AuthManager], so any source can be +/// wired in — the manager, an [AuthManagerGroup], or your own. +/// 变体:绝不发送过期令牌。它读取 [AuthTokenSource.validAccessToken],必要时先续期 +/// (复用单飞刷新)再附加请求头。 +/// +/// 它接受接口而不是 [AuthManager],因此可接入任意令牌源 —— 管理器、 +/// [AuthManagerGroup] 或你自己的实现。 +/// /// Extends [QueuedInterceptor] so concurrent requests wait for one shared /// refresh instead of each triggering its own. final class RefreshingAuthInterceptor extends QueuedInterceptor { - RefreshingAuthInterceptor(this.manager); + RefreshingAuthInterceptor(this.source); - final AuthManager manager; + final AuthTokenSource source; @override void onRequest( RequestOptions options, RequestInterceptorHandler handler, ) async { - final token = await manager.validAccessToken(); + final token = await source.validAccessToken(); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } diff --git a/lib/src/auth_manager.dart b/lib/src/auth_manager.dart index cdc0fed..9c26f32 100644 --- a/lib/src/auth_manager.dart +++ b/lib/src/auth_manager.dart @@ -42,9 +42,28 @@ final class AuthManager implements AuthTokenSource { StreamController.broadcast(); Completer? _refreshCompleter; + + /// Epoch the in-flight refresh was started in. A refresh started before the + /// session was replaced (by a login, for instance) must not be joined, or the + /// caller would receive a session that is already stale. + /// 进行中刷新启动时所处的 epoch。若会话在其启动后被替换(例如重新登录),则不能再 + /// 加入该次刷新,否则调用方会拿到已经过期的会话。 + int _refreshCompleterEpoch = 0; + Timer? _autoRefreshTimer; + + /// When the last proactive renewal started, used to throttle a run of + /// already-due renewals. + /// 上一次主动续期的开始时刻,用于节流一连串「已到期」的续期。 + DateTime? _lastProactiveRefreshAt; + bool _disposed = false; + /// Guards concurrent [restore] calls: they share one attempt instead of + /// activating two sessions. + /// 守卫并发的 [restore] 调用:它们共享同一次尝试,而不会激活两个会话。 + Future? _restoreInFlight; + /// Bumped on [logout] / [dispose]. Work started before a bump is dropped, so a /// late refresh can never resurrect a session the user already left. /// 在 [logout] / [dispose] 时递增。自增之前启动的工作会被丢弃,因此迟到的刷新永远 @@ -60,7 +79,9 @@ final class AuthManager implements AuthTokenSource { /// /// [refreshFailurePolicy] decides whether a failed refresh signs the user out; /// it defaults to [defaultRefreshFailurePolicy]. [clock] overrides the time - /// source used for expiry maths and proactive scheduling (tests, clock skew). + /// source used for expiry maths and proactive scheduling (tests, clock skew), + /// while [clockSkew] is how much *earlier* a token counts as expired, so a + /// device clock that runs ahead cannot hand out a token that dies in flight. /// 创建管理器,默认使用 [InMemoryTokenStore]。 /// /// 传入 [autoRefreshAhead] 可开启「临近过期自动刷新」:当会话同时带有 @@ -69,7 +90,8 @@ final class AuthManager implements AuthTokenSource { /// /// [refreshFailurePolicy] 决定刷新失败是否让用户登出,默认为 /// [defaultRefreshFailurePolicy];[clock] 可覆盖过期计算与主动刷新调度所用的时间源 - /// (便于测试与应对时钟偏移)。 + /// (便于测试与应对时钟偏移),[clockSkew] 则表示提前多久把令牌视为过期,以免设备 + /// 时钟偏快时发出一个途中就会失效的令牌。 /// [autoRefreshAhead] enables proactive renewal; when such a renewal fails, /// [autoRefreshRetryDelay] re-arms it instead of silently stopping. /// @@ -80,23 +102,38 @@ final class AuthManager implements AuthTokenSource { /// /// [onStateChanged] 是可选回调,每次发出状态时被调用,便于在不订阅 [state] 的情况下 /// 做日志或埋点。 + /// + /// [preserveSessionDetails] (default `true`) carries the identity fields — user + /// id, display name, claims — over to a refreshed session when the backend only + /// returns tokens. [autoRefreshMinInterval] floors the delay of a proactive + /// renewal that is already due, so a backend handing out very short-lived + /// tokens cannot turn renewal into a tight loop. + /// [preserveSessionDetails](默认 `true`)在后端刷新只返回令牌时,把身份字段 + /// (用户 id、显示名、claims)带到新会话上;[autoRefreshMinInterval] 为「已到期」 + /// 的主动续期设置最小等待,避免后端发放极短寿命令牌时把续期变成紧密循环。 AuthManager({ required this.strategy, TokenStore? tokenStore, Duration? autoRefreshAhead, Duration? autoRefreshRetryDelay, int? autoRefreshMaxRetries, + Duration? autoRefreshMinInterval, RefreshFailurePolicy? refreshFailurePolicy, DateTime Function()? clock, + Duration? clockSkew, + this.preserveSessionDetails = true, this.onStateChanged, }) : tokenStore = tokenStore ?? InMemoryTokenStore(), _autoRefreshAhead = autoRefreshAhead, _autoRefreshRetryDelay = autoRefreshRetryDelay ?? const Duration(seconds: 30), _autoRefreshMaxRetries = autoRefreshMaxRetries ?? 3, + _autoRefreshMinInterval = + autoRefreshMinInterval ?? const Duration(seconds: 5), refreshFailurePolicy = refreshFailurePolicy ?? defaultRefreshFailurePolicy, - clock = clock ?? _systemClock; + clock = clock ?? _systemClock, + clockSkew = clockSkew ?? const Duration(seconds: 30); final Duration? _autoRefreshAhead; @@ -109,6 +146,23 @@ final class AuthManager implements AuthTokenSource { /// 主动续期失败多少次后放弃重试。 final int _autoRefreshMaxRetries; + /// Floor for a proactive renewal that is already due, so a backend handing out + /// very short-lived tokens cannot turn renewal into a tight loop. + /// 「已到期」主动续期的最小等待,避免后端发放极短寿命令牌时把续期变成紧密循环。 + final Duration _autoRefreshMinInterval; + + /// How much earlier a token counts as expired. Absorbs a device clock that runs + /// ahead of the server's, and the network latency of the request the token is + /// about to be attached to. Defaults to 30 seconds. + /// 提前多久把令牌视为过期。用于吸收设备时钟快于服务端的情况,以及令牌即将附着的 + /// 那次请求自身的网络延迟。默认 30 秒。 + final Duration clockSkew; + + /// Whether identity fields survive a refresh that only returns tokens. + /// Defaults to `true`. + /// 身份字段是否在「只返回令牌」的刷新中保留。默认 `true`。 + final bool preserveSessionDetails; + /// Consecutive proactive renewal failures; reset once one succeeds. /// 连续主动续期失败次数;成功后归零。 int _proactiveFailures = 0; @@ -133,6 +187,15 @@ final class AuthManager implements AuthTokenSource { static DateTime _systemClock() => DateTime.now(); + /// `now` shifted forward by [clockSkew] — the instant a token must still be + /// valid at to be handed out. + /// 把 `now` 前移 [clockSkew] 后的时刻;令牌要能发出,就必须在该时刻仍然有效。 + DateTime _expiryNow() => clock().add(clockSkew); + + /// Whether [session] must be considered expired, tolerating [clockSkew]. + /// [session] 是否应被视为已过期(计入 [clockSkew])。 + bool _isExpired(AuthSession session) => session.isExpiredAt(_expiryNow()); + /// The current state (always available, replay-last). /// 当前状态(始终可用,重放最近值)。 AuthState get current => _state; @@ -170,12 +233,35 @@ final class AuthManager implements AuthTokenSource { /// When [refreshIfExpired] is `true` (default) and the persisted session is /// already expired, a refresh is attempted before falling back to /// [Unauthenticated]; pass `false` to restore the session as-is. + /// + /// A renewal that fails *transiently* (network hiccup, 5xx) keeps the persisted + /// session: the user stays signed in and the next [validAccessToken] call + /// retries. Only a failure that [refreshFailurePolicy] calls terminal clears + /// the store. + /// + /// Concurrent callers share a single attempt instead of racing to activate two + /// sessions; the arguments of the first call win. /// 启动时恢复持久化会话。 /// /// 当 [refreshIfExpired] 为 `true`(默认)且持久化会话已过期时,会先尝试刷新,失败 /// 才降级为 [Unauthenticated];传 `false` 则原样恢复会话。 + /// + /// **瞬时**失败(网络抖动、5xx)会保留持久化会话:用户仍处于登录态,下次 + /// [validAccessToken] 会重试。只有被 [refreshFailurePolicy] 判定为终局的失败才会 + /// 清空存储。 + /// + /// 并发调用方共享同一次尝试,不会争抢激活两个会话;以首次调用的参数为准。 Future restore({bool refreshIfExpired = true}) async { _checkUsable(); + final inFlight = _restoreInFlight; + if (inFlight != null) return inFlight; + final attempt = + _restore(refreshIfExpired).whenComplete(() => _restoreInFlight = null); + _restoreInFlight = attempt; + return attempt; + } + + Future _restore(bool refreshIfExpired) async { final epoch = _epoch; final AuthSession? session; @@ -201,7 +287,7 @@ final class AuthManager implements AuthTokenSource { return; } - final expired = session.isExpiredAt(clock()); + final expired = _isExpired(session); // Only reconsider an expired session when the caller opted in; otherwise the // session is restored verbatim, exactly as persisted. // 仅在调用方开启时才对过期会话做再处理;否则原样恢复会话,与持久化内容一致。 @@ -218,12 +304,32 @@ final class AuthManager implements AuthTokenSource { final attempt = await _tryRefresh(session); if (!_isCurrent(epoch)) return; + final failure = attempt.failure; + if (failure != null) { + // Transient: keep the persisted session so a later call can retry. Only a + // terminal failure (per the policy) is allowed to destroy it. + // 瞬时失败:保留持久化会话以便稍后重试;只有策略判定的终局失败才能销毁它。 + if (!refreshFailurePolicy(failure)) { + _emit(AuthError(failure)); + // Activated without an immediate renewal — it has just failed — but + // proactive renewal, when configured, is re-armed with its backoff so + // the session does not stay stale forever. + // 激活时不立即续期(刚刚才失败过),但若配置了主动续期,则按其退避策略 + // 重新排程,避免会话一直停留在过期状态。 + _activate(session, scheduleProactive: false); + if (_autoRefreshAhead != null) _scheduleProactiveRetry(); + return; + } + await _dropSession(failure); + return; + } + final renewed = attempt.session; - if (renewed == null || renewed.isExpiredAt(clock())) { + if (renewed == null || _isExpired(renewed)) { await _dropSession( SessionExpiredException( message: 'Persisted session expired and could not be renewed', - cause: attempt.failure, + cause: failure, ), ); return; @@ -244,15 +350,34 @@ final class AuthManager implements AuthTokenSource { /// 清空持久化会话并落到 [Unauthenticated],同时上报原因,以便界面区分 /// 「从未登录」与「会话过期」。 Future _dropSession(AppException failure) async { - await tokenStore.clear(); + Object? clearError; + try { + await tokenStore.clear(); + } catch (e) { + // A store that cannot be cleared must not block the sign-out itself; the + // cause is reported after the state has settled. + // 无法清空的存储不应阻断登出本身;状态落定后再上报原因。 + clearError = e; + } _emit(AuthError(failure)); _emit(const Unauthenticated()); + if (clearError != null) { + throw UnexpectedAuthException( + message: + 'The session was dropped, but the stored session could not be cleared', + cause: clearError, + ); + } } /// Log in: emits [Authenticating] → [Authenticated] (or [AuthError] on failure). /// 登录:先发 [Authenticating],成功发 [Authenticated],失败发 [AuthError]。 Future login(Credentials credentials) async { _beginAuthFlow(); + // A refresh started before this flow must not overwrite the session it is + // about to install with a token from the previous one. + // 本次流程之前启动的刷新,不能用旧会话的令牌覆盖即将安装的会话。 + _invalidateInFlight(); _emit(const Authenticating()); final epoch = _epoch; try { @@ -275,6 +400,10 @@ final class AuthManager implements AuthTokenSource { /// 注册新账户并返回首个会话。 Future register(RegistrationInput input) async { _beginAuthFlow(); + // A refresh started before this flow must not overwrite the session it is + // about to install with a token from the previous one. + // 本次流程之前启动的刷新,不能用旧会话的令牌覆盖即将安装的会话。 + _invalidateInFlight(); _emit(const Authenticating()); final epoch = _epoch; try { @@ -309,6 +438,10 @@ final class AuthManager implements AuthTokenSource { Future Function(AuthStrategy strategy) flow, ) async { _beginAuthFlow(); + // A refresh started before this flow must not overwrite the session it is + // about to install with a token from the previous one. + // 本次流程之前启动的刷新,不能用旧会话的令牌覆盖即将安装的会话。 + _invalidateInFlight(); _emit(const Authenticating()); final epoch = _epoch; try { @@ -349,12 +482,28 @@ final class AuthManager implements AuthTokenSource { // `update` may be async (a round trip to fetch fresh profile data), so the // session is re-checked on both sides of it. // `update` 可以是异步的(例如先请求最新资料),因此前后都要重新校验。 - final epoch = _epoch; - _ensureCurrent(epoch); + final startEpoch = _epoch; + _ensureCurrent(startEpoch); final updated = await update(current); - _ensureCurrent(epoch); + _ensureCurrent(startEpoch); + + // A refresh started before the update would overwrite it with a token from + // the previous session, so it is invalidated here. + // 更新前启动的刷新会用旧会话的令牌覆盖本次结果,因此在此令其失效。 + _invalidateInFlight(); + final epoch = _epoch; - await tokenStore.save(updated); + try { + await tokenStore.save(updated); + } catch (e) { + // Persistence is part of the contract: a store failure must leave through + // the same AppException doorway as everything else. + // 持久化属于契约的一部分:存储失败必须和其它失败一样,从 AppException 这道门出去。 + throw UnexpectedAuthException( + message: 'Failed to persist the updated session', + cause: e, + ); + } // The session could have been invalidated while saving; do not leave a // session behind after a logout. @@ -393,6 +542,10 @@ final class AuthManager implements AuthTokenSource { /// 登出:发 [LoggingOut]、通知后端、清空存储,最后发 [Unauthenticated]。 Future logout() async { _checkUsable(); + // Invalidated first: a renewal that lands while the backend logout is in + // flight must not emit `Authenticated` after the sign-out has begun. + // 先令旧工作失效:后端登出期间落地的续期,不应在登出开始后又发出 `Authenticated`。 + _invalidateInFlight(); _cancelProactiveRefresh(); final session = _activeSession; if (session != null) { @@ -409,10 +562,6 @@ final class AuthManager implements AuthTokenSource { // 尽力而为:后端登出失败不应阻断本地登出。 } } - // Invalidate everything started before now (e.g. an in-flight refresh). - // 让此刻之前启动的所有异步工作失效(例如正在进行的刷新)。 - _epoch++; - // Local logout must always complete, even when the store itself fails. // 即使存储本身出错,本地登出也必须完成。 Object? clearError; @@ -450,17 +599,29 @@ final class AuthManager implements AuthTokenSource { /// An access token that is guaranteed not to be expired, refreshing first when /// needed. Returns `null` when unauthenticated, or when the refresh failed and /// the session was dropped. Ideal for HTTP interceptors. + /// + /// [leeway] is how long the token must stay valid for (defaults to + /// [clockSkew]), so a token that would die while the request is in flight is + /// renewed first. /// 保证未过期的访问令牌,必要时先刷新。未认证、或刷新失败导致会话被丢弃时返回 /// `null`。非常适合用在 HTTP 拦截器里。 - Future validAccessToken() async { + /// + /// [leeway] 表示令牌必须还能维持有效的时长(默认取 [clockSkew]),因此会在请求 + /// 途中失效的令牌会被提前续期。 + @override + Future validAccessToken({Duration? leeway}) async { _checkUsable(); final session = currentSession; if (session == null) return null; - if (!session.isExpiredAt(clock())) return session.accessToken; + if (!session.isExpiredAt(clock().add(leeway ?? clockSkew))) { + return session.accessToken; + } - // Expired. Without a refresh token there is no way to make it valid again, - // so an expired token must never be handed to the network layer. - // 已过期。没有刷新令牌就无法恢复有效性,因此绝不能把过期令牌交给网络层。 + // Expired (or expiring within the leeway). Without a refresh token there is + // no way to make it valid again, so an expired token must never be handed to + // the network layer. + // 已过期(或在容差内即将过期)。没有刷新令牌就无法恢复有效性,因此绝不能把过期 + // 令牌交给网络层。 if (session.refreshToken == null) return null; try { @@ -474,8 +635,10 @@ final class AuthManager implements AuthTokenSource { /// 释放内部资源。不再使用时调用。 Future dispose() { _disposed = true; - _epoch++; + _invalidateInFlight(); _cancelProactiveRefresh(); + _restoreInFlight?.ignore(); + _restoreInFlight = null; return _controller.close(); } @@ -491,6 +654,13 @@ final class AuthManager implements AuthTokenSource { bool _isCurrent(int epoch) => !_disposed && epoch == _epoch; + /// Invalidates every piece of work started before now — an in-flight refresh + /// above all — so a late result can never overwrite the session a newer + /// operation installed. + /// 让此刻之前启动的所有工作失效(尤其是进行中的刷新),迟到的结果永远不会覆盖更新 + /// 的操作所安装的会话。 + void _invalidateInFlight() => _epoch++; + void _ensureCurrent(int epoch) { if (!_isCurrent(epoch)) { throw NoActiveSessionException( @@ -547,23 +717,46 @@ final class AuthManager implements AuthTokenSource { Future _startRefresh(AuthSession session) { final inFlight = _refreshCompleter; - if (inFlight != null) return inFlight.future; + // Only join a refresh started within the current epoch: one started before + // the session was replaced (by a login, for instance) would hand back a + // session that is already stale — or worse, one minted from a refresh token + // that a rotation has since retired. + // 只加入在当前 epoch 内启动的刷新:会话被替换(例如重新登录)之前启动的那次会 + // 返回已经过期的会话 —— 更糟的情况是返回由已被轮换退役的刷新令牌换来的会话。 + if (inFlight != null && _refreshCompleterEpoch == _epoch) { + return inFlight.future; + } final epoch = _epoch; _emit(Refreshing(session)); final completer = Completer(); _refreshCompleter = completer; + _refreshCompleterEpoch = epoch; unawaited(_runRefresh(session, epoch, completer)); return completer.future; } + /// Carries the identity fields of [previous] over to [refreshed] when the + /// backend renewed tokens only, so a renewal cannot silently erase who is + /// signed in (and, with it, the `SessionHandle.userId` sent on logout). + /// 当后端只续期令牌时,把 [previous] 的身份字段带到 [refreshed] 上,避免续期悄悄 + /// 抹掉「谁在登录」(连带抹掉登出时发送的 `SessionHandle.userId`)。 + AuthSession _mergeRefreshed(AuthSession previous, AuthSession refreshed) { + if (!preserveSessionDetails) return refreshed; + return refreshed.copyWith( + userId: refreshed.userId ?? previous.userId, + displayName: refreshed.displayName ?? previous.displayName, + claims: refreshed.claims ?? previous.claims, + ); + } + Future _runRefresh( AuthSession session, int epoch, Completer completer, ) async { try { - final refreshed = await strategy.refresh(session.refreshToken!); + final renewed = await strategy.refresh(session.refreshToken!); if (!_isCurrent(epoch)) { completer.completeError( NoActiveSessionException( @@ -572,6 +765,7 @@ final class AuthManager implements AuthTokenSource { ); return; } + final refreshed = _mergeRefreshed(session, renewed); await tokenStore.save(refreshed); _activate(refreshed); completer.complete(refreshed); @@ -580,7 +774,14 @@ final class AuthManager implements AuthTokenSource { if (_isCurrent(epoch)) { _emit(AuthError(failure)); if (refreshFailurePolicy(failure)) { - await tokenStore.clear(); + try { + await tokenStore.clear(); + } catch (_) { + // A store that cannot be cleared must neither strand the completer + // nor surface as an unhandled async error: local sign-out wins. + // 无法清空的存储既不能让 completer 悬挂,也不能变成未处理的异步错误: + // 本地登出优先。 + } _emit(const Unauthenticated()); } else { // Transient failure: keep the previous session usable. @@ -638,8 +839,15 @@ final class AuthManager implements AuthTokenSource { /// Schedule a one-shot [refresh] [autoRefreshAhead] before [AuthSession.expiresAt]. /// No-op when proactive refresh is disabled, or the session lacks an expiry or /// a refresh token. + /// + /// A session that is already due is renewed after [autoRefreshMinInterval] + /// rather than immediately, so a backend that keeps handing out very + /// short-lived tokens cannot turn renewal into a tight loop. /// 在 [AuthSession.expiresAt] 之前 [autoRefreshAhead] 调度一次 [refresh]。 /// 当未开启主动刷新、或会话缺少过期时间 / 刷新令牌时为空操作。 + /// + /// 已经到期的会话会在 [autoRefreshMinInterval] 后续期,而不是立刻续期,以免后端 + /// 持续发放极短寿命的令牌时把续期变成紧密循环。 void _scheduleAutoRefresh(AuthSession session) { _cancelProactiveRefresh(); final ahead = _autoRefreshAhead; @@ -648,12 +856,40 @@ final class AuthManager implements AuthTokenSource { session.refreshToken == null) { return; } - final delay = session.expiresAt!.difference(clock()) - ahead; + final delay = session.expiresAt!.difference(clock()) - ahead - clockSkew; if (delay <= Duration.zero) { - unawaited(_refreshQuietly()); + // Already due, so renew at once — unless a renewal only just ran: then the + // next one waits its turn instead of spinning. + // 已经到期,于是立刻续期 —— 除非刚刚才续过一次:那时下一轮会稍等,而不是连轴转。 + final wait = _throttleWait(); + if (wait <= Duration.zero) { + _renewNow(); + } else { + _autoRefreshTimer = Timer(wait, _renewNow); + } return; } - _autoRefreshTimer = Timer(delay, () => unawaited(_refreshQuietly())); + _autoRefreshTimer = Timer(delay, _renewNow); + } + + /// Starts a proactive renewal and remembers when it began, so a series of + /// already-due renewals is throttled by [_throttleWait]. + /// 启动一次主动续期并记下开始时刻,使一连串「已到期」的续期能被 [_throttleWait] 节流。 + void _renewNow() { + _lastProactiveRefreshAt = clock(); + unawaited(_refreshQuietly()); + } + + /// How long an already-due renewal must still wait under + /// [autoRefreshMinInterval]. + /// 在 [autoRefreshMinInterval] 之下,一次「已到期」的续期还需等待多久。 + Duration _throttleWait() { + final last = _lastProactiveRefreshAt; + if (last == null) return Duration.zero; + final since = clock().difference(last); + return since >= _autoRefreshMinInterval + ? Duration.zero + : _autoRefreshMinInterval - since; } void _cancelProactiveRefresh() { @@ -667,6 +903,20 @@ final class AuthManager implements AuthTokenSource { if (_state == state) return; _state = state; if (!_controller.isClosed) _controller.add(state); - onStateChanged?.call(state); + _notifyObserver(state); + } + + /// Observers are a side channel: the state has already been emitted by the time + /// this runs, so a throwing callback must not corrupt the state machine. + /// 观察者是旁路:执行到这里时状态已经发出,因此回调抛出的异常不能破坏状态机。 + void _notifyObserver(AuthState state) { + final observer = onStateChanged; + if (observer == null) return; + try { + observer(state); + } catch (_) { + // Deliberately swallowed — logging or analytics must never break auth. + // 有意吞掉 —— 日志或埋点绝不能破坏认证流程。 + } } } diff --git a/lib/src/auth_manager_group.dart b/lib/src/auth_manager_group.dart index 0c05ea6..0e95eb6 100644 --- a/lib/src/auth_manager_group.dart +++ b/lib/src/auth_manager_group.dart @@ -34,13 +34,32 @@ final class AuthManagerGroup implements AuthTokenSource { /// /// Give every account its own [TokenStore] instance (for example a secure /// store keyed by account id) so persisted sessions stay isolated. + /// + /// Every [AuthManager] tuning knob — `autoRefreshAhead`, `clockSkew`, + /// `refreshFailurePolicy`, `onStateChanged`… — is applied to the managers this + /// group creates, so a multi-account session behaves exactly like a standalone + /// one. Pass [managerFactory] to build them yourself instead. /// 创建分组。两个工厂方法按账号惰性调用一次。 /// /// 请为每个账号提供独立的 [TokenStore] 实例(例如按账号 id 命名的安全存储), /// 以保证持久化会话彼此隔离。 + /// + /// 所有 [AuthManager] 的调参项 —— `autoRefreshAhead`、`clockSkew`、 + /// `refreshFailurePolicy`、`onStateChanged`…… —— 都会应用到分组创建的管理器上, + /// 因此多账号会话的行为与单账号完全一致。也可传 [managerFactory] 自行构建。 AuthManagerGroup({ required AuthStrategy Function(String accountId) strategyFactory, required TokenStore Function(String accountId) storeFactory, + this.managerFactory, + this.autoRefreshAhead, + this.autoRefreshRetryDelay, + this.autoRefreshMaxRetries, + this.autoRefreshMinInterval, + this.refreshFailurePolicy, + this.clock, + this.clockSkew, + this.preserveSessionDetails = true, + this.onStateChanged, }) : _strategyFactory = strategyFactory, _storeFactory = storeFactory; @@ -51,9 +70,38 @@ final class AuthManagerGroup implements AuthTokenSource { final AuthStrategy Function(String accountId) _strategyFactory; final TokenStore Function(String accountId) _storeFactory; + /// Optional override for how each account's [AuthManager] is built. It receives + /// the account id plus the strategy and store the factories produced, and wins + /// over the individual knobs below. + /// 可选的构建覆盖:自行创建每个账号的 [AuthManager]。它收到账号 id 以及工厂产出的 + /// strategy 与 store,并优先于下面的各项参数。 + final AuthManager Function( + String accountId, + AuthStrategy strategy, + TokenStore store, + )? managerFactory; + + /// Forwarded to every manager this group creates — see [AuthManager.new]. + /// 转发给分组创建的每个管理器 —— 参见 [AuthManager.new]。 + final Duration? autoRefreshAhead; + final Duration? autoRefreshRetryDelay; + final int? autoRefreshMaxRetries; + final Duration? autoRefreshMinInterval; + final RefreshFailurePolicy? refreshFailurePolicy; + final DateTime Function()? clock; + final Duration? clockSkew; + final bool preserveSessionDetails; + + /// Invoked for every state emitted by **any** account's manager, tagged with + /// the account it came from. Handy for logging or analytics across accounts. + /// 任一账号的管理器发出状态时调用,并带上来源账号。便于跨账号做日志或埋点。 + final void Function(String accountId, AuthState state)? onStateChanged; + final Map _managers = {}; final StreamController _controller = StreamController.broadcast(); + final StreamController _activeIdController = + StreamController.broadcast(); StreamSubscription? _activeSubscription; String? _activeId; @@ -87,12 +135,29 @@ final class AuthManagerGroup implements AuthTokenSource { /// 返回 [accountId] 对应的管理器,首次使用时创建。 AuthManager forAccount(String accountId) { _checkUsable(); - return _managers.putIfAbsent( - accountId, - () => AuthManager( - strategy: _strategyFactory(accountId), - tokenStore: _storeFactory(accountId), - ), + return _managers.putIfAbsent(accountId, () => _createManager(accountId)); + } + + AuthManager _createManager(String accountId) { + final strategy = _strategyFactory(accountId); + final store = _storeFactory(accountId); + final factory = managerFactory; + if (factory != null) return factory(accountId, strategy, store); + + final observer = onStateChanged; + return AuthManager( + strategy: strategy, + tokenStore: store, + autoRefreshAhead: autoRefreshAhead, + autoRefreshRetryDelay: autoRefreshRetryDelay, + autoRefreshMaxRetries: autoRefreshMaxRetries, + autoRefreshMinInterval: autoRefreshMinInterval, + refreshFailurePolicy: refreshFailurePolicy, + clock: clock, + clockSkew: clockSkew, + preserveSessionDetails: preserveSessionDetails, + onStateChanged: + observer == null ? null : (state) => observer(accountId, state), ); } @@ -125,16 +190,23 @@ final class AuthManagerGroup implements AuthTokenSource { /// Makes [accountId] the active account. /// /// The group's [state] stream switches to that manager and replays its - /// current state; nothing is signed in or out by this call. + /// current state, and [activeIdChanges] emits the new id; nothing is signed in + /// or out by this call. /// 把 [accountId] 设为激活账号。 /// - /// 分组的 [state] 流会切到该管理器并重放其当前状态;此调用不会登录或登出任何账号。 + /// 分组的 [state] 流会切到该管理器并重放其当前状态,[activeIdChanges] 也会发出新的 + /// id;此调用不会登录或登出任何账号。 void switchTo(String accountId) { _checkUsable(); if (_activeId == accountId && _activeSubscription != null) return; _activeId = accountId; forAccount(accountId); _rebind(); + _emitActiveId(); + } + + void _emitActiveId() { + if (!_activeIdController.isClosed) _activeIdController.add(_activeId); } /// The active account's state, or [Unauthenticated] when none is active. @@ -148,6 +220,16 @@ final class AuthManagerGroup implements AuthTokenSource { @override String? get accessToken => active?.accessToken; + /// The active account's guaranteed-valid token, or `null` when none is active + /// (or renewal failed and the session was dropped). + /// 激活账号「保证有效」的令牌;无激活账号(或续期失败导致会话被丢弃)时为 `null`。 + @override + Future validAccessToken({Duration? leeway}) async { + final manager = active; + if (manager == null) return null; + return manager.validAccessToken(leeway: leeway); + } + /// State of the active account. Replays the latest value to new listeners, /// exactly like [AuthManager.state]. /// 激活账号的状态流。与 [AuthManager.state] 一样,对新订阅者重放最近值。 @@ -164,9 +246,23 @@ final class AuthManagerGroup implements AuthTokenSource { return sc.stream; } + /// Emits every change of [activeId], including `null` when the group becomes + /// inactive. Complements [state], which only mirrors the active account. + /// 每次 [activeId] 变化时发出新值(分组转为无激活时发 `null`)。它补充 [state], + /// 后者只反映激活账号的状态。 + Stream get activeIdChanges { + _checkUsable(); + return _activeIdController.stream; + } + /// Restores every account in [accountIds], then activates [activeId] (or the /// first restored account when omitted). + /// + /// One account failing to restore does not abandon the rest: they are all + /// attempted and the first error is reported at the end. /// 依次恢复 [accountIds] 中的所有账号,然后激活 [activeId](省略时激活第一个)。 + /// + /// 某个账号恢复失败不会连累其余账号:所有账号都会被尝试,最后统一上报第一个错误。 Future restoreAll( Iterable accountIds, { String? activeId, @@ -180,15 +276,26 @@ final class AuthManagerGroup implements AuthTokenSource { if (keep.contains(id)) continue; final stale = _managers.remove(id); await stale?.dispose(); - if (_activeId == id) _activeId = null; + if (_activeId == id) { + _activeId = null; + _emitActiveId(); + } } } + Object? firstError; for (final id in accountIds) { - await forAccount(id).restore(); + try { + await forAccount(id).restore(); + } catch (e) { + // Keep going: one unreadable store must not leave the others unrestored. + // 继续处理:某个存储读不出来不应让其余账号无法恢复。 + firstError ??= e; + } } final target = activeId ?? (accountIds.isEmpty ? null : accountIds.first); if (target != null) switchTo(target); + if (firstError != null) throw firstError; } /// Signs an account out and forgets it. When it was active, the group becomes @@ -213,11 +320,12 @@ final class AuthManagerGroup implements AuthTokenSource { _activeId = null; _rebind(); if (!_controller.isClosed) _controller.add(const Unauthenticated()); + _emitActiveId(); } } - /// Disposes every manager and closes the group's stream. - /// 释放所有管理器并关闭分组的状态流。 + /// Disposes every manager and closes the group's streams. + /// 释放所有管理器并关闭分组的各个流。 Future disposeAll() async { if (_closed) return; _closed = true; @@ -231,13 +339,30 @@ final class AuthManagerGroup implements AuthTokenSource { _activeId = null; await _controller.close(); + await _activeIdController.close(); } void _rebind() { - _activeSubscription?.cancel(); + unawaited(_activeSubscription?.cancel()); _activeSubscription = active?.state.listen( _controller.add, onError: _controller.addError, + onDone: _onActiveClosed, ); } + + /// The active manager's stream closed — it was disposed outside the group. + /// Forget it instead of holding on to a released manager, and tell listeners + /// the group is no longer active. + /// 激活账号的流已关闭 —— 它在分组之外被释放了。此时应将其遗忘,而不是继续持有已 + /// 释放的管理器,同时通知监听者分组已无激活账号。 + void _onActiveClosed() { + final id = _activeId; + if (id == null) return; + _managers.remove(id); + _activeId = null; + _activeSubscription = null; + if (!_controller.isClosed) _controller.add(const Unauthenticated()); + _emitActiveId(); + } } diff --git a/lib/src/auth_session.dart b/lib/src/auth_session.dart index cac49b5..2b24958 100644 --- a/lib/src/auth_session.dart +++ b/lib/src/auth_session.dart @@ -127,10 +127,11 @@ final class AuthSession { /// [claims] is written verbatim, so it must only contain JSON-safe values /// (String, num, bool, null, List, Map). A `DateTime` or a custom object there /// would make `jsonEncode` throw at the storage layer. - /// [claims] 会被原样写入,因此只能包含 JSON 安全的值(String、num、bool、null、 - /// List、Map)。放入 `DateTime` 或自定义对象会让存储层的 `jsonEncode` 抛错。 /// 序列化为 JSON 安全映射,供持久化使用(如写入磁盘或安全存储的 [TokenStore])。 /// 为 `null` 的字段会被省略。 + /// + /// [claims] 会被原样写入,因此只能包含 JSON 安全的值(String、num、bool、null、 + /// List、Map)。放入 `DateTime` 或自定义对象会让存储层的 `jsonEncode` 抛错。 Map toJson() => { 'accessToken': accessToken, if (refreshToken != null) 'refreshToken': refreshToken!.value, @@ -155,6 +156,55 @@ final class AuthSession { claims: (json['claims'] as Map?)?.cast(), ); + /// Deserialize from a map produced by [toJson], or return `null` when the map + /// does not describe a valid session. + /// + /// Prefer this over [fromJson] whenever the map comes from disk, secure + /// storage or the network: a schema change, a partial write or a hand-edited + /// value makes [fromJson] throw a [FormatException] / [TypeError] — a failure + /// outside the `AppException` vocabulary this package promises. + /// 从 [toJson] 生成的映射反序列化;映射无法描述合法会话时返回 `null`。 + /// + /// 只要映射来自磁盘、安全存储或网络,就应优先使用它而非 [fromJson]:schema 变更、 + /// 写入中断或人为改值都会让 [fromJson] 抛出 [FormatException] / [TypeError] —— + /// 那是本包承诺的 `AppException` 词汇之外的失败。 + static AuthSession? tryFromJson(Object? json) { + if (json is! Map) return null; + + final accessToken = json['accessToken']; + if (accessToken is! String) return null; + + final refreshToken = json['refreshToken']; + if (refreshToken != null && refreshToken is! String) return null; + + DateTime? expiresAt; + final rawExpiry = json['expiresAt']; + if (rawExpiry != null) { + if (rawExpiry is! String) return null; + expiresAt = DateTime.tryParse(rawExpiry); + if (expiresAt == null) return null; + } + + final userId = json['userId']; + if (userId != null && userId is! String) return null; + + final displayName = json['displayName']; + if (displayName != null && displayName is! String) return null; + + final claims = json['claims']; + if (claims != null && claims is! Map) return null; + + return AuthSession( + accessToken: accessToken, + refreshToken: + refreshToken == null ? null : RefreshToken(refreshToken as String), + expiresAt: expiresAt, + userId: userId as String?, + displayName: displayName as String?, + claims: (claims as Map?)?.cast(), + ); + } + @override bool operator ==(Object other) => other is AuthSession && @@ -177,27 +227,71 @@ final class AuthSession { /// Claims participate in equality so a session whose *only* change is in /// `claims` still counts as new — otherwise a state emission could be - /// suppressed as a duplicate. + /// suppressed as a duplicate. Nested maps and lists are compared structurally. /// claims 参与相等性比较,这样仅 claims 发生变化的会话也算新值, - /// 否则该次状态通知会被当作重复值抑制。 + /// 否则该次状态通知会被当作重复值抑制。嵌套的 Map 与 List 按内容比较。 static bool _claimsEqual(Map? a, Map? b) { if (identical(a, b)) return true; - if (a == null || b == null) return a == null && b == null; + if (a == null || b == null) return false; if (a.length != b.length) return false; for (final entry in a.entries) { - if (!b.containsKey(entry.key) || b[entry.key] != entry.value) { - return false; - } + if (!b.containsKey(entry.key)) return false; + if (!_deepEqual(entry.value, b[entry.key])) return false; } return true; } + /// Structural equality for JSON-shaped values: nested maps and lists are + /// compared by content, everything else falls back to `==`. + /// 面向 JSON 形状值的结构化相等:嵌套 Map / List 按内容比较,其余回落到 `==`。 + static bool _deepEqual(Object? a, Object? b) { + if (identical(a, b)) return true; + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final key in a.keys) { + if (!b.containsKey(key)) return false; + if (!_deepEqual(a[key], b[key])) return false; + } + return true; + } + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!_deepEqual(a[i], b[i])) return false; + } + return true; + } + return a == b; + } + static int _claimsHash(Map? claims) { if (claims == null) return 0; var hash = 0; for (final entry in claims.entries) { - hash ^= Object.hash(entry.key, entry.value); + hash ^= Object.hash(entry.key, _deepHash(entry.value)); } return hash; } + + /// Mirrors [_deepEqual]: two values that compare equal must hash alike, so + /// nested maps and lists are hashed by content too. + /// 与 [_deepEqual] 对应:相等的值必须有相同的哈希,因此嵌套 Map / List 也按内容取哈希。 + static int _deepHash(Object? value) { + if (value == null) return 0; + if (value is Map) { + var hash = 0; + for (final entry in value.entries) { + hash ^= Object.hash(entry.key, _deepHash(entry.value)); + } + return hash; + } + if (value is List) { + var hash = 0; + for (final item in value) { + hash ^= _deepHash(item); + } + return hash; + } + return value.hashCode; + } } diff --git a/lib/src/auth_state.dart b/lib/src/auth_state.dart index 9239327..e9e9c3a 100644 --- a/lib/src/auth_state.dart +++ b/lib/src/auth_state.dart @@ -23,6 +23,21 @@ sealed class AuthState { /// 是否仍有操作在进行中(登录 / 注册 / 刷新 / 登出),可用于禁用按钮或显示加载态。 bool get isBusy => this is Authenticating || this is Refreshing || this is LoggingOut; + + /// The session this state carries, or `null` when it carries none. + /// + /// [Authenticated], [Refreshing] and [LoggingOut] all carry one; prefer this + /// over pattern-matching the three subtypes when all a UI needs is the session. + /// 该状态携带的会话;不携带时为 `null`。 + /// + /// [Authenticated]、[Refreshing] 与 [LoggingOut] 都携带会话;当界面只需要会话时, + /// 请优先使用此属性,而不是对三个子类分别做模式匹配。 + AuthSession? get session => switch (this) { + Authenticated(:final session) => session, + Refreshing(:final session) => session, + LoggingOut(:final session) => session, + _ => null, + }; } /// No active session. @@ -52,6 +67,7 @@ final class Authenticating extends AuthState { /// A session is active. /// 会话处于活动状态。 final class Authenticated extends AuthState { + @override final AuthSession session; const Authenticated(this.session); @@ -70,6 +86,7 @@ final class Authenticated extends AuthState { /// 刷新进行中,同时保留旧的、仍然可用的 [session]。与 [Authenticated] 区分,便于 UI /// 显示「续期中…」提示,而不必卸载已登录界面。 final class Refreshing extends AuthState { + @override final AuthSession session; const Refreshing(this.session); @@ -85,6 +102,7 @@ final class Refreshing extends AuthState { /// A logout is in flight; [session] is the one being discarded. /// 登出进行中;[session] 是即将被丢弃的会话。 final class LoggingOut extends AuthState { + @override final AuthSession session; const LoggingOut(this.session); diff --git a/lib/src/auth_token_source.dart b/lib/src/auth_token_source.dart index bbbcca2..d95f40f 100644 --- a/lib/src/auth_token_source.dart +++ b/lib/src/auth_token_source.dart @@ -2,6 +2,28 @@ /// 供网络层(Dio / GraphQL 拦截器)使用的只读访问令牌来源。 abstract class AuthTokenSource { /// Current access token, or `null` when unauthenticated. + /// + /// This value may already be expired — read [validAccessToken] when the token + /// is about to be attached to a request. /// 当前访问令牌;未认证时为 `null`。 + /// + /// 该值可能已经过期 —— 令牌即将附着到请求上时,请读取 [validAccessToken]。 String? get accessToken; + + /// A token that is guaranteed not to be expired, refreshing first when needed. + /// Returns `null` when unauthenticated. + /// + /// [leeway] is how long the token must stay valid for; a token that would die + /// while the request is in flight is renewed first. + /// + /// Sources backed by an `AuthManager` renew the session; a plain source falls + /// back to [accessToken]. The default implementation keeps every existing + /// implementation source-compatible. + /// 保证未过期的令牌,必要时先刷新;未认证时返回 `null`。 + /// + /// [leeway] 表示令牌必须还能维持有效的时长;会在请求途中失效的令牌会被提前续期。 + /// + /// 由 `AuthManager` 支持的来源会续期会话;普通来源回落到 [accessToken]。默认实现 + /// 使所有既有实现保持源码兼容。 + Future validAccessToken({Duration? leeway}) async => accessToken; } diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart index 8de9a7e..b27d31f 100644 --- a/lib/src/exceptions.dart +++ b/lib/src/exceptions.dart @@ -71,13 +71,13 @@ final class UnexpectedAuthException extends AuthException { /// Maps a caught failure into the most specific [AuthException] subclass. /// -/// Strategy authors opt in by throwing an [AuthException] carrying one of the -/// codes below; anything unrecognised is preserved, or wrapped as -/// [UnexpectedAuthException]. +/// Strategy authors opt in by throwing an [AuthException] **or** an [AuthFail] +/// carrying one of the codes below; anything unrecognised is preserved, or +/// wrapped as [UnexpectedAuthException]. /// 将捕获的失败映射为最具体的 [AuthException] 子类。 /// -/// 策略实现者可抛出携带下列 code 的 [AuthException] 来参与映射;无法识别的错误会被 -/// 原样保留,或包装为 [UnexpectedAuthException]。 +/// 策略实现者可抛出携带下列 code 的 [AuthException] **或** [AuthFail] 来参与映射; +/// 无法识别的错误会被原样保留,或包装为 [UnexpectedAuthException]。 /// /// | code | mapped type / 映射结果 | /// |---|---| @@ -87,33 +87,39 @@ final class UnexpectedAuthException extends AuthException { /// | `refresh_token_missing` | [RefreshTokenMissingException] | /// | anything else / 其他 | preserved as-is, or [UnexpectedAuthException] / 原样保留或包装 | AuthException mapAuthFailure(Object error) { - if (error is AppException) { - final cause = error.cause ?? error; - switch (error.code) { - case 'invalid_credentials': - return InvalidCredentialsException( - message: error.message, - cause: cause, - ); - case 'invalid_grant': - case 'invalid_refresh_token': - case 'token_expired': - case 'session_expired': - return SessionExpiredException(message: error.message, cause: cause); - case 'no_active_session': - return NoActiveSessionException(message: error.message, cause: cause); - case 'refresh_token_missing': - return RefreshTokenMissingException( - message: error.message, - cause: cause, - ); - default: - // Unclassifiable: keep whatever vocabulary the author already threw. - // 无法归类:保留作者原本抛出的错误类型。 - return error is AuthException - ? error - : UnexpectedAuthException(message: error.message, cause: cause); - } + final appError = error is AppException ? error : null; + final fail = error is AuthFail ? error : null; + if (appError == null && fail == null) { + return UnexpectedAuthException(cause: error); + } + + final code = appError?.code ?? fail?.code; + final message = appError?.message ?? fail!.message; + final cause = appError?.cause ?? fail?.cause ?? error; + + switch (code) { + case 'invalid_credentials': + return InvalidCredentialsException( + message: message, + cause: cause, + ); + case 'invalid_grant': + case 'invalid_refresh_token': + case 'token_expired': + case 'session_expired': + return SessionExpiredException(message: message, cause: cause); + case 'no_active_session': + return NoActiveSessionException(message: message, cause: cause); + case 'refresh_token_missing': + return RefreshTokenMissingException( + message: message, + cause: cause, + ); + default: + // Unclassifiable: keep whatever vocabulary the author already threw. + // 无法归类:保留作者原本抛出的错误类型。 + return error is AuthException + ? error + : UnexpectedAuthException(message: message, cause: cause); } - return UnexpectedAuthException(cause: error); } diff --git a/test/auth_manager_v1_test.dart b/test/auth_manager_v1_test.dart new file mode 100644 index 0000000..b6b2b4e --- /dev/null +++ b/test/auth_manager_v1_test.dart @@ -0,0 +1,847 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:test/test.dart'; +import 'package:zero_auth/zero_auth.dart'; + +/// In-memory store that also counts reads, so shared [AuthManager.restore] +/// attempts are observable. +final class _MemStore implements TokenStore { + _MemStore([AuthSession? initial]) : value = initial; + + AuthSession? value; + int loadCount = 0; + + @override + Future save(AuthSession session) async => value = session; + + @override + Future load() async { + loadCount++; + return value; + } + + @override + Future clear() async => value = null; +} + +/// A store whose reads always fail. +final class _BrokenStore implements TokenStore { + @override + Future save(AuthSession session) async {} + + @override + Future load() async => throw StateError('store unavailable'); + + @override + Future clear() async {} +} + +/// Store that can fail on write or on clear, and counts writes so a test can let +/// the first `save` (login) succeed and break a later one. +final class _FailingStore implements TokenStore { + _FailingStore({ + this.failSaveAfter = 0, + this.failClear = false, + AuthSession? initial, + }) : value = initial, + _allowedSaves = failSaveAfter; + + final int failSaveAfter; + final bool failClear; + final int _allowedSaves; + + AuthSession? value; + int saveCount = 0; + + @override + Future save(AuthSession session) async { + saveCount++; + if (saveCount > _allowedSaves) throw StateError('write failed'); + value = session; + } + + @override + Future load() async => value; + + @override + Future clear() async { + if (failClear) throw StateError('clear failed'); + value = null; + } +} + +/// Strategy whose [refresh] only resolves once [gate] completes. +final class _GatedStrategy implements AuthStrategy { + _GatedStrategy({required this.gate, required this.refreshed}); + + final Completer gate; + final AuthSession refreshed; + int loginCount = 0; + int refreshCount = 0; + + @override + Future login(Credentials credentials) async => + _session('login-${++loginCount}'); + + @override + Future register(RegistrationInput input) async => + _session('register'); + + @override + Future logout(SessionHandle handle) async {} + + @override + Future refresh(RefreshToken token) { + refreshCount++; + return gate.future; + } +} + +/// Strategy with fixed [session] / [refreshed] answers. +final class _FixedStrategy implements AuthStrategy { + _FixedStrategy({required this.session, required this.refreshed}); + + final AuthSession session; + final AuthSession refreshed; + int refreshCount = 0; + + @override + Future login(Credentials credentials) async => session; + + @override + Future register(RegistrationInput input) async => session; + + @override + Future logout(SessionHandle handle) async {} + + @override + Future refresh(RefreshToken token) async { + refreshCount++; + return refreshed; + } +} + +/// Strategy whose [refresh] always throws [error]. +final class _FailingRefreshStrategy implements AuthStrategy { + _FailingRefreshStrategy(this.error, {required this.session}); + + final Object error; + final AuthSession session; + int refreshCount = 0; + + @override + Future login(Credentials credentials) async => session; + + @override + Future register(RegistrationInput input) async => session; + + @override + Future logout(SessionHandle handle) async {} + + @override + Future refresh(RefreshToken token) async { + refreshCount++; + throw error; + } +} + +/// Strategy with a real (wall-clock) short TTL, to prove the proactive scheduler +/// actually fires on a real [Timer] rather than only under `fake_async`. +final class _ShortTtlStrategy implements AuthStrategy { + int refreshCount = 0; + + @override + Future login(Credentials credentials) async => _issue(); + + @override + Future register(RegistrationInput input) async => _issue(); + + @override + Future logout(SessionHandle handle) async {} + + @override + Future refresh(RefreshToken token) async { + refreshCount++; + return _issue(); + } + + AuthSession _issue() => AuthSession( + accessToken: 'access-$refreshCount', + refreshToken: const RefreshToken('refresh'), + expiresAt: DateTime.now().add(const Duration(milliseconds: 300)), + ); +} + +/// A strategy that keeps handing out sessions that are already due, to prove the +/// proactive scheduler throttles instead of spinning. +final class _ShortLivedStrategy implements AuthStrategy { + _ShortLivedStrategy(this.clock); + + final DateTime Function() clock; + int refreshCount = 0; + + @override + Future login(Credentials credentials) async => _issued(); + + @override + Future register(RegistrationInput input) async => _issued(); + + @override + Future logout(SessionHandle handle) async {} + + @override + Future refresh(RefreshToken token) async { + refreshCount++; + return _issued(); + } + + AuthSession _issued() => AuthSession( + accessToken: 'access-$refreshCount', + refreshToken: const RefreshToken('refresh'), + expiresAt: clock().add(const Duration(seconds: 1)), + ); +} + +final class _StaticTokenSource implements AuthTokenSource { + @override + String? get accessToken => 'static-token'; + + // Implementations that only mirror a stored token simply forward the getter. + // 只镜像已存令牌的实现,直接转发 getter 即可。 + @override + Future validAccessToken({Duration? leeway}) async => accessToken; +} + +AuthSession _session( + String token, { + DateTime? expiresAt, + String? userId, + String? displayName, + Map? claims, +}) => + AuthSession( + accessToken: token, + refreshToken: const RefreshToken('refresh'), + expiresAt: expiresAt, + userId: userId, + displayName: displayName, + claims: claims, + ); + +const _credentials = Credentials(username: 'user', password: 'user'); + +void main() { + final now = DateTime.utc(2026, 1, 1, 12); + + group('restore — transient renewal failures', () { + test('keeps the persisted session when the renewal fails transiently', + () async { + final store = _MemStore( + _session( + 'old', + expiresAt: now.subtract(const Duration(minutes: 5)), + userId: 'u1', + ), + ); + final manager = AuthManager( + strategy: _FailingRefreshStrategy( + AuthException('offline', code: 'network_unreachable'), + session: _session('unused'), + ), + tokenStore: store, + clock: () => now, + ); + + await manager.restore(); + + // The whole point of the failure policy: a transient error must not + // destroy a session that a later attempt could still renew. + expect(store.value, isNotNull); + expect(manager.currentSession?.accessToken, 'old'); + expect(manager.current, isA()); + }); + + test('still clears the session when the failure is terminal', () async { + final store = _MemStore( + _session('old', expiresAt: now.subtract(const Duration(minutes: 5))), + ); + final manager = AuthManager( + strategy: _FailingRefreshStrategy( + SessionExpiredException(), + session: _session('unused'), + ), + tokenStore: store, + clock: () => now, + ); + + await manager.restore(); + + expect(store.value, isNull); + expect(manager.current, isA()); + }); + + test('concurrent calls share a single attempt', () async { + final store = _MemStore( + _session('stored', expiresAt: now.add(const Duration(minutes: 5))), + ); + final manager = AuthManager( + strategy: _FixedStrategy( + session: _session('a'), + refreshed: _session('b'), + ), + tokenStore: store, + clock: () => now, + ); + + await Future.wait([manager.restore(), manager.restore()]); + + expect(store.loadCount, 1); + }); + + test('re-arms proactive renewal when it is configured', () { + FakeAsync().run((async) { + final strategy = _FailingRefreshStrategy( + AuthException('offline', code: 'network_unreachable'), + session: _session('unused'), + ); + final store = _MemStore( + _session('old', expiresAt: now.subtract(const Duration(minutes: 5))), + ); + final manager = AuthManager( + strategy: strategy, + tokenStore: store, + clock: () => now, + autoRefreshAhead: const Duration(minutes: 5), + ); + + unawaited(manager.restore()); + async.flushMicrotasks(); + expect(store.value, isNotNull); + expect(strategy.refreshCount, 1); + + // The renewal is not retried immediately, but it is not abandoned either. + // 续期不会立刻重试,但也没有被放弃。 + async.elapse(const Duration(seconds: 30)); + expect(strategy.refreshCount, 2); + + unawaited(manager.dispose()); + }); + }); + }); + + group('storage failures', () { + test('a store that cannot be cleared still settles the refresh', () async { + final manager = AuthManager( + strategy: _FailingRefreshStrategy( + SessionExpiredException(), + session: _session('a'), + ), + tokenStore: _FailingStore(failClear: true, failSaveAfter: 1), + clock: () => now, + ); + + await manager.login(_credentials); + + // Would hang (or leak an unhandled async error) if the failing `clear()` + // escaped before the completer was settled. + // 若失败的 `clear()` 在 completer 落定之前逃逸,这里会挂住(或泄漏未处理的 + // 异步错误)。 + await expectLater(manager.refresh(), throwsA(isA())); + expect(manager.current, isA()); + }); + + test('a store that cannot save surfaces an AppException', () async { + final manager = AuthManager( + strategy: _FixedStrategy( + session: _session('a'), + refreshed: _session('b'), + ), + tokenStore: _FailingStore(failSaveAfter: 1), + clock: () => now, + ); + + await manager.login(_credentials); + + await expectLater( + manager.updateSession((s) => s.copyWith(displayName: 'Renamed')), + throwsA(isA()), + ); + }); + }); + + group('a stale refresh can never win', () { + test('a refresh in flight does not overwrite a newer login', () async { + final gate = Completer(); + final strategy = + _GatedStrategy(gate: gate, refreshed: _session('refreshed')); + final store = _MemStore(); + final manager = AuthManager( + strategy: strategy, + tokenStore: store, + clock: () => now, + ); + + await manager.login(_credentials); + final refresh = manager.refresh(); + await manager.login(_credentials); + + gate.complete(_session('refreshed')); + + await expectLater(refresh, throwsA(isA())); + expect(manager.currentSession?.accessToken, 'login-2'); + expect(store.value?.accessToken, 'login-2'); + }); + + test('a refresh in flight does not overwrite an updated session', () async { + final gate = Completer(); + final strategy = + _GatedStrategy(gate: gate, refreshed: _session('refreshed')); + final store = _MemStore(); + final manager = AuthManager( + strategy: strategy, + tokenStore: store, + clock: () => now, + ); + + await manager.login(_credentials); + final refresh = manager.refresh(); + await manager.updateSession( + (current) => current.copyWith(displayName: 'Renamed'), + ); + + gate.complete(_session('refreshed')); + + await expectLater(refresh, throwsA(isA())); + expect(manager.currentSession?.displayName, 'Renamed'); + expect(manager.currentSession?.accessToken, 'login-1'); + }); + + test('a new refresh does not join one started in a previous epoch', + () async { + final gate = Completer(); + final strategy = + _GatedStrategy(gate: gate, refreshed: _session('refreshed')); + final manager = AuthManager( + strategy: strategy, + tokenStore: _MemStore(), + clock: () => now, + ); + + await manager.login(_credentials); + final first = manager.refresh(); + // The login invalidates the first attempt; a fresh one must start instead + // of joining it. + await manager.login(_credentials); + final second = manager.refresh(); + expect(strategy.refreshCount, 2); + + gate.complete(_session('refreshed')); + await expectLater(first, throwsA(isA())); + expect(await second, isA()); + }); + }); + + group('refresh keeps the signed-in identity', () { + test('identity survives a renewal that only returns tokens', () async { + final manager = AuthManager( + strategy: _FixedStrategy( + session: _session( + 'a', + userId: 'u1', + displayName: 'Ada', + claims: {'plan': 'pro'}, + ), + refreshed: _session('b'), + ), + tokenStore: _MemStore(), + clock: () => now, + ); + + await manager.login(_credentials); + await manager.refresh(); + + expect(manager.currentSession!.userId, 'u1'); + expect(manager.currentSession!.displayName, 'Ada'); + expect(manager.currentSession!.claims, {'plan': 'pro'}); + }); + + test('preserveSessionDetails: false keeps the backend answer verbatim', + () async { + final manager = AuthManager( + strategy: _FixedStrategy( + session: _session('a', userId: 'u1', displayName: 'Ada'), + refreshed: _session('b'), + ), + tokenStore: _MemStore(), + clock: () => now, + preserveSessionDetails: false, + ); + + await manager.login(_credentials); + await manager.refresh(); + + expect(manager.currentSession!.userId, isNull); + expect(manager.currentSession!.displayName, isNull); + }); + }); + + group('clock skew', () { + test('renews a token that would expire in flight', () async { + final manager = AuthManager( + strategy: _FixedStrategy( + session: + _session('a', expiresAt: now.add(const Duration(seconds: 10))), + refreshed: _session( + 'b', + expiresAt: now.add(const Duration(minutes: 5)), + ), + ), + tokenStore: _MemStore(), + clock: () => now, + clockSkew: const Duration(seconds: 30), + ); + + await manager.login(_credentials); + + // 10s left is less than the 30s skew, so the token is renewed first. + expect(await manager.validAccessToken(), 'b'); + }); + + test('validAccessToken(leeway:) overrides the manager default', () async { + final manager = AuthManager( + strategy: _FixedStrategy( + session: + _session('a', expiresAt: now.add(const Duration(seconds: 10))), + refreshed: _session( + 'b', + expiresAt: now.add(const Duration(minutes: 5)), + ), + ), + tokenStore: _MemStore(), + clock: () => now, + clockSkew: Duration.zero, + ); + + await manager.login(_credentials); + + expect(await manager.validAccessToken(), 'a'); + expect( + await manager.validAccessToken(leeway: const Duration(seconds: 30)), + 'b', + ); + }); + }); + + group('proactive renewal throttling', () { + test('an always-due session is throttled instead of spinning', () { + FakeAsync().run((async) { + final strategy = _ShortLivedStrategy(() => now); + final manager = AuthManager( + strategy: strategy, + tokenStore: _MemStore(), + autoRefreshAhead: const Duration(minutes: 5), + clock: () => now, + ); + + unawaited(manager.login(_credentials)); + async.flushMicrotasks(); + // Already due, so the first renewal is immediate… + expect(strategy.refreshCount, 1); + + // …but the next one waits for `autoRefreshMinInterval`. + async.elapse(const Duration(seconds: 4)); + expect(strategy.refreshCount, 1); + async.elapse(const Duration(seconds: 2)); + expect(strategy.refreshCount, 2); + + unawaited(manager.dispose()); + }); + }); + }); + + group('AuthSession serialization', () { + test('tryFromJson returns null instead of throwing on bad input', () { + expect(AuthSession.tryFromJson({'accessToken': 'a'}), isA()); + expect(AuthSession.tryFromJson(null), isNull); + expect(AuthSession.tryFromJson({'nope': true}), isNull); + expect(AuthSession.tryFromJson({'accessToken': 42}), isNull); + expect( + AuthSession.tryFromJson({'accessToken': 'a', 'expiresAt': 'yesterday'}), + isNull, + ); + expect( + AuthSession.tryFromJson({'accessToken': 'a', 'claims': 'nope'}), + isNull, + ); + }); + + test('tryFromJson round-trips a serialized session', () { + final session = _session( + 'a', + expiresAt: now, + userId: 'u1', + displayName: 'Ada', + claims: {'plan': 'pro'}, + ); + + expect(AuthSession.tryFromJson(session.toJson()), session); + }); + + test('nested claims compare and hash by content', () { + final a = _session( + 'a', + claims: { + 'roles': ['admin', 'owner'], + }, + ); + final same = _session( + 'a', + claims: { + 'roles': ['admin', 'owner'], + }, + ); + final different = _session( + 'a', + claims: { + 'roles': ['admin'], + }, + ); + + expect(a, same); + expect(a.hashCode, same.hashCode); + expect(a, isNot(different)); + }); + }); + + group('AuthState.session', () { + test('exposes the session of every state that carries one', () { + final session = _session('a'); + + expect(const Unauthenticated().session, isNull); + expect(const Authenticating().session, isNull); + expect(Authenticated(session).session, session); + expect(Refreshing(session).session, session); + expect(LoggingOut(session).session, session); + expect(AuthError(SessionExpiredException()).session, isNull); + }); + }); + + group('AuthTokenSource', () { + test('a plain source falls back to accessToken', () async { + expect(await _StaticTokenSource().validAccessToken(), 'static-token'); + }); + + test('a manager-backed source renews first', () async { + final manager = AuthManager( + strategy: _FixedStrategy( + session: _session( + 'a', + expiresAt: now.subtract(const Duration(minutes: 1)), + ), + refreshed: + _session('b', expiresAt: now.add(const Duration(minutes: 5))), + ), + tokenStore: _MemStore(), + clock: () => now, + ); + await manager.login(_credentials); + + final AuthTokenSource source = manager; + expect(await source.validAccessToken(), 'b'); + }); + }); + + group('mapAuthFailure', () { + test('reads the code of a bare AuthFail', () { + expect( + mapAuthFailure(const AuthFail('nope', code: 'invalid_credentials')), + isA(), + ); + expect( + mapAuthFailure(const AuthFail('gone', code: 'invalid_grant')), + isA(), + ); + expect( + mapAuthFailure(const AuthFail('gone', code: 'token_expired')), + isA(), + ); + }); + + test('preserves the message and cause of a bare AuthFail', () { + final cause = StateError('boom'); + final mapped = mapAuthFailure( + AuthFail('bad password', code: 'invalid_credentials', cause: cause), + ); + + expect(mapped.message, 'bad password'); + expect(mapped.cause, cause); + }); + + test('still wraps anything it cannot classify', () { + expect( + mapAuthFailure(StateError('boom')), + isA(), + ); + }); + }); + + group('observers', () { + test('a throwing onStateChanged never breaks the flow', () async { + final seen = []; + final manager = AuthManager( + strategy: _FixedStrategy( + session: _session('a'), + refreshed: _session('b'), + ), + tokenStore: _MemStore(), + clock: () => now, + onStateChanged: (state) { + seen.add(state); + throw StateError('observer blew up'); + }, + ); + + // The observer is a side channel: the machine still reaches Authenticated. + // 观察者只是旁路:状态机依然走到了 Authenticated。 + await manager.login(_credentials); + + expect(manager.current, isA()); + expect(seen, isNotEmpty); + expect(await manager.validAccessToken(), 'a'); + }); + }); + + group('real timers', () { + test('proactive renewal fires on a real timer', () async { + final strategy = _ShortTtlStrategy(); + final manager = AuthManager( + strategy: strategy, + tokenStore: _MemStore(), + autoRefreshAhead: const Duration(milliseconds: 100), + clockSkew: Duration.zero, + autoRefreshMinInterval: const Duration(milliseconds: 40), + ); + + await manager.login(_credentials); + // 300ms TTL − 100ms lead ⇒ scheduled, not immediate. + // 300 毫秒寿命 − 100 毫秒提前量 ⇒ 走排程,而非立即刷新。 + expect(strategy.refreshCount, 0); + + await Future.delayed(const Duration(milliseconds: 600)); + expect(strategy.refreshCount, greaterThanOrEqualTo(1)); + + await manager.dispose(); + }); + }); + + group('AuthManagerGroup configuration', () { + test('forwards the tuning knobs to every account', () async { + final seen = []; + final group = AuthManagerGroup( + strategyFactory: (id) => _FixedStrategy( + session: _session('a-$id'), + refreshed: _session('b-$id'), + ), + storeFactory: (id) => _MemStore(), + clock: () => now, + clockSkew: const Duration(seconds: 30), + autoRefreshAhead: const Duration(minutes: 2), + onStateChanged: (id, state) => seen.add('$id:${state.runtimeType}'), + ); + + final manager = group.addAccount('alice'); + expect(manager.clockSkew, const Duration(seconds: 30)); + + await manager.login(_credentials); + expect(seen, contains('alice:Authenticated')); + + await group.disposeAll(); + }); + + test('managerFactory wins over the individual knobs', () async { + final strategy = _FixedStrategy( + session: _session('a'), + refreshed: _session('b'), + ); + AuthManager? built; + final group = AuthManagerGroup( + strategyFactory: (id) => strategy, + storeFactory: (id) => _MemStore(), + managerFactory: (id, s, store) { + built = AuthManager(strategy: s, tokenStore: store); + return built!; + }, + ); + + expect(identical(group.addAccount('alice'), built), isTrue); + await group.disposeAll(); + }); + + test('activeIdChanges mirrors the active account', () async { + final group = AuthManagerGroup( + strategyFactory: (id) => _FixedStrategy( + session: _session('a-$id'), + refreshed: _session('b-$id'), + ), + storeFactory: (id) => _MemStore(), + ); + final seen = []; + final subscription = group.activeIdChanges.listen(seen.add); + + group.switchTo('alice'); + await Future.delayed(Duration.zero); + expect(seen, ['alice']); + + await group.remove('alice'); + await Future.delayed(Duration.zero); + expect(seen, ['alice', null]); + + await subscription.cancel(); + await group.disposeAll(); + }); + + test('a manager disposed outside the group is forgotten', () async { + final group = AuthManagerGroup( + strategyFactory: (id) => _FixedStrategy( + session: _session('a-$id'), + refreshed: _session('b-$id'), + ), + storeFactory: (id) => _MemStore(), + ); + final manager = group.addAccount('alice'); + await manager.login(_credentials); + group.switchTo('alice'); + await Future.delayed(Duration.zero); + + // Bypassing the group: it must not keep pointing at a released manager. + // 绕过分组释放:分组不能继续指向一个已释放的管理器。 + await manager.dispose(); + await Future.delayed(Duration.zero); + + expect(group.activeId, isNull); + expect(group.accountIds, isEmpty); + expect(group.current, isA()); + + await group.disposeAll(); + }); + + test('restoreAll keeps going when one account fails', () async { + final group = AuthManagerGroup( + strategyFactory: (id) => _FixedStrategy( + session: _session('a-$id'), + refreshed: _session('b-$id'), + ), + storeFactory: (id) => id == 'broken' ? _BrokenStore() : _MemStore(), + ); + + await expectLater( + group.restoreAll(['broken', 'ok']), + throwsA(isA()), + ); + + expect(group.accountIds, contains('ok')); + await group.disposeAll(); + }); + }); +} From 54aa4c6fb23259d34ef14dedf3f37e28481ed99c Mon Sep 17 00:00:00 2001 From: AmisKwok Date: Tue, 22 Sep 2026 02:57:40 +0800 Subject: [PATCH 2/7] docs: document the 1.0.0 surface, changelog and known risks - README.md / README_zh.md: version 1.0.0, stability callout, new knobs (clockSkew, preserveSessionDetails, autoRefreshMinInterval) and API tables - USAGE.md: renewing AuthTokenSource, clock skew and throttling rows, AuthFail mapping, new pitfalls - CHANGELOG.md: the 1.0.0 section with fixes, breaking change, additions and the stability promise - website: Network-Integration, Configuration, Errors, Multi-Account, Auth-State-Machine, Persistence, Token-Store, FAQ and index - KNOWN_RISKS.md: the accepted risks and the post-release watchlist (repo-internal, excluded via .pubignore) - AGENTS.md: epoch and transient-failure invariants for future agents --- .pubignore | 1 + AGENTS.md | 6 +- CHANGELOG.md | 125 ++++++++++++++++++ KNOWN_RISKS.md | 122 +++++++++++++++++ README.md | 80 ++++++++--- README_zh.md | 78 ++++++++--- USAGE.md | 113 ++++++++++++++-- docs/404.html | 2 +- docs/404/index.html | 2 +- docs/Auth-State-Machine/index.html | 4 +- docs/Backend-Strategy/index.html | 4 +- docs/Configuration/index.html | 4 +- docs/Errors/index.html | 4 +- docs/FAQ/index.html | 4 +- docs/Getting-Started/index.html | 4 +- docs/Installation/index.html | 4 +- docs/Multi-Account/index.html | 4 +- docs/Network-Integration/index.html | 4 +- docs/Persistence/index.html | 4 +- docs/Third-Party-Login/index.html | 4 +- docs/Token-Store/index.html | 4 +- docs/Usage/index.html | 4 +- .../static/chunks/nextra-data-en-US.json | 2 +- .../_buildManifest.js | 0 .../_ssgManifest.js | 0 docs/index.html | 4 +- website/pages/Auth-State-Machine.md | 18 ++- website/pages/Configuration.md | 8 ++ website/pages/Errors.md | 16 ++- website/pages/FAQ.md | 16 ++- website/pages/Multi-Account.md | 17 ++- website/pages/Network-Integration.md | 24 +++- website/pages/Persistence.md | 6 + website/pages/Token-Store.md | 4 +- website/pages/index.md | 4 +- 35 files changed, 591 insertions(+), 109 deletions(-) create mode 100644 KNOWN_RISKS.md rename docs/_next/static/{mMoJzqLtuvXxc8MM2_6u7 => kjpeBAQZfIa0cAXLmehHB}/_buildManifest.js (100%) rename docs/_next/static/{mMoJzqLtuvXxc8MM2_6u7 => kjpeBAQZfIa0cAXLmehHB}/_ssgManifest.js (100%) diff --git a/.pubignore b/.pubignore index 90207d6..0e2b5be 100644 --- a/.pubignore +++ b/.pubignore @@ -11,6 +11,7 @@ wiki/ TODO.md AGENTS.md CONTRIBUTING.md +KNOWN_RISKS.md # Local design doc (repo-internal, not package API documentation). zero_auth_design.md diff --git a/AGENTS.md b/AGENTS.md index 25d83e8..c08731b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ This file defines the architecture, coding conventions, and required workflows f - `auth_manager_group.dart` — `AuthManagerGroup`: optional multi-account coordination. Owns one `AuthManager` per account (`forAccount` / `addAccount` / `switchTo` / `restoreAll` / `remove` / `logoutAll` / `disposeAll`); implements `AuthTokenSource` for the active account. `AuthManager` itself stays single-session on purpose. - `auth_capabilities.dart` — optional capability interfaces (`SupportsPasswordReset`, `SupportsPasswordChange`, `SupportsReauthentication`) detected via `AuthManager.supports()`, so the four-method `AuthStrategy` contract never has to grow. - `token_store.dart` — `TokenStore` (the only persistence boundary) + `InMemoryTokenStore`. - - `auth_token_source.dart` — `AuthTokenSource`: read-only token source for network layers. + - `auth_token_source.dart` — `AuthTokenSource`: read-only token source for network layers (`accessToken`, plus `validAccessToken({leeway})` which renews first when a manager backs the source). - `exceptions.dart` — `AuthException` (auth-specific failures). - `error/` — the error kernel: `app_exception.dart` (`AppException`), `result.dart` (`Result` = `Ok` / `Err`), `error.dart` (barrel). - `test/` — unit tests written with `package:test` (NOT `flutter_test`); `fake_async` is used for timer-driven refresh behaviour; `fake_strategy.dart` is the shared test double. @@ -34,7 +34,9 @@ This file defines the architecture, coding conventions, and required workflows f - **Pure Dart only.** No `dart:io`, no `dart:html`/`package:web`, no platform channels, no Flutter widgets, no HTTP client. It must run unchanged on Flutter, server and CLI. - **No hidden state.** The manager holds no UI, no backend and no native code; every side effect goes through `AuthStrategy` or `TokenStore`. - **Errors never escape raw.** Every failure surfaced publicly is an `AppException` (typically `AuthException`); raw `Exception`s must not cross the public surface. -- **Single-flight refresh.** Concurrent `refresh()` callers must keep sharing one backend call (`_refreshCompleter`); do not "simplify" it into independent calls. +- **Single-flight refresh.** Concurrent `refresh()` callers must keep sharing one backend call (`_refreshCompleter`); do not "simplify" it into independent calls. Sharing is **epoch-scoped**: a call started before the session was replaced must not be joined (`_refreshCompleterEpoch`). +- **Every path that installs a session bumps the epoch.** `login` / `register` / `loginWith` / `updateSession` / `logout` / `dispose` call `_invalidateInFlight()` first, so a refresh already in flight can never overwrite the session they install (or write back a token minted from a rotated refresh token). Adding a new session-writing path means bumping the epoch too. +- **A transient renewal failure never destroys a session.** Whether in `refresh()` or `restore()`, only `refreshFailurePolicy(failure) == true` may clear the store; everything else keeps the session so a later attempt can retry. - **Rethrow after emit.** `login()` / `register()` emit `AuthError` and then rethrow; callers (and the example app) must handle or deliberately swallow that rethrow. ## Dependencies and SDK constraints diff --git a/CHANGELOG.md b/CHANGELOG.md index 358ddbf..07adb92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,130 @@ # Changelog +## 1.0.0 + +**First stable release / 首个稳定版本.** From here on the public surface follows +strict semver: a `1.x` release never breaks it, and every new symbol is additive. +从本版本起,公共 API 严格遵循语义化版本:`1.x` 绝不做破坏性变更,新增能力一律以 +追加方式提供。 + +### Fixed / 修复 + +- **`restore()` no longer signs the user out on a transient renewal failure.** + An expired persisted session plus a network hiccup used to clear the store and + land on `Unauthenticated` — the opposite of what `refreshFailurePolicy` promises. + A transient failure now keeps the persisted session so the next call can retry; + only a terminal one clears it. + - **`restore()` 不再因瞬时续期失败而让用户登出。** 过去「持久化会话已过期 + 网络抖动」 + 会清空存储并落到 `Unauthenticated`,与 `refreshFailurePolicy` 的承诺正好相反。现在 + 瞬时失败会保留持久化会话以便下次重试,只有终局失败才会清空。 +- **A late refresh can no longer overwrite a newer session.** `login`, `register`, + `loginWith` and `updateSession` now join the epoch guard, so a renewal started + before them is aborted instead of replacing the session they just installed (or + writing a token minted from an already-rotated refresh token). + - **迟到的刷新再也无法覆盖更新的会话。** `login`、`register`、`loginWith` 与 + `updateSession` 现在都参与 epoch 守卫:在它们之前启动的续期会被中止,而不是替换 + 刚安装的会话(也不会写回由已轮换的刷新令牌换来的令牌)。 +- **A renewal no longer erases who is signed in.** Backends that return tokens only + used to drop `userId` / `displayName` / `claims` — which also emptied the + `SessionHandle.userId` sent on logout. The identity now carries over. + - **续期不再抹掉「谁在登录」。** 只返回令牌的后端过去会丢失 `userId` / `displayName` / + `claims`,连带让登出时发送的 `SessionHandle.userId` 变空。现在身份字段会被保留。 +- **`mapAuthFailure` honours the `code` of a bare `AuthFail`.** Throwing the + documented domain failure type no longer flattens every case into + `UnexpectedAuthException`. + - **`mapAuthFailure` 现在认得裸 `AuthFail` 的 `code`。** 抛出文档推荐的领域失败类型, + 不再把所有情况都压成 `UnexpectedAuthException`。 +- **`claims` are compared structurally.** Nested maps and lists participate in + equality (and hashing), so a change buried inside `claims` is no longer swallowed + by the duplicate-emission filter. + - **`claims` 改为结构化比较。** 嵌套的 Map / List 参与相等性与哈希比较,藏在 + `claims` 内部的变化不再被去重逻辑吞掉。 +- **An already-due proactive renewal is throttled** by `autoRefreshMinInterval` + (default 5s), so a backend handing out very short-lived tokens cannot turn + renewal into a tight loop. + - **「已到期」的主动续期现在有节流**(`autoRefreshMinInterval`,默认 5 秒),后端持续 + 发放极短寿命的令牌时不会把续期变成紧密循环。 +- **Concurrent `restore()` calls share one attempt**, instead of racing to load and + activate two sessions. + - **并发的 `restore()` 共享同一次尝试**,不再争抢读取并激活两个会话。 +- **Dropping an unrenewable session completes even when the store fails**: the + sign-out happens first and the store error is reported afterwards. + - **丢弃无法续期的会话在存储出错时也能完成**:先完成登出,再上报存储错误。 +- **A throwing `onStateChanged` observer can no longer corrupt the state machine.** + The observer is a side channel, so its error is swallowed after the state has + already been emitted to the stream. + - **抛异常的 `onStateChanged` 观察者再也不会破坏状态机。** 观察者只是旁路,状态已发 + 到流上之后,它的异常会被吞掉。 +- **`AuthManagerGroup` forgets a manager disposed outside the group**, instead of + silently keeping a released manager as the active one. + - **`AuthManagerGroup` 会遗忘在分组之外被释放的管理器**,不再把已释放的管理器悄悄 + 当作激活账号。 + +### Changed / 变更 + +- **BREAKING: `AuthTokenSource` gained `validAccessToken({Duration? leeway})`.** + Implementations that only declare `accessToken` must add one line: + `@override Future validAccessToken({Duration? leeway}) async => accessToken;`. + `AuthManager` (and `AuthManagerGroup`) override it with a renewing version, so + network layers can now guarantee an unexpired token through the interface alone. + - **破坏性:`AuthTokenSource` 新增 `validAccessToken({Duration? leeway})`。** 只声明 + `accessToken` 的实现需补一行:`@override Future validAccessToken({Duration? leeway}) async => accessToken;`。 + `AuthManager`(与 `AuthManagerGroup`)会覆写为会续期的版本,因此网络层仅凭接口就能 + 拿到保证未过期的令牌。 +- **`clockSkew` defaults to 30 seconds.** Tokens are treated as expired that much + earlier, so a device clock running ahead (or a slow request) cannot hand out a + token that dies in flight. Pass `Duration.zero` for the old behaviour. + - **`clockSkew` 默认为 30 秒。** 令牌会提前这么多被视为过期,避免设备时钟偏快(或请求 + 较慢)时发出一个途中失效的令牌。传 `Duration.zero` 可恢复旧行为。 +- **A single-flight refresh is only joined within the same epoch.** Concurrent + callers still share one backend call, but never one that started before the + session was replaced. + - **单飞刷新只在同一 epoch 内被共享。** 并发调用方仍共享同一次后端调用,但不会共享 + 会话被替换之前启动的那一次。 +- `logout()` invalidates in-flight work **before** calling the backend, so a + renewal that lands mid-logout can no longer flash `Authenticated`. + - `logout()` 在调用后端**之前**就让进行中的工作失效,登出途中落地的续期不会再闪一下 + `Authenticated`。 + +### Added / 新增 + +- **`clockSkew`** — how much earlier a token counts as expired (default 30s), plus + **`validAccessToken(leeway:)`** for a per-call override. + - **`clockSkew`** —— 提前多久把令牌视为过期(默认 30 秒);另有 + **`validAccessToken(leeway:)`** 供单次调用覆盖。 +- **`autoRefreshMinInterval`** — floor for a proactive renewal that is already due + (default 5s). + - **`autoRefreshMinInterval`** —— 「已到期」主动续期的最小等待(默认 5 秒)。 +- **`preserveSessionDetails`** — carry identity fields across a renewal that only + returns tokens (default `true`; set `false` to keep the backend answer verbatim). + - **`preserveSessionDetails`** —— 在只返回令牌的续期中保留身份字段(默认 `true`; + 设为 `false` 则原样使用后端响应)。 +- **`AuthSession.tryFromJson`** — deserialize without throwing on malformed input; + returns `null` instead. Use it for anything read from disk or secure storage. + - **`AuthSession.tryFromJson`** —— 反序列化畸形数据时不抛异常,而是返回 `null`。 + 凡是从磁盘或安全存储读出的内容都建议用它。 +- **`AuthState.session`** — the session carried by `Authenticated` / `Refreshing` / + `LoggingOut`, or `null`; no pattern-matching needed for the common case. + - **`AuthState.session`** —— `Authenticated` / `Refreshing` / `LoggingOut` 携带的 + 会话(无则为 `null`),常见场景无需再做模式匹配。 +- **`AuthManagerGroup`**: `managerFactory`, full forwarding of the manager knobs + (`autoRefreshAhead`, `clockSkew`, `refreshFailurePolicy`, `clock`…), an + account-tagged `onStateChanged`, **`activeIdChanges`** (a stream of the active + account id), `validAccessToken()` and a `restoreAll()` that keeps going when one + account fails. + - **`AuthManagerGroup`**:新增 `managerFactory`、完整转发管理器调参(`autoRefreshAhead`、 + `clockSkew`、`refreshFailurePolicy`、`clock`……)、带账号标记的 `onStateChanged`、 + **`activeIdChanges`**(激活账号 id 流)、`validAccessToken()`,以及某个账号失败也会 + 继续的 `restoreAll()`。 + +### Stability / 稳定性 + +- The public surface is now frozen under semver: `AuthState` stays sealed with its + current six subtypes, `AuthStrategy` stays at four methods, and new capabilities + arrive as additive symbols (or optional interfaces detected with `supports()`). + - 公共 API 自此按 semver 冻结:`AuthState` 保持当前六个子类的密封层级,`AuthStrategy` + 保持四个方法,新能力以追加符号(或可用 `supports()` 检测的可选接口)的形式提供。 + ## 0.5.0 ### Fixed / 修复 diff --git a/KNOWN_RISKS.md b/KNOWN_RISKS.md new file mode 100644 index 0000000..5bae4ae --- /dev/null +++ b/KNOWN_RISKS.md @@ -0,0 +1,122 @@ +# Known Risks & Post-release Watchlist / 已知风险与发版后观察清单 + +> Scope / 适用范围: `zero_auth` **1.0.0**. +> 本文件记录发版时**有意接受**的风险与需要观察的项 —— 它们不是已知缺陷(缺陷见 +> `CHANGELOG.md` 的 1.0.0 "Fixed" 段),而是"当前判断下性价比最高的选择"。 +> Each entry states what could go wrong, how to notice it, and what to do if it +> does. 每条都写清:会出什么问题、如何察觉、真发生了怎么办。 +> +> Repo-internal: this file is **not** published to pub.dev (see `.pubignore`). +> 仓库内部文档,**不**随包发布(见 `.pubignore`)。 + +--- + +## R1. `AuthManagerGroup.restoreAll()` restores accounts serially / 顺序恢复账号 + +| | | +|---|---| +| Risk / 风险 | Every account's `TokenStore.load()` (and the renewal that may follow) waits for the previous one, so startup cost is the **sum**, not the max. 每个账号的存储读取(以及可能的续期)都要等前一个完成,启动耗时是**累加**而非取最大值。 | +| Why accepted / 为何接受 | Account counts are small (usually 1–5) and serial restores keep error attribution exact: a failure is reported for a known account, in a known order. 账号数量通常是个位数;串行恢复让错误归属精确 —— 失败能定位到具体账号与顺序。 | +| Trigger / 触发条件 | Many saved accounts **and** a slow store (Keychain / encrypted storage / network-backed). 账号较多**且**存储较慢(Keychain、加密存储、网络型存储)。 | +| Watch / 观察 | Startup traces: time from app start to first `Authenticated` scales linearly with account count. Issue reports of "slow startup with several accounts". 启动埋点:从冷启动到首个 `Authenticated` 的时间随账号数线性增长;issue 中出现「多账号启动慢」。 | +| Mitigation today / 当前缓解 | Restore outside the critical path, or call `forAccount(id).restore()` yourself with your own concurrency. 把恢复移出关键路径,或自行并发调用 `forAccount(id).restore()`。 | +| Plan / 计划 | `1.1.0`: additive `restoreAll(..., {bool parallel = true})` (or a sibling `restoreAllConcurrently`), keeping the serial default. `1.1.0` 以追加方式提供并发开关,串行仍是默认。 | + +--- + +## R2. Extreme timing interleavings are reasoned about, not exhaustively tested / 极端时序交错靠推理,未穷举测试 + +| | | +|---|---| +| Risk / 风险 | Combinations such as "a refresh resolves in the same microtask as a `logout()`", or "a proactive timer fires in the same frame as `dispose()`", are guarded by the epoch mechanism but have no dedicated test. 诸如「刷新与 `logout()` 在同一微任务中落地」「主动刷新定时器与 `dispose()` 同帧触发」这类组合,由 epoch 机制守卫,但没有专门的测试。 | +| Why accepted / 为何接受 | Such tests are inherently flaky (they race the event loop); the invariants they would check are enforced structurally: every session-writing path calls `_invalidateInFlight()`, and single-flight sharing is epoch-scoped. 这类测试天生易 flaky;而它们要验证的不变量已由结构保证:所有写会话的路径都调用 `_invalidateInFlight()`,单飞共享限定在同一 epoch 内。 | +| Trigger / 触发条件 | Rapid login → logout → login, or app teardown while a renewal is in flight. 快速连续 登录 → 登出 → 登录,或续期进行中时应用销毁。 | +| Watch / 观察 | Reports of a "resurrected" session (still signed in after logout), or `Authenticated` emitted after `Unauthenticated`. 出现「会话复活」(登出后仍处于登录态)或 `Unauthenticated` 之后又发出 `Authenticated` 的报告。 | +| Mitigation today / 当前缓解 | `logout()` invalidates in-flight work **before** calling the backend; a late refresh completes with `NoActiveSessionException` instead of writing. `logout()` 在调用后端**之前**就让进行中的工作失效;迟到的刷新以 `NoActiveSessionException` 结束而不是写入。 | +| Plan / 计划 | If a single report lands, add a targeted regression test first, then harden. Otherwise revisit at `1.1.0`. 一旦出现真实报告,先补定向回归测试再加固;否则 `1.1.0` 时复查。 | + +--- + +## R3. `clockSkew` (default 30s) amplifies renewal for very short-lived tokens / 默认 30 秒的时钟容差会放大短寿命令牌的续期 + +| | | +|---|---| +| Risk / 风险 | When access tokens live **shorter than the skew**, nearly every `validAccessToken()` read renews first — turning one request into one refresh. 当访问令牌寿命**短于容差**时,几乎每次 `validAccessToken()` 都会先续期 —— 一次请求伴随一次刷新。 | +| Why accepted / 为何接受 | 30s is the right default for the common 5–15 minute access token: it absorbs a device clock that runs ahead plus request latency. Shorter TTLs are a deliberate configuration choice. 30 秒对常见的 5–15 分钟令牌是正确的默认值,可吸收设备时钟偏快与请求延迟;更短 TTL 属于显式的配置选择。 | +| Trigger / 触发条件 | `expiresIn` under ~60s, or a backend that mints per-request tokens. `expiresIn` 小于约 60 秒,或后端签发一次性/每请求令牌。 | +| Watch / 观察 | Refresh-endpoint QPS tracking request QPS 1:1; users reporting "it refreshes on every call". 刷新端点 QPS 与请求 QPS 接近 1:1;用户反馈「每次调用都在刷新」。 | +| Mitigation today / 当前缓解 | Documented: lower `clockSkew` (e.g. 5s) or pass `Duration.zero` (README, `Configuration` page). 已写入文档:调小 `clockSkew`(如 5 秒)或传 `Duration.zero`。 | +| Plan / 计划 | `1.1.0`: consider clamping the effective skew to a fraction of the observed token lifetime. `1.1.0` 考虑把有效容差钳制为令牌寿命的一个比例。 | + +--- + +## R4. A throwing `onStateChanged` observer is swallowed / 观察者抛出的异常会被吞掉 + +| | | +|---|---| +| Risk / 风险 | Since 1.0.0 the observer is a true side channel: its error cannot corrupt the state machine, but it also becomes **invisible** — a broken logger/analytics sink fails silently. 自 1.0.0 起观察者是真正的旁路:它的错误不会破坏状态机,但也因此**不可见** —— 坏掉的日志/埋点会静默失败。 | +| Why accepted / 为何接受 | A callback that can break authentication is far worse than one that fails quietly. 一个能破坏认证流程的回调,远比一个安静失败的回调危险。 | +| Trigger / 触发条件 | Any exception inside `onStateChanged` (or the group's `(accountId, state)` variant). `onStateChanged`(或分组的 `(accountId, state)` 版本)内部抛出任何异常。 | +| Watch / 观察 | Missing analytics rows while auth events clearly occur. 认证事件明显发生,但埋点/日志缺失。 | +| Mitigation today / 当前缓解 | Wrap your own observer body in `try/catch` and log there. 在自己的观察者内部 `try/catch` 并记录。 | +| Plan / 计划 | `1.1.0`: additive `onObserverError(Object error, StackTrace stack)` hook, defaulting to silence. `1.1.0` 追加 `onObserverError` 钩子,默认仍然静默。 | + +--- + +## R5. The group now forgets a manager disposed outside it / 分组会遗忘在外部被释放的管理器 + +| | | +|---|---| +| Risk / 风险 | `_onActiveClosed()` removes the account and clears `activeId` when that manager's stream closes — including when **your own code** disposed it, not `group.remove()`. This is a behaviour change from 0.x. 当某个 manager 的流关闭时,`_onActiveClosed()` 会移除该账号并清空 `activeId` —— 包括**你自己**释放它(而非通过 `group.remove()`)的情况。这是相对 0.x 的行为变化。 | +| Why accepted / 为何接受 | Holding a released manager is a latent crash (`manager_disposed` on every later read); forgetting it is strictly safer. 持有已释放的管理器是潜伏崩溃(之后每次读取都是 `manager_disposed`);将其遗忘显然更安全。 | +| Trigger / 触发条件 | Mixing lifecycles: a manager created by the group but disposed by a provider/scope of your own. 生命周期混用:由分组创建、却被你自己的 provider/作用域释放。 | +| Watch / 观察 | `activeId` unexpectedly `null`, or an account disappearing from `accountIds`. `activeId` 意外变成 `null`,或某个账号从 `accountIds` 中消失。 | +| Mitigation today / 当前缓解 | Always release accounts through `group.remove(id)` / `group.disposeAll()`. 始终通过 `group.remove(id)` / `group.disposeAll()` 释放。 | +| Plan / 计划 | None — intended behaviour. Keep as-is in 1.x. 无需变更,这是预期行为,1.x 保持。 | + +--- + +## R6. Freezing the API means some future needs cost a major / 冻结 API 的代价:部分需求将需要 major 版本 + +| | | +|---|---| +| Risk / 风险 | `AuthState` is sealed: adding a subtype (say `Restoring`) after 1.0.0 is a breaking change, so it waits for 2.0. `AuthState` 是密封类:1.0.0 之后新增子类(如 `Restoring`)属于破坏性变更,只能等到 2.0。 | +| Why accepted / 为何接受 | 1.0.0 exists precisely to make that promise. Consumers can rely on exhaustive switches. 1.0.0 的意义就在于给出这个承诺,使用方可以放心写穷举 switch。 | +| Trigger / 触发条件 | A future feature that genuinely needs a new state (e.g. "restoring…" as a distinct UI state). 未来某个特性确实需要新状态(例如把「恢复中」做成独立 UI 状态)。 | +| Watch / 观察 | Feature requests asking for new states or for `AuthStrategy` to grow beyond four methods. 要求新增状态、或要求 `AuthStrategy` 突破四个方法的 feature request。 | +| Mitigation today / 当前缓解 | Express variation additively: `AuthState.session`, `isBusy`, `AuthError.error.code`, optional capability interfaces via `supports()`. 用可追加的方式表达变化:`AuthState.session`、`isBusy`、`AuthError.error.code`,以及用 `supports()` 检测的可选能力接口。 | +| Plan / 计划 | Track such requests; batch them into a single 2.0 if the pattern repeats. 收集此类需求;若形成模式,集中到 2.0 一次解决。 | + +--- + +## R7. Proactive throttling follows the injected `clock` / 主动刷新的节流跟随注入的时钟 + +| | | +|---|---| +| Risk / 风险 | `_throttleWait()` measures `clock().difference(last)`. With a **fixed/injected** clock (common in tests, or a clock that only advances per request), the elapsed time stays 0, so an already-due renewal always waits the full `autoRefreshMinInterval`. `_throttleWait()` 用 `clock().difference(last)` 计算。若时钟是**固定/注入**的(测试常见,或只在请求时才推进),间隔恒为 0,于是「已到期」的续期总是等待完整的 `autoRefreshMinInterval`。 | +| Why accepted / 为何接受 | A real wall clock always advances; the fixed-clock case is a test fixture, and the behaviour there is still deterministic and safe. 真实的墙钟总会推进;固定时钟属于测试夹具,而该场景下的行为依然确定且安全。 | +| Trigger / 触发条件 | Tests or hosts that inject a non-advancing `clock` while relying on immediate re-renewal. 注入了不推进的 `clock`,却又依赖「立即再次续期」的测试或宿主。 | +| Watch / 观察 | A test that expects N renewals within one fake-clock instant. 某个测试期望在同一个 fake 时钟瞬间内发生 N 次续期。 | +| Mitigation today / 当前缓解 | Advance the injected clock between renewals, or assert on the timer rather than the count. 在两次续期之间推进注入时钟,或改为断言定时器而非次数。 | +| Plan / 计划 | None unless a real host reports it. 除非真实宿主报告,否则不变更。 | + +--- + +## Post-release review cadence / 发版后复查节奏 + +- **First 7 days / 前 7 天**: watch issues for the signals listed above — + `resurrected session`, `signed out after restart`, `refresh loop`, + `activeId null`, `slow startup`. 按上面的信号关注 issue。 +- **Day 30 / 第 30 天**: any item with **no** reports drops to "accepted, no action"; + any item with a report becomes a `fix/` branch plus a regression test, released + as `1.0.x`. 无报告项降为「已接受」;有报告项转为 `fix/` 分支 + 回归测试,发 `1.0.x`。 +- **Every release / 每个版本**: re-read this file and update it — an accepted risk + that no longer applies should be deleted, not left to rot. + 每次发版复查并更新本文件:不再适用的风险应删除,而不是留着腐烂。 + +## Reporting / 上报 + +Use the bug template and quote the risk id (`R1`…`R7`) if it matches: +提交 bug 时若匹配某项,请引用风险编号(`R1`…`R7`): + +- 🐛 [Report a Bug](https://github.com/zero-labsco/zero_auth/issues/new?template=bug_report.md) diff --git a/README.md b/README.md index db7d64b..70dcc9b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A backend-agnostic **auth state machine & session lifecycle** for Dart/Flutter: [![Dart](https://img.shields.io/badge/Dart-✓-0175C2?logo=dart)](https://dart.dev) [![Style: effective dart](https://img.shields.io/badge/style-effective_dart-40c4ff.svg)](https://pub.dev/packages/effective_dart) -> **🔔 Upgrade recommended:** `0.5.0` closes the remaining lifecycle holes: `validAccessToken()` no longer returns an expired token when it cannot be renewed, `AuthManagerGroup.remove()` can no longer leak a manager when logout fails, and using a group after `disposeAll()` is rejected instead of crashing. It also adds `AuthSession.copyWith()` / `isExpiringWithin()`, async `updateSession()`, bounded proactive retries and `restoreAll(dropOthers:)`. Nothing to migrate. Pin `zero_auth: ^0.5.0` (or git `ref: release/v0.5.0`). +> **🔔 Upgrade recommended:** `1.0.0` is the **first stable release** — the public API is now frozen under semver. It closes the last lifecycle holes: `restore()` no longer signs you out when a renewal fails transiently, a late refresh can no longer overwrite a newer login, and a renewal no longer erases `userId` / `displayName` / `claims`. It also adds clock-skew tolerance (`clockSkew`, default 30s), `AuthSession.tryFromJson`, `AuthState.session` and renewing `AuthTokenSource.validAccessToken()`. **One migration step:** if you `implements AuthTokenSource`, add `@override Future validAccessToken({Duration? leeway}) async => accessToken;`. Pin `zero_auth: ^1.0.0` (or git `ref: release/v1.0.0`). 🌐 **[Official Website](https://www.zerolabsco.com/)**  ·  📦 **[View on pub.dev](https://pub.dev/packages/zero_auth)**  ·  🔗 **[View on GitHub](https://github.com/zero-labsco/zero_auth)** @@ -45,7 +45,8 @@ A backend-agnostic **auth state machine & session lifecycle** for Dart/Flutter: - **Explicit state machine** — `Unauthenticated`, `Authenticating`, `Authenticated`, `Refreshing`, `LoggingOut` and `AuthError`, broadcast as a replay-last stream. Prefer `state.isAuthenticated` / `state.isBusy` over `state is Authenticated`, so a token renewal never unmounts your signed-in UI. - **Silent restore & refresh** — restores the persisted session at startup (refreshing it first when it has expired) and refreshes tokens transparently (single-flight, so concurrent callers share one call). - **Bring your own login flow** — `loginWith` adopts a session from any flow you drive yourself: third-party OAuth, magic links, passkeys or biometric unlock. -- **Never send an expired token** — `validAccessToken()` renews the session first when the token has expired; ideal for HTTP interceptors. +- **Never send an expired token** — `validAccessToken()` renews the session first when the token has expired (or is about to, per `clockSkew`); ideal for HTTP interceptors. +- **Clock-skew tolerant** — `clockSkew` (default 30s) treats a token as expired a little early, so a device clock running ahead cannot hand out a token that dies in flight. - **Typed auth exceptions** — `InvalidCredentialsException`, `SessionExpiredException`, and friends, mapped automatically from your strategy's `AuthException.code`. - **Configurable refresh failure handling** — `refreshFailurePolicy` decides whether a failed refresh signs the user out (default: yes for unrecoverable failures, no for transient ones). - **Multiple accounts (opt-in)** — `AuthManagerGroup` keeps one `AuthManager` per account so several can stay signed in at once; the core itself stays single-session. @@ -54,7 +55,7 @@ A backend-agnostic **auth state machine & session lifecycle** for Dart/Flutter: - **Network-ready** — `AuthTokenSource` is the extension point that lets Dio / GraphQL interceptors attach `Authorization: Bearer` headers. - **Zero native code** — no plugins, no `dart:io`-only APIs; runs on server, CLI, and Flutter alike. - **Strongly-typed session** — `AuthSession` carries access/refresh tokens, expiry, and raw claims. -- **Session (de)serialization** — `AuthSession.toJson` / `AuthSession.fromJson` make persistence a one-liner; a file-based reference store ships for server/CLI. +- **Session (de)serialization** — `AuthSession.toJson` / `AuthSession.fromJson` make persistence a one-liner; `AuthSession.tryFromJson` returns `null` instead of throwing on malformed data. A file-based reference store ships for server/CLI. - **Proactive auto-refresh** — pass `autoRefreshAhead` to `AuthManager` and tokens renew before expiry (single-flight), so callers rarely hit an expired access token. ## Installation @@ -63,7 +64,7 @@ A backend-agnostic **auth state machine & session lifecycle** for Dart/Flutter: ```yaml dependencies: - zero_auth: ^0.5.0 + zero_auth: ^1.0.0 ``` ### Git @@ -73,7 +74,7 @@ dependencies: zero_auth: git: url: https://github.com/zero-labsco/zero_auth.git - ref: release/v0.5.0 # pin the release/vX.Y.Z branch (immutable per release) + ref: release/v1.0.0 # pin the release/vX.Y.Z branch (immutable per release) ``` ## Usage @@ -126,12 +127,20 @@ final auth = AuthManager( ### Attach tokens to the network (`AuthTokenSource`) -`AuthManager` *is* an `AuthTokenSource`. Hand it to a Dio interceptor (reference implementation in `example/lib/dio_interceptor.dart`): +`AuthManager` *is* an `AuthTokenSource`. Hand it to a Dio interceptor (reference implementations in `example/lib/dio_interceptor.dart`): ```dart -dio.interceptors.add(AuthInterceptor(auth)); // adds `Authorization: Bearer ` +// Renews first when needed — the safe default for real apps. +dio.interceptors.add(RefreshingAuthInterceptor(auth)); + +// Synchronous variant: attaches whatever the session currently holds, which may +// already be expired. Fine for short-lived sessions you renew elsewhere. +dio.interceptors.add(AuthInterceptor(auth)); ``` +Because `AuthTokenSource` itself now exposes `validAccessToken()`, an interceptor +can depend on the interface alone and still get a renewed token. + ### Bring your own login flow (`loginWith`) Third-party OAuth, magic links and passkeys are flows *you* drive; `zero_auth` @@ -170,6 +179,21 @@ final token = await auth.validAccessToken(); if (token != null) headers['Authorization'] = 'Bearer $token'; ``` +It also refuses a token that would die *while the request is in flight*: +`clockSkew` (default 30s) is how much validity is left as a margin, and +`validAccessToken(leeway:)` overrides it per call. + +```dart +// A long upload: demand a token that survives the next two minutes. +final token = await auth.validAccessToken(leeway: const Duration(minutes: 2)); + +// Opt out of the skew entirely. +final strict = AuthManager(strategy: strategy, clockSkew: Duration.zero); +``` + +> If your access tokens live **shorter than a minute**, lower `clockSkew` (say +> `const Duration(seconds: 5)`) — otherwise almost every read renews first. + Or let the manager renew proactively before expiry: ```dart @@ -179,6 +203,10 @@ final auth = AuthManager( ); ``` +Either way, a renewal that only returns tokens keeps the signed-in identity +(`userId` / `displayName` / `claims`); pass `preserveSessionDetails: false` to +take the backend answer verbatim. + ### Handle failures with typed errors Failures never leak raw `Exception`s. Your strategy opts into precise types by @@ -252,25 +280,28 @@ flutter run ## API Reference -> **Upgrading from 0.2.x** — `AuthState` gained two subtypes: `Refreshing` and -> `LoggingOut`. Exhaustive `switch` statements must handle them. Prefer -> `state.isAuthenticated` and `state.isBusy`, which stay correct as states evolve. +> **Upgrading to 1.0** — the public surface is frozen under semver from here on. +> One migration step: if you `implements AuthTokenSource`, add +> `@override Future validAccessToken({Duration? leeway}) async => accessToken;`. +> (From 0.2.x: `AuthState` gained the `Refreshing` and `LoggingOut` subtypes, so +> exhaustive `switch` statements must handle them — prefer `state.isAuthenticated` +> and `state.isBusy`, which stay correct as states evolve.) ### `AuthManager` | Member | Signature | Notes | |--------|-----------|-------| -| constructor | `AuthManager({required strategy, TokenStore? tokenStore, Duration? autoRefreshAhead, Duration? autoRefreshRetryDelay, RefreshFailurePolicy? refreshFailurePolicy, DateTime Function()? clock, void Function(AuthState)? onStateChanged})` | `tokenStore` defaults to `InMemoryTokenStore`; `autoRefreshAhead` enables proactive renewal, `autoRefreshRetryDelay` re-arms a failed one and `autoRefreshMaxRetries` caps those retries (default 3); `clock` overrides the time source; `onStateChanged` observes every emission | +| constructor | `AuthManager({required strategy, TokenStore? tokenStore, Duration? autoRefreshAhead, Duration? autoRefreshRetryDelay, int? autoRefreshMaxRetries, Duration? autoRefreshMinInterval, RefreshFailurePolicy? refreshFailurePolicy, DateTime Function()? clock, Duration? clockSkew, bool preserveSessionDetails = true, void Function(AuthState)? onStateChanged})` | `tokenStore` defaults to `InMemoryTokenStore`; `autoRefreshAhead` enables proactive renewal, `autoRefreshRetryDelay` re-arms a failed one, `autoRefreshMaxRetries` caps those retries (default 3) and `autoRefreshMinInterval` floors an already-due one (default 5s); `clockSkew` is how much earlier a token counts as expired (default 30s); `preserveSessionDetails` keeps identity fields across a renewal; `clock` overrides the time source; `onStateChanged` observes every emission | | `current` | `AuthState get current` | Latest state, always readable | | `state` | `Stream get state` | Broadcast, replays the latest value to new listeners | | `currentSession` | `AuthSession? get currentSession` | Available while `Authenticated` **and** `Refreshing` | | `accessToken` | `String? get accessToken` | May already be expired — use `validAccessToken` for requests | -| `restore()` | `Future restore({bool refreshIfExpired = true})` | Heals an expired persisted session, or drops it when it cannot renew | +| `restore()` | `Future restore({bool refreshIfExpired = true})` | Heals an expired persisted session; keeps it when the renewal fails transiently, drops it when the failure is terminal. Concurrent calls share one attempt | | `login()` | `Future login(Credentials)` | Emits `Authenticating → Authenticated`; on failure emits `AuthError` **and rethrows** | | `register()` | `Future register(RegistrationInput)` | Same semantics as `login` | | `loginWith()` | `Future loginWith(Future Function(AuthStrategy))` | Adopts a session from any flow you drive yourself | -| `refresh()` | `Future` | Emits `Refreshing`; single-flight; applies `refreshFailurePolicy` on failure | -| `validAccessToken()` | `Future` | Never returns an expired token; renews first when needed | +| `refresh()` | `Future` | Emits `Refreshing`; single-flight within one epoch; applies `refreshFailurePolicy` on failure | +| `validAccessToken()` | `Future validAccessToken({Duration? leeway})` | Never returns an expired token; renews first when it expires within `leeway` (default `clockSkew`) | | `logout()` | `Future` | Emits `LoggingOut`, best-effort backend call, clears the store, lands on `Unauthenticated` | | `updateSession()` | `Future updateSession(AuthSession Function(AuthSession))` | Replaces the active session without a re-login; throws when nothing is signed in | | `supports()` | `bool supports()` | Whether the strategy implements an optional capability | @@ -283,12 +314,15 @@ single-session, so nothing changes unless you use this. | Member | Notes | |--------|-------| -| constructor | `AuthManagerGroup({required strategyFactory, required storeFactory})` — both receive the account id; give each account its own `TokenStore` | +| constructor | `AuthManagerGroup({required strategyFactory, required storeFactory, managerFactory, autoRefreshAhead, autoRefreshRetryDelay, autoRefreshMaxRetries, autoRefreshMinInterval, refreshFailurePolicy, clock, clockSkew, preserveSessionDetails, onStateChanged})` — both factories receive the account id; give each account its own `TokenStore`. Every manager knob is forwarded to the managers it creates; `managerFactory` builds them yourself instead | | `forAccount(id)` / `addAccount(id)` | Lazily creates and caches that account's `AuthManager` | -| `switchTo(id)` | Makes an account active; the group's `state` follows it | +| `switchTo(id)` | Makes an account active; the group's `state` follows it and `activeIdChanges` emits | +| `activeIdChanges` | `Stream` of the active account id (`null` when none is active) | +| `onStateChanged` | Called as `(accountId, state)` for every emission of every account | | `logoutAll()` | Signs every account out and forgets them | | `current` / `state` / `currentSession` / `accessToken` | Mirror the active account | -| `restoreAll(ids, {activeId})` | Restores every account, then activates one | +| `validAccessToken()` | The active account's renewed token, or `null` | +| `restoreAll(ids, {activeId})` | Restores every account — one failing account does not abandon the rest — then activates one | | `remove(id)` | Signs out and forgets an account | | `disposeAll()` | Releases every manager | @@ -307,7 +341,9 @@ enough — see the [Multi-Account cookbook](https://zero-labsco.github.io/zero_a | `AuthError` | `AppException error` | Last operation failed | Helpers: `isAuthenticated` is `true` for `Authenticated` **and** `Refreshing`; -`isBusy` covers `Authenticating`, `Refreshing` and `LoggingOut`. +`isBusy` covers `Authenticating`, `Refreshing` and `LoggingOut`; `session` +returns the session any state carries (`Authenticated` / `Refreshing` / +`LoggingOut`), or `null`. ### `AuthSession` @@ -319,7 +355,8 @@ Helpers: `isAuthenticated` is `true` for `Authenticated` **and** `Refreshing`; | `timeUntilExpiry([DateTime])` | Remaining lifetime, or `null` when there is no expiry | | `isExpiringWithin(window, [DateTime])` | Renew a little before it actually dies | | `copyWith(...)` | Replace only the fields you pass; `null` keeps the current value (build a new session to clear one) | -| `toJson()` / `AuthSession.fromJson()` | Persistence; `null` fields are omitted | +| `toJson()` / `AuthSession.fromJson()` | Persistence; `null` fields are omitted. `fromJson` throws on malformed input | +| `AuthSession.tryFromJson()` | Same, but returns `null` instead of throwing — use it for anything read from disk or secure storage | ### Boundaries & value objects @@ -327,7 +364,7 @@ Helpers: `isAuthenticated` is `true` for `Authenticated` **and** `Refreshing`; |------|------| | `AuthStrategy` | Backend boundary you implement: `login` / `register` / `logout` / `refresh` | | `TokenStore` | Persistence boundary: `save` / `load` / `clear`; `InMemoryTokenStore` ships in-core | -| `AuthTokenSource` | Read-only token source for network layers; `AuthManager` implements it | +| `AuthTokenSource` | Read-only token source for network layers: `accessToken` plus `validAccessToken({leeway})` (renews first when backed by a manager). `AuthManager` and `AuthManagerGroup` implement it | | `Credentials`, `RegistrationInput`, `SessionHandle`, `RefreshToken` | Value objects passed across those boundaries | | `SupportsPasswordReset`, `SupportsPasswordChange`, `SupportsReauthentication` | Optional capability interfaces; detect with `AuthManager.supports()` so the four-method contract stays intact | @@ -342,7 +379,7 @@ Helpers: `isAuthenticated` is `true` for `Authenticated` **and** `Refreshing`; | `NoActiveSessionException` | An operation needed an active session and there was none | | `RefreshTokenMissingException` | Refresh requested for a session without a refresh token | | `UnexpectedAuthException` | Fallback for anything unclassifiable | -| `mapAuthFailure(Object)` | Maps a caught error onto the richest subclass, by `code` | +| `mapAuthFailure(Object)` | Maps a caught error onto the richest subclass, by `code` — works for both `AuthException` and a bare `AuthFail` | | `defaultRefreshFailurePolicy` | Signs out on unrecoverable failures, keeps the session on transient ones | | `Result` | Optional explicit `Ok` / `Err` wrapper | @@ -368,6 +405,7 @@ Helpers: `isAuthenticated` is `true` for `Authenticated` **and** `Refreshing`; logout ──► LoggingOut ──► Unauthenticated restore() ──► Authenticated, or Unauthenticated when nothing persists + (a transient renewal failure keeps the persisted session) ``` The manager holds no UI, backend, or native code. Wire your backend via `AuthStrategy` and your persistence via `TokenStore`; network layers depend only on `AuthTokenSource`. diff --git a/README_zh.md b/README_zh.md index 1f4688b..84d4c41 100644 --- a/README_zh.md +++ b/README_zh.md @@ -12,7 +12,7 @@ [![Dart](https://img.shields.io/badge/Dart-✓-0175C2?logo=dart)](https://dart.dev) [![Style: effective dart](https://img.shields.io/badge/style-effective_dart-40c4ff.svg)](https://pub.dev/packages/effective_dart) -> **🔔 推荐升级:** `0.5.0` 补上了剩余的生命周期漏洞:无法续期时 `validAccessToken()` 不再返回过期令牌;登出失败时 `AuthManagerGroup.remove()` 不再泄漏管理器;`disposeAll()` 之后使用分组会被明确拒绝而不是崩溃。另新增 `AuthSession.copyWith()` / `isExpiringWithin()`、异步 `updateSession()`、有上限的主动续期重试与 `restoreAll(dropOthers:)`。无需迁移。请使用 `zero_auth: ^0.5.0`(Git 方式用 `ref: release/v0.5.0`)。 +> **🔔 推荐升级:** `1.0.0` 是**首个稳定版本** —— 公共 API 自此按 semver 冻结。它补上了最后一批生命周期漏洞:续期瞬时失败时 `restore()` 不再让用户登出;迟到的刷新再也无法覆盖更新的登录;续期不再抹掉 `userId` / `displayName` / `claims`。同时新增时钟偏移容忍(`clockSkew`,默认 30 秒)、`AuthSession.tryFromJson`、`AuthState.session` 与会续期的 `AuthTokenSource.validAccessToken()`。**唯一需要迁移的一点:** 若你 `implements AuthTokenSource`,请补上 `@override Future validAccessToken({Duration? leeway}) async => accessToken;`。请使用 `zero_auth: ^1.0.0`(Git 方式用 `ref: release/v1.0.0`)。 🌐 **[官方网站](https://www.zerolabsco.com/)**  ·  📦 **[在 pub.dev 查看](https://pub.dev/packages/zero_auth)**  ·  🔗 **[查看 GitHub 仓库](https://github.com/zero-labsco/zero_auth)** @@ -45,7 +45,8 @@ - **显式状态机**:`Unauthenticated`、`Authenticating`、`Authenticated`、`Refreshing`、`LoggingOut`、`AuthError`,以「重放最近值」的广播流对外暴露。建议用 `state.isAuthenticated` / `state.isBusy` 代替 `state is Authenticated`,这样令牌续期时不会卸载已登录界面。 - **静默恢复与刷新**:启动时恢复持久化会话(会话已过期则先续期),并透明刷新令牌(单飞机制,并发调用方共享同一次刷新)。 - **自带任意登录流程**:`loginWith` 可接纳你自行驱动的流程所产生的会话——第三方 OAuth、魔法链接、Passkey 或生物识别解锁。 -- **绝不发送过期令牌**:`validAccessToken()` 在令牌过期时先续期再返回,非常适合 HTTP 拦截器。 +- **绝不发送过期令牌**:`validAccessToken()` 在令牌已过期(或按 `clockSkew` 即将过期)时先续期再返回,非常适合 HTTP 拦截器。 +- **容忍时钟偏移**:`clockSkew`(默认 30 秒)会提前一点把令牌视为过期,设备时钟偏快时也不会发出一个途中失效的令牌。 - **类型化认证异常**:`InvalidCredentialsException`、`SessionExpiredException` 等,可由你策略里的 `AuthException.code` 自动映射而来。 - **可配置的刷新失败处理**:`refreshFailurePolicy` 决定一次刷新失败是否让用户登出(默认:不可恢复的失败登出,瞬时故障保留会话)。 - **多账号(可选)**:`AuthManagerGroup` 为每个账号持有一个 `AuthManager`,可让多个账号同时保持登录;内核本身仍是单会话。 @@ -54,7 +55,7 @@ - **面向网络**:`AuthTokenSource` 是扩展点,让 Dio / GraphQL 拦截器能为请求附加 `Authorization: Bearer` 头。 - **零原生代码**:无插件、无 `dart:io`-only API;可在服务端、CLI 与 Flutter 中运行。 - **强类型会话**:`AuthSession` 携带访问 / 刷新令牌、过期时间与原始 claims。 -- **会话(反)序列化**:`AuthSession.toJson` / `AuthSession.fromJson` 让持久化成为一行代码;并附带面向服务端 / CLI 的基于文件的参考存储。 +- **会话(反)序列化**:`AuthSession.toJson` / `AuthSession.fromJson` 让持久化成为一行代码;遇到畸形数据时 `AuthSession.tryFromJson` 返回 `null` 而不是抛异常。并附带面向服务端 / CLI 的基于文件的参考存储。 - **临近过期自动刷新**:给 `AuthManager` 传入 `autoRefreshAhead`,令牌会在过期前自动续期(单飞机制),调用方几乎不会撞上过期的访问令牌。 ## 安装 @@ -63,7 +64,7 @@ ```yaml dependencies: - zero_auth: ^0.5.0 + zero_auth: ^1.0.0 ``` ### Git @@ -73,7 +74,7 @@ dependencies: zero_auth: git: url: https://github.com/zero-labsco/zero_auth.git - ref: release/v0.5.0 # 固定到 release/vX.Y.Z 分支(每个版本不可变) + ref: release/v1.0.0 # 固定到 release/vX.Y.Z 分支(每个版本不可变) ``` ## 使用方法 @@ -129,9 +130,15 @@ final auth = AuthManager( `AuthManager` **本身就是一个** `AuthTokenSource`。把它交给 Dio 拦截器(参考实现位于 `example/lib/dio_interceptor.dart`): ```dart -dio.interceptors.add(AuthInterceptor(auth)); // 自动添加 `Authorization: Bearer ` +// 必要时先续期 —— 真实应用的稳妥默认选择。 +dio.interceptors.add(RefreshingAuthInterceptor(auth)); + +// 同步版本:附加会话当前持有的令牌,它可能已经过期。适合你在别处统一续期的短寿命令牌。 +dio.interceptors.add(AuthInterceptor(auth)); ``` +由于 `AuthTokenSource` 自身也暴露了 `validAccessToken()`,拦截器只依赖接口也能拿到续期后的令牌。 + ### 接入自定义登录流程(`loginWith`) 第三方 OAuth、魔法链接、Passkey 都是**由你驱动**的流程,`zero_auth` 只负责之后的部分:提供方握手在本包之外(需要平台代码),你的后端校验提供方凭据并签发**你自己的**令牌,再由 `loginWith` 把会话交给管理器: @@ -164,6 +171,20 @@ final token = await auth.validAccessToken(); if (token != null) headers['Authorization'] = 'Bearer $token'; ``` +它还会拒绝那些「请求途中就会失效」的令牌:`clockSkew`(默认 30 秒)是保留的余量, +`validAccessToken(leeway:)` 可按单次调用覆盖。 + +```dart +// 长耗时上传:要求令牌至少还能撑过接下来两分钟。 +final token = await auth.validAccessToken(leeway: const Duration(minutes: 2)); + +// 完全关闭偏移容忍。 +final strict = AuthManager(strategy: strategy, clockSkew: Duration.zero); +``` + +> 若你的访问令牌**寿命不足一分钟**,请把 `clockSkew` 调小(例如 +> `const Duration(seconds: 5)`)—— 否则几乎每次读取都会先续期。 + 也可以让管理器在过期前主动续期: ```dart @@ -173,6 +194,9 @@ final auth = AuthManager( ); ``` +无论哪种方式,只返回令牌的续期都会保留已登录身份(`userId` / `displayName` / +`claims`);传 `preserveSessionDetails: false` 可原样采用后端响应。 + ### 用类型化异常处理失败 失败绝不会以裸 `Exception` 泄漏。你的策略只要抛出带 `code` 的 `AuthException`,就能映射为精确类型: @@ -243,25 +267,28 @@ flutter run ## API 参考 -> **从 0.2.x 升级** —— `AuthState` 新增了两个子类:`Refreshing` 与 `LoggingOut`, -> 因此穷举 `switch` 必须处理它们。建议改用 `state.isAuthenticated` 与 -> `state.isBusy`,状态继续演进也不会失效。 +> **升级到 1.0** —— 公共 API 自此按 semver 冻结。唯一需要迁移的一点:若你 +> `implements AuthTokenSource`,请补上 +> `@override Future validAccessToken({Duration? leeway}) async => accessToken;`。 +> (从 0.2.x 升级:`AuthState` 新增了 `Refreshing` 与 `LoggingOut` 两个子类,穷举 +> `switch` 必须处理它们 —— 建议改用 `state.isAuthenticated` 与 `state.isBusy`, +> 状态继续演进也不会失效。) ### `AuthManager` | 成员 | 签名 | 说明 | |------|------|------| -| 构造函数 | `AuthManager({required strategy, TokenStore? tokenStore, Duration? autoRefreshAhead, Duration? autoRefreshRetryDelay, RefreshFailurePolicy? refreshFailurePolicy, DateTime Function()? clock, void Function(AuthState)? onStateChanged})` | `tokenStore` 默认为 `InMemoryTokenStore`;`autoRefreshAhead` 开启主动续期、`autoRefreshRetryDelay` 在失败后重新排程;`clock` 覆盖时间源;`onStateChanged` 观察每次状态 | +| 构造函数 | `AuthManager({required strategy, TokenStore? tokenStore, Duration? autoRefreshAhead, Duration? autoRefreshRetryDelay, int? autoRefreshMaxRetries, Duration? autoRefreshMinInterval, RefreshFailurePolicy? refreshFailurePolicy, DateTime Function()? clock, Duration? clockSkew, bool preserveSessionDetails = true, void Function(AuthState)? onStateChanged})` | `tokenStore` 默认为 `InMemoryTokenStore`;`autoRefreshAhead` 开启主动续期、`autoRefreshRetryDelay` 在失败后重新排程、`autoRefreshMaxRetries` 限制重试次数(默认 3)、`autoRefreshMinInterval` 为「已到期」的续期设下限(默认 5 秒);`clockSkew` 表示提前多久视为过期(默认 30 秒);`preserveSessionDetails` 在续期时保留身份字段;`clock` 覆盖时间源;`onStateChanged` 观察每次状态 | | `current` | `AuthState get current` | 最新状态,始终可读 | | `state` | `Stream get state` | 广播流,对新订阅者重放最新值 | | `currentSession` | `AuthSession? get currentSession` | 在 `Authenticated` **与** `Refreshing` 期间可用 | | `accessToken` | `String? get accessToken` | 可能已过期,请求请用 `validAccessToken` | -| `restore()` | `Future restore({bool refreshIfExpired = true})` | 修复已过期的持久化会话,无法续期则丢弃 | +| `restore()` | `Future restore({bool refreshIfExpired = true})` | 修复已过期的持久化会话:续期瞬时失败时**保留**会话,终局失败才丢弃;并发调用共享同一次尝试 | | `login()` | `Future login(Credentials)` | 发出 `Authenticating → Authenticated`;失败发 `AuthError` **并重新抛出** | | `register()` | `Future register(RegistrationInput)` | 语义同 `login` | | `loginWith()` | `Future loginWith(Future Function(AuthStrategy))` | 接纳任意自定义流程产生的会话 | -| `refresh()` | `Future` | 发出 `Refreshing`;单飞;失败时按 `refreshFailurePolicy` 处理 | -| `validAccessToken()` | `Future` | 绝不返回过期令牌,必要时先续期 | +| `refresh()` | `Future` | 发出 `Refreshing`;同一 epoch 内单飞;失败时按 `refreshFailurePolicy` 处理 | +| `validAccessToken()` | `Future validAccessToken({Duration? leeway})` | 绝不返回过期令牌;当令牌会在 `leeway`(默认 `clockSkew`)内过期时先续期 | | `logout()` | `Future` | 发出 `LoggingOut`、尽力调用后端、清空存储,落到 `Unauthenticated` | | `updateSession()` | `Future updateSession(AuthSession Function(AuthSession))` | 无需重新登录即可替换活动会话;未登录时抛出 | | `supports()` | `bool supports()` | 策略是否实现了某个可选能力 | @@ -273,12 +300,15 @@ flutter run | 成员 | 说明 | |------|------| -| 构造函数 | `AuthManagerGroup({required strategyFactory, required storeFactory})` —— 两者都会收到账号 id;请为每个账号提供独立的 `TokenStore` | +| 构造函数 | `AuthManagerGroup({required strategyFactory, required storeFactory, managerFactory, autoRefreshAhead, autoRefreshRetryDelay, autoRefreshMaxRetries, autoRefreshMinInterval, refreshFailurePolicy, clock, clockSkew, preserveSessionDetails, onStateChanged})` —— 两个工厂都会收到账号 id;请为每个账号提供独立的 `TokenStore`。管理器调参会完整转发给它创建的管理器;也可传 `managerFactory` 自行构建 | | `forAccount(id)` / `addAccount(id)` | 惰性创建并缓存该账号的 `AuthManager` | -| `switchTo(id)` | 激活某个账号,分组的 `state` 随之切换 | +| `switchTo(id)` | 激活某个账号,分组的 `state` 随之切换,并让 `activeIdChanges` 发出新值 | +| `activeIdChanges` | `Stream`:激活账号 id(无激活时为 `null`) | +| `onStateChanged` | 以 `(accountId, state)` 形式调用,覆盖所有账号 | | `logoutAll()` | 一次性登出所有账号并全部遗忘 | | `current` / `state` / `currentSession` / `accessToken` | 反映激活账号 | -| `restoreAll(ids, {activeId})` | 恢复所有账号,然后激活其中一个 | +| `validAccessToken()` | 激活账号续期后的令牌;无则为 `null` | +| `restoreAll(ids, {activeId})` | 恢复所有账号(某个账号失败不会连累其余),然后激活其中一个 | | `remove(id)` | 登出并移除某个账号 | | `disposeAll()` | 释放所有管理器 | @@ -297,7 +327,8 @@ flutter run | `AuthError` | `AppException error` | 上一次操作失败 | 辅助属性:`isAuthenticated` 在 `Authenticated` **与** `Refreshing` 下为 `true`; -`isBusy` 覆盖 `Authenticating`、`Refreshing`、`LoggingOut`。 +`isBusy` 覆盖 `Authenticating`、`Refreshing`、`LoggingOut`;`session` 返回该状态携带的 +会话(`Authenticated` / `Refreshing` / `LoggingOut`),无则为 `null`。 ### `AuthSession` @@ -306,7 +337,11 @@ flutter run | `accessToken`、`refreshToken`、`expiresAt`、`userId`、`displayName`、`claims` | 令牌、过期时间、身份与原始 claims | | `isExpired` | 按系统时钟判断是否过期 | | `isExpiredAt(DateTime)` | 按你自己的时钟判断 | -| `toJson()` / `AuthSession.fromJson()` | 持久化用,`null` 字段会被省略 | +| `timeUntilExpiry([DateTime])` | 剩余有效期;无过期时间时为 `null` | +| `isExpiringWithin(window, [DateTime])` | 是否会在 `window` 内过期,便于提前续期 | +| `copyWith(...)` | 只替换传入的字段;传 `null` 表示保留原值(要清空请新建会话) | +| `toJson()` / `AuthSession.fromJson()` | 持久化用,`null` 字段会被省略;`fromJson` 遇畸形数据会抛异常 | +| `AuthSession.tryFromJson()` | 同上,但畸形数据返回 `null`;凡是从磁盘或安全存储读出的内容都建议用它 | ### 边界与值对象 @@ -314,7 +349,7 @@ flutter run |------|------| | `AuthStrategy` | 由你实现的后端边界:`login` / `register` / `logout` / `refresh` | | `TokenStore` | 持久化边界:`save` / `load` / `clear`;内核自带 `InMemoryTokenStore` | -| `AuthTokenSource` | 供网络层使用的只读令牌来源,`AuthManager` 即实现它 | +| `AuthTokenSource` | 供网络层使用的只读令牌来源:`accessToken` 与 `validAccessToken({leeway})`(由管理器支持时会先续期)。`AuthManager` 与 `AuthManagerGroup` 均实现它 | | `Credentials`、`RegistrationInput`、`SessionHandle`、`RefreshToken` | 跨边界传递的值对象 | | `SupportsPasswordReset`、`SupportsPasswordChange`、`SupportsReauthentication` | 可选能力接口,用 `AuthManager.supports()` 检测,从而保持四方法契约不变 | @@ -329,7 +364,7 @@ flutter run | `NoActiveSessionException` | 需要活动会话的操作却没有会话 | | `RefreshTokenMissingException` | 会话无刷新令牌却请求了刷新 | | `UnexpectedAuthException` | 无法归类时的兜底类型 | -| `mapAuthFailure(Object)` | 按 `code` 把捕获的错误映射为最具体的子类 | +| `mapAuthFailure(Object)` | 按 `code` 把捕获的错误映射为最具体的子类 —— 对 `AuthException` 与裸 `AuthFail` 都生效 | | `defaultRefreshFailurePolicy` | 不可恢复的失败登出,瞬时故障保留会话 | | `Result` | 可选的显式 `Ok` / `Err` 包装 | @@ -354,7 +389,8 @@ flutter run (保留上一个会话) 登出 ──► LoggingOut ──► Unauthenticated - restore() ──► Authenticated;无持久化内容或无法续期时为 Unauthenticated + restore() ──► Authenticated;无持久化内容或终局失败时为 Unauthenticated + (续期瞬时失败时保留持久化会话) ``` 管理器不含 UI、后端或原生代码。通过 `AuthStrategy` 接入后端,通过 `TokenStore` 接入持久化;网络层只依赖 `AuthTokenSource`。 diff --git a/USAGE.md b/USAGE.md index 5cd6cac..79ccb89 100644 --- a/USAGE.md +++ b/USAGE.md @@ -50,7 +50,7 @@ your storage ──▶ TokenStore ──▶ ──▶ acces ```yaml dependencies: - zero_auth: ^0.5.0 + zero_auth: ^1.0.0 ``` ```dart @@ -359,6 +359,14 @@ final class PrefsTokenStore implements TokenStore { > `load()` returning `null` simply means "not signed in" — `restore()` then emits `Unauthenticated`. > `load()` 返回 `null` 即代表未登录,`restore()` 会发出 `Unauthenticated`。 +> +> **Prefer `AuthSession.tryFromJson()` in `load()`.** `fromJson()` throws a +> `FormatException` / `TypeError` on malformed data, which a schema change or a +> partial write can easily produce; `tryFromJson()` returns `null` instead, and +> `null` already means "not signed in". +> **`load()` 里请优先用 `AuthSession.tryFromJson()`。** `fromJson()` 遇到畸形数据会抛 +> `FormatException` / `TypeError`,而 schema 变更或写入中断很容易造成畸形数据; +> `tryFromJson()` 则返回 `null`,而 `null` 本来就代表「未登录」。 --- @@ -424,13 +432,19 @@ The manager is framework-agnostic: anything that can listen to a `Stream validAccessToken({Duration? leeway}); // renewed first / 先续期 } ``` `AuthManager` **implements** `AuthTokenSource`, so hand the manager to your interceptor and it always reads the live token (no copying, no stale closures). `AuthManager` **本身即** `AuthTokenSource`,把管理器交给拦截器即可,永远读到最新令牌。 +> **1.0 note / 说明:** `validAccessToken()` is new on the interface. If you +> `implements AuthTokenSource` yourself, add one line: +> `@override Future validAccessToken({Duration? leeway}) async => accessToken;`. +> 若你自己 `implements AuthTokenSource`,请补一行(同上)。 + ### 8.1 Attach the bearer header / 附加 Bearer 头 ```dart @@ -449,6 +463,31 @@ final class AuthInterceptor extends Interceptor { final dio = Dio()..interceptors.add(AuthInterceptor(auth)); ``` +That synchronous version attaches whatever the session currently holds, which may +already be expired. When the token actually reaches a server, read +`validAccessToken()` instead — it renews first (reusing the single-flight refresh): +同步版本附加的是会话当前持有的令牌,它可能已经过期。令牌真要发到服务端时,请改用 +`validAccessToken()` —— 它会先续期(复用单飞刷新): + +```dart +final class RefreshingAuthInterceptor extends QueuedInterceptor { + RefreshingAuthInterceptor(this.source); + final AuthTokenSource source; + + @override + void onRequest(RequestOptions o, RequestInterceptorHandler h) async { + final token = await source.validAccessToken(); + if (token != null) o.headers['Authorization'] = 'Bearer $token'; + h.next(o); + } +} +``` + +Both live in `example/lib/dio_interceptor.dart` (together with an +`AuthRetryInterceptor` that replays a request once after a 401). +两者都在 `example/lib/dio_interceptor.dart` 中(另有一个在 401 后重放一次请求的 +`AuthRetryInterceptor`)。 + ### 8.2 Auto-refresh on 401 (single-flight safe) / 401 自动刷新并重试 ```dart @@ -487,12 +526,15 @@ Future refresh(); | Behaviour / 行为 | Detail / 说明 | |---|---| -| Single-flight / 单飞 | Concurrent callers share one `Future`; the guard resets in `finally`. 并发调用共享同一个 `Future`,守卫在 `finally` 中释放。 | +| Single-flight / 单飞 | Concurrent callers share one `Future`, **but only within the same epoch**: a call started before the session was replaced (login / `updateSession`) is not joined, so nobody receives a stale session. 并发调用共享同一个 `Future`,**但仅限同一 epoch 内**:会话被替换(登录 / `updateSession`)之前启动的那次不会被共享,因此没人会拿到过期会话。 | | Preconditions | Needs an active session **and** a non-null `refreshToken`; otherwise throws `NoActiveSessionException` / `RefreshTokenMissingException`. 需要活动会话且 `refreshToken` 非空,否则抛出这两个异常。 | | In flight | Emits `Refreshing(session)`; the previous session stays usable meanwhile / 期间发出 `Refreshing(session)`,旧会话仍可用。 | -| On success | Persists via `TokenStore.save`, emits `Authenticated(newSession)`. 经 `TokenStore.save` 持久化并发出 `Authenticated(newSession)`。 | +| On success | Persists via `TokenStore.save`, emits `Authenticated(newSession)`. Identity fields (`userId` / `displayName` / `claims`) carry over when the backend returned tokens only — pass `preserveSessionDetails: false` to opt out. 经 `TokenStore.save` 持久化并发出 `Authenticated(newSession)`;后端只返回令牌时身份字段会被保留,传 `preserveSessionDetails: false` 可关闭。 | | On failure | Emits `AuthError`, then either `Unauthenticated` (unrecoverable) or back to the previous `Authenticated` (transient) per `refreshFailurePolicy`; the returned `Future` also completes with the typed error. 先发 `AuthError`,再按策略转为 `Unauthenticated`(不可恢复)或回到上一个 `Authenticated`(瞬时);返回的 `Future` 同时以类型化错误完成。 | | Expiry | `AuthSession.isExpired` is `false` when `expiresAt == null` (expiry unknown ⇒ assume valid). Use `isExpiredAt(now)` to evaluate against your own clock. | +| Clock skew / 时钟偏移 | `clockSkew` (default 30s) makes the manager treat a token as expired that much earlier, so a device clock running ahead cannot hand out a token that dies in flight. `validAccessToken(leeway:)` overrides it per call; `clockSkew: Duration.zero` restores the strict behaviour. `clockSkew`(默认 30 秒)让管理器提前这么多把令牌视为过期,设备时钟偏快时不会发出途中失效的令牌;`validAccessToken(leeway:)` 可按单次调用覆盖,`clockSkew: Duration.zero` 恢复严格判定。 | +| Proactive / 主动续期 | `autoRefreshAhead` schedules the renewal before expiry; a failure is re-armed with a linear backoff up to `autoRefreshMaxRetries` (default 3), and an already-due renewal waits at least `autoRefreshMinInterval` (default 5s) so a backend issuing very short-lived tokens cannot cause a tight loop. `autoRefreshAhead` 在过期前排程续期;失败按线性退避重试,上限 `autoRefreshMaxRetries`(默认 3),已到期的续期至少等待 `autoRefreshMinInterval`(默认 5 秒),避免后端发放极短寿命令牌时形成紧密循环。 | +| `restore()` | An expired persisted session is renewed first. If that renewal fails **transiently** the session is kept (the next `validAccessToken()` retries); only a terminal failure clears the store. 过期的持久化会话会先续期;续期**瞬时**失败时保留会话(下次 `validAccessToken()` 会重试),只有终局失败才清空存储。 | > **0.3.0 note / 说明:** passing `autoRefreshAhead` to `AuthManager` lets the core > schedule the renewal for you (still single-flight, failures handled internally). @@ -569,6 +611,13 @@ Codes your strategy should throw / 策略建议抛出的 code: throw AuthException('Wrong password', code: 'invalid_credentials'); ``` +You can also throw the bare domain type — `mapAuthFailure` reads its `code` just +the same / 也可以直接抛出裸的领域类型,`mapAuthFailure` 一样会读取它的 `code`: + +```dart +throw const AuthFail('Wrong password', code: 'invalid_credentials'); +``` + Codes raised by the manager itself / 管理器自身产生的 code: | Code | Meaning | @@ -606,8 +655,12 @@ final class AuthManager implements AuthTokenSource { TokenStore? tokenStore, Duration? autoRefreshAhead, Duration? autoRefreshRetryDelay, + int? autoRefreshMaxRetries, + Duration? autoRefreshMinInterval, RefreshFailurePolicy? refreshFailurePolicy, DateTime Function()? clock, + Duration? clockSkew, // default 30s / 默认 30 秒 + bool preserveSessionDetails = true, void Function(AuthState state)? onStateChanged, }); } @@ -621,18 +674,22 @@ final class AuthManager implements AuthTokenSource { | `state` | `Stream get state` | Broadcast, replays last value / 广播且重放最近值 | | `autoRefreshAhead` | `Duration?` | Proactive renewal lead time; `null` disables it / 主动续期提前量,`null` 为关闭 | | `autoRefreshRetryDelay` | `Duration` | Re-arms a proactive renewal that failed (default 30s), while a session still exists / 主动续期失败后重新排程(默认 30 秒) | +| `autoRefreshMaxRetries` | `int` | How many failed proactive renewals to retry (default 3) / 主动续期最多重试几次(默认 3) | +| `autoRefreshMinInterval` | `Duration` | Floor for an already-due proactive renewal (default 5s) / 「已到期」主动续期的最小等待(默认 5 秒) | +| `clockSkew` | `Duration` | How much earlier a token counts as expired (default 30s) / 提前多久把令牌视为过期(默认 30 秒) | +| `preserveSessionDetails` | `bool` | Carry identity fields across a renewal that returns tokens only (default `true`) / 只返回令牌的续期是否保留身份字段(默认 `true`) | | `onStateChanged` | `void Function(AuthState)?` | Called for every emission; handy for logging or analytics without subscribing / 每次发出状态时调用,便于日志或埋点 | | `refreshFailurePolicy` | `RefreshFailurePolicy` | Whether a failed refresh signs out / 刷新失败是否登出 | | `clock` | `DateTime Function()` | Time source; defaults to the system clock / 时间源,默认系统时钟 | | `currentSession` | `AuthSession? get currentSession` | `null` unless `Authenticated` / `Refreshing` | | `accessToken` | `String? get accessToken` | From `AuthTokenSource`; may already be expired — see `validAccessToken` / 可能已过期,见 `validAccessToken` | -| `restore()` | `Future restore({bool refreshIfExpired = true})` | Loads from `TokenStore`; an expired session is refreshed first, or dropped when it cannot renew / 载入持久化会话;过期会话先续期,无法续期则丢弃 | +| `restore()` | `Future restore({bool refreshIfExpired = true})` | Loads from `TokenStore`; an expired session is refreshed first, dropped when the failure is terminal and **kept** when it is transient. Concurrent calls share one attempt / 载入持久化会话;过期会话先续期,终局失败丢弃、**瞬时**失败保留。并发调用共享同一次尝试 | | `login()` | `Future login(Credentials)` | Emits `Authenticating → Authenticated`; on failure emits `AuthError` **and rethrows** | | `register()` | `Future register(RegistrationInput)` | Same semantics as `login` | | `loginWith()` | `Future loginWith(Future Function(AuthStrategy))` | Adopts a session from any flow (OAuth, magic link, passkey) / 接纳任意流程的会话 | | `logout()` | `Future` | Emits `LoggingOut`, best-effort `strategy.logout`, then `clear()`, then `Unauthenticated` | -| `refresh()` | `Future` | Emits `Refreshing`; single-flight; on failure applies `refreshFailurePolicy` / 发出 `Refreshing`;单飞;失败时按策略处理 | -| `validAccessToken()` | `Future` | Never returns an expired token; refreshes first when needed / 绝不返回过期令牌,必要时先续期 | +| `refresh()` | `Future` | Emits `Refreshing`; single-flight within an epoch; on failure applies `refreshFailurePolicy` / 发出 `Refreshing`;同一 epoch 内单飞;失败时按策略处理 | +| `validAccessToken()` | `Future validAccessToken({Duration? leeway})` | Never returns an expired token; refreshes first when it expires within `leeway` (default `clockSkew`) / 绝不返回过期令牌;会在 `leeway`(默认 `clockSkew`)内过期时先续期 | | `updateSession()` | `Future updateSession(AuthSession Function(AuthSession current))` | Replaces the active session without a re-login (profile update, refreshed claims); throws `NoActiveSessionException` when signed out / 免重新登录替换活动会话 | | `supports()` | `bool supports()` | Whether the strategy implements an optional capability / 策略是否实现了某可选能力 | | `dispose()` | `Future` | Closes the stream and cancels proactive refresh. **Later operations throw** `AuthException(code: 'manager_disposed')` / 关闭状态流;之后再操作会抛 `manager_disposed` | @@ -650,10 +707,14 @@ final class AuthManager implements AuthTokenSource { `bool get isAuthenticated` — `true` for `Authenticated` **and** `Refreshing`, so a token renewal never unmounts signed-in UI. `bool get isBusy` covers -`Authenticating`, `Refreshing` and `LoggingOut`. +`Authenticating`, `Refreshing` and `LoggingOut`. `AuthSession? get session` +returns the session any state carries (`Authenticated` / `Refreshing` / +`LoggingOut`), or `null` — no pattern-matching needed for the common case. `isAuthenticated` 在 `Authenticated` 与 `Refreshing` 下均为 `true`,令牌续期不会卸载 -已登录界面;`isBusy` 覆盖 `Authenticating`、`Refreshing`、`LoggingOut`。 +已登录界面;`isBusy` 覆盖 `Authenticating`、`Refreshing`、`LoggingOut`; +`AuthSession? get session` 返回该状态携带的会话(`Authenticated` / `Refreshing` / +`LoggingOut`)或 `null`,常见场景无需再做模式匹配。 Prefer those two getters over `state is Authenticated` and over exhaustive `switch` when you do not need per-case payloads — they keep compiling as states @@ -685,8 +746,19 @@ const AuthSession({ | `userId` / `displayName` | `String?` | Identity for UI / backend calls | | `claims` | `Map?` | Raw claims, untouched | | `isExpired` | `bool` | `false` when `expiresAt == null` | +| `isExpiredAt(now)` | `bool` | Evaluate against your own clock / 按你自己的时钟判断 | +| `timeUntilExpiry([now])` | `Duration?` | Remaining lifetime / 剩余有效期 | +| `isExpiringWithin(window, [now])` | `bool` | Renew a little before it dies / 在真正失效前提前续期 | +| `copyWith(...)` | `AuthSession` | `null` keeps the current value / 传 `null` 表示保留原值 | +| `toJson()` / `fromJson()` | `Map` / `AuthSession` | Persistence; `fromJson` throws on malformed input / 持久化,畸形数据会抛异常 | +| `AuthSession.tryFromJson()` | `AuthSession?` | Same, but `null` instead of throwing / 同上,但返回 `null` 而非抛异常 | -Value equality on `accessToken`, `refreshToken`, `expiresAt`, `userId`, `displayName`. +Value equality on `accessToken`, `refreshToken`, `expiresAt`, `userId`, +`displayName` **and `claims`** — nested maps and lists are compared by content, +so a change inside `claims` counts as a new session. + +相等性覆盖 `accessToken`、`refreshToken`、`expiresAt`、`userId`、`displayName` +**与 `claims`** —— 嵌套的 Map / List 按内容比较,因此 `claims` 内部的变化也算新会话。 ### 11.4 `AuthStrategy` @@ -710,9 +782,15 @@ Ships `InMemoryTokenStore` (non-durable). ### 11.6 `AuthTokenSource` ```dart -String? get accessToken; +String? get accessToken; // may be expired / 可能已过期 +Future validAccessToken({Duration? leeway}); // renewed first / 先续期 ``` +`AuthManager` and `AuthManagerGroup` override `validAccessToken()` to renew the +session first. A source without a manager behind it can simply forward the getter. +`AuthManager` 与 `AuthManagerGroup` 会覆写 `validAccessToken()` 以先续期;背后没有 +管理器的令牌源直接转发 getter 即可。 + ### 11.7 Value objects / 值对象 | Type | Constructor | @@ -757,10 +835,13 @@ single-session. See the | Member | Notes | |---|---| +| `AuthManagerGroup({strategyFactory, storeFactory, managerFactory?, autoRefreshAhead?, autoRefreshRetryDelay?, autoRefreshMaxRetries?, autoRefreshMinInterval?, refreshFailurePolicy?, clock?, clockSkew?, preserveSessionDetails?, onStateChanged?})` | Every manager knob is forwarded to the managers it creates; `managerFactory` builds them yourself. `onStateChanged` is called as `(accountId, state)` / 管理器调参会完整转发;也可传 `managerFactory` 自行构建;`onStateChanged` 以 `(accountId, state)` 形式调用 | | `forAccount(id)` / `addAccount(id)` | Lazily creates and caches that account's manager / 惰性创建并缓存 | -| `switchTo(id)` | Makes an account active; the group's `state` follows it / 激活账号,状态流随之切换 | +| `switchTo(id)` | Makes an account active; the group's `state` follows it and `activeIdChanges` emits / 激活账号,状态流随之切换且 `activeIdChanges` 发出新值 | +| `activeIdChanges` | `Stream` of the active account id (`null` when none) / 激活账号 id 流(无则为 `null`) | | `current` / `state` / `currentSession` / `accessToken` | Mirror the active account / 反映激活账号 | -| `restoreAll(ids, {activeId})` | Restores every account, then activates one / 恢复所有账号并激活其一 | +| `validAccessToken()` | The active account's renewed token / 激活账号续期后的令牌 | +| `restoreAll(ids, {activeId})` | Restores every account — one failing account does not abandon the rest — then activates one / 恢复所有账号(某账号失败不连累其余)并激活其一 | | `remove(id)` | Signs out and forgets an account / 登出并移除 | | `logoutAll()` | Signs every account out / 一次性登出所有账号 | | `disposeAll()` | Releases every manager / 释放所有管理器 | @@ -794,10 +875,16 @@ single-session. See the | UI stuck on `AuthError` | It is a terminal state | Call `logout()` or start a new `login()` | | `login()` throws even though UI shows the error | By design: emit **and** rethrow | Swallow it, or handle once — not both | | `refresh()` throws immediately | No session, or `refreshToken == null` | Check `currentSession?.refreshToken`; re-login | +| `refresh()` throws `NoActiveSessionException: Refresh aborted` | The refresh started before a newer login / `updateSession` replaced the session; it is aborted on purpose / 刷新在会话被替换之前启动,被有意中止 | Catch `AuthException` in interceptors and retry, or simply read `validAccessToken()` / 在拦截器里捕获 `AuthException` 重试,或直接读 `validAccessToken()` | | Session lost after restart | Using `InMemoryTokenStore` | Inject a durable `TokenStore` | | Token never renewed automatically | No built-in timer | Refresh lazily / near expiry / with your own timer ([§9](#9-refresh--expiry--刷新与过期)) | | Duplicate refresh calls | Bypassing `refresh()` (e.g. calling strategy directly) | Always go through `AuthManager.refresh()` | | `isExpired` always `false` | `expiresAt` was never set | Populate `expiresAt` when building `AuthSession` | +| Signed out right after launch, on a flaky network | (fixed in 1.0) a transient renewal failure during `restore()` used to clear the store | Upgrade to 1.0: transient failures keep the session / 升级到 1.0,瞬时失败会保留会话 | +| `userId` / `displayName` vanish after a refresh | Your backend returns tokens only | (fixed in 1.0) identity carries over; `preserveSessionDetails: false` opts out / 1.0 起身份字段会保留 | +| `AuthException: Unexpected auth failure` although you threw `AuthFail` with a code | (fixed in 1.0) the code of a bare `AuthFail` used to be ignored | Upgrade to 1.0; `mapAuthFailure` now reads it / 升级到 1.0,`mapAuthFailure` 会读取它 | +| `Bad state`/`FormatException` from your `TokenStore.load()` | `AuthSession.fromJson()` on malformed data | Use `AuthSession.tryFromJson()` / 改用 `tryFromJson()` | +| Compiler error: "missing implementation of `validAccessToken`" | You `implements AuthTokenSource` and upgraded to 1.0 | Add `@override Future validAccessToken({Duration? leeway}) async => accessToken;` | --- diff --git a/docs/404.html b/docs/404.html index 13ae361..590efd0 100644 --- a/docs/404.html +++ b/docs/404.html @@ -1 +1 @@ -404: This page could not be found

    404

    This page could not be found.

    \ No newline at end of file +404: This page could not be found

    404

    This page could not be found.

    \ No newline at end of file diff --git a/docs/404/index.html b/docs/404/index.html index 13ae361..590efd0 100644 --- a/docs/404/index.html +++ b/docs/404/index.html @@ -1 +1 @@ -404: This page could not be found

    404

    This page could not be found.

    \ No newline at end of file +404: This page could not be found

    404

    This page could not be found.

    \ No newline at end of file diff --git a/docs/Auth-State-Machine/index.html b/docs/Auth-State-Machine/index.html index 3f661fa..63c3954 100644 --- a/docs/Auth-State-Machine/index.html +++ b/docs/Auth-State-Machine/index.html @@ -1,4 +1,4 @@ -
    🧭 Auth State Machine

    Auth State Machine / 认证状态机

    +
    🧭 Auth State Machine

    Auth State Machine / 认证状态机

    States / 状态

    AuthState is a sealed class with six subtypes:

    AuthState 是一个密封类,包含六个子类型:

    @@ -132,4 +132,4 @@

  • Usage — How to drive the UI from the stream / 如何用状态流驱动界面
  • Backend Strategy — What each operation calls / 各操作调用什么
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Backend-Strategy/index.html b/docs/Backend-Strategy/index.html index d7d43a9..9f842d0 100644 --- a/docs/Backend-Strategy/index.html +++ b/docs/Backend-Strategy/index.html @@ -1,4 +1,4 @@ -
    🔌 Backend Strategy

    Backend Strategy / 后端策略

    +
    🔌 Backend Strategy

    Backend Strategy / 后端策略

    AuthStrategy is the only boundary the core needs from a backend. Implement four methods and zero_auth works with REST, gRPC, Firebase, or a private RPC.

    AuthStrategy 是内核向后台要求的唯一边界。实现四个方法后,zero_auth 即可对接 REST、gRPC、Firebase 或私有 RPC。

    The contract / 契约

    @@ -125,4 +125,4 @@

  • Token Store — Where sessions are persisted / 会话持久化到何处
  • Network Integration — Attaching the bearer token / 附加 Bearer 令牌
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Configuration/index.html b/docs/Configuration/index.html index f8bfe54..843210c 100644 --- a/docs/Configuration/index.html +++ b/docs/Configuration/index.html @@ -1,4 +1,4 @@ -
    ⚙️ Configuration

    Configuration / 配置

    +
    ⚙️ Configuration

    Configuration / 配置

    zero_auth is deliberately minimal: there is no global config object. Behavior is composed from the two boundaries you pass to AuthManager.

    zero_auth 刻意保持精简:没有全局配置对象。行为由你传给 AuthManager 的两个边界组合而成。

    Composing AuthManager / 组合管理器

    @@ -139,4 +139,4 @@

  • Usage — Putting it together / 综合使用
  • FAQ — Common questions / 常见问题
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Errors/index.html b/docs/Errors/index.html index 1095b47..5967887 100644 --- a/docs/Errors/index.html +++ b/docs/Errors/index.html @@ -1,4 +1,4 @@ -
    ⚠️ Errors

    Errors / 错误

    +
    ⚠️ Errors

    Errors / 错误

    zero_auth never lets a raw Exception cross its public surface. Every failure is expressed through one of two mechanisms.

    zero_auth 绝不允许裸 Exception 越过公共边界。每个失败都通过下面两种机制之一表达。

    AppException / 异常类型

    @@ -135,4 +135,4 @@

  • Backend Strategy — Map your API errors / 映射你的 API 错误
  • Configuration — Tuning refresh & retry / 调优刷新与重试
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/FAQ/index.html b/docs/FAQ/index.html index 0f6c34a..8dbcd02 100644 --- a/docs/FAQ/index.html +++ b/docs/FAQ/index.html @@ -1,4 +1,4 @@ -
    ❓ FAQ

    FAQ / 常见问题

    +
    ❓ FAQ

    FAQ / 常见问题

    Does zero_auth ship an HTTP client? / zero_auth 内置 HTTP 客户端吗?

    No. The core is backend-agnostic and ships no HTTP, SDK or native code. You implement AuthStrategy to call your backend.

    不。内核后端无关,不含任何 HTTP、SDK 或原生代码。你通过实现 AuthStrategy 来调用自己的后端。

    @@ -35,4 +35,4 @@

    refreshFailurePolicy 会让该设备登出,并由 AuthError 说明原因。

    Which platforms are supported? / 支持哪些平台?

    Any platform where Dart or Flutter runs (Android, iOS, Web, Windows, macOS, Linux).

    -

    任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。


    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Getting-Started/index.html b/docs/Getting-Started/index.html index 75c1530..00942d7 100644 --- a/docs/Getting-Started/index.html +++ b/docs/Getting-Started/index.html @@ -1,4 +1,4 @@ -
    🚀 Getting Started

    Getting Started / 快速开始

    +
    🚀 Getting Started

    Getting Started / 快速开始

    Quick Start / 快速开始

    zero_auth is a headless core: you bring the backend (AuthStrategy) and the persistence (TokenStore), and AuthManager drives the session lifecycle.

    zero_auth 是一个无头内核:你提供后端(AuthStrategy)与持久化(TokenStore),AuthManager 负责驱动会话生命周期。

    @@ -88,4 +88,4 @@

    Installation — Detailed installation methods / 详细安装方式
  • Usage — Full usage guide / 完整使用指南
  • Backend Strategy — Implement AuthStrategy / 实现后端边界
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Installation/index.html b/docs/Installation/index.html index ed5da6d..82eccef 100644 --- a/docs/Installation/index.html +++ b/docs/Installation/index.html @@ -1,4 +1,4 @@ -
    📦 Installation

    Installation / 安装

    +
    📦 Installation

    Installation / 安装

    Add the following to your pubspec.yaml:

    在 pubspec.yaml 中添加以下依赖:

    @@ -42,4 +42,4 @@

  • Getting Started — Quick start guide / 快速开始
  • Usage — Full usage guide / 完整使用指南
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Multi-Account/index.html b/docs/Multi-Account/index.html index 573562c..bda6ef5 100644 --- a/docs/Multi-Account/index.html +++ b/docs/Multi-Account/index.html @@ -1,4 +1,4 @@ -
    👥 Multiple Accounts

    Multiple Accounts / 多账号

    +

    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Network-Integration/index.html b/docs/Network-Integration/index.html index 5b14876..f42c06d 100644 --- a/docs/Network-Integration/index.html +++ b/docs/Network-Integration/index.html @@ -1,4 +1,4 @@ -
    🌐 Network Integration

    Network Integration / 网络集成

    +
    🌐 Network Integration

    Network Integration / 网络集成

    AuthManager implements AuthTokenSource, so HTTP clients can read the current access token without depending on the manager object. This keeps your Dio setup decoupled from zero_auth.

    AuthManager 实现了 AuthTokenSource,因此 HTTP 客户端可在不依赖管理器对象的前提下读取当前访问令牌,使 Dio 配置与 zero_auth 解耦。

    AuthTokenSource / 令牌源

    @@ -60,4 +60,4 @@

  • Usage — Full integration walkthrough / 完整集成讲解
  • Errors — Handling 401 and refresh failures / 处理 401 与刷新失败
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Persistence/index.html b/docs/Persistence/index.html index 827a310..4345a7d 100644 --- a/docs/Persistence/index.html +++ b/docs/Persistence/index.html @@ -1,4 +1,4 @@ -
    💽 Session Persistence

    Session Persistence / 会话持久化

    +
    💽 Session Persistence

    Session Persistence / 会话持久化

    InMemoryTokenStore loses the session on every restart. For a real app you must persist it — and zero_auth now ships the primitives that make this a one-liner: session (de)serialization plus two ready-to-copy reference stores.

    InMemoryTokenStore 会在每次重启时丢失会话。真实应用必须把它持久化——而 zero_auth 现在提供了让这件事变成「一行代码」的原语:会话(反)序列化,外加两个开箱即用的参考存储。

    1. Serialize the session / 序列化会话

    @@ -48,4 +48,4 @@

  • Token Store — the save / load / clear contract / save / load / clear 契约
  • Configuration — enable proactive auto-refresh / 开启主动自动刷新
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Third-Party-Login/index.html b/docs/Third-Party-Login/index.html index 0a244c9..a792e88 100644 --- a/docs/Third-Party-Login/index.html +++ b/docs/Third-Party-Login/index.html @@ -1,4 +1,4 @@ -
    🔑 Third-Party Login

    Third-Party Login / 第三方登录

    +
    🔑 Third-Party Login

    Third-Party Login / 第三方登录

    The key idea / 核心思路

    zero_auth does not run the OAuth handshake — and that is deliberate. It is a pure-Dart package, so it ships no platform code, no browser plumbing and no @@ -108,4 +108,4 @@

    Backend Strategy — what your strategy must implement / 策略需要实现什么
  • Auth State Machine — the states this flow emits / 该流程发出的状态
  • Errors — typed exceptions and codes / 类型化异常与 code
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Token-Store/index.html b/docs/Token-Store/index.html index 920e269..9c5cf02 100644 --- a/docs/Token-Store/index.html +++ b/docs/Token-Store/index.html @@ -1,4 +1,4 @@ -
    💾 Token Store

    Token Store / 令牌存储

    +
    💾 Token Store

    Token Store / 令牌存储

    TokenStore is the only persistence surface. The core never touches disk, secure storage, or SharedPreferences directly — you decide where tokens live.

    TokenStore 是唯一的持久化接口。内核绝不直接访问磁盘、安全存储或 SharedPreferences——由你决定令牌存放位置。

    The contract / 契约

    @@ -40,4 +40,4 @@

    Session Persistence — persist sessions across restarts (serialization + reference stores) / 跨重启持久化会话(序列化与参考存储)
  • Backend Strategy — The other boundary / 另一个边界
  • Errors — What load() failures become / load() 失败会变成什么
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Usage/index.html b/docs/Usage/index.html index 392e4b7..bd7699a 100644 --- a/docs/Usage/index.html +++ b/docs/Usage/index.html @@ -1,4 +1,4 @@ -
    📖 Usage

    Usage / 使用指南

    +

    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/_next/static/chunks/nextra-data-en-US.json b/docs/_next/static/chunks/nextra-data-en-US.json index dc3aff3..f756086 100644 --- a/docs/_next/static/chunks/nextra-data-en-US.json +++ b/docs/_next/static/chunks/nextra-data-en-US.json @@ -1 +1 @@ -{"/Auth-State-Machine":{"title":"Auth State Machine / 认证状态机","data":{"states--状态#States / 状态":"AuthState is a sealed class with six subtypes:AuthState 是一个密封类,包含六个子类型:\nState\tFields\tMeaning\tUnauthenticated\t—\tNo session / 无会话\tAuthenticating\t—\tlogin/register in flight / 登录/注册进行中\tAuthenticated\tsession: AuthSession\tA valid session / 有效会话\tRefreshing\tsession: AuthSession\tRenewal in flight; the previous session is still usable / 续期进行中,旧会话仍可用\tLoggingOut\tsession: AuthSession\tlogout() in flight; that session is being discarded / 登出进行中,该会话即将被丢弃\tAuthError\terror: AppException\tLast operation failed / 上一次操作失败\t\nTwo helpers save you from spelling out every case:两个辅助属性可避免你手写全部分支:\nstate.isAuthenticated — true for Authenticated and Refreshing, so a\nrenewal never unmounts your signed-in UI. / 在 Authenticated 与 Refreshing\n下均为 true,续期不会卸载已登录界面。\nstate.isBusy — true while Authenticating, Refreshing or LoggingOut. /\n在 Authenticating、Refreshing、LoggingOut 期间为 true。\nstate.session — the session the state carries (Authenticated / Refreshing /\nLoggingOut), or null; no pattern-matching needed for the common case. /\n该状态携带的会话(Authenticated / Refreshing / LoggingOut)或 null,\n常见场景无需再做模式匹配。","the-stream--状态流#The stream / 状态流":"AuthManager.state is a Stream that replays the latest value to every new listener (BehaviorSubject-like). This means:AuthManager.state 是一个 Stream,会向每个新订阅者重放最近的值(类似于 BehaviorSubject)。这意味着:\nA freshly mounted widget renders the correct screen on its first frame / 新挂载的 widget 首帧即渲染正确界面。\nYou never need to read a separate \"current state\" field / 无需另读一个\"当前状态\"字段。\nauth.state.listen((state) {\n switch (state) {\n case Authenticated(:final session):\n /* ... */\n case AuthError(:final error):\n /* ... */\n default:\n }\n});","transitions--状态转移#Transitions / 状态转移":"login/register ──► Authenticating ──► Authenticated\n │ ▲\n refresh │ │ success\n ─────────► ▼ │\n Refreshing\n │\n failure │\n ▼\n AuthError ──policy: sign out──► Unauthenticated\n └──policy: keep session─► Authenticated (old session)\n Authenticated ──logout──► LoggingOut ──► Unauthenticated\n Unauthenticated ──restore() finds nothing / nothing persisted──► Unauthenticated\nrestore() restores a live session → Authenticated.\nrestore() restores an expired session → attempts Refreshing; if the\nrenewal fails transiently (network, 5xx) the persisted session is kept and\nactivated as-is, so the next request can retry. Only a terminal failure — or the\nabsence of a refresh token — clears the store and lands on Unauthenticated.\nrestore(refreshIfExpired: false) restores it verbatim instead.\nA failed login/register → AuthError, and the future rethrows.\nA failed refresh → AuthError, followed by Unauthenticated when the\nfailure policy considers the grant unrecoverable,\nor by Authenticated (previous session) when it looks transient.\nDuplicate consecutive emissions are suppressed, so listeners only rebuild on a\nreal change.\nrestore() 恢复未过期会话 → Authenticated。\nrestore() 恢复已过期会话 → 先走 Refreshing;若续期瞬时失败(网络、5xx),\n则保留持久化会话并按原样激活,下次请求可再试。只有终局失败(或没有刷新令牌)\n才会清空存储并落到 Unauthenticated。restore(refreshIfExpired: false) 则原样恢复。\nlogin/register 失败 → AuthError,同时 future 再次抛出错误。\nrefresh 失败 → AuthError;随后由失败策略决定:\n认为授权不可恢复则转 Unauthenticated,认为只是瞬时故障则回到 Authenticated。\n连续重复的状态会被抑制,监听器只会在真正变化时重建。","next-steps--下一步#Next Steps / 下一步":"Usage — How to drive the UI from the stream / 如何用状态流驱动界面\nBackend Strategy — What each operation calls / 各操作调用什么"}},"/Backend-Strategy":{"title":"Backend Strategy / 后端策略","data":{"":"AuthStrategy is the only boundary the core needs from a backend. Implement four methods and zero_auth works with REST, gRPC, Firebase, or a private RPC.AuthStrategy 是内核向后台要求的唯一边界。实现四个方法后,zero_auth 即可对接 REST、gRPC、Firebase 或私有 RPC。","the-contract--契约#The contract / 契约":"abstract class AuthStrategy {\n Future login(Credentials credentials);\n Future register(RegistrationInput input);\n Future logout(SessionHandle handle);\n Future refresh(RefreshToken token);\n}\nMethod\tWhen called\tReceives\tMust return\tlogin\tAuthManager.login\tCredentials(username, password)\tA fresh AuthSession\tregister\tAuthManager.register\tRegistrationInput(username, password, displayName?, email?)\tA fresh AuthSession\tlogout\tAuthManager.logout\tSessionHandle(userId, refreshToken?)\tvoid (revoke server-side if applicable)\trefresh\tAuthManager.refresh\tRefreshToken(value)\tA new AuthSession (new tokens)\t\nusername is just a name — it is an opaque identifier, and the core never\nvalidates or interprets it. Put an email or a phone number in it and map it to\nwhatever field your API expects.username 只是字段名,它是个不透明标识符,内核从不校验或解析它。把邮箱或手机号放进去,\n再映射成你接口需要的字段即可。","what-is-fixed-and-where-the-escape-hatches-are--哪些是固定的逃生口在哪#What is fixed, and where the escape hatches are / 哪些是固定的,逃生口在哪":"The four signatures above are the only thing the contract fixes. Transport, URLs,\ntoken format and rotation are entirely yours.上面这四个签名是契约唯一固定的东西。传输方式、URL、令牌格式与轮换策略完全由你决定。\nYou need\tDo this\tLogin that is not username + password (SMS code, OAuth, passkey)\tAuthManager.loginWith — see Third-Party Login\tExtra fixed context (tenant id, app id, device id)\tHold it as a field on your strategy; do not squeeze it into Credentials\tExtra data returned by the backend (roles, tenant, permissions)\tPut it in AuthSession.claims\tSignalling why a call failed\tThrow AuthException with a code — see Errors\t\nGotchas / 注意点:\nAlways set userId when building sessions. logout receives\nuserId: session.userId ?? '', so a session without one hands your backend an\nempty string (the refresh token is passed too, when present).\n务必设置 userId:登出时收到的是 session.userId ?? '',没有它后端会拿到空\n字符串(有刷新令牌时会一并传入)。\nLogging in again does not sign the previous session out. The new session\nsimply replaces it locally; revoking the old one server-side is your call — or\nuse Multiple Accounts if both must survive.\n再次登录不会登出上一个会话:新会话只是本地替换;是否在服务端吊销旧的由你决定;\n若两者都要保留,请用多账号。","mapping-failures--映射失败#Mapping failures / 映射失败":"Every thrown error should be mapped to AppException (or AuthException for auth-specific cases). Raw Exceptions must never cross the public surface — the manager wraps unknowns.所有抛出的错误都应映射为 AppException(认证相关场景用 AuthException)。裸 Exception 不得越过公共边界——管理器会包裹未知异常。\n@override\nFuture login(Credentials credentials) async {\n try {\n final json = await myApi.post('/login', {\n 'account': credentials.username, // email, phone, whatever you accept\n 'password': credentials.password,\n });\n return AuthSession.fromJson(json as Map);\n } on MyApiAuthError catch (e) {\n throw AuthException(code: 'invalid_credentials', message: e.message);\n }\n}","building-the-session--构造会话#Building the session / 构造会话":"Return an AuthSession carrying at least the access token, the refresh token, and an expiry:返回 AuthSession,至少包含访问令牌、刷新令牌与过期时间:\nAuthSession(\n accessToken: json['access'],\n refreshToken: json['refresh'],\n expiresAt: DateTime.parse(json['expiresAt']),\n userId: json['userId'],\n displayName: json['displayName'],\n claims: json, // optional raw claims / 可选的原始 claims\n)","example-backend--演示后端#Example backend / 演示后端":"server/ is a layered dart:io demo backend exercising /login, /refresh, /logout, /me and /health. Run it with dart run bin/server.dart on port 8080. It issues real HMAC-SHA256 JWTs with refresh-token rotation and replay detection, and logs every request with its status and duration. The demo account is user / user.server/ 是一个分层的 dart:io 演示后端,提供 /login、/refresh、/logout、/me 与 /health。用 dart run bin/server.dart 在 8080 端口启动。它签发真实的 HMAC-SHA256 JWT,带刷新令牌轮换与重放检测,并记录每条请求的状态码与耗时。演示账号为 user / user。","next-steps--下一步#Next Steps / 下一步":"Token Store — Where sessions are persisted / 会话持久化到何处\nNetwork Integration — Attaching the bearer token / 附加 Bearer 令牌"}},"/Errors":{"title":"Errors / 错误","data":{"":"zero_auth never lets a raw Exception cross its public surface. Every failure is expressed through one of two mechanisms.zero_auth 绝不允许裸 Exception 越过公共边界。每个失败都通过下面两种机制之一表达。","appexception--异常类型#AppException / 异常类型":"All domain failures extend AppException:所有领域失败都继承 AppException:\nsealed class AppException implements Exception {\n final String code; // stable, machine-readable / 稳定、机器可读\n final String message; // human-readable / 人类可读\n final Object? cause; // original error, if any / 原始错误\n}\nfinal class AuthException extends AppException {\n // Base type for every auth failure / 所有认证失败的基类型\n}\n// Concrete subtypes — catch these to branch on what actually went wrong.\n// 具体子类型——捕获它们即可按实际错误分支处理。\nfinal class InvalidCredentialsException extends AuthException {} // invalid_credentials / 凭据无效\nfinal class SessionExpiredException extends AuthException {} // session_expired / 会话过期\nfinal class NoActiveSessionException extends AuthException {} // no_active_session / 无活动会话\nfinal class RefreshTokenMissingException extends AuthException {} // refresh_token_missing / 缺少刷新令牌\nfinal class UnexpectedAuthException extends AuthException {} // unexpected_auth_failure / 意外失败\nAuthStrategy authors opt into these subtypes simply by throwing an\nAuthException — or the bare AuthFail domain type — carrying the matching\ncode; mapAuthFailure performs the mapping, preserving any vocabulary it does\nnot recognise.AuthStrategy 实现者只需抛出携带对应 code 的 AuthException —— 或裸的领域类型\nAuthFail —— 即可参与映射;mapAuthFailure 负责转换,并保留它无法识别的自定义错误类型。\n// Both of these map to InvalidCredentialsException / 两者都会映射为 InvalidCredentialsException\nthrow AuthException('Wrong password', code: 'invalid_credentials');\nthrow const AuthFail('Wrong password', code: 'invalid_credentials');\ncode\tMapped type / 映射结果\tinvalid_credentials\tInvalidCredentialsException\tinvalid_grant, invalid_refresh_token, token_expired, session_expired\tSessionExpiredException\tno_active_session\tNoActiveSessionException\trefresh_token_missing\tRefreshTokenMissingException\tanything else / 其它\tpreserved as-is, or UnexpectedAuthException / 原样保留或包装为 UnexpectedAuthException\t\nCodes raised by the manager itself (not your strategy) / 由管理器自身产生的 code:\ncode\tRaised when / 何时触发\tauth_flow_in_progress\tAnother login / register / loginWith is already running / 已有登录流程在执行\tmanager_disposed\tAn operation is called after dispose() / dispose() 后又调用操作\t\nOverlapping authentication flows are rejected rather than racing: a second\nlogin() while one is in flight throws auth_flow_in_progress.重叠的登录流程会被拒绝而非争抢:进行中再次 login() 会抛 auth_flow_in_progress。\nAuthStrategy implementations should map transport/API errors into AuthException (or a custom AppException subclass) so callers get a stable code.AuthStrategy 实现方应将传输层/API 错误映射为 AuthException(或自定义 AppException 子类),让调用方拿到稳定的 code。","resultt--结果类型#Result / 结果类型":"For explicit, exception-free handling, use Result:如需显式、无异常的处理,可使用 Result:\nsealed class Result {\n const factory Result.ok(T value) = Ok;\n const factory Result.err(AppException error) = Err;\n}\nfinal result = await auth.tryLogin(user, pass); // returns Result\nswitch (result) {\n case Ok(:final value):\n /* authenticated / 已认证 */\n case Err(:final error):\n /* inspect error.code / 读取 error.code */\n}","how-failures-surface--失败如何呈现#How failures surface / 失败如何呈现":"Situation\tSurfaced as\tlogin/register fails\tAuthState becomes AuthError(error) and the future throws AppException / 状态变为 AuthError,且 future 抛出 AppException\trefresh fails\tAuthError(error) emitted, then Unauthenticated (unrecoverable) or back to Authenticated (transient) per the failure policy / 发出 AuthError,随后按失败策略转为 Unauthenticated(不可恢复)或回到 Authenticated(瞬时)\trestore() finds nothing\tStays Unauthenticated (not an error) / 保持 Unauthenticated(不算错误)\trestore() renewal fails transiently\tSession kept (the next validAccessToken() retries); only a terminal failure clears the store / 保留会话(下次 validAccessToken() 会重试);只有终局失败才清空存储\tTokenStore.load() throws\tMapped to AppException, treated as \"no session\" / 映射为 AppException,按\"无会话\"处理","next-steps--下一步#Next Steps / 下一步":"Backend Strategy — Map your API errors / 映射你的 API 错误\nConfiguration — Tuning refresh & retry / 调优刷新与重试"}},"/Configuration":{"title":"Configuration / 配置","data":{"":"zero_auth is deliberately minimal: there is no global config object. Behavior is composed from the two boundaries you pass to AuthManager.zero_auth 刻意保持精简:没有全局配置对象。行为由你传给 AuthManager 的两个边界组合而成。","composing-authmanager--组合管理器#Composing AuthManager / 组合管理器":"final auth = AuthManager(\n strategy: MyAuthStrategy(), // how to talk to the backend / 如何与后端通信\n tokenStore: SecureTokenStore(), // where to persist tokens / 令牌持久化位置\n autoRefreshAhead: const Duration(minutes: 5), // proactive renewal / 主动续期\n autoRefreshRetryDelay: const Duration(seconds: 30), // re-arm after a failed renewal / 续期失败后重新排程\n autoRefreshMaxRetries: 3, // cap those retries / 重试上限\n autoRefreshMinInterval: const Duration(seconds: 5), // floor for an already-due renewal / 已到期续期的下限\n refreshFailurePolicy: defaultRefreshFailurePolicy, // sign-out rule / 登出规则\n clock: () => DateTime.now(), // time source (tests, skew) / 时间源(测试、时钟偏移)\n clockSkew: const Duration(seconds: 30), // treat tokens as expired this much earlier / 提前多久视为过期\n preserveSessionDetails: true, // keep identity across a tokens-only renewal / 只换令牌的续期保留身份\n onStateChanged: (state) => debugPrint('$state'), // observe every emission / 观察每次状态\n);\nKnob\tWhere\tEffect\tBackend endpoints & auth scheme\tAuthStrategy\tWhat login/refresh/… actually do / login/refresh 等的实际行为\tToken persistence\tTokenStore\tDisk / secure storage / in-memory / 磁盘/安全存储/内存\tRefresh timing\tAuthSession.expiresAt + autoRefreshAhead\tProactive renewal is scheduled that far before expiry / 在过期前该时长调度主动续期\tRenewal retry\tautoRefreshRetryDelay\tAfter a proactive renewal fails, it is re-armed this much later (default 30s) while a session still exists / 主动续期失败后按此时长重新排程(默认 30 秒),会话仍在才重试\tRenewal retry cap\tautoRefreshMaxRetries\tHow many failed proactive renewals to retry before giving up (default 3) / 主动续期失败最多重试几次(默认 3)\tRenewal floor\tautoRefreshMinInterval\tMinimum wait for a renewal that is already due (default 5s), so very short-lived tokens cannot cause a tight loop / 「已到期」续期的最小等待(默认 5 秒),极短寿命令牌不会造成紧密循环\tClock skew\tclockSkew\tHow much earlier a token counts as expired (default 30s); absorbs a device clock that runs ahead and the latency of the request itself. If your access tokens live under a minute, lower it (e.g. 5s) or almost every read renews first / 提前多久把令牌视为过期(默认 30 秒),用于吸收设备时钟偏快与请求自身的延迟。若访问令牌寿命不足一分钟,请调小(如 5 秒),否则几乎每次读取都会先续期\tIdentity on renewal\tpreserveSessionDetails\tCarry userId / displayName / claims over a renewal that returns tokens only (default true) / 只返回令牌的续期是否保留身份字段(默认 true)\tObservation\tonStateChanged\tOptional callback for every emitted state, for logging or analytics / 可选回调,每次发出状态时触发,便于日志或埋点\tRefresh failure handling\trefreshFailurePolicy\tWhether a failed refresh signs the user out / 刷新失败是否让用户登出\tTime source\tclock\tDrives expiry maths and proactive scheduling; inject one for deterministic tests or to tolerate device clock skew / 驱动过期计算与主动刷新调度;注入时钟可实现确定性测试或容忍设备时钟偏移\tToken injection\tAuthTokenSource / validAccessToken()\tHow the bearer token reaches HTTP clients, optionally renewing an expired token first / 令牌如何到达 HTTP 客户端,必要时先续期过期令牌","refresh-strategy--刷新策略#Refresh strategy / 刷新策略":"refresh() is single-flight by construction: concurrent callers share one in-flight request. You decide when to refresh:refresh() 天生单飞:并发调用方共享同一次进行中的请求。何时刷新由你决定:\nProactively, when session.isExpired is approaching, before a request. / 在请求前、当 session.isExpired 临近时主动刷新。\nReactively, on a 401 from your API (see Network Integration). / 响应式地,在 API 返回 401 时刷新。\nA failed refresh is always reported through AuthError (and the rethrown future),\nbut whether it ends the session is decided by refreshFailurePolicy. See\nRefresh failure policy below.刷新失败总会通过 AuthError(以及重新抛出的 future)上报,但是否终止会话由\nrefreshFailurePolicy 决定,详见下方「刷新失败策略」。","refresh-failure-policy--刷新失败策略#Refresh failure policy / 刷新失败策略":"The default policy signs the user out only for failures that can never succeed\nagain, keeping the session when the failure looks transient (so a network blip\ndoes not log people out):默认策略仅在失败「注定无法重试成功」时让用户登出;看起来像瞬时故障时保留会话(避免一次\n网络抖动就把人踢下线):\nFailure / 失败类型\tDefault outcome / 默认结果\tSessionExpiredException, InvalidCredentialsException\tStore cleared → Unauthenticated / 清空存储 → Unauthenticated\tAnything else (network, 5xx…) / 其它(网络、5xx 等)\tSession kept → back to Authenticated / 保留会话 → 回到 Authenticated\t\n// Never sign out on a failed refresh — useful when refresh tokens are\n// long-lived and your backend is occasionally flaky.\n// 刷新失败也绝不登出——适用于刷新令牌长期有效、后端偶尔不稳定的场景。\nfinal auth = AuthManager(\n strategy: strategy,\n refreshFailurePolicy: (AppException error) => false,\n);\nProactive (background) refresh failures are handled internally, so a scheduled\nrenewal can never surface as an unhandled async error.主动(后台)刷新的失败会在内部处理,因此定时续期永远不会以未处理异步错误的形式泄漏。","no-global-singletons--没有全局单例#No global singletons / 没有全局单例":"AuthManager is a plain object — create one per app, hold it in your DI container or a top-level variable. The core has no static mutable state.AuthManager 是一个普通对象——每个应用创建一个,放在 DI 容器或顶层变量中即可。内核没有静态可变状态。","next-steps--下一步#Next Steps / 下一步":"Usage — Putting it together / 综合使用\nFAQ — Common questions / 常见问题"}},"/Getting-Started":{"title":"Getting Started / 快速开始","data":{"quick-start--快速开始#Quick Start / 快速开始":"zero_auth is a headless core: you bring the backend (AuthStrategy) and the persistence (TokenStore), and AuthManager drives the session lifecycle.zero_auth 是一个无头内核:你提供后端(AuthStrategy)与持久化(TokenStore),AuthManager 负责驱动会话生命周期。\nimport 'package:zero_auth/zero_auth.dart';\n// 1. Implement your backend (four methods only).\n// 1. 实现你的后端(只需四个方法)。\nfinal strategy = MyAuthStrategy();\n// 2. Pick a token store. InMemoryTokenStore ships in-core; for production use\n// the flutter_secure_storage reference in example/lib/secure_token_store.dart.\n// 2. 选择令牌存储。内核自带 InMemoryTokenStore;生产环境请用\n// example/lib/secure_token_store.dart 中的 flutter_secure_storage 实现。\nfinal store = InMemoryTokenStore();\n// 3. Create the manager and restore any persisted session.\n// 3. 创建管理器并恢复已持久化的会话。\nfinal auth = AuthManager(strategy: strategy, tokenStore: store);\nawait auth.restore();\nThen drive the UI from the state stream:随后用状态流驱动界面:\nauth.state.listen((state) {\n switch (state) {\n case Unauthenticated():\n showLoginScreen();\n case Authenticating():\n case LoggingOut():\n showSpinner();\n case Authenticated(:final session):\n showHome(session.userId);\n case Refreshing(:final session):\n // Still signed in: only the token is being renewed.\n // 仍处于已登录状态:只是令牌在续期。\n showHome(session.userId, renewing: true);\n case AuthError(:final error):\n showError(error);\n }\n});\nIf you prefer if/else over exhaustiveness, use state.isAuthenticated and\nstate.isBusy instead of state is Authenticated — isAuthenticated stays\ntrue during Refreshing, so your UI never bounces back to the login screen\nmid-refresh.若不想用穷举匹配,请用 state.isAuthenticated / state.isBusy 代替\nstate is Authenticated——isAuthenticated 在 Refreshing 期间仍为 true,\n界面不会在刷新途中退回登录页。","the-lifecycle--生命周期#The Lifecycle / 生命周期":"AuthManager broadcasts an explicit, sealed state machine:AuthManager 广播一个显式、密封的状态机:\nState\tMeaning\tUnauthenticated\tNo session / 无会话\tAuthenticating\tlogin/register in flight / 登录/注册进行中\tAuthenticated\tA valid session is present / 存在有效会话\tRefreshing\tSession renewal in flight; the previous session stays usable / 续期进行中,旧会话仍可用\tLoggingOut\tlogout() in flight / 登出进行中\tAuthError\tThe last operation failed / 上一次操作失败\t\nThe stream replays the latest value to new listeners, so a widget renders the correct screen on its first frame.状态流会向新订阅者重放最近的值,因此 widget 在首帧即可渲染正确界面。","next-steps--下一步#Next Steps / 下一步":"Installation — Detailed installation methods / 详细安装方式\nUsage — Full usage guide / 完整使用指南\nBackend Strategy — Implement AuthStrategy / 实现后端边界"}},"/Multi-Account":{"title":"Multiple Accounts / 多账号","data":{"first-which-kind-do-you-need--先分清你需要哪一种#First: which kind do you need? / 先分清你需要哪一种":"\"Multiple accounts\" means two very different things, and they do not need the\r\nsame solution:「多账号」其实指两种截然不同的需求,它们的解法不一样:\nShape\tDescription\tRecommendation\tSwitching / 账号切换\tSeveral accounts are remembered, but only one is used at a time (like Gmail's account picker) / 记住多个账号,但同时只用一个\tPlain AuthManager: logout() then login(). No new API. / 普通 AuthManager:登出再登录,不需要新 API\tConcurrent / 账号并存\tSeveral accounts are signed in at the same time and all can make requests / 多个账号同时登录,且都能发请求\tAuthManagerGroup — one AuthManager per account\t\nMost apps only need the first. Reach for AuthManagerGroup only when an account\r\nmust keep working while another one is in the foreground.多数应用只需要第一种。只有当「某个账号在后台也要继续工作」时,才需要 AuthManagerGroup。","shape-1-switching--形态一切换#Shape 1: switching / 形态一:切换":"AuthManager models one session. Switching accounts is just logging out and back\r\nin — the state machine stays honest, and there is no second source of truth:\n// Keep your own list of saved account identifiers (email, phone, …).\r\n// 自己维护已保存账号标识(邮箱、手机号等)的列表。\r\nfinal savedAccounts = ['alice@example.com', 'bob@example.com'];\r\n\r\nFuture switchTo(String account, String password) async {\r\n if (auth.current.isAuthenticated) await auth.logout();\r\n await auth.login(Credentials(username: account, password: password));\r\n}\nBecause username is just an opaque identifier, this works for email or phone\r\nlogins with no extra plumbing — see\r\nBackend Strategy for what the contract fixes.","shape-2-concurrent--形态二并存#Shape 2: concurrent / 形态二:并存":"AuthManagerGroup owns one AuthManager per account and tracks which is active.\nfinal group = AuthManagerGroup(\r\n // Both factories receive the account id; sharing one strategy instance is fine.\r\n // 两个工厂都会收到账号 id;共用一个策略实例也没问题。\r\n strategyFactory: (accountId) => MyAuthStrategy(),\r\n // CRITICAL: one store per account, so persisted sessions stay isolated.\r\n // 关键:每个账号一个存储,持久化会话才不会互相覆盖。\r\n storeFactory: (accountId) => SecureTokenStore(key: 'auth_$accountId'),\r\n // Optional: every AuthManager knob is forwarded to the managers it creates.\r\n // 可选:所有 AuthManager 调参都会转发给它创建的管理器。\r\n autoRefreshAhead: const Duration(minutes: 5),\r\n clockSkew: const Duration(seconds: 30),\r\n onStateChanged: (accountId, state) => debugPrint('$accountId → $state'),\r\n // Or build them yourself / 也可自行构建:\r\n // managerFactory: (id, strategy, store) => AuthManager(...),\r\n);\r\n\r\n// Sign in (or restore) each account independently.\r\n// 各账号独立登录(或恢复)。\r\nawait group.forAccount('alice').login(\r\n Credentials(username: 'alice@example.com', password: pw),\r\n);\r\nawait group.forAccount('bob').login(\r\n Credentials(username: 'bob@example.com', password: pw),\r\n);\r\n\r\n// Pick the active one — nothing is signed out by this call.\r\n// 选择激活账号 —— 此调用不会登出任何账号。\r\ngroup.switchTo('bob');","reading-the-active-account--读取激活账号#Reading the active account / 读取激活账号":"The group mirrors the active account and implements AuthTokenSource, so\r\ninterceptors keep depending on the narrow interface:\ngroup.current // active account's AuthState / 激活账号的状态\r\ngroup.currentSession // active account's session / 激活账号的会话\r\ngroup.accessToken // active account's token / 激活账号的令牌\r\ngroup.state // stream that follows the active account / 跟随激活账号的流\r\ngroup.activeIdChanges // stream of the active account id / 激活账号 id 的流\r\n\r\nawait group.validAccessToken(); // renewed token of the active account / 激活账号续期后的令牌\r\n\r\ndio.interceptors.add(RefreshingAuthInterceptor(group)); // renews then attaches / 先续期再附加","restoring-on-startup--启动时恢复#Restoring on startup / 启动时恢复":"// `knownIds` comes from your own saved-account list.\r\n// `knownIds` 来自你自己保存的账号列表。\r\nawait group.restoreAll(knownIds, activeId: lastUsedId);\nOne account failing to restore does not abandon the rest: every account is\r\nattempted and the first error is thrown at the end.某个账号恢复失败不会连累其余账号:所有账号都会被尝试,最后统一抛出第一个错误。","adding-accounts-explicitly--显式新增账号#Adding accounts explicitly / 显式新增账号":"forAccount creates on first use; addAccount is the same call with a name that\r\nreads better when you mean \"register an account here\":forAccount 首次使用时创建;addAccount 是同一个调用,只是名字在「这里注册账号」的\r\n语境下更好读:\nfinal alice = group.addAccount('alice');","removing-an-account--移除账号#Removing an account / 移除账号":"await group.remove('alice'); // logs out, disposes, forgets it","signing-everything-out--全部登出#Signing everything out / 全部登出":"await group.logoutAll(); // every account, in turn\nIf the removed account was active, the group becomes inactive and emits\r\nUnauthenticated.","teardown--释放#Teardown / 释放":"await group.disposeAll(); // disposes every manager and closes the stream","why-the-core-stays-single-session--为什么核心仍是单会话#Why the core stays single-session / 为什么核心仍是单会话":"AuthManager answers a singular question: who is logged in? Multi-account asks\r\na different one: of these signed-in identities, which is active?Folding the second into the first would mean a Map of sessions inside every\r\nstate, a Refreshing that needs an account id, and a breaking change for every\r\nexisting user. Keeping AuthManager single-session and adding an opt-in\r\ncoordinator gives you both without muddying either.把第二个问题塞进第一个,会导致每个状态里都带一个会话 Map、Refreshing 需要携带账号\r\nid,并且对所有现有用户构成破坏性变更。让 AuthManager 保持单会话、再提供一个可选的\r\n协调层,两者都能得到,且互不污染。","gotchas--注意点#Gotchas / 注意点":"Never share one TokenStore between accounts — the second save() would\r\noverwrite the first session. / 绝不要在多个账号间共用一个 TokenStore,第二次\r\nsave() 会覆盖第一个会话。\nSwitching does not sign anyone out; all managers keep refreshing in the\r\nbackground. / 切换不会登出任何账号,所有管理器仍会在后台续期。\nremove() also disposes that manager — do not use it afterwards. /\r\nremove() 会同时释放该管理器,之后不要再使用它。","next-steps--下一步#Next Steps / 下一步":"Auth State Machine — what each manager emits / 各管理器会发出什么\nToken Store — keying persisted sessions per account / 按账号隔离持久化会话\nBackend Strategy — what the contract fixes / 契约固定了什么"}},"/Network-Integration":{"title":"Network Integration / 网络集成","data":{"":"AuthManager implements AuthTokenSource, so HTTP clients can read the current access token without depending on the manager object. This keeps your Dio setup decoupled from zero_auth.AuthManager 实现了 AuthTokenSource,因此 HTTP 客户端可在不依赖管理器对象的前提下读取当前访问令牌,使 Dio 配置与 zero_auth 解耦。","authtokensource--令牌源#AuthTokenSource / 令牌源":"abstract class AuthTokenSource {\n String? get accessToken; // may be expired / 可能已过期\n Future validAccessToken({Duration? leeway}); // renewed first / 先续期\n}\nAuthManager.accessToken returns the current token, or null when unauthenticated — and it may already be expired.AuthManager.accessToken 返回当前令牌;未认证时返回 null —— 而且它可能已经过期。validAccessToken() is the one to use before a request: it renews first (reusing\nthe single-flight refresh) and returns null only when there is nothing to send.\nleeway is how long the token must stay valid for, defaulting to the manager's\nclockSkew (30s), so a token that would die mid-request is renewed first.请求之前应当用 validAccessToken():它会在必要时先续期(复用单飞刷新),只有真的无令牌\n可发时才返回 null。leeway 表示令牌必须还能维持有效的时长,默认取管理器的\nclockSkew(30 秒),因此会在请求途中失效的令牌会被提前续期。","dio-interceptor--dio-拦截器#Dio interceptor / Dio 拦截器":"example/lib/dio_interceptor.dart ships a ready-to-use interceptor:example/lib/dio_interceptor.dart 提供了一个开箱即用的拦截器:\nclass AuthInterceptor extends Interceptor {\n AuthInterceptor(this.tokens); // an AuthTokenSource / 一个令牌源\n final AuthTokenSource tokens;\n @override\n void onRequest(RequestOptions o, RequestInterceptorHandler h) async {\n final t = await tokens.validAccessToken(); // renews first / 先续期\n if (t != null) o.headers['Authorization'] = 'Bearer $t';\n h.next(o);\n }\n}\nIf you only want the synchronous read, use tokens.accessToken — but remember\nit may hand you a token that has already expired.\n若只需要同步读取,可用 tokens.accessToken —— 但请记住它可能返回一个已过期的令牌。\nfinal dio = Dio()\n ..interceptors.add(AuthInterceptor(authManager));\nWhen a 401 is returned, refresh the session and retry; if refresh fails, the manager emits AuthError and your app routes back to login.当返回 401 时,刷新会话并重试;若刷新失败,管理器会发出 AuthError,应用随之跳转登录。example/lib/dio_interceptor.dart also ships AuthRetryInterceptor, which does\nexactly that — refreshes through the manager (single-flight) and replays the\nrequest once, so a retry loop cannot form:example/lib/dio_interceptor.dart 里还提供了 AuthRetryInterceptor,正是做这件事 ——\n通过管理器刷新(单飞)并把请求重试一次,因此不会形成重试风暴:\nfinal dio = Dio()\n ..interceptors.add(\n AuthRetryInterceptor(manager: authManager, dio: dio),\n );\nTwo more interceptor variants there / 那里还有另外两种拦截器:\nRefreshingAuthInterceptor — never sends an expired token: it renews first via\nvalidAccessToken() / 绝不发送过期令牌,先用 validAccessToken() 续期。\nAuthInterceptor — the plain AuthTokenSource version / 基础的令牌源版本。","other-http-clients--其它客户端#Other HTTP clients / 其它客户端":"Because AuthTokenSource is a plain interface, the same pattern works for package:http, chopper, or any client that lets you mutate request headers.由于 AuthTokenSource 是普通接口,同样的模式适用于 package:http、chopper 或任何允许修改请求头的客户端。","next-steps--下一步#Next Steps / 下一步":"Usage — Full integration walkthrough / 完整集成讲解\nErrors — Handling 401 and refresh failures / 处理 401 与刷新失败"}},"/Persistence":{"title":"Session Persistence / 会话持久化","data":{"":"InMemoryTokenStore loses the session on every restart. For a real app you must persist it — and zero_auth now ships the primitives that make this a one-liner: session (de)serialization plus two ready-to-copy reference stores.InMemoryTokenStore 会在每次重启时丢失会话。真实应用必须把它持久化——而 zero_auth 现在提供了让这件事变成「一行代码」的原语:会话(反)序列化,外加两个开箱即用的参考存储。","1-serialize-the-session--序列化会话#1. Serialize the session / 序列化会话":"AuthSession is JSON-safe out of the box. null fields are omitted, and claims round-trips as a plain map.AuthSession 天生就是 JSON 安全的:null 字段会被省略,claims 作为普通映射原样往返。\nfinal json = session.toJson(); // Map\nfinal restored = AuthSession.fromJson(json);\n// Prefer this inside TokenStore.load(): malformed data yields null (\"not signed\n// in\") instead of a FormatException / TypeError.\n// TokenStore.load() 里请优先用它:畸形数据返回 null(即「未登录」),而不是抛\n// FormatException / TypeError。\nfinal safe = AuthSession.tryFromJson(json);\nRefreshToken serializes to its raw string too, so a persisted session rehydrates losslessly.RefreshToken 也序列化为其原始字符串,因此持久化的会话可无损恢复。","2-flutter--encrypted-secure-storage--flutter--加密安全存储#2. Flutter — encrypted secure storage / Flutter —— 加密安全存储":"For mobile, persist into the OS keychain / keystore via flutter_secure_storage. A reference implementation lives at example/lib/secure_token_store.dart:在移动端,通过 flutter_secure_storage 把令牌存入系统钥匙串 / Keystore。参考实现位于 example/lib/secure_token_store.dart:\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: SecureTokenStore(), // from example/\n);\nawait auth.restore(); // rehydrate before the first frame\nNever store refresh tokens in plain SharedPreferences. Use OS-backed secure storage only.切勿把刷新令牌明文存入 SharedPreferences,只使用操作系统级安全存储。","3-server--cli--desktop--json-file--服务端--cli--桌面--json-文件#3. Server / CLI / desktop — JSON file / 服务端 / CLI / 桌面 —— JSON 文件":"On non-mobile Dart targets you can write the serialized session to a file. A reference implementation lives at example/lib/json_token_store.dart:在非移动端 Dart 目标上,可以把序列化后的会话写入文件。参考实现位于 example/lib/json_token_store.dart:\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: FileTokenStore(File('.zero_auth_session.json')),\n);\nThis is ideal for a backend that keeps a logged-in user across process restarts, or a CLI that stays authenticated between invocations.这非常适合「重启后仍保持登录」的后端,或「多次调用之间保持认证」的 CLI。","4-combine-with-auto-refresh--结合自动刷新#4. Combine with auto-refresh / 结合自动刷新":"When you enable proactive auto-refresh, the manager renews the token before it expires — and every renewal is persisted through save(), so the on-disk session always carries a fresh access token.当你开启主动自动刷新时,管理器会在令牌过期前续期——而每次续期都会通过 save() 落盘,因此磁盘上的会话始终持有崭新的访问令牌。\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: SecureTokenStore(),\n autoRefreshAhead: const Duration(minutes: 5),\n);","next-steps--下一步#Next Steps / 下一步":"Token Store — the save / load / clear contract / save / load / clear 契约\nConfiguration — enable proactive auto-refresh / 开启主动自动刷新"}},"/Third-Party-Login":{"title":"Third-Party Login / 第三方登录","data":{"the-key-idea--核心思路#The key idea / 核心思路":"zero_auth does not run the OAuth handshake — and that is deliberate. It is a\r\npure-Dart package, so it ships no platform code, no browser plumbing and no\r\nredirect URL handling (those live outside lib/).Instead, zero_auth manages what comes after the handshake: the session your\r\nown backend issues.zero_auth 不负责 OAuth 握手环节,这是刻意为之:它是纯 Dart 包,不含任何平台\r\n代码、浏览器通道或重定向回调处理(这些只能在 lib/ 之外)。它负责的是握手之后的事:你自己后端所签发的会话。\nprovider consent ──► code / id_token ──► YOUR BACKEND ──► your access + refresh tokens ──► zero_auth\r\n (outside) (any language) (this package)\nBecause the login method is invisible to the manager, password login and\r\nGoogle/Apple login end up sharing one token lifecycle. That is the whole\r\npoint.登录方式对管理器是透明的,因此密码登录与 Google / Apple 登录最终共享同一套\r\n令牌生命周期机制。这正是关键所在。","when-this-fits--适用场景#When this fits / 适用场景":"This pattern fits when your backend brokers the provider — you have a\r\nbackend, it verifies the provider token, then mints your own tokens.下面的模式适用于你的后端作为经纪方:你有后端,它校验提供方令牌,然后签发你自己的\r\n令牌。\nScenario\tFit\tOwn backend + Google/Apple/GitHub sign-in / 自有后端 + 第三方登录\t✅ Ideal / 理想\tOwn backend + password AND social logins / 自有后端,密码与社交登录并存\t✅ One shared session model / 共用一套会话模型\tFirebase/Supabase issues the tokens directly / 由 Firebase/Supabase 直接签发令牌\t⚠️ Use their SDK instead / 请改用其 SDK","wiring-it-up--接线步骤#Wiring it up / 接线步骤":"1. Run the handshake outside this package.\r\nUse whichever OAuth client fits your targets (flutter_web_auth, AppAuth, or a\r\nserver-side flow). This code lives in your app or a sibling Flutter package, never\r\nin lib/.1. 在本包之外完成握手。\r\n使用适合目标平台的 OAuth 客户端(flutter_web_auth、AppAuth 或服务端流程)。这部分\r\n代码放在你的应用或兄弟 Flutter 包里,绝不放在 lib/。2. Trade the provider credential for your tokens on your backend.2. 用提供方凭据到你的后端换取自有令牌。\nFuture signInWithGoogle(BuildContext context) async {\r\n // 1) Provider consent (outside zero_auth) → authorization code / id_token.\r\n // 提供方授权(零依赖,在 zero_auth 之外)→ 授权码 / id_token。\r\n final code = await myOAuthClient.authenticate();\r\n\r\n // 2) Your backend verifies it with Google and returns YOUR tokens.\r\n // 你的后端用它与 Google 校验,并返回「你自己的」令牌。\r\n final res = await myApi.post('/auth/google', data: {'code': code});\r\n\r\n return AuthSession(\r\n accessToken: res['accessToken'] as String,\r\n refreshToken: RefreshToken(res['refreshToken'] as String),\r\n expiresAt: DateTime.now().add(Duration(seconds: res['expiresIn'] as int)),\r\n userId: res['userId'] as String,\r\n );\r\n}\n3. Hand the session to the manager with loginWith.\r\nloginWith is the escape hatch for flows that are not login / register: the\r\nmanager still owns persistence, the state machine and proactive refresh.3. 用 loginWith 把会话交给管理器。\r\nloginWith 是为那些不符合 login / register 形态的流程准备的逃生口:持久化、状态机\r\n与主动刷新仍由管理器负责。\nfinal result = await auth.loginWith((strategy) => signInWithGoogle(context));\r\n// emits Authenticating → Authenticated, or AuthError + rethrow.\r\n// 发出 Authenticating → Authenticated;失败则发 AuthError 并重新抛出。\nKeep signInWithGoogle throwing AuthException with a meaningful code\r\n('invalid_credentials', 'session_expired', …) so failures map to the typed\r\nexceptions in Errors.让 signInWithGoogle 抛出带有实用 code('invalid_credentials'、\r\n'session_expired' 等)的 AuthException,这样失败就能映射为\r\nErrors 里的具体异常类型。","same-model-for-every-method--各登录方式共用一套模型#Same model for every method / 各登录方式共用一套模型":"// Password users and Google users end up with the same AuthSession shape.\r\n// 密码用户与 Google 用户最终得到的都是同一种 AuthSession。\r\nawait auth.login(const Credentials(username: user, password: pass));\r\nawait auth.loginWith((strategy) => signInWithGoogle(context));\nYour UI only ever reads state.isAuthenticated, and your network layer only ever\r\nreads auth.validAccessToken() — regardless of how the user signed in.你的界面只需要读 state.isAuthenticated,网络层只需要读 auth.validAccessToken()\r\n——无需关心用户是哪种方式登录的。","also-works-for--同样适用于#Also works for / 同样适用于":"loginWith covers any flow you drive yourself:loginWith 适用于任何由你驱动的流程:\nMagic links / email OTP / 魔法链接、邮箱验证码\nPasskeys / WebAuthn\nBiometric re-unlock (load a locked session and re-mint tokens) / 生物识别解锁\nMigrating from another auth SDK / 从其它认证 SDK 迁移","next-steps--下一步#Next Steps / 下一步":"Backend Strategy — what your strategy must implement / 策略需要实现什么\nAuth State Machine — the states this flow emits / 该流程发出的状态\nErrors — typed exceptions and codes / 类型化异常与 code"}},"/Token-Store":{"title":"Token Store / 令牌存储","data":{"":"TokenStore is the only persistence surface. The core never touches disk, secure storage, or SharedPreferences directly — you decide where tokens live.TokenStore 是唯一的持久化接口。内核绝不直接访问磁盘、安全存储或 SharedPreferences——由你决定令牌存放位置。","the-contract--契约#The contract / 契约":"abstract class TokenStore {\n Future save(AuthSession session);\n Future load();\n Future clear();\n}\nAuthManager calls load() during restore(), save() after every successful login/refresh, and clear() on logout or a failed refresh.AuthManager 在 restore() 时调用 load(),每次登录/刷新成功后调用 save(),在登出或刷新失败失败时调用 clear()。","in-memory-ships-in-core--内存实现内核自带#In-memory (ships in-core) / 内存实现(内核自带)":"InMemoryTokenStore is the default. It survives process restarts? No — it is in-memory only, so the session is lost on restart. Use it for tests and quick prototypes.InMemoryTokenStore 是默认实现,仅存在于内存,进程重启后会话丢失。适用于测试与原型。\nfinal auth = AuthManager(strategy: s, tokenStore: InMemoryTokenStore());","secure-storage-production--安全存储生产#Secure storage (production) / 安全存储(生产)":"example/lib/secure_token_store.dart shows a flutter_secure_storage-backed reference implementation. Adapt it for your app:example/lib/secure_token_store.dart 提供了基于 flutter_secure_storage 的参考实现,可据此改造:\nclass SecureTokenStore implements TokenStore {\n final _box = FlutterSecureStorage();\n @override\n Future save(AuthSession s) =>\n _box.write(key: 'zero_auth', value: jsonEncode(s.toJson()));\n @override\n Future load() async {\n final raw = await _box.read(key: 'zero_auth');\n // tryFromJson: corrupt data means \"not signed in\", not a crash.\n // tryFromJson:数据损坏代表「未登录」,而不是崩溃。\n return AuthSession.tryFromJson(raw == null ? null : jsonDecode(raw));\n }\n @override\n Future clear() => _box.delete(key: 'zero_auth');\n}\nPersist tokens only in OS-backed secure storage (Keychain / Keystore). Never store refresh tokens in plain SharedPreferences.令牌只应存放在操作系统级安全存储(Keychain / Keystore)中,切勿把刷新令牌明文存入 SharedPreferences。","next-steps--下一步#Next Steps / 下一步":"Session Persistence — persist sessions across restarts (serialization + reference stores) / 跨重启持久化会话(序列化与参考存储)\nBackend Strategy — The other boundary / 另一个边界\nErrors — What load() failures become / load() 失败会变成什么"}},"/Usage":{"title":"Usage / 使用指南","data":{"three-step-integration--三步集成#Three-step integration / 三步集成":"","1-implement-authstrategy--实现后端边界#1. Implement AuthStrategy / 实现后端边界":"class MyAuthStrategy implements AuthStrategy {\n @override\n Future login(Credentials credentials) => myApi.login(\n credentials.username, // an email or phone works too\n credentials.password,\n );\n @override\n Future register(RegistrationInput input) =>\n myApi.register(input.username, input.password);\n @override\n Future logout(SessionHandle handle) => myApi.logout(handle.userId);\n @override\n Future refresh(RefreshToken token) =>\n myApi.refresh(token.value);\n}","2-create-authmanager-and-restore--创建管理器并恢复#2. Create AuthManager and restore / 创建管理器并恢复":"final auth = AuthManager(\n strategy: MyAuthStrategy(),\n tokenStore: InMemoryTokenStore(),\n);\nawait auth.restore(); // rehydrate a persisted session / 恢复持久化会话","3-drive-the-ui-from-the-state-stream--用状态流驱动界面#3. Drive the UI from the state stream / 用状态流驱动界面":"await for (final state in auth.state) {\n // render based on state / 根据状态渲染\n}","operations--操作#Operations / 操作":"Method\tDescription\trestore()\tRehydrate the persisted session at startup / 启动时恢复会话\tlogin(username, password)\tAuthenticate and enter Authenticated / 登录并进入已认证\tregister(username, password)\tRegister and authenticate / 注册并认证\tlogout()\tClear the session / 清除会话\trefresh()\tSilent token refresh — concurrent calls share one flight / 静默刷新,并发共享单飞\ttokenSource\tAn AuthTokenSource for HTTP clients / 供 HTTP 客户端使用的令牌源","token-refresh--expiry--令牌刷新与过期#Token refresh & expiry / 令牌刷新与过期":"AuthSession exposes isExpired so callers can refresh proactively. refresh() is single-flight: if several callers request a refresh at once, only one network call is made and its result is shared.AuthSession 提供 isExpired,调用方可主动刷新。refresh() 为单飞:多个调用方同时请求刷新时,只发起一次网络调用并共享结果。 A failed refresh clears the session and emits AuthError.刷新失败会清空会话并发出 AuthError。","attaching-the-bearer-token--附加-bearer-令牌#Attaching the bearer token / 附加 Bearer 令牌":"AuthManager implements AuthTokenSource, so a Dio interceptor can read the current token without depending on the manager. See Network Integration.AuthManager 实现了 AuthTokenSource,Dio 拦截器可在不依赖管理器的情况下读取当前令牌。详见网络集成。","next-steps--下一步#Next Steps / 下一步":"Auth State Machine — State lifecycle details / 状态机细节\nBackend Strategy — Full AuthStrategy contract / 完整后端契约\nErrors — Exception & Result model / 异常与结果模型"}},"/":{"title":"Zero Auth","data":{"":"A backend-agnostic auth state machine & session lifecycle core for Dart/Flutter. Pure Dart, headless, no HTTP/SDK/native code. Wire your own backend via AuthStrategy and your own persistence via TokenStore.一个后端无关的认证状态机与会话生命周期内核,面向 Dart/Flutter。纯 Dart、无头(headless),不含 HTTP、SDK 或原生代码。通过 AuthStrategy 接入自有后端,通过 TokenStore 接入自有持久化层。","-features--功能特性#✨ Features / 功能特性":"Feature\tDescription\tBackend-agnostic\tImplement only login/register/logout/refresh; REST, gRPC, Firebase or a private RPC are equally valid targets / 只需实现四个方法,REST、gRPC、Firebase 或私有 RPC 均可接入\tAuth State Machine\tSealed Unauthenticated / Authenticating / Authenticated / Refreshing / LoggingOut / AuthError on a Stream that replays the latest value to new listeners; use isAuthenticated / isBusy / 密封六态状态机 + 重放最近值的状态流,可用 isAuthenticated / isBusy\tSilent Restore\trestore() rehydrates the persisted session at startup / 启动时静默恢复持久化会话\tSingle-flight Refresh\tConcurrent refresh() calls share one in-flight request instead of stampeding the backend / 并发刷新共享同一次请求,避免冲击后端\tPluggable Persistence\tThe only persistence surface is TokenStore.save/load/clear; InMemoryTokenStore ships in-core / 唯一的持久化接口,内核自带内存实现\tTyped Session\tAuthSession carries access/refresh tokens, expiry (isExpired), user id, display name and raw claims / 强类型会话,无需手工解析令牌\tUnified Errors\tEvery failure maps to AppException (AuthException for auth cases) or a Result wrapper; raw exceptions never cross the public surface / 统一异常与结果,裸异常不越界\tNetwork Integration\tAuthManager itself is an AuthTokenSource, so a Dio interceptor can attach Authorization: Bearer without depending on the manager / 管理器即令牌源,Dio 拦截器零依赖附加令牌\tSession Serialization\tAuthSession.toJson / fromJson make persistence a one-liner, and tryFromJson returns null on malformed data; a file-based reference store ships for server/CLI / AuthSession.toJson / fromJson 让持久化一行搞定,tryFromJson 遇畸形数据返回 null,并附带面向服务端 / CLI 的文件参考存储\tProactive Auto-refresh\tPass autoRefreshAhead to renew tokens before expiry (single-flight) / 传入 autoRefreshAhead 在过期前自动续期(单飞)\tNever an Expired Token\tvalidAccessToken() renews first when the token has expired — or is about to, per clockSkew — so interceptors never send a dead bearer token / 令牌过期(或按 clockSkew 即将过期)时先续期,拦截器不会发出失效令牌\tBring Your Own Login\tloginWith adopts a session from any flow you drive: third-party OAuth, magic links, passkeys / loginWith 可接纳第三方 OAuth、魔法链接、Passkey 等自定义流程\tTyped Auth Exceptions\tInvalidCredentialsException, SessionExpiredException and friends, mapped from your strategy's code / InvalidCredentialsException、SessionExpiredException 等,由策略的 code 映射而来\tConfigurable Failure Policy\trefreshFailurePolicy decides whether a failed refresh signs the user out / refreshFailurePolicy 决定刷新失败是否登出\tRunnable Example\tA full Flutter demo app (Android/iOS/Web/Windows) plus a layered dart:io demo backend that issues real JWTs / 完整 Flutter 示例与一个签发真实 JWT 的分层演示后端\tMultiple Accounts\tOptional AuthManagerGroup keeps one manager per account / 可选 AuthManagerGroup,每账号一个管理器\tCross-platform\tPure Dart — runs anywhere Dart or Flutter runs / 纯 Dart,跨平台","-table-of-contents--目录#📚 Table of Contents / 目录":"Page\tDescription\tGetting Started\tQuick start guide / 快速开始\tInstallation\tHow to install / 安装方式\tUsage\tDetailed usage / 详细使用\tAuth State Machine\tState lifecycle & stream / 状态机与状态流\tBackend Strategy\tImplement AuthStrategy / 实现后端边界\tToken Store\tPersistence boundary / 持久化边界\tNetwork Integration\tDio interceptor & token source / 网络集成与拦截器\tErrors\tException & Result model / 异常与结果模型\tConfiguration\tConfiguration options / 配置说明\tSession Persistence\tRestoring and renewing a saved session / 会话持久化与恢复\tThird-Party Login\tOAuth / magic links via loginWith / 用 loginWith 接入第三方登录\tMultiple Accounts\tSwitching vs concurrent accounts / 账号切换与多账号并存\tFAQ\tFrequently asked questions / 常见问题","-links--链接#🔗 Links / 链接":"GitHub\nOfficial Website\npub.dev","-license--许可证#📄 License / 许可证":"This project is licensed under the Mozilla Public License 2.0 (MPL-2.0).本项目采用 Mozilla Public License 2.0 (MPL-2.0) 许可证。This package is provided \"as is\", without warranty of any kind. The author assumes no responsibility or liability for the functionality, security, or any consequences arising from the use of modified versions or derivative projects.本包按\"原样\"提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。"}},"/FAQ":{"title":"FAQ / 常见问题","data":{"does-zero_auth-ship-an-http-client--zero_auth-内置-http-客户端吗#Does zero_auth ship an HTTP client? / zero_auth 内置 HTTP 客户端吗?":"No. The core is backend-agnostic and ships no HTTP, SDK or native code. You implement AuthStrategy to call your backend.不。内核后端无关,不含任何 HTTP、SDK 或原生代码。你通过实现 AuthStrategy 来调用自己的后端。","does-it-depend-on-flutter--它依赖-flutter-吗#Does it depend on Flutter? / 它依赖 Flutter 吗?":"No. It is a pure-Dart package; flutter: is only a declared constraint because the example app uses Flutter. The runtime has no Flutter dependency.不。它是纯 Dart 包;仅因示例 App 用到 Flutter 才声明 flutter: 约束,运行时不依赖 Flutter。","where-are-tokens-stored--令牌存在哪里#Where are tokens stored? / 令牌存在哪里?":"Wherever your TokenStore puts them. The core ships InMemoryTokenStore (lost on restart). For production, use the flutter_secure_storage-backed reference in example/lib/secure_token_store.dart.取决于你的 TokenStore。内核自带 InMemoryTokenStore(重启即丢失)。生产请用 example/lib/secure_token_store.dart 中基于 flutter_secure_storage 的参考实现。","what-happens-on-a-failed-refresh--刷新失败会怎样#What happens on a failed refresh? / 刷新失败会怎样?":"The failure is always reported as AuthError and rethrown. What happens next\ndepends on refreshFailurePolicy: an unrecoverable failure (SessionExpiredException,\nInvalidCredentialsException) clears the store and lands on Unauthenticated,\nwhile a transient one (network, 5xx) keeps the previous session so a retry can\nsucceed. See Configuration.失败总会以 AuthError 上报并重新抛出。之后如何取决于 refreshFailurePolicy:\n不可恢复的失败(SessionExpiredException、InvalidCredentialsException)会清空存储\n并落到 Unauthenticated;瞬时故障(网络、5xx)保留上一个会话以便重试成功。参见\n配置。","is-token-refresh-concurrent-safe--令牌刷新是否并发安全#Is token refresh concurrent-safe? / 令牌刷新是否并发安全?":"Yes — refresh() is single-flight: many concurrent callers share one in-flight request and its result.是的——refresh() 为单飞:多个并发调用方共享同一次请求及其结果。","how-do-i-attach-the-bearer-token-to-requests--如何给请求附加-bearer-令牌#How do I attach the bearer token to requests? / 如何给请求附加 Bearer 令牌?":"AuthManager is an AuthTokenSource. Use the Dio interceptor in example/lib/dio_interceptor.dart, or read accessToken directly — but prefer validAccessToken() (or RefreshingAuthInterceptor) whenever the token reaches a server, since accessToken may already be expired.AuthManager 即 AuthTokenSource。可用 example/lib/dio_interceptor.dart 中的 Dio 拦截器,或直接读取 accessToken —— 但只要令牌要发到服务端,就请优先用 validAccessToken()(或 RefreshingAuthInterceptor),因为 accessToken 可能已经过期。","does-it-sync-sessions-across-devices--它会在多设备间同步会话吗#Does it sync sessions across devices? / 它会在多设备间同步会话吗?":"No — and by design. A session is local state plus whatever your backend decides;\npushing \"signed out elsewhere\" to a device needs server push (or polling), which\nis outside a headless state machine. Model it with your backend revoking the\ngrant: the next refresh fails, refreshFailurePolicy signs the device out, and\nAuthError explains why.不会 —— 这是有意的设计。会话是本地状态加上你后端的决定;把「在别处已登出」推送到某台\n设备需要服务端推送(或轮询),那超出了无头状态机的职责。可由后端吊销授权来实现:下一次\n刷新失败,refreshFailurePolicy 会让该设备登出,并由 AuthError 说明原因。","which-platforms-are-supported--支持哪些平台#Which platforms are supported? / 支持哪些平台?":"Any platform where Dart or Flutter runs (Android, iOS, Web, Windows, macOS, Linux).任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。"}},"/Installation":{"title":"Installation / 安装","data":{"from-pubdev-recommended--从-pubdev-安装推荐#From pub.dev (Recommended) / 从 pub.dev 安装(推荐)":"Add the following to your pubspec.yaml:在 pubspec.yaml 中添加以下依赖:\ndependencies:\n zero_auth: ^1.0.0\nThen run:然后运行:\ndart pub get","from-github--从-github-安装#From GitHub / 从 GitHub 安装":"Alternatively, install from GitHub:或者从 GitHub 安装:\ndependencies:\n zero_auth:\n git:\n url: https://github.com/zero-labsco/zero_auth.git\n ref: release/v1.0.0","import--导入#Import / 导入":"import 'package:zero_auth/zero_auth.dart';","requirements--环境要求#Requirements / 环境要求":"Requirement\tVersion\tDart SDK\t>= 3.4.0\tFlutter\t>= 3.0.0 (only a constraint; no Flutter runtime dependency)\t\nzero_auth is a pure-Dart package. Flutter is only listed because the example app uses it; the core has no Flutter dependency.zero_auth 是纯 Dart 包,仅因示例 App 用到 Flutter 才列出;内核本身不依赖 Flutter。","next-steps--下一步#Next Steps / 下一步":"Getting Started — Quick start guide / 快速开始\nUsage — Full usage guide / 完整使用指南"}}} \ No newline at end of file +{"/Auth-State-Machine":{"title":"Auth State Machine / 认证状态机","data":{"states--状态#States / 状态":"AuthState is a sealed class with six subtypes:AuthState 是一个密封类,包含六个子类型:\nState\tFields\tMeaning\tUnauthenticated\t—\tNo session / 无会话\tAuthenticating\t—\tlogin/register in flight / 登录/注册进行中\tAuthenticated\tsession: AuthSession\tA valid session / 有效会话\tRefreshing\tsession: AuthSession\tRenewal in flight; the previous session is still usable / 续期进行中,旧会话仍可用\tLoggingOut\tsession: AuthSession\tlogout() in flight; that session is being discarded / 登出进行中,该会话即将被丢弃\tAuthError\terror: AppException\tLast operation failed / 上一次操作失败\t\nTwo helpers save you from spelling out every case:两个辅助属性可避免你手写全部分支:\nstate.isAuthenticated — true for Authenticated and Refreshing, so a\nrenewal never unmounts your signed-in UI. / 在 Authenticated 与 Refreshing\n下均为 true,续期不会卸载已登录界面。\nstate.isBusy — true while Authenticating, Refreshing or LoggingOut. /\n在 Authenticating、Refreshing、LoggingOut 期间为 true。\nstate.session — the session the state carries (Authenticated / Refreshing /\nLoggingOut), or null; no pattern-matching needed for the common case. /\n该状态携带的会话(Authenticated / Refreshing / LoggingOut)或 null,\n常见场景无需再做模式匹配。","the-stream--状态流#The stream / 状态流":"AuthManager.state is a Stream that replays the latest value to every new listener (BehaviorSubject-like). This means:AuthManager.state 是一个 Stream,会向每个新订阅者重放最近的值(类似于 BehaviorSubject)。这意味着:\nA freshly mounted widget renders the correct screen on its first frame / 新挂载的 widget 首帧即渲染正确界面。\nYou never need to read a separate \"current state\" field / 无需另读一个\"当前状态\"字段。\nauth.state.listen((state) {\n switch (state) {\n case Authenticated(:final session):\n /* ... */\n case AuthError(:final error):\n /* ... */\n default:\n }\n});","transitions--状态转移#Transitions / 状态转移":"login/register ──► Authenticating ──► Authenticated\n │ ▲\n refresh │ │ success\n ─────────► ▼ │\n Refreshing\n │\n failure │\n ▼\n AuthError ──policy: sign out──► Unauthenticated\n └──policy: keep session─► Authenticated (old session)\n Authenticated ──logout──► LoggingOut ──► Unauthenticated\n Unauthenticated ──restore() finds nothing / nothing persisted──► Unauthenticated\nrestore() restores a live session → Authenticated.\nrestore() restores an expired session → attempts Refreshing; if the\nrenewal fails transiently (network, 5xx) the persisted session is kept and\nactivated as-is, so the next request can retry. Only a terminal failure — or the\nabsence of a refresh token — clears the store and lands on Unauthenticated.\nrestore(refreshIfExpired: false) restores it verbatim instead.\nA failed login/register → AuthError, and the future rethrows.\nA failed refresh → AuthError, followed by Unauthenticated when the\nfailure policy considers the grant unrecoverable,\nor by Authenticated (previous session) when it looks transient.\nDuplicate consecutive emissions are suppressed, so listeners only rebuild on a\nreal change.\nrestore() 恢复未过期会话 → Authenticated。\nrestore() 恢复已过期会话 → 先走 Refreshing;若续期瞬时失败(网络、5xx),\n则保留持久化会话并按原样激活,下次请求可再试。只有终局失败(或没有刷新令牌)\n才会清空存储并落到 Unauthenticated。restore(refreshIfExpired: false) 则原样恢复。\nlogin/register 失败 → AuthError,同时 future 再次抛出错误。\nrefresh 失败 → AuthError;随后由失败策略决定:\n认为授权不可恢复则转 Unauthenticated,认为只是瞬时故障则回到 Authenticated。\n连续重复的状态会被抑制,监听器只会在真正变化时重建。","next-steps--下一步#Next Steps / 下一步":"Usage — How to drive the UI from the stream / 如何用状态流驱动界面\nBackend Strategy — What each operation calls / 各操作调用什么"}},"/Backend-Strategy":{"title":"Backend Strategy / 后端策略","data":{"":"AuthStrategy is the only boundary the core needs from a backend. Implement four methods and zero_auth works with REST, gRPC, Firebase, or a private RPC.AuthStrategy 是内核向后台要求的唯一边界。实现四个方法后,zero_auth 即可对接 REST、gRPC、Firebase 或私有 RPC。","the-contract--契约#The contract / 契约":"abstract class AuthStrategy {\n Future login(Credentials credentials);\n Future register(RegistrationInput input);\n Future logout(SessionHandle handle);\n Future refresh(RefreshToken token);\n}\nMethod\tWhen called\tReceives\tMust return\tlogin\tAuthManager.login\tCredentials(username, password)\tA fresh AuthSession\tregister\tAuthManager.register\tRegistrationInput(username, password, displayName?, email?)\tA fresh AuthSession\tlogout\tAuthManager.logout\tSessionHandle(userId, refreshToken?)\tvoid (revoke server-side if applicable)\trefresh\tAuthManager.refresh\tRefreshToken(value)\tA new AuthSession (new tokens)\t\nusername is just a name — it is an opaque identifier, and the core never\nvalidates or interprets it. Put an email or a phone number in it and map it to\nwhatever field your API expects.username 只是字段名,它是个不透明标识符,内核从不校验或解析它。把邮箱或手机号放进去,\n再映射成你接口需要的字段即可。","what-is-fixed-and-where-the-escape-hatches-are--哪些是固定的逃生口在哪#What is fixed, and where the escape hatches are / 哪些是固定的,逃生口在哪":"The four signatures above are the only thing the contract fixes. Transport, URLs,\ntoken format and rotation are entirely yours.上面这四个签名是契约唯一固定的东西。传输方式、URL、令牌格式与轮换策略完全由你决定。\nYou need\tDo this\tLogin that is not username + password (SMS code, OAuth, passkey)\tAuthManager.loginWith — see Third-Party Login\tExtra fixed context (tenant id, app id, device id)\tHold it as a field on your strategy; do not squeeze it into Credentials\tExtra data returned by the backend (roles, tenant, permissions)\tPut it in AuthSession.claims\tSignalling why a call failed\tThrow AuthException with a code — see Errors\t\nGotchas / 注意点:\nAlways set userId when building sessions. logout receives\nuserId: session.userId ?? '', so a session without one hands your backend an\nempty string (the refresh token is passed too, when present).\n务必设置 userId:登出时收到的是 session.userId ?? '',没有它后端会拿到空\n字符串(有刷新令牌时会一并传入)。\nLogging in again does not sign the previous session out. The new session\nsimply replaces it locally; revoking the old one server-side is your call — or\nuse Multiple Accounts if both must survive.\n再次登录不会登出上一个会话:新会话只是本地替换;是否在服务端吊销旧的由你决定;\n若两者都要保留,请用多账号。","mapping-failures--映射失败#Mapping failures / 映射失败":"Every thrown error should be mapped to AppException (or AuthException for auth-specific cases). Raw Exceptions must never cross the public surface — the manager wraps unknowns.所有抛出的错误都应映射为 AppException(认证相关场景用 AuthException)。裸 Exception 不得越过公共边界——管理器会包裹未知异常。\n@override\nFuture login(Credentials credentials) async {\n try {\n final json = await myApi.post('/login', {\n 'account': credentials.username, // email, phone, whatever you accept\n 'password': credentials.password,\n });\n return AuthSession.fromJson(json as Map);\n } on MyApiAuthError catch (e) {\n throw AuthException(code: 'invalid_credentials', message: e.message);\n }\n}","building-the-session--构造会话#Building the session / 构造会话":"Return an AuthSession carrying at least the access token, the refresh token, and an expiry:返回 AuthSession,至少包含访问令牌、刷新令牌与过期时间:\nAuthSession(\n accessToken: json['access'],\n refreshToken: json['refresh'],\n expiresAt: DateTime.parse(json['expiresAt']),\n userId: json['userId'],\n displayName: json['displayName'],\n claims: json, // optional raw claims / 可选的原始 claims\n)","example-backend--演示后端#Example backend / 演示后端":"server/ is a layered dart:io demo backend exercising /login, /refresh, /logout, /me and /health. Run it with dart run bin/server.dart on port 8080. It issues real HMAC-SHA256 JWTs with refresh-token rotation and replay detection, and logs every request with its status and duration. The demo account is user / user.server/ 是一个分层的 dart:io 演示后端,提供 /login、/refresh、/logout、/me 与 /health。用 dart run bin/server.dart 在 8080 端口启动。它签发真实的 HMAC-SHA256 JWT,带刷新令牌轮换与重放检测,并记录每条请求的状态码与耗时。演示账号为 user / user。","next-steps--下一步#Next Steps / 下一步":"Token Store — Where sessions are persisted / 会话持久化到何处\nNetwork Integration — Attaching the bearer token / 附加 Bearer 令牌"}},"/Configuration":{"title":"Configuration / 配置","data":{"":"zero_auth is deliberately minimal: there is no global config object. Behavior is composed from the two boundaries you pass to AuthManager.zero_auth 刻意保持精简:没有全局配置对象。行为由你传给 AuthManager 的两个边界组合而成。","composing-authmanager--组合管理器#Composing AuthManager / 组合管理器":"final auth = AuthManager(\n strategy: MyAuthStrategy(), // how to talk to the backend / 如何与后端通信\n tokenStore: SecureTokenStore(), // where to persist tokens / 令牌持久化位置\n autoRefreshAhead: const Duration(minutes: 5), // proactive renewal / 主动续期\n autoRefreshRetryDelay: const Duration(seconds: 30), // re-arm after a failed renewal / 续期失败后重新排程\n autoRefreshMaxRetries: 3, // cap those retries / 重试上限\n autoRefreshMinInterval: const Duration(seconds: 5), // floor for an already-due renewal / 已到期续期的下限\n refreshFailurePolicy: defaultRefreshFailurePolicy, // sign-out rule / 登出规则\n clock: () => DateTime.now(), // time source (tests, skew) / 时间源(测试、时钟偏移)\n clockSkew: const Duration(seconds: 30), // treat tokens as expired this much earlier / 提前多久视为过期\n preserveSessionDetails: true, // keep identity across a tokens-only renewal / 只换令牌的续期保留身份\n onStateChanged: (state) => debugPrint('$state'), // observe every emission / 观察每次状态\n);\nKnob\tWhere\tEffect\tBackend endpoints & auth scheme\tAuthStrategy\tWhat login/refresh/… actually do / login/refresh 等的实际行为\tToken persistence\tTokenStore\tDisk / secure storage / in-memory / 磁盘/安全存储/内存\tRefresh timing\tAuthSession.expiresAt + autoRefreshAhead\tProactive renewal is scheduled that far before expiry / 在过期前该时长调度主动续期\tRenewal retry\tautoRefreshRetryDelay\tAfter a proactive renewal fails, it is re-armed this much later (default 30s) while a session still exists / 主动续期失败后按此时长重新排程(默认 30 秒),会话仍在才重试\tRenewal retry cap\tautoRefreshMaxRetries\tHow many failed proactive renewals to retry before giving up (default 3) / 主动续期失败最多重试几次(默认 3)\tRenewal floor\tautoRefreshMinInterval\tMinimum wait for a renewal that is already due (default 5s), so very short-lived tokens cannot cause a tight loop / 「已到期」续期的最小等待(默认 5 秒),极短寿命令牌不会造成紧密循环\tClock skew\tclockSkew\tHow much earlier a token counts as expired (default 30s); absorbs a device clock that runs ahead and the latency of the request itself. If your access tokens live under a minute, lower it (e.g. 5s) or almost every read renews first / 提前多久把令牌视为过期(默认 30 秒),用于吸收设备时钟偏快与请求自身的延迟。若访问令牌寿命不足一分钟,请调小(如 5 秒),否则几乎每次读取都会先续期\tIdentity on renewal\tpreserveSessionDetails\tCarry userId / displayName / claims over a renewal that returns tokens only (default true) / 只返回令牌的续期是否保留身份字段(默认 true)\tObservation\tonStateChanged\tOptional callback for every emitted state, for logging or analytics / 可选回调,每次发出状态时触发,便于日志或埋点\tRefresh failure handling\trefreshFailurePolicy\tWhether a failed refresh signs the user out / 刷新失败是否让用户登出\tTime source\tclock\tDrives expiry maths and proactive scheduling; inject one for deterministic tests or to tolerate device clock skew / 驱动过期计算与主动刷新调度;注入时钟可实现确定性测试或容忍设备时钟偏移\tToken injection\tAuthTokenSource / validAccessToken()\tHow the bearer token reaches HTTP clients, optionally renewing an expired token first / 令牌如何到达 HTTP 客户端,必要时先续期过期令牌","refresh-strategy--刷新策略#Refresh strategy / 刷新策略":"refresh() is single-flight by construction: concurrent callers share one in-flight request. You decide when to refresh:refresh() 天生单飞:并发调用方共享同一次进行中的请求。何时刷新由你决定:\nProactively, when session.isExpired is approaching, before a request. / 在请求前、当 session.isExpired 临近时主动刷新。\nReactively, on a 401 from your API (see Network Integration). / 响应式地,在 API 返回 401 时刷新。\nA failed refresh is always reported through AuthError (and the rethrown future),\nbut whether it ends the session is decided by refreshFailurePolicy. See\nRefresh failure policy below.刷新失败总会通过 AuthError(以及重新抛出的 future)上报,但是否终止会话由\nrefreshFailurePolicy 决定,详见下方「刷新失败策略」。","refresh-failure-policy--刷新失败策略#Refresh failure policy / 刷新失败策略":"The default policy signs the user out only for failures that can never succeed\nagain, keeping the session when the failure looks transient (so a network blip\ndoes not log people out):默认策略仅在失败「注定无法重试成功」时让用户登出;看起来像瞬时故障时保留会话(避免一次\n网络抖动就把人踢下线):\nFailure / 失败类型\tDefault outcome / 默认结果\tSessionExpiredException, InvalidCredentialsException\tStore cleared → Unauthenticated / 清空存储 → Unauthenticated\tAnything else (network, 5xx…) / 其它(网络、5xx 等)\tSession kept → back to Authenticated / 保留会话 → 回到 Authenticated\t\n// Never sign out on a failed refresh — useful when refresh tokens are\n// long-lived and your backend is occasionally flaky.\n// 刷新失败也绝不登出——适用于刷新令牌长期有效、后端偶尔不稳定的场景。\nfinal auth = AuthManager(\n strategy: strategy,\n refreshFailurePolicy: (AppException error) => false,\n);\nProactive (background) refresh failures are handled internally, so a scheduled\nrenewal can never surface as an unhandled async error.主动(后台)刷新的失败会在内部处理,因此定时续期永远不会以未处理异步错误的形式泄漏。","no-global-singletons--没有全局单例#No global singletons / 没有全局单例":"AuthManager is a plain object — create one per app, hold it in your DI container or a top-level variable. The core has no static mutable state.AuthManager 是一个普通对象——每个应用创建一个,放在 DI 容器或顶层变量中即可。内核没有静态可变状态。","next-steps--下一步#Next Steps / 下一步":"Usage — Putting it together / 综合使用\nFAQ — Common questions / 常见问题"}},"/Errors":{"title":"Errors / 错误","data":{"":"zero_auth never lets a raw Exception cross its public surface. Every failure is expressed through one of two mechanisms.zero_auth 绝不允许裸 Exception 越过公共边界。每个失败都通过下面两种机制之一表达。","appexception--异常类型#AppException / 异常类型":"All domain failures extend AppException:所有领域失败都继承 AppException:\nsealed class AppException implements Exception {\n final String code; // stable, machine-readable / 稳定、机器可读\n final String message; // human-readable / 人类可读\n final Object? cause; // original error, if any / 原始错误\n}\nfinal class AuthException extends AppException {\n // Base type for every auth failure / 所有认证失败的基类型\n}\n// Concrete subtypes — catch these to branch on what actually went wrong.\n// 具体子类型——捕获它们即可按实际错误分支处理。\nfinal class InvalidCredentialsException extends AuthException {} // invalid_credentials / 凭据无效\nfinal class SessionExpiredException extends AuthException {} // session_expired / 会话过期\nfinal class NoActiveSessionException extends AuthException {} // no_active_session / 无活动会话\nfinal class RefreshTokenMissingException extends AuthException {} // refresh_token_missing / 缺少刷新令牌\nfinal class UnexpectedAuthException extends AuthException {} // unexpected_auth_failure / 意外失败\nAuthStrategy authors opt into these subtypes simply by throwing an\nAuthException — or the bare AuthFail domain type — carrying the matching\ncode; mapAuthFailure performs the mapping, preserving any vocabulary it does\nnot recognise.AuthStrategy 实现者只需抛出携带对应 code 的 AuthException —— 或裸的领域类型\nAuthFail —— 即可参与映射;mapAuthFailure 负责转换,并保留它无法识别的自定义错误类型。\n// Both of these map to InvalidCredentialsException / 两者都会映射为 InvalidCredentialsException\nthrow AuthException('Wrong password', code: 'invalid_credentials');\nthrow const AuthFail('Wrong password', code: 'invalid_credentials');\ncode\tMapped type / 映射结果\tinvalid_credentials\tInvalidCredentialsException\tinvalid_grant, invalid_refresh_token, token_expired, session_expired\tSessionExpiredException\tno_active_session\tNoActiveSessionException\trefresh_token_missing\tRefreshTokenMissingException\tanything else / 其它\tpreserved as-is, or UnexpectedAuthException / 原样保留或包装为 UnexpectedAuthException\t\nCodes raised by the manager itself (not your strategy) / 由管理器自身产生的 code:\ncode\tRaised when / 何时触发\tauth_flow_in_progress\tAnother login / register / loginWith is already running / 已有登录流程在执行\tmanager_disposed\tAn operation is called after dispose() / dispose() 后又调用操作\t\nOverlapping authentication flows are rejected rather than racing: a second\nlogin() while one is in flight throws auth_flow_in_progress.重叠的登录流程会被拒绝而非争抢:进行中再次 login() 会抛 auth_flow_in_progress。\nAuthStrategy implementations should map transport/API errors into AuthException (or a custom AppException subclass) so callers get a stable code.AuthStrategy 实现方应将传输层/API 错误映射为 AuthException(或自定义 AppException 子类),让调用方拿到稳定的 code。","resultt--结果类型#Result / 结果类型":"For explicit, exception-free handling, use Result:如需显式、无异常的处理,可使用 Result:\nsealed class Result {\n const factory Result.ok(T value) = Ok;\n const factory Result.err(AppException error) = Err;\n}\nfinal result = await auth.tryLogin(user, pass); // returns Result\nswitch (result) {\n case Ok(:final value):\n /* authenticated / 已认证 */\n case Err(:final error):\n /* inspect error.code / 读取 error.code */\n}","how-failures-surface--失败如何呈现#How failures surface / 失败如何呈现":"Situation\tSurfaced as\tlogin/register fails\tAuthState becomes AuthError(error) and the future throws AppException / 状态变为 AuthError,且 future 抛出 AppException\trefresh fails\tAuthError(error) emitted, then Unauthenticated (unrecoverable) or back to Authenticated (transient) per the failure policy / 发出 AuthError,随后按失败策略转为 Unauthenticated(不可恢复)或回到 Authenticated(瞬时)\trestore() finds nothing\tStays Unauthenticated (not an error) / 保持 Unauthenticated(不算错误)\trestore() renewal fails transiently\tSession kept (the next validAccessToken() retries); only a terminal failure clears the store / 保留会话(下次 validAccessToken() 会重试);只有终局失败才清空存储\tTokenStore.load() throws\tMapped to AppException, treated as \"no session\" / 映射为 AppException,按\"无会话\"处理","next-steps--下一步#Next Steps / 下一步":"Backend Strategy — Map your API errors / 映射你的 API 错误\nConfiguration — Tuning refresh & retry / 调优刷新与重试"}},"/Getting-Started":{"title":"Getting Started / 快速开始","data":{"quick-start--快速开始#Quick Start / 快速开始":"zero_auth is a headless core: you bring the backend (AuthStrategy) and the persistence (TokenStore), and AuthManager drives the session lifecycle.zero_auth 是一个无头内核:你提供后端(AuthStrategy)与持久化(TokenStore),AuthManager 负责驱动会话生命周期。\nimport 'package:zero_auth/zero_auth.dart';\n// 1. Implement your backend (four methods only).\n// 1. 实现你的后端(只需四个方法)。\nfinal strategy = MyAuthStrategy();\n// 2. Pick a token store. InMemoryTokenStore ships in-core; for production use\n// the flutter_secure_storage reference in example/lib/secure_token_store.dart.\n// 2. 选择令牌存储。内核自带 InMemoryTokenStore;生产环境请用\n// example/lib/secure_token_store.dart 中的 flutter_secure_storage 实现。\nfinal store = InMemoryTokenStore();\n// 3. Create the manager and restore any persisted session.\n// 3. 创建管理器并恢复已持久化的会话。\nfinal auth = AuthManager(strategy: strategy, tokenStore: store);\nawait auth.restore();\nThen drive the UI from the state stream:随后用状态流驱动界面:\nauth.state.listen((state) {\n switch (state) {\n case Unauthenticated():\n showLoginScreen();\n case Authenticating():\n case LoggingOut():\n showSpinner();\n case Authenticated(:final session):\n showHome(session.userId);\n case Refreshing(:final session):\n // Still signed in: only the token is being renewed.\n // 仍处于已登录状态:只是令牌在续期。\n showHome(session.userId, renewing: true);\n case AuthError(:final error):\n showError(error);\n }\n});\nIf you prefer if/else over exhaustiveness, use state.isAuthenticated and\nstate.isBusy instead of state is Authenticated — isAuthenticated stays\ntrue during Refreshing, so your UI never bounces back to the login screen\nmid-refresh.若不想用穷举匹配,请用 state.isAuthenticated / state.isBusy 代替\nstate is Authenticated——isAuthenticated 在 Refreshing 期间仍为 true,\n界面不会在刷新途中退回登录页。","the-lifecycle--生命周期#The Lifecycle / 生命周期":"AuthManager broadcasts an explicit, sealed state machine:AuthManager 广播一个显式、密封的状态机:\nState\tMeaning\tUnauthenticated\tNo session / 无会话\tAuthenticating\tlogin/register in flight / 登录/注册进行中\tAuthenticated\tA valid session is present / 存在有效会话\tRefreshing\tSession renewal in flight; the previous session stays usable / 续期进行中,旧会话仍可用\tLoggingOut\tlogout() in flight / 登出进行中\tAuthError\tThe last operation failed / 上一次操作失败\t\nThe stream replays the latest value to new listeners, so a widget renders the correct screen on its first frame.状态流会向新订阅者重放最近的值,因此 widget 在首帧即可渲染正确界面。","next-steps--下一步#Next Steps / 下一步":"Installation — Detailed installation methods / 详细安装方式\nUsage — Full usage guide / 完整使用指南\nBackend Strategy — Implement AuthStrategy / 实现后端边界"}},"/Network-Integration":{"title":"Network Integration / 网络集成","data":{"":"AuthManager implements AuthTokenSource, so HTTP clients can read the current access token without depending on the manager object. This keeps your Dio setup decoupled from zero_auth.AuthManager 实现了 AuthTokenSource,因此 HTTP 客户端可在不依赖管理器对象的前提下读取当前访问令牌,使 Dio 配置与 zero_auth 解耦。","authtokensource--令牌源#AuthTokenSource / 令牌源":"abstract class AuthTokenSource {\n String? get accessToken; // may be expired / 可能已过期\n Future validAccessToken({Duration? leeway}); // renewed first / 先续期\n}\nAuthManager.accessToken returns the current token, or null when unauthenticated — and it may already be expired.AuthManager.accessToken 返回当前令牌;未认证时返回 null —— 而且它可能已经过期。validAccessToken() is the one to use before a request: it renews first (reusing\nthe single-flight refresh) and returns null only when there is nothing to send.\nleeway is how long the token must stay valid for, defaulting to the manager's\nclockSkew (30s), so a token that would die mid-request is renewed first.请求之前应当用 validAccessToken():它会在必要时先续期(复用单飞刷新),只有真的无令牌\n可发时才返回 null。leeway 表示令牌必须还能维持有效的时长,默认取管理器的\nclockSkew(30 秒),因此会在请求途中失效的令牌会被提前续期。","dio-interceptor--dio-拦截器#Dio interceptor / Dio 拦截器":"example/lib/dio_interceptor.dart ships a ready-to-use interceptor:example/lib/dio_interceptor.dart 提供了一个开箱即用的拦截器:\nclass AuthInterceptor extends Interceptor {\n AuthInterceptor(this.tokens); // an AuthTokenSource / 一个令牌源\n final AuthTokenSource tokens;\n @override\n void onRequest(RequestOptions o, RequestInterceptorHandler h) async {\n final t = await tokens.validAccessToken(); // renews first / 先续期\n if (t != null) o.headers['Authorization'] = 'Bearer $t';\n h.next(o);\n }\n}\nIf you only want the synchronous read, use tokens.accessToken — but remember\nit may hand you a token that has already expired.\n若只需要同步读取,可用 tokens.accessToken —— 但请记住它可能返回一个已过期的令牌。\nfinal dio = Dio()\n ..interceptors.add(AuthInterceptor(authManager));\nWhen a 401 is returned, refresh the session and retry; if refresh fails, the manager emits AuthError and your app routes back to login.当返回 401 时,刷新会话并重试;若刷新失败,管理器会发出 AuthError,应用随之跳转登录。example/lib/dio_interceptor.dart also ships AuthRetryInterceptor, which does\nexactly that — refreshes through the manager (single-flight) and replays the\nrequest once, so a retry loop cannot form:example/lib/dio_interceptor.dart 里还提供了 AuthRetryInterceptor,正是做这件事 ——\n通过管理器刷新(单飞)并把请求重试一次,因此不会形成重试风暴:\nfinal dio = Dio()\n ..interceptors.add(\n AuthRetryInterceptor(manager: authManager, dio: dio),\n );\nTwo more interceptor variants there / 那里还有另外两种拦截器:\nRefreshingAuthInterceptor — never sends an expired token: it renews first via\nvalidAccessToken() / 绝不发送过期令牌,先用 validAccessToken() 续期。\nAuthInterceptor — the plain AuthTokenSource version / 基础的令牌源版本。","other-http-clients--其它客户端#Other HTTP clients / 其它客户端":"Because AuthTokenSource is a plain interface, the same pattern works for package:http, chopper, or any client that lets you mutate request headers.由于 AuthTokenSource 是普通接口,同样的模式适用于 package:http、chopper 或任何允许修改请求头的客户端。","next-steps--下一步#Next Steps / 下一步":"Usage — Full integration walkthrough / 完整集成讲解\nErrors — Handling 401 and refresh failures / 处理 401 与刷新失败"}},"/Multi-Account":{"title":"Multiple Accounts / 多账号","data":{"first-which-kind-do-you-need--先分清你需要哪一种#First: which kind do you need? / 先分清你需要哪一种":"\"Multiple accounts\" means two very different things, and they do not need the\r\nsame solution:「多账号」其实指两种截然不同的需求,它们的解法不一样:\nShape\tDescription\tRecommendation\tSwitching / 账号切换\tSeveral accounts are remembered, but only one is used at a time (like Gmail's account picker) / 记住多个账号,但同时只用一个\tPlain AuthManager: logout() then login(). No new API. / 普通 AuthManager:登出再登录,不需要新 API\tConcurrent / 账号并存\tSeveral accounts are signed in at the same time and all can make requests / 多个账号同时登录,且都能发请求\tAuthManagerGroup — one AuthManager per account\t\nMost apps only need the first. Reach for AuthManagerGroup only when an account\r\nmust keep working while another one is in the foreground.多数应用只需要第一种。只有当「某个账号在后台也要继续工作」时,才需要 AuthManagerGroup。","shape-1-switching--形态一切换#Shape 1: switching / 形态一:切换":"AuthManager models one session. Switching accounts is just logging out and back\r\nin — the state machine stays honest, and there is no second source of truth:\n// Keep your own list of saved account identifiers (email, phone, …).\r\n// 自己维护已保存账号标识(邮箱、手机号等)的列表。\r\nfinal savedAccounts = ['alice@example.com', 'bob@example.com'];\r\n\r\nFuture switchTo(String account, String password) async {\r\n if (auth.current.isAuthenticated) await auth.logout();\r\n await auth.login(Credentials(username: account, password: password));\r\n}\nBecause username is just an opaque identifier, this works for email or phone\r\nlogins with no extra plumbing — see\r\nBackend Strategy for what the contract fixes.","shape-2-concurrent--形态二并存#Shape 2: concurrent / 形态二:并存":"AuthManagerGroup owns one AuthManager per account and tracks which is active.\nfinal group = AuthManagerGroup(\r\n // Both factories receive the account id; sharing one strategy instance is fine.\r\n // 两个工厂都会收到账号 id;共用一个策略实例也没问题。\r\n strategyFactory: (accountId) => MyAuthStrategy(),\r\n // CRITICAL: one store per account, so persisted sessions stay isolated.\r\n // 关键:每个账号一个存储,持久化会话才不会互相覆盖。\r\n storeFactory: (accountId) => SecureTokenStore(key: 'auth_$accountId'),\r\n // Optional: every AuthManager knob is forwarded to the managers it creates.\r\n // 可选:所有 AuthManager 调参都会转发给它创建的管理器。\r\n autoRefreshAhead: const Duration(minutes: 5),\r\n clockSkew: const Duration(seconds: 30),\r\n onStateChanged: (accountId, state) => debugPrint('$accountId → $state'),\r\n // Or build them yourself / 也可自行构建:\r\n // managerFactory: (id, strategy, store) => AuthManager(...),\r\n);\r\n\r\n// Sign in (or restore) each account independently.\r\n// 各账号独立登录(或恢复)。\r\nawait group.forAccount('alice').login(\r\n Credentials(username: 'alice@example.com', password: pw),\r\n);\r\nawait group.forAccount('bob').login(\r\n Credentials(username: 'bob@example.com', password: pw),\r\n);\r\n\r\n// Pick the active one — nothing is signed out by this call.\r\n// 选择激活账号 —— 此调用不会登出任何账号。\r\ngroup.switchTo('bob');","reading-the-active-account--读取激活账号#Reading the active account / 读取激活账号":"The group mirrors the active account and implements AuthTokenSource, so\r\ninterceptors keep depending on the narrow interface:\ngroup.current // active account's AuthState / 激活账号的状态\r\ngroup.currentSession // active account's session / 激活账号的会话\r\ngroup.accessToken // active account's token / 激活账号的令牌\r\ngroup.state // stream that follows the active account / 跟随激活账号的流\r\ngroup.activeIdChanges // stream of the active account id / 激活账号 id 的流\r\n\r\nawait group.validAccessToken(); // renewed token of the active account / 激活账号续期后的令牌\r\n\r\ndio.interceptors.add(RefreshingAuthInterceptor(group)); // renews then attaches / 先续期再附加","restoring-on-startup--启动时恢复#Restoring on startup / 启动时恢复":"// `knownIds` comes from your own saved-account list.\r\n// `knownIds` 来自你自己保存的账号列表。\r\nawait group.restoreAll(knownIds, activeId: lastUsedId);\nOne account failing to restore does not abandon the rest: every account is\r\nattempted and the first error is thrown at the end.某个账号恢复失败不会连累其余账号:所有账号都会被尝试,最后统一抛出第一个错误。","adding-accounts-explicitly--显式新增账号#Adding accounts explicitly / 显式新增账号":"forAccount creates on first use; addAccount is the same call with a name that\r\nreads better when you mean \"register an account here\":forAccount 首次使用时创建;addAccount 是同一个调用,只是名字在「这里注册账号」的\r\n语境下更好读:\nfinal alice = group.addAccount('alice');","removing-an-account--移除账号#Removing an account / 移除账号":"await group.remove('alice'); // logs out, disposes, forgets it","signing-everything-out--全部登出#Signing everything out / 全部登出":"await group.logoutAll(); // every account, in turn\nIf the removed account was active, the group becomes inactive and emits\r\nUnauthenticated.","teardown--释放#Teardown / 释放":"await group.disposeAll(); // disposes every manager and closes the stream","why-the-core-stays-single-session--为什么核心仍是单会话#Why the core stays single-session / 为什么核心仍是单会话":"AuthManager answers a singular question: who is logged in? Multi-account asks\r\na different one: of these signed-in identities, which is active?Folding the second into the first would mean a Map of sessions inside every\r\nstate, a Refreshing that needs an account id, and a breaking change for every\r\nexisting user. Keeping AuthManager single-session and adding an opt-in\r\ncoordinator gives you both without muddying either.把第二个问题塞进第一个,会导致每个状态里都带一个会话 Map、Refreshing 需要携带账号\r\nid,并且对所有现有用户构成破坏性变更。让 AuthManager 保持单会话、再提供一个可选的\r\n协调层,两者都能得到,且互不污染。","gotchas--注意点#Gotchas / 注意点":"Never share one TokenStore between accounts — the second save() would\r\noverwrite the first session. / 绝不要在多个账号间共用一个 TokenStore,第二次\r\nsave() 会覆盖第一个会话。\nSwitching does not sign anyone out; all managers keep refreshing in the\r\nbackground. / 切换不会登出任何账号,所有管理器仍会在后台续期。\nremove() also disposes that manager — do not use it afterwards. /\r\nremove() 会同时释放该管理器,之后不要再使用它。","next-steps--下一步#Next Steps / 下一步":"Auth State Machine — what each manager emits / 各管理器会发出什么\nToken Store — keying persisted sessions per account / 按账号隔离持久化会话\nBackend Strategy — what the contract fixes / 契约固定了什么"}},"/Third-Party-Login":{"title":"Third-Party Login / 第三方登录","data":{"the-key-idea--核心思路#The key idea / 核心思路":"zero_auth does not run the OAuth handshake — and that is deliberate. It is a\r\npure-Dart package, so it ships no platform code, no browser plumbing and no\r\nredirect URL handling (those live outside lib/).Instead, zero_auth manages what comes after the handshake: the session your\r\nown backend issues.zero_auth 不负责 OAuth 握手环节,这是刻意为之:它是纯 Dart 包,不含任何平台\r\n代码、浏览器通道或重定向回调处理(这些只能在 lib/ 之外)。它负责的是握手之后的事:你自己后端所签发的会话。\nprovider consent ──► code / id_token ──► YOUR BACKEND ──► your access + refresh tokens ──► zero_auth\r\n (outside) (any language) (this package)\nBecause the login method is invisible to the manager, password login and\r\nGoogle/Apple login end up sharing one token lifecycle. That is the whole\r\npoint.登录方式对管理器是透明的,因此密码登录与 Google / Apple 登录最终共享同一套\r\n令牌生命周期机制。这正是关键所在。","when-this-fits--适用场景#When this fits / 适用场景":"This pattern fits when your backend brokers the provider — you have a\r\nbackend, it verifies the provider token, then mints your own tokens.下面的模式适用于你的后端作为经纪方:你有后端,它校验提供方令牌,然后签发你自己的\r\n令牌。\nScenario\tFit\tOwn backend + Google/Apple/GitHub sign-in / 自有后端 + 第三方登录\t✅ Ideal / 理想\tOwn backend + password AND social logins / 自有后端,密码与社交登录并存\t✅ One shared session model / 共用一套会话模型\tFirebase/Supabase issues the tokens directly / 由 Firebase/Supabase 直接签发令牌\t⚠️ Use their SDK instead / 请改用其 SDK","wiring-it-up--接线步骤#Wiring it up / 接线步骤":"1. Run the handshake outside this package.\r\nUse whichever OAuth client fits your targets (flutter_web_auth, AppAuth, or a\r\nserver-side flow). This code lives in your app or a sibling Flutter package, never\r\nin lib/.1. 在本包之外完成握手。\r\n使用适合目标平台的 OAuth 客户端(flutter_web_auth、AppAuth 或服务端流程)。这部分\r\n代码放在你的应用或兄弟 Flutter 包里,绝不放在 lib/。2. Trade the provider credential for your tokens on your backend.2. 用提供方凭据到你的后端换取自有令牌。\nFuture signInWithGoogle(BuildContext context) async {\r\n // 1) Provider consent (outside zero_auth) → authorization code / id_token.\r\n // 提供方授权(零依赖,在 zero_auth 之外)→ 授权码 / id_token。\r\n final code = await myOAuthClient.authenticate();\r\n\r\n // 2) Your backend verifies it with Google and returns YOUR tokens.\r\n // 你的后端用它与 Google 校验,并返回「你自己的」令牌。\r\n final res = await myApi.post('/auth/google', data: {'code': code});\r\n\r\n return AuthSession(\r\n accessToken: res['accessToken'] as String,\r\n refreshToken: RefreshToken(res['refreshToken'] as String),\r\n expiresAt: DateTime.now().add(Duration(seconds: res['expiresIn'] as int)),\r\n userId: res['userId'] as String,\r\n );\r\n}\n3. Hand the session to the manager with loginWith.\r\nloginWith is the escape hatch for flows that are not login / register: the\r\nmanager still owns persistence, the state machine and proactive refresh.3. 用 loginWith 把会话交给管理器。\r\nloginWith 是为那些不符合 login / register 形态的流程准备的逃生口:持久化、状态机\r\n与主动刷新仍由管理器负责。\nfinal result = await auth.loginWith((strategy) => signInWithGoogle(context));\r\n// emits Authenticating → Authenticated, or AuthError + rethrow.\r\n// 发出 Authenticating → Authenticated;失败则发 AuthError 并重新抛出。\nKeep signInWithGoogle throwing AuthException with a meaningful code\r\n('invalid_credentials', 'session_expired', …) so failures map to the typed\r\nexceptions in Errors.让 signInWithGoogle 抛出带有实用 code('invalid_credentials'、\r\n'session_expired' 等)的 AuthException,这样失败就能映射为\r\nErrors 里的具体异常类型。","same-model-for-every-method--各登录方式共用一套模型#Same model for every method / 各登录方式共用一套模型":"// Password users and Google users end up with the same AuthSession shape.\r\n// 密码用户与 Google 用户最终得到的都是同一种 AuthSession。\r\nawait auth.login(const Credentials(username: user, password: pass));\r\nawait auth.loginWith((strategy) => signInWithGoogle(context));\nYour UI only ever reads state.isAuthenticated, and your network layer only ever\r\nreads auth.validAccessToken() — regardless of how the user signed in.你的界面只需要读 state.isAuthenticated,网络层只需要读 auth.validAccessToken()\r\n——无需关心用户是哪种方式登录的。","also-works-for--同样适用于#Also works for / 同样适用于":"loginWith covers any flow you drive yourself:loginWith 适用于任何由你驱动的流程:\nMagic links / email OTP / 魔法链接、邮箱验证码\nPasskeys / WebAuthn\nBiometric re-unlock (load a locked session and re-mint tokens) / 生物识别解锁\nMigrating from another auth SDK / 从其它认证 SDK 迁移","next-steps--下一步#Next Steps / 下一步":"Backend Strategy — what your strategy must implement / 策略需要实现什么\nAuth State Machine — the states this flow emits / 该流程发出的状态\nErrors — typed exceptions and codes / 类型化异常与 code"}},"/Token-Store":{"title":"Token Store / 令牌存储","data":{"":"TokenStore is the only persistence surface. The core never touches disk, secure storage, or SharedPreferences directly — you decide where tokens live.TokenStore 是唯一的持久化接口。内核绝不直接访问磁盘、安全存储或 SharedPreferences——由你决定令牌存放位置。","the-contract--契约#The contract / 契约":"abstract class TokenStore {\n Future save(AuthSession session);\n Future load();\n Future clear();\n}\nAuthManager calls load() during restore(), save() after every successful login/refresh, and clear() on logout or a failed refresh.AuthManager 在 restore() 时调用 load(),每次登录/刷新成功后调用 save(),在登出或刷新失败失败时调用 clear()。","in-memory-ships-in-core--内存实现内核自带#In-memory (ships in-core) / 内存实现(内核自带)":"InMemoryTokenStore is the default. It survives process restarts? No — it is in-memory only, so the session is lost on restart. Use it for tests and quick prototypes.InMemoryTokenStore 是默认实现,仅存在于内存,进程重启后会话丢失。适用于测试与原型。\nfinal auth = AuthManager(strategy: s, tokenStore: InMemoryTokenStore());","secure-storage-production--安全存储生产#Secure storage (production) / 安全存储(生产)":"example/lib/secure_token_store.dart shows a flutter_secure_storage-backed reference implementation. Adapt it for your app:example/lib/secure_token_store.dart 提供了基于 flutter_secure_storage 的参考实现,可据此改造:\nclass SecureTokenStore implements TokenStore {\n final _box = FlutterSecureStorage();\n @override\n Future save(AuthSession s) =>\n _box.write(key: 'zero_auth', value: jsonEncode(s.toJson()));\n @override\n Future load() async {\n final raw = await _box.read(key: 'zero_auth');\n // tryFromJson: corrupt data means \"not signed in\", not a crash.\n // tryFromJson:数据损坏代表「未登录」,而不是崩溃。\n return AuthSession.tryFromJson(raw == null ? null : jsonDecode(raw));\n }\n @override\n Future clear() => _box.delete(key: 'zero_auth');\n}\nPersist tokens only in OS-backed secure storage (Keychain / Keystore). Never store refresh tokens in plain SharedPreferences.令牌只应存放在操作系统级安全存储(Keychain / Keystore)中,切勿把刷新令牌明文存入 SharedPreferences。","next-steps--下一步#Next Steps / 下一步":"Session Persistence — persist sessions across restarts (serialization + reference stores) / 跨重启持久化会话(序列化与参考存储)\nBackend Strategy — The other boundary / 另一个边界\nErrors — What load() failures become / load() 失败会变成什么"}},"/Usage":{"title":"Usage / 使用指南","data":{"three-step-integration--三步集成#Three-step integration / 三步集成":"","1-implement-authstrategy--实现后端边界#1. Implement AuthStrategy / 实现后端边界":"class MyAuthStrategy implements AuthStrategy {\n @override\n Future login(Credentials credentials) => myApi.login(\n credentials.username, // an email or phone works too\n credentials.password,\n );\n @override\n Future register(RegistrationInput input) =>\n myApi.register(input.username, input.password);\n @override\n Future logout(SessionHandle handle) => myApi.logout(handle.userId);\n @override\n Future refresh(RefreshToken token) =>\n myApi.refresh(token.value);\n}","2-create-authmanager-and-restore--创建管理器并恢复#2. Create AuthManager and restore / 创建管理器并恢复":"final auth = AuthManager(\n strategy: MyAuthStrategy(),\n tokenStore: InMemoryTokenStore(),\n);\nawait auth.restore(); // rehydrate a persisted session / 恢复持久化会话","3-drive-the-ui-from-the-state-stream--用状态流驱动界面#3. Drive the UI from the state stream / 用状态流驱动界面":"await for (final state in auth.state) {\n // render based on state / 根据状态渲染\n}","operations--操作#Operations / 操作":"Method\tDescription\trestore()\tRehydrate the persisted session at startup / 启动时恢复会话\tlogin(username, password)\tAuthenticate and enter Authenticated / 登录并进入已认证\tregister(username, password)\tRegister and authenticate / 注册并认证\tlogout()\tClear the session / 清除会话\trefresh()\tSilent token refresh — concurrent calls share one flight / 静默刷新,并发共享单飞\ttokenSource\tAn AuthTokenSource for HTTP clients / 供 HTTP 客户端使用的令牌源","token-refresh--expiry--令牌刷新与过期#Token refresh & expiry / 令牌刷新与过期":"AuthSession exposes isExpired so callers can refresh proactively. refresh() is single-flight: if several callers request a refresh at once, only one network call is made and its result is shared.AuthSession 提供 isExpired,调用方可主动刷新。refresh() 为单飞:多个调用方同时请求刷新时,只发起一次网络调用并共享结果。 A failed refresh clears the session and emits AuthError.刷新失败会清空会话并发出 AuthError。","attaching-the-bearer-token--附加-bearer-令牌#Attaching the bearer token / 附加 Bearer 令牌":"AuthManager implements AuthTokenSource, so a Dio interceptor can read the current token without depending on the manager. See Network Integration.AuthManager 实现了 AuthTokenSource,Dio 拦截器可在不依赖管理器的情况下读取当前令牌。详见网络集成。","next-steps--下一步#Next Steps / 下一步":"Auth State Machine — State lifecycle details / 状态机细节\nBackend Strategy — Full AuthStrategy contract / 完整后端契约\nErrors — Exception & Result model / 异常与结果模型"}},"/Persistence":{"title":"Session Persistence / 会话持久化","data":{"":"InMemoryTokenStore loses the session on every restart. For a real app you must persist it — and zero_auth now ships the primitives that make this a one-liner: session (de)serialization plus two ready-to-copy reference stores.InMemoryTokenStore 会在每次重启时丢失会话。真实应用必须把它持久化——而 zero_auth 现在提供了让这件事变成「一行代码」的原语:会话(反)序列化,外加两个开箱即用的参考存储。","1-serialize-the-session--序列化会话#1. Serialize the session / 序列化会话":"AuthSession is JSON-safe out of the box. null fields are omitted, and claims round-trips as a plain map.AuthSession 天生就是 JSON 安全的:null 字段会被省略,claims 作为普通映射原样往返。\nfinal json = session.toJson(); // Map\nfinal restored = AuthSession.fromJson(json);\n// Prefer this inside TokenStore.load(): malformed data yields null (\"not signed\n// in\") instead of a FormatException / TypeError.\n// TokenStore.load() 里请优先用它:畸形数据返回 null(即「未登录」),而不是抛\n// FormatException / TypeError。\nfinal safe = AuthSession.tryFromJson(json);\nRefreshToken serializes to its raw string too, so a persisted session rehydrates losslessly.RefreshToken 也序列化为其原始字符串,因此持久化的会话可无损恢复。","2-flutter--encrypted-secure-storage--flutter--加密安全存储#2. Flutter — encrypted secure storage / Flutter —— 加密安全存储":"For mobile, persist into the OS keychain / keystore via flutter_secure_storage. A reference implementation lives at example/lib/secure_token_store.dart:在移动端,通过 flutter_secure_storage 把令牌存入系统钥匙串 / Keystore。参考实现位于 example/lib/secure_token_store.dart:\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: SecureTokenStore(), // from example/\n);\nawait auth.restore(); // rehydrate before the first frame\nNever store refresh tokens in plain SharedPreferences. Use OS-backed secure storage only.切勿把刷新令牌明文存入 SharedPreferences,只使用操作系统级安全存储。","3-server--cli--desktop--json-file--服务端--cli--桌面--json-文件#3. Server / CLI / desktop — JSON file / 服务端 / CLI / 桌面 —— JSON 文件":"On non-mobile Dart targets you can write the serialized session to a file. A reference implementation lives at example/lib/json_token_store.dart:在非移动端 Dart 目标上,可以把序列化后的会话写入文件。参考实现位于 example/lib/json_token_store.dart:\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: FileTokenStore(File('.zero_auth_session.json')),\n);\nThis is ideal for a backend that keeps a logged-in user across process restarts, or a CLI that stays authenticated between invocations.这非常适合「重启后仍保持登录」的后端,或「多次调用之间保持认证」的 CLI。","4-combine-with-auto-refresh--结合自动刷新#4. Combine with auto-refresh / 结合自动刷新":"When you enable proactive auto-refresh, the manager renews the token before it expires — and every renewal is persisted through save(), so the on-disk session always carries a fresh access token.当你开启主动自动刷新时,管理器会在令牌过期前续期——而每次续期都会通过 save() 落盘,因此磁盘上的会话始终持有崭新的访问令牌。\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: SecureTokenStore(),\n autoRefreshAhead: const Duration(minutes: 5),\n);","next-steps--下一步#Next Steps / 下一步":"Token Store — the save / load / clear contract / save / load / clear 契约\nConfiguration — enable proactive auto-refresh / 开启主动自动刷新"}},"/":{"title":"Zero Auth","data":{"":"A backend-agnostic auth state machine & session lifecycle core for Dart/Flutter. Pure Dart, headless, no HTTP/SDK/native code. Wire your own backend via AuthStrategy and your own persistence via TokenStore.一个后端无关的认证状态机与会话生命周期内核,面向 Dart/Flutter。纯 Dart、无头(headless),不含 HTTP、SDK 或原生代码。通过 AuthStrategy 接入自有后端,通过 TokenStore 接入自有持久化层。","-features--功能特性#✨ Features / 功能特性":"Feature\tDescription\tBackend-agnostic\tImplement only login/register/logout/refresh; REST, gRPC, Firebase or a private RPC are equally valid targets / 只需实现四个方法,REST、gRPC、Firebase 或私有 RPC 均可接入\tAuth State Machine\tSealed Unauthenticated / Authenticating / Authenticated / Refreshing / LoggingOut / AuthError on a Stream that replays the latest value to new listeners; use isAuthenticated / isBusy / 密封六态状态机 + 重放最近值的状态流,可用 isAuthenticated / isBusy\tSilent Restore\trestore() rehydrates the persisted session at startup / 启动时静默恢复持久化会话\tSingle-flight Refresh\tConcurrent refresh() calls share one in-flight request instead of stampeding the backend / 并发刷新共享同一次请求,避免冲击后端\tPluggable Persistence\tThe only persistence surface is TokenStore.save/load/clear; InMemoryTokenStore ships in-core / 唯一的持久化接口,内核自带内存实现\tTyped Session\tAuthSession carries access/refresh tokens, expiry (isExpired), user id, display name and raw claims / 强类型会话,无需手工解析令牌\tUnified Errors\tEvery failure maps to AppException (AuthException for auth cases) or a Result wrapper; raw exceptions never cross the public surface / 统一异常与结果,裸异常不越界\tNetwork Integration\tAuthManager itself is an AuthTokenSource, so a Dio interceptor can attach Authorization: Bearer without depending on the manager / 管理器即令牌源,Dio 拦截器零依赖附加令牌\tSession Serialization\tAuthSession.toJson / fromJson make persistence a one-liner, and tryFromJson returns null on malformed data; a file-based reference store ships for server/CLI / AuthSession.toJson / fromJson 让持久化一行搞定,tryFromJson 遇畸形数据返回 null,并附带面向服务端 / CLI 的文件参考存储\tProactive Auto-refresh\tPass autoRefreshAhead to renew tokens before expiry (single-flight) / 传入 autoRefreshAhead 在过期前自动续期(单飞)\tNever an Expired Token\tvalidAccessToken() renews first when the token has expired — or is about to, per clockSkew — so interceptors never send a dead bearer token / 令牌过期(或按 clockSkew 即将过期)时先续期,拦截器不会发出失效令牌\tBring Your Own Login\tloginWith adopts a session from any flow you drive: third-party OAuth, magic links, passkeys / loginWith 可接纳第三方 OAuth、魔法链接、Passkey 等自定义流程\tTyped Auth Exceptions\tInvalidCredentialsException, SessionExpiredException and friends, mapped from your strategy's code / InvalidCredentialsException、SessionExpiredException 等,由策略的 code 映射而来\tConfigurable Failure Policy\trefreshFailurePolicy decides whether a failed refresh signs the user out / refreshFailurePolicy 决定刷新失败是否登出\tRunnable Example\tA full Flutter demo app (Android/iOS/Web/Windows) plus a layered dart:io demo backend that issues real JWTs / 完整 Flutter 示例与一个签发真实 JWT 的分层演示后端\tMultiple Accounts\tOptional AuthManagerGroup keeps one manager per account / 可选 AuthManagerGroup,每账号一个管理器\tCross-platform\tPure Dart — runs anywhere Dart or Flutter runs / 纯 Dart,跨平台","-table-of-contents--目录#📚 Table of Contents / 目录":"Page\tDescription\tGetting Started\tQuick start guide / 快速开始\tInstallation\tHow to install / 安装方式\tUsage\tDetailed usage / 详细使用\tAuth State Machine\tState lifecycle & stream / 状态机与状态流\tBackend Strategy\tImplement AuthStrategy / 实现后端边界\tToken Store\tPersistence boundary / 持久化边界\tNetwork Integration\tDio interceptor & token source / 网络集成与拦截器\tErrors\tException & Result model / 异常与结果模型\tConfiguration\tConfiguration options / 配置说明\tSession Persistence\tRestoring and renewing a saved session / 会话持久化与恢复\tThird-Party Login\tOAuth / magic links via loginWith / 用 loginWith 接入第三方登录\tMultiple Accounts\tSwitching vs concurrent accounts / 账号切换与多账号并存\tFAQ\tFrequently asked questions / 常见问题","-links--链接#🔗 Links / 链接":"GitHub\nOfficial Website\npub.dev","-license--许可证#📄 License / 许可证":"This project is licensed under the Mozilla Public License 2.0 (MPL-2.0).本项目采用 Mozilla Public License 2.0 (MPL-2.0) 许可证。This package is provided \"as is\", without warranty of any kind. The author assumes no responsibility or liability for the functionality, security, or any consequences arising from the use of modified versions or derivative projects.本包按\"原样\"提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。"}},"/Installation":{"title":"Installation / 安装","data":{"from-pubdev-recommended--从-pubdev-安装推荐#From pub.dev (Recommended) / 从 pub.dev 安装(推荐)":"Add the following to your pubspec.yaml:在 pubspec.yaml 中添加以下依赖:\ndependencies:\n zero_auth: ^1.0.0\nThen run:然后运行:\ndart pub get","from-github--从-github-安装#From GitHub / 从 GitHub 安装":"Alternatively, install from GitHub:或者从 GitHub 安装:\ndependencies:\n zero_auth:\n git:\n url: https://github.com/zero-labsco/zero_auth.git\n ref: release/v1.0.0","import--导入#Import / 导入":"import 'package:zero_auth/zero_auth.dart';","requirements--环境要求#Requirements / 环境要求":"Requirement\tVersion\tDart SDK\t>= 3.4.0\tFlutter\t>= 3.0.0 (only a constraint; no Flutter runtime dependency)\t\nzero_auth is a pure-Dart package. Flutter is only listed because the example app uses it; the core has no Flutter dependency.zero_auth 是纯 Dart 包,仅因示例 App 用到 Flutter 才列出;内核本身不依赖 Flutter。","next-steps--下一步#Next Steps / 下一步":"Getting Started — Quick start guide / 快速开始\nUsage — Full usage guide / 完整使用指南"}},"/FAQ":{"title":"FAQ / 常见问题","data":{"does-zero_auth-ship-an-http-client--zero_auth-内置-http-客户端吗#Does zero_auth ship an HTTP client? / zero_auth 内置 HTTP 客户端吗?":"No. The core is backend-agnostic and ships no HTTP, SDK or native code. You implement AuthStrategy to call your backend.不。内核后端无关,不含任何 HTTP、SDK 或原生代码。你通过实现 AuthStrategy 来调用自己的后端。","does-it-depend-on-flutter--它依赖-flutter-吗#Does it depend on Flutter? / 它依赖 Flutter 吗?":"No. It is a pure-Dart package; flutter: is only a declared constraint because the example app uses Flutter. The runtime has no Flutter dependency.不。它是纯 Dart 包;仅因示例 App 用到 Flutter 才声明 flutter: 约束,运行时不依赖 Flutter。","where-are-tokens-stored--令牌存在哪里#Where are tokens stored? / 令牌存在哪里?":"Wherever your TokenStore puts them. The core ships InMemoryTokenStore (lost on restart). For production, use the flutter_secure_storage-backed reference in example/lib/secure_token_store.dart.取决于你的 TokenStore。内核自带 InMemoryTokenStore(重启即丢失)。生产请用 example/lib/secure_token_store.dart 中基于 flutter_secure_storage 的参考实现。","what-happens-on-a-failed-refresh--刷新失败会怎样#What happens on a failed refresh? / 刷新失败会怎样?":"The failure is always reported as AuthError and rethrown. What happens next\ndepends on refreshFailurePolicy: an unrecoverable failure (SessionExpiredException,\nInvalidCredentialsException) clears the store and lands on Unauthenticated,\nwhile a transient one (network, 5xx) keeps the previous session so a retry can\nsucceed. See Configuration.失败总会以 AuthError 上报并重新抛出。之后如何取决于 refreshFailurePolicy:\n不可恢复的失败(SessionExpiredException、InvalidCredentialsException)会清空存储\n并落到 Unauthenticated;瞬时故障(网络、5xx)保留上一个会话以便重试成功。参见\n配置。","is-token-refresh-concurrent-safe--令牌刷新是否并发安全#Is token refresh concurrent-safe? / 令牌刷新是否并发安全?":"Yes — refresh() is single-flight: many concurrent callers share one in-flight request and its result.是的——refresh() 为单飞:多个并发调用方共享同一次请求及其结果。","how-do-i-attach-the-bearer-token-to-requests--如何给请求附加-bearer-令牌#How do I attach the bearer token to requests? / 如何给请求附加 Bearer 令牌?":"AuthManager is an AuthTokenSource. Use the Dio interceptor in example/lib/dio_interceptor.dart, or read accessToken directly — but prefer validAccessToken() (or RefreshingAuthInterceptor) whenever the token reaches a server, since accessToken may already be expired.AuthManager 即 AuthTokenSource。可用 example/lib/dio_interceptor.dart 中的 Dio 拦截器,或直接读取 accessToken —— 但只要令牌要发到服务端,就请优先用 validAccessToken()(或 RefreshingAuthInterceptor),因为 accessToken 可能已经过期。","does-it-sync-sessions-across-devices--它会在多设备间同步会话吗#Does it sync sessions across devices? / 它会在多设备间同步会话吗?":"No — and by design. A session is local state plus whatever your backend decides;\npushing \"signed out elsewhere\" to a device needs server push (or polling), which\nis outside a headless state machine. Model it with your backend revoking the\ngrant: the next refresh fails, refreshFailurePolicy signs the device out, and\nAuthError explains why.不会 —— 这是有意的设计。会话是本地状态加上你后端的决定;把「在别处已登出」推送到某台\n设备需要服务端推送(或轮询),那超出了无头状态机的职责。可由后端吊销授权来实现:下一次\n刷新失败,refreshFailurePolicy 会让该设备登出,并由 AuthError 说明原因。","which-platforms-are-supported--支持哪些平台#Which platforms are supported? / 支持哪些平台?":"Any platform where Dart or Flutter runs (Android, iOS, Web, Windows, macOS, Linux).任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。"}}} \ No newline at end of file diff --git a/docs/_next/static/mMoJzqLtuvXxc8MM2_6u7/_buildManifest.js b/docs/_next/static/kjpeBAQZfIa0cAXLmehHB/_buildManifest.js similarity index 100% rename from docs/_next/static/mMoJzqLtuvXxc8MM2_6u7/_buildManifest.js rename to docs/_next/static/kjpeBAQZfIa0cAXLmehHB/_buildManifest.js diff --git a/docs/_next/static/mMoJzqLtuvXxc8MM2_6u7/_ssgManifest.js b/docs/_next/static/kjpeBAQZfIa0cAXLmehHB/_ssgManifest.js similarity index 100% rename from docs/_next/static/mMoJzqLtuvXxc8MM2_6u7/_ssgManifest.js rename to docs/_next/static/kjpeBAQZfIa0cAXLmehHB/_ssgManifest.js diff --git a/docs/index.html b/docs/index.html index 3e01aee..430c075 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,4 +1,4 @@ -
    🏠 Home

    Zero Auth

    +
    🏠 Home

    Zero Auth

    A backend-agnostic auth state machine & session lifecycle core for Dart/Flutter. Pure Dart, headless, no HTTP/SDK/native code. Wire your own backend via AuthStrategy and your own persistence via TokenStore.

    一个后端无关的认证状态机与会话生命周期内核,面向 Dart/Flutter。纯 Dart、无头(headless),不含 HTTP、SDK 或原生代码。通过 AuthStrategy 接入自有后端,通过 TokenStore 接入自有持久化层。

    ✨ Features / 功能特性

    @@ -153,4 +153,4 @@

    This project is licensed under the Mozilla Public License 2.0 (MPL-2.0).

    本项目采用 Mozilla Public License 2.0 (MPL-2.0) 许可证。

    This package is provided “as is”, without warranty of any kind. The author assumes no responsibility or liability for the functionality, security, or any consequences arising from the use of modified versions or derivative projects.

    -

    本包按”原样”提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    本包按”原样”提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。


    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/website/pages/Auth-State-Machine.md b/website/pages/Auth-State-Machine.md index febe188..8301d32 100644 --- a/website/pages/Auth-State-Machine.md +++ b/website/pages/Auth-State-Machine.md @@ -24,6 +24,10 @@ Two helpers save you from spelling out every case: 下均为 `true`,续期不会卸载已登录界面。 - `state.isBusy` — `true` while `Authenticating`, `Refreshing` or `LoggingOut`. / 在 `Authenticating`、`Refreshing`、`LoggingOut` 期间为 `true`。 +- `state.session` — the session the state carries (`Authenticated` / `Refreshing` / + `LoggingOut`), or `null`; no pattern-matching needed for the common case. / + 该状态携带的会话(`Authenticated` / `Refreshing` / `LoggingOut`)或 `null`, + 常见场景无需再做模式匹配。 ## The stream / 状态流 @@ -65,10 +69,11 @@ auth.state.listen((state) { ``` - `restore()` restores a live session → `Authenticated`. -- `restore()` restores an **expired** session → attempts `Refreshing`; if it - cannot be renewed (or there is no refresh token) the store is cleared and it - lands on `Unauthenticated`. `restore(refreshIfExpired: false)` restores it - verbatim instead. +- `restore()` restores an **expired** session → attempts `Refreshing`; if the + renewal fails *transiently* (network, 5xx) the persisted session is **kept** and + activated as-is, so the next request can retry. Only a terminal failure — or the + absence of a refresh token — clears the store and lands on `Unauthenticated`. + `restore(refreshIfExpired: false)` restores it verbatim instead. - A failed `login`/`register` → `AuthError`, and the future rethrows. - A failed `refresh` → `AuthError`, followed by `Unauthenticated` when the [failure policy](#refresh-failure-policy) considers the grant unrecoverable, @@ -77,8 +82,9 @@ auth.state.listen((state) { real change. - `restore()` 恢复未过期会话 → `Authenticated`。 -- `restore()` 恢复**已过期**会话 → 先走 `Refreshing`;若无法续期(或没有刷新令牌), - 则清空存储并落到 `Unauthenticated`。`restore(refreshIfExpired: false)` 则原样恢复。 +- `restore()` 恢复**已过期**会话 → 先走 `Refreshing`;若续期**瞬时**失败(网络、5xx), + 则**保留**持久化会话并按原样激活,下次请求可再试。只有终局失败(或没有刷新令牌) + 才会清空存储并落到 `Unauthenticated`。`restore(refreshIfExpired: false)` 则原样恢复。 - `login`/`register` 失败 → `AuthError`,同时 future 再次抛出错误。 - `refresh` 失败 → `AuthError`;随后由[失败策略](#refresh-failure-policy)决定: 认为授权不可恢复则转 `Unauthenticated`,认为只是瞬时故障则回到 `Authenticated`。 diff --git a/website/pages/Configuration.md b/website/pages/Configuration.md index 677c27f..0d69c78 100644 --- a/website/pages/Configuration.md +++ b/website/pages/Configuration.md @@ -12,8 +12,12 @@ final auth = AuthManager( tokenStore: SecureTokenStore(), // where to persist tokens / 令牌持久化位置 autoRefreshAhead: const Duration(minutes: 5), // proactive renewal / 主动续期 autoRefreshRetryDelay: const Duration(seconds: 30), // re-arm after a failed renewal / 续期失败后重新排程 + autoRefreshMaxRetries: 3, // cap those retries / 重试上限 + autoRefreshMinInterval: const Duration(seconds: 5), // floor for an already-due renewal / 已到期续期的下限 refreshFailurePolicy: defaultRefreshFailurePolicy, // sign-out rule / 登出规则 clock: () => DateTime.now(), // time source (tests, skew) / 时间源(测试、时钟偏移) + clockSkew: const Duration(seconds: 30), // treat tokens as expired this much earlier / 提前多久视为过期 + preserveSessionDetails: true, // keep identity across a tokens-only renewal / 只换令牌的续期保留身份 onStateChanged: (state) => debugPrint('$state'), // observe every emission / 观察每次状态 ); ``` @@ -24,6 +28,10 @@ final auth = AuthManager( | Token persistence | `TokenStore` | Disk / secure storage / in-memory / 磁盘/安全存储/内存 | | Refresh timing | `AuthSession.expiresAt` + `autoRefreshAhead` | Proactive renewal is scheduled that far before expiry / 在过期前该时长调度主动续期 | | Renewal retry | `autoRefreshRetryDelay` | After a *proactive* renewal fails, it is re-armed this much later (default 30s) while a session still exists / 主动续期失败后按此时长重新排程(默认 30 秒),会话仍在才重试 | +| Renewal retry cap | `autoRefreshMaxRetries` | How many failed proactive renewals to retry before giving up (default 3) / 主动续期失败最多重试几次(默认 3) | +| Renewal floor | `autoRefreshMinInterval` | Minimum wait for a renewal that is already due (default 5s), so very short-lived tokens cannot cause a tight loop / 「已到期」续期的最小等待(默认 5 秒),极短寿命令牌不会造成紧密循环 | +| Clock skew | `clockSkew` | How much earlier a token counts as expired (default 30s); absorbs a device clock that runs ahead and the latency of the request itself. **If your access tokens live under a minute, lower it** (e.g. 5s) or almost every read renews first / 提前多久把令牌视为过期(默认 30 秒),用于吸收设备时钟偏快与请求自身的延迟。**若访问令牌寿命不足一分钟,请调小**(如 5 秒),否则几乎每次读取都会先续期 | +| Identity on renewal | `preserveSessionDetails` | Carry `userId` / `displayName` / `claims` over a renewal that returns tokens only (default `true`) / 只返回令牌的续期是否保留身份字段(默认 `true`) | | Observation | `onStateChanged` | Optional callback for every emitted state, for logging or analytics / 可选回调,每次发出状态时触发,便于日志或埋点 | | Refresh failure handling | `refreshFailurePolicy` | Whether a failed refresh signs the user out / 刷新失败是否让用户登出 | | Time source | `clock` | Drives expiry maths and proactive scheduling; inject one for deterministic tests or to tolerate device clock skew / 驱动过期计算与主动刷新调度;注入时钟可实现确定性测试或容忍设备时钟偏移 | diff --git a/website/pages/Errors.md b/website/pages/Errors.md index 9f21993..fcfd823 100644 --- a/website/pages/Errors.md +++ b/website/pages/Errors.md @@ -31,11 +31,18 @@ final class UnexpectedAuthException extends AuthException {} // unexpected_a ``` `AuthStrategy` authors opt into these subtypes simply by throwing an -`AuthException` carrying the matching `code`; `mapAuthFailure` performs the -mapping, preserving any vocabulary it does not recognise. +`AuthException` — or the bare `AuthFail` domain type — carrying the matching +`code`; `mapAuthFailure` performs the mapping, preserving any vocabulary it does +not recognise. -`AuthStrategy` 实现者只需抛出携带对应 `code` 的 `AuthException` 即可参与映射; -`mapAuthFailure` 负责转换,并保留它无法识别的自定义错误类型。 +`AuthStrategy` 实现者只需抛出携带对应 `code` 的 `AuthException` —— 或裸的领域类型 +`AuthFail` —— 即可参与映射;`mapAuthFailure` 负责转换,并保留它无法识别的自定义错误类型。 + +```dart +// Both of these map to InvalidCredentialsException / 两者都会映射为 InvalidCredentialsException +throw AuthException('Wrong password', code: 'invalid_credentials'); +throw const AuthFail('Wrong password', code: 'invalid_credentials'); +``` | code | Mapped type / 映射结果 | |------|------------------------| @@ -91,6 +98,7 @@ switch (result) { | `login`/`register` fails | `AuthState` becomes `AuthError(error)` **and** the future throws `AppException` / 状态变为 `AuthError`,且 future 抛出 `AppException` | | `refresh` fails | `AuthError(error)` emitted, then `Unauthenticated` (unrecoverable) or back to `Authenticated` (transient) per the [failure policy](Configuration#refresh-failure-policy) / 发出 `AuthError`,随后按[失败策略](Configuration#refresh-failure-policy)转为 `Unauthenticated`(不可恢复)或回到 `Authenticated`(瞬时) | | `restore()` finds nothing | Stays `Unauthenticated` (not an error) / 保持 `Unauthenticated`(不算错误) | +| `restore()` renewal fails transiently | Session **kept** (the next `validAccessToken()` retries); only a terminal failure clears the store / **保留**会话(下次 `validAccessToken()` 会重试);只有终局失败才清空存储 | | `TokenStore.load()` throws | Mapped to `AppException`, treated as "no session" / 映射为 `AppException`,按"无会话"处理 | ## Next Steps / 下一步 diff --git a/website/pages/FAQ.md b/website/pages/FAQ.md index 8aa352d..8f8e24a 100644 --- a/website/pages/FAQ.md +++ b/website/pages/FAQ.md @@ -39,9 +39,21 @@ Yes — `refresh()` is single-flight: many concurrent callers share one in-fligh ### How do I attach the bearer token to requests? / 如何给请求附加 Bearer 令牌? -`AuthManager` is an `AuthTokenSource`. Use the Dio interceptor in `example/lib/dio_interceptor.dart`, or read `accessToken` directly. +`AuthManager` is an `AuthTokenSource`. Use the Dio interceptor in `example/lib/dio_interceptor.dart`, or read `accessToken` directly — but prefer `validAccessToken()` (or `RefreshingAuthInterceptor`) whenever the token reaches a server, since `accessToken` may already be expired. -`AuthManager` 即 `AuthTokenSource`。可用 `example/lib/dio_interceptor.dart` 中的 Dio 拦截器,或直接读取 `accessToken`。 +`AuthManager` 即 `AuthTokenSource`。可用 `example/lib/dio_interceptor.dart` 中的 Dio 拦截器,或直接读取 `accessToken` —— 但只要令牌要发到服务端,就请优先用 `validAccessToken()`(或 `RefreshingAuthInterceptor`),因为 `accessToken` 可能已经过期。 + +### Does it sync sessions across devices? / 它会在多设备间同步会话吗? + +No — and by design. A session is local state plus whatever your backend decides; +pushing "signed out elsewhere" to a device needs server push (or polling), which +is outside a headless state machine. Model it with your backend revoking the +grant: the next refresh fails, `refreshFailurePolicy` signs the device out, and +`AuthError` explains why. + +不会 —— 这是有意的设计。会话是本地状态加上你后端的决定;把「在别处已登出」推送到某台 +设备需要服务端推送(或轮询),那超出了无头状态机的职责。可由后端吊销授权来实现:下一次 +刷新失败,`refreshFailurePolicy` 会让该设备登出,并由 `AuthError` 说明原因。 ### Which platforms are supported? / 支持哪些平台? diff --git a/website/pages/Multi-Account.md b/website/pages/Multi-Account.md index e94594a..f9136e7 100644 --- a/website/pages/Multi-Account.md +++ b/website/pages/Multi-Account.md @@ -49,6 +49,13 @@ final group = AuthManagerGroup( // CRITICAL: one store per account, so persisted sessions stay isolated. // 关键:每个账号一个存储,持久化会话才不会互相覆盖。 storeFactory: (accountId) => SecureTokenStore(key: 'auth_$accountId'), + // Optional: every AuthManager knob is forwarded to the managers it creates. + // 可选:所有 AuthManager 调参都会转发给它创建的管理器。 + autoRefreshAhead: const Duration(minutes: 5), + clockSkew: const Duration(seconds: 30), + onStateChanged: (accountId, state) => debugPrint('$accountId → $state'), + // Or build them yourself / 也可自行构建: + // managerFactory: (id, strategy, store) => AuthManager(...), ); // Sign in (or restore) each account independently. @@ -75,8 +82,11 @@ group.current // active account's AuthState / 激活账号的状态 group.currentSession // active account's session / 激活账号的会话 group.accessToken // active account's token / 激活账号的令牌 group.state // stream that follows the active account / 跟随激活账号的流 +group.activeIdChanges // stream of the active account id / 激活账号 id 的流 -dio.interceptors.add(AuthInterceptor(group)); // reads the active token / 读激活账号令牌 +await group.validAccessToken(); // renewed token of the active account / 激活账号续期后的令牌 + +dio.interceptors.add(RefreshingAuthInterceptor(group)); // renews then attaches / 先续期再附加 ``` ### Restoring on startup / 启动时恢复 @@ -87,6 +97,11 @@ dio.interceptors.add(AuthInterceptor(group)); // reads the active token / 读激 await group.restoreAll(knownIds, activeId: lastUsedId); ``` +One account failing to restore does not abandon the rest: every account is +attempted and the first error is thrown at the end. + +某个账号恢复失败不会连累其余账号:所有账号都会被尝试,最后统一抛出第一个错误。 + ### Adding accounts explicitly / 显式新增账号 `forAccount` creates on first use; `addAccount` is the same call with a name that diff --git a/website/pages/Network-Integration.md b/website/pages/Network-Integration.md index f69b005..2163c81 100644 --- a/website/pages/Network-Integration.md +++ b/website/pages/Network-Integration.md @@ -8,13 +8,23 @@ ```dart abstract class AuthTokenSource { - Future get accessToken; + String? get accessToken; // may be expired / 可能已过期 + Future validAccessToken({Duration? leeway}); // renewed first / 先续期 } ``` -`AuthManager.accessToken` returns the current token, or `null` when unauthenticated. It can also trigger a transparent refresh when the token is near expiry (implementation-dependent). +`AuthManager.accessToken` returns the current token, or `null` when unauthenticated — and it may already be expired. -`AuthManager.accessToken` 返回当前令牌;未认证时返回 `null`。在令牌接近过期时,它还可触发一次透明的刷新(取决于实现)。 +`AuthManager.accessToken` 返回当前令牌;未认证时返回 `null` —— 而且它可能已经过期。 + +`validAccessToken()` is the one to use before a request: it renews first (reusing +the single-flight refresh) and returns `null` only when there is nothing to send. +`leeway` is how long the token must stay valid for, defaulting to the manager's +`clockSkew` (30s), so a token that would die mid-request is renewed first. + +请求之前应当用 `validAccessToken()`:它会在必要时先续期(复用单飞刷新),只有真的无令牌 +可发时才返回 `null`。`leeway` 表示令牌必须还能维持有效的时长,默认取管理器的 +`clockSkew`(30 秒),因此会在请求途中失效的令牌会被提前续期。 ## Dio interceptor / Dio 拦截器 @@ -24,18 +34,22 @@ abstract class AuthTokenSource { ```dart class AuthInterceptor extends Interceptor { - AuthInterceptor(this.tokens); // an AuthTokenSource + AuthInterceptor(this.tokens); // an AuthTokenSource / 一个令牌源 final AuthTokenSource tokens; @override void onRequest(RequestOptions o, RequestInterceptorHandler h) async { - final t = await tokens.accessToken; + final t = await tokens.validAccessToken(); // renews first / 先续期 if (t != null) o.headers['Authorization'] = 'Bearer $t'; h.next(o); } } ``` +> If you only want the synchronous read, use `tokens.accessToken` — but remember +> it may hand you a token that has already expired. +> 若只需要同步读取,可用 `tokens.accessToken` —— 但请记住它可能返回一个已过期的令牌。 + ```dart final dio = Dio() ..interceptors.add(AuthInterceptor(authManager)); diff --git a/website/pages/Persistence.md b/website/pages/Persistence.md index 9914e9e..ed69f00 100644 --- a/website/pages/Persistence.md +++ b/website/pages/Persistence.md @@ -13,6 +13,12 @@ ```dart final json = session.toJson(); // Map final restored = AuthSession.fromJson(json); + +// Prefer this inside TokenStore.load(): malformed data yields null ("not signed +// in") instead of a FormatException / TypeError. +// TokenStore.load() 里请优先用它:畸形数据返回 null(即「未登录」),而不是抛 +// FormatException / TypeError。 +final safe = AuthSession.tryFromJson(json); ``` `RefreshToken` serializes to its raw string too, so a persisted session rehydrates losslessly. diff --git a/website/pages/Token-Store.md b/website/pages/Token-Store.md index 99308db..fb3ce9e 100644 --- a/website/pages/Token-Store.md +++ b/website/pages/Token-Store.md @@ -43,7 +43,9 @@ class SecureTokenStore implements TokenStore { @override Future load() async { final raw = await _box.read(key: 'zero_auth'); - return raw == null ? null : AuthSession.fromJson(jsonDecode(raw)); + // tryFromJson: corrupt data means "not signed in", not a crash. + // tryFromJson:数据损坏代表「未登录」,而不是崩溃。 + return AuthSession.tryFromJson(raw == null ? null : jsonDecode(raw)); } @override Future clear() => _box.delete(key: 'zero_auth'); diff --git a/website/pages/index.md b/website/pages/index.md index 71d3153..ad15d33 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -16,9 +16,9 @@ A backend-agnostic auth state machine & session lifecycle core for Dart/Flutter. | **Typed Session** | `AuthSession` carries access/refresh tokens, expiry (`isExpired`), user id, display name and raw claims / 强类型会话,无需手工解析令牌 | | **Unified Errors** | Every failure maps to `AppException` (`AuthException` for auth cases) or a `Result` wrapper; raw exceptions never cross the public surface / 统一异常与结果,裸异常不越界 | | **Network Integration** | `AuthManager` itself is an `AuthTokenSource`, so a Dio interceptor can attach `Authorization: Bearer` without depending on the manager / 管理器即令牌源,Dio 拦截器零依赖附加令牌 | -| **Session Serialization** | `AuthSession.toJson` / `fromJson` make persistence a one-liner; a file-based reference store ships for server/CLI / `AuthSession.toJson` / `fromJson` 让持久化一行搞定,并附带面向服务端 / CLI 的文件参考存储 | +| **Session Serialization** | `AuthSession.toJson` / `fromJson` make persistence a one-liner, and `tryFromJson` returns `null` on malformed data; a file-based reference store ships for server/CLI / `AuthSession.toJson` / `fromJson` 让持久化一行搞定,`tryFromJson` 遇畸形数据返回 `null`,并附带面向服务端 / CLI 的文件参考存储 | | **Proactive Auto-refresh** | Pass `autoRefreshAhead` to renew tokens before expiry (single-flight) / 传入 `autoRefreshAhead` 在过期前自动续期(单飞) | -| **Never an Expired Token** | `validAccessToken()` renews first when the token has expired, so interceptors never send a dead bearer token / 令牌过期时先续期,拦截器不会发出失效令牌 | +| **Never an Expired Token** | `validAccessToken()` renews first when the token has expired — or is about to, per `clockSkew` — so interceptors never send a dead bearer token / 令牌过期(或按 `clockSkew` 即将过期)时先续期,拦截器不会发出失效令牌 | | **Bring Your Own Login** | `loginWith` adopts a session from any flow you drive: third-party OAuth, magic links, passkeys / `loginWith` 可接纳第三方 OAuth、魔法链接、Passkey 等自定义流程 | | **Typed Auth Exceptions** | `InvalidCredentialsException`, `SessionExpiredException` and friends, mapped from your strategy's `code` / `InvalidCredentialsException`、`SessionExpiredException` 等,由策略的 `code` 映射而来 | | **Configurable Failure Policy** | `refreshFailurePolicy` decides whether a failed refresh signs the user out / `refreshFailurePolicy` 决定刷新失败是否登出 | From 83247200281ac85e36d169b684214280c82689d7 Mon Sep 17 00:00:00 2001 From: AmisKwok Date: Tue, 22 Sep 2026 02:59:04 +0800 Subject: [PATCH 3/7] chore(release): bump version to 1.0.0 First stable release. The public surface is frozen under semver from here on; see the 1.0.0 changelog entry for the one migration step (AuthTokenSource.validAccessToken). --- docs/404.html | 2 +- docs/404/index.html | 2 +- docs/Auth-State-Machine/index.html | 4 ++-- docs/Backend-Strategy/index.html | 4 ++-- docs/Configuration/index.html | 4 ++-- docs/Errors/index.html | 4 ++-- docs/FAQ/index.html | 4 ++-- docs/Getting-Started/index.html | 4 ++-- docs/Installation/index.html | 4 ++-- docs/Multi-Account/index.html | 4 ++-- docs/Network-Integration/index.html | 4 ++-- docs/Persistence/index.html | 4 ++-- docs/Third-Party-Login/index.html | 4 ++-- docs/Token-Store/index.html | 4 ++-- docs/Usage/index.html | 4 ++-- docs/_next/static/chunks/nextra-data-en-US.json | 2 +- .../_buildManifest.js | 0 .../_ssgManifest.js | 0 docs/index.html | 4 ++-- pubspec.yaml | 2 +- 20 files changed, 32 insertions(+), 32 deletions(-) rename docs/_next/static/{kjpeBAQZfIa0cAXLmehHB => snGQ1XTBh5zEItHPAKYhp}/_buildManifest.js (100%) rename docs/_next/static/{kjpeBAQZfIa0cAXLmehHB => snGQ1XTBh5zEItHPAKYhp}/_ssgManifest.js (100%) diff --git a/docs/404.html b/docs/404.html index 590efd0..db6d3b3 100644 --- a/docs/404.html +++ b/docs/404.html @@ -1 +1 @@ -404: This page could not be found

    404

    This page could not be found.

    \ No newline at end of file +404: This page could not be found

    404

    This page could not be found.

    \ No newline at end of file diff --git a/docs/404/index.html b/docs/404/index.html index 590efd0..db6d3b3 100644 --- a/docs/404/index.html +++ b/docs/404/index.html @@ -1 +1 @@ -404: This page could not be found

    404

    This page could not be found.

    \ No newline at end of file +404: This page could not be found

    404

    This page could not be found.

    \ No newline at end of file diff --git a/docs/Auth-State-Machine/index.html b/docs/Auth-State-Machine/index.html index 63c3954..6e58439 100644 --- a/docs/Auth-State-Machine/index.html +++ b/docs/Auth-State-Machine/index.html @@ -1,4 +1,4 @@ -
    🧭 Auth State Machine

    Auth State Machine / 认证状态机

    +
    🧭 Auth State Machine

    Auth State Machine / 认证状态机

    States / 状态

    AuthState is a sealed class with six subtypes:

    AuthState 是一个密封类,包含六个子类型:

    @@ -132,4 +132,4 @@

  • Usage — How to drive the UI from the stream / 如何用状态流驱动界面
  • Backend Strategy — What each operation calls / 各操作调用什么
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Backend-Strategy/index.html b/docs/Backend-Strategy/index.html index 9f842d0..053fb9a 100644 --- a/docs/Backend-Strategy/index.html +++ b/docs/Backend-Strategy/index.html @@ -1,4 +1,4 @@ -
    🔌 Backend Strategy

    Backend Strategy / 后端策略

    +
    🔌 Backend Strategy

    Backend Strategy / 后端策略

    AuthStrategy is the only boundary the core needs from a backend. Implement four methods and zero_auth works with REST, gRPC, Firebase, or a private RPC.

    AuthStrategy 是内核向后台要求的唯一边界。实现四个方法后,zero_auth 即可对接 REST、gRPC、Firebase 或私有 RPC。

    The contract / 契约

    @@ -125,4 +125,4 @@

  • Token Store — Where sessions are persisted / 会话持久化到何处
  • Network Integration — Attaching the bearer token / 附加 Bearer 令牌
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Configuration/index.html b/docs/Configuration/index.html index 843210c..686e38b 100644 --- a/docs/Configuration/index.html +++ b/docs/Configuration/index.html @@ -1,4 +1,4 @@ -
    ⚙️ Configuration

    Configuration / 配置

    +
    ⚙️ Configuration

    Configuration / 配置

    zero_auth is deliberately minimal: there is no global config object. Behavior is composed from the two boundaries you pass to AuthManager.

    zero_auth 刻意保持精简:没有全局配置对象。行为由你传给 AuthManager 的两个边界组合而成。

    Composing AuthManager / 组合管理器

    @@ -139,4 +139,4 @@

  • Usage — Putting it together / 综合使用
  • FAQ — Common questions / 常见问题
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Errors/index.html b/docs/Errors/index.html index 5967887..f175c36 100644 --- a/docs/Errors/index.html +++ b/docs/Errors/index.html @@ -1,4 +1,4 @@ -
    ⚠️ Errors

    Errors / 错误

    +
    ⚠️ Errors

    Errors / 错误

    zero_auth never lets a raw Exception cross its public surface. Every failure is expressed through one of two mechanisms.

    zero_auth 绝不允许裸 Exception 越过公共边界。每个失败都通过下面两种机制之一表达。

    AppException / 异常类型

    @@ -135,4 +135,4 @@

  • Backend Strategy — Map your API errors / 映射你的 API 错误
  • Configuration — Tuning refresh & retry / 调优刷新与重试
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/FAQ/index.html b/docs/FAQ/index.html index 8dbcd02..7757d6c 100644 --- a/docs/FAQ/index.html +++ b/docs/FAQ/index.html @@ -1,4 +1,4 @@ -
    ❓ FAQ

    FAQ / 常见问题

    +
    ❓ FAQ

    FAQ / 常见问题

    Does zero_auth ship an HTTP client? / zero_auth 内置 HTTP 客户端吗?

    No. The core is backend-agnostic and ships no HTTP, SDK or native code. You implement AuthStrategy to call your backend.

    不。内核后端无关,不含任何 HTTP、SDK 或原生代码。你通过实现 AuthStrategy 来调用自己的后端。

    @@ -35,4 +35,4 @@

    refreshFailurePolicy 会让该设备登出,并由 AuthError 说明原因。

    Which platforms are supported? / 支持哪些平台?

    Any platform where Dart or Flutter runs (Android, iOS, Web, Windows, macOS, Linux).

    -

    任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。


    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Getting-Started/index.html b/docs/Getting-Started/index.html index 00942d7..2d7688b 100644 --- a/docs/Getting-Started/index.html +++ b/docs/Getting-Started/index.html @@ -1,4 +1,4 @@ -
    🚀 Getting Started

    Getting Started / 快速开始

    +
    🚀 Getting Started

    Getting Started / 快速开始

    Quick Start / 快速开始

    zero_auth is a headless core: you bring the backend (AuthStrategy) and the persistence (TokenStore), and AuthManager drives the session lifecycle.

    zero_auth 是一个无头内核:你提供后端(AuthStrategy)与持久化(TokenStore),AuthManager 负责驱动会话生命周期。

    @@ -88,4 +88,4 @@

    Installation — Detailed installation methods / 详细安装方式
  • Usage — Full usage guide / 完整使用指南
  • Backend Strategy — Implement AuthStrategy / 实现后端边界
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Installation/index.html b/docs/Installation/index.html index 82eccef..8cd1df9 100644 --- a/docs/Installation/index.html +++ b/docs/Installation/index.html @@ -1,4 +1,4 @@ -
    📦 Installation

    Installation / 安装

    +
    📦 Installation

    Installation / 安装

    Add the following to your pubspec.yaml:

    在 pubspec.yaml 中添加以下依赖:

    @@ -42,4 +42,4 @@

  • Getting Started — Quick start guide / 快速开始
  • Usage — Full usage guide / 完整使用指南
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Multi-Account/index.html b/docs/Multi-Account/index.html index bda6ef5..11a33de 100644 --- a/docs/Multi-Account/index.html +++ b/docs/Multi-Account/index.html @@ -1,4 +1,4 @@ -
    👥 Multiple Accounts

    Multiple Accounts / 多账号

    +

    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Network-Integration/index.html b/docs/Network-Integration/index.html index f42c06d..3cd1f08 100644 --- a/docs/Network-Integration/index.html +++ b/docs/Network-Integration/index.html @@ -1,4 +1,4 @@ -
    🌐 Network Integration

    Network Integration / 网络集成

    +
    🌐 Network Integration

    Network Integration / 网络集成

    AuthManager implements AuthTokenSource, so HTTP clients can read the current access token without depending on the manager object. This keeps your Dio setup decoupled from zero_auth.

    AuthManager 实现了 AuthTokenSource,因此 HTTP 客户端可在不依赖管理器对象的前提下读取当前访问令牌,使 Dio 配置与 zero_auth 解耦。

    AuthTokenSource / 令牌源

    @@ -60,4 +60,4 @@

  • Usage — Full integration walkthrough / 完整集成讲解
  • Errors — Handling 401 and refresh failures / 处理 401 与刷新失败
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Persistence/index.html b/docs/Persistence/index.html index 4345a7d..6f40608 100644 --- a/docs/Persistence/index.html +++ b/docs/Persistence/index.html @@ -1,4 +1,4 @@ -
    💽 Session Persistence

    Session Persistence / 会话持久化

    +
    💽 Session Persistence

    Session Persistence / 会话持久化

    InMemoryTokenStore loses the session on every restart. For a real app you must persist it — and zero_auth now ships the primitives that make this a one-liner: session (de)serialization plus two ready-to-copy reference stores.

    InMemoryTokenStore 会在每次重启时丢失会话。真实应用必须把它持久化——而 zero_auth 现在提供了让这件事变成「一行代码」的原语:会话(反)序列化,外加两个开箱即用的参考存储。

    1. Serialize the session / 序列化会话

    @@ -48,4 +48,4 @@

  • Token Store — the save / load / clear contract / save / load / clear 契约
  • Configuration — enable proactive auto-refresh / 开启主动自动刷新
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Third-Party-Login/index.html b/docs/Third-Party-Login/index.html index a792e88..ec2d455 100644 --- a/docs/Third-Party-Login/index.html +++ b/docs/Third-Party-Login/index.html @@ -1,4 +1,4 @@ -
    🔑 Third-Party Login

    Third-Party Login / 第三方登录

    +
    🔑 Third-Party Login

    Third-Party Login / 第三方登录

    The key idea / 核心思路

    zero_auth does not run the OAuth handshake — and that is deliberate. It is a pure-Dart package, so it ships no platform code, no browser plumbing and no @@ -108,4 +108,4 @@

    Backend Strategy — what your strategy must implement / 策略需要实现什么
  • Auth State Machine — the states this flow emits / 该流程发出的状态
  • Errors — typed exceptions and codes / 类型化异常与 code
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Token-Store/index.html b/docs/Token-Store/index.html index 9c5cf02..cee014b 100644 --- a/docs/Token-Store/index.html +++ b/docs/Token-Store/index.html @@ -1,4 +1,4 @@ -
    💾 Token Store

    Token Store / 令牌存储

    +
    💾 Token Store

    Token Store / 令牌存储

    TokenStore is the only persistence surface. The core never touches disk, secure storage, or SharedPreferences directly — you decide where tokens live.

    TokenStore 是唯一的持久化接口。内核绝不直接访问磁盘、安全存储或 SharedPreferences——由你决定令牌存放位置。

    The contract / 契约

    @@ -40,4 +40,4 @@

    Session Persistence — persist sessions across restarts (serialization + reference stores) / 跨重启持久化会话(序列化与参考存储)
  • Backend Strategy — The other boundary / 另一个边界
  • Errors — What load() failures become / load() 失败会变成什么
  • -


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/Usage/index.html b/docs/Usage/index.html index bd7699a..1a2263b 100644 --- a/docs/Usage/index.html +++ b/docs/Usage/index.html @@ -1,4 +1,4 @@ -
    📖 Usage

    Usage / 使用指南

    +

    Zero Auth · MPL-2.0
    \ No newline at end of file +

    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/docs/_next/static/chunks/nextra-data-en-US.json b/docs/_next/static/chunks/nextra-data-en-US.json index f756086..847f756 100644 --- a/docs/_next/static/chunks/nextra-data-en-US.json +++ b/docs/_next/static/chunks/nextra-data-en-US.json @@ -1 +1 @@ -{"/Auth-State-Machine":{"title":"Auth State Machine / 认证状态机","data":{"states--状态#States / 状态":"AuthState is a sealed class with six subtypes:AuthState 是一个密封类,包含六个子类型:\nState\tFields\tMeaning\tUnauthenticated\t—\tNo session / 无会话\tAuthenticating\t—\tlogin/register in flight / 登录/注册进行中\tAuthenticated\tsession: AuthSession\tA valid session / 有效会话\tRefreshing\tsession: AuthSession\tRenewal in flight; the previous session is still usable / 续期进行中,旧会话仍可用\tLoggingOut\tsession: AuthSession\tlogout() in flight; that session is being discarded / 登出进行中,该会话即将被丢弃\tAuthError\terror: AppException\tLast operation failed / 上一次操作失败\t\nTwo helpers save you from spelling out every case:两个辅助属性可避免你手写全部分支:\nstate.isAuthenticated — true for Authenticated and Refreshing, so a\nrenewal never unmounts your signed-in UI. / 在 Authenticated 与 Refreshing\n下均为 true,续期不会卸载已登录界面。\nstate.isBusy — true while Authenticating, Refreshing or LoggingOut. /\n在 Authenticating、Refreshing、LoggingOut 期间为 true。\nstate.session — the session the state carries (Authenticated / Refreshing /\nLoggingOut), or null; no pattern-matching needed for the common case. /\n该状态携带的会话(Authenticated / Refreshing / LoggingOut)或 null,\n常见场景无需再做模式匹配。","the-stream--状态流#The stream / 状态流":"AuthManager.state is a Stream that replays the latest value to every new listener (BehaviorSubject-like). This means:AuthManager.state 是一个 Stream,会向每个新订阅者重放最近的值(类似于 BehaviorSubject)。这意味着:\nA freshly mounted widget renders the correct screen on its first frame / 新挂载的 widget 首帧即渲染正确界面。\nYou never need to read a separate \"current state\" field / 无需另读一个\"当前状态\"字段。\nauth.state.listen((state) {\n switch (state) {\n case Authenticated(:final session):\n /* ... */\n case AuthError(:final error):\n /* ... */\n default:\n }\n});","transitions--状态转移#Transitions / 状态转移":"login/register ──► Authenticating ──► Authenticated\n │ ▲\n refresh │ │ success\n ─────────► ▼ │\n Refreshing\n │\n failure │\n ▼\n AuthError ──policy: sign out──► Unauthenticated\n └──policy: keep session─► Authenticated (old session)\n Authenticated ──logout──► LoggingOut ──► Unauthenticated\n Unauthenticated ──restore() finds nothing / nothing persisted──► Unauthenticated\nrestore() restores a live session → Authenticated.\nrestore() restores an expired session → attempts Refreshing; if the\nrenewal fails transiently (network, 5xx) the persisted session is kept and\nactivated as-is, so the next request can retry. Only a terminal failure — or the\nabsence of a refresh token — clears the store and lands on Unauthenticated.\nrestore(refreshIfExpired: false) restores it verbatim instead.\nA failed login/register → AuthError, and the future rethrows.\nA failed refresh → AuthError, followed by Unauthenticated when the\nfailure policy considers the grant unrecoverable,\nor by Authenticated (previous session) when it looks transient.\nDuplicate consecutive emissions are suppressed, so listeners only rebuild on a\nreal change.\nrestore() 恢复未过期会话 → Authenticated。\nrestore() 恢复已过期会话 → 先走 Refreshing;若续期瞬时失败(网络、5xx),\n则保留持久化会话并按原样激活,下次请求可再试。只有终局失败(或没有刷新令牌)\n才会清空存储并落到 Unauthenticated。restore(refreshIfExpired: false) 则原样恢复。\nlogin/register 失败 → AuthError,同时 future 再次抛出错误。\nrefresh 失败 → AuthError;随后由失败策略决定:\n认为授权不可恢复则转 Unauthenticated,认为只是瞬时故障则回到 Authenticated。\n连续重复的状态会被抑制,监听器只会在真正变化时重建。","next-steps--下一步#Next Steps / 下一步":"Usage — How to drive the UI from the stream / 如何用状态流驱动界面\nBackend Strategy — What each operation calls / 各操作调用什么"}},"/Backend-Strategy":{"title":"Backend Strategy / 后端策略","data":{"":"AuthStrategy is the only boundary the core needs from a backend. Implement four methods and zero_auth works with REST, gRPC, Firebase, or a private RPC.AuthStrategy 是内核向后台要求的唯一边界。实现四个方法后,zero_auth 即可对接 REST、gRPC、Firebase 或私有 RPC。","the-contract--契约#The contract / 契约":"abstract class AuthStrategy {\n Future login(Credentials credentials);\n Future register(RegistrationInput input);\n Future logout(SessionHandle handle);\n Future refresh(RefreshToken token);\n}\nMethod\tWhen called\tReceives\tMust return\tlogin\tAuthManager.login\tCredentials(username, password)\tA fresh AuthSession\tregister\tAuthManager.register\tRegistrationInput(username, password, displayName?, email?)\tA fresh AuthSession\tlogout\tAuthManager.logout\tSessionHandle(userId, refreshToken?)\tvoid (revoke server-side if applicable)\trefresh\tAuthManager.refresh\tRefreshToken(value)\tA new AuthSession (new tokens)\t\nusername is just a name — it is an opaque identifier, and the core never\nvalidates or interprets it. Put an email or a phone number in it and map it to\nwhatever field your API expects.username 只是字段名,它是个不透明标识符,内核从不校验或解析它。把邮箱或手机号放进去,\n再映射成你接口需要的字段即可。","what-is-fixed-and-where-the-escape-hatches-are--哪些是固定的逃生口在哪#What is fixed, and where the escape hatches are / 哪些是固定的,逃生口在哪":"The four signatures above are the only thing the contract fixes. Transport, URLs,\ntoken format and rotation are entirely yours.上面这四个签名是契约唯一固定的东西。传输方式、URL、令牌格式与轮换策略完全由你决定。\nYou need\tDo this\tLogin that is not username + password (SMS code, OAuth, passkey)\tAuthManager.loginWith — see Third-Party Login\tExtra fixed context (tenant id, app id, device id)\tHold it as a field on your strategy; do not squeeze it into Credentials\tExtra data returned by the backend (roles, tenant, permissions)\tPut it in AuthSession.claims\tSignalling why a call failed\tThrow AuthException with a code — see Errors\t\nGotchas / 注意点:\nAlways set userId when building sessions. logout receives\nuserId: session.userId ?? '', so a session without one hands your backend an\nempty string (the refresh token is passed too, when present).\n务必设置 userId:登出时收到的是 session.userId ?? '',没有它后端会拿到空\n字符串(有刷新令牌时会一并传入)。\nLogging in again does not sign the previous session out. The new session\nsimply replaces it locally; revoking the old one server-side is your call — or\nuse Multiple Accounts if both must survive.\n再次登录不会登出上一个会话:新会话只是本地替换;是否在服务端吊销旧的由你决定;\n若两者都要保留,请用多账号。","mapping-failures--映射失败#Mapping failures / 映射失败":"Every thrown error should be mapped to AppException (or AuthException for auth-specific cases). Raw Exceptions must never cross the public surface — the manager wraps unknowns.所有抛出的错误都应映射为 AppException(认证相关场景用 AuthException)。裸 Exception 不得越过公共边界——管理器会包裹未知异常。\n@override\nFuture login(Credentials credentials) async {\n try {\n final json = await myApi.post('/login', {\n 'account': credentials.username, // email, phone, whatever you accept\n 'password': credentials.password,\n });\n return AuthSession.fromJson(json as Map);\n } on MyApiAuthError catch (e) {\n throw AuthException(code: 'invalid_credentials', message: e.message);\n }\n}","building-the-session--构造会话#Building the session / 构造会话":"Return an AuthSession carrying at least the access token, the refresh token, and an expiry:返回 AuthSession,至少包含访问令牌、刷新令牌与过期时间:\nAuthSession(\n accessToken: json['access'],\n refreshToken: json['refresh'],\n expiresAt: DateTime.parse(json['expiresAt']),\n userId: json['userId'],\n displayName: json['displayName'],\n claims: json, // optional raw claims / 可选的原始 claims\n)","example-backend--演示后端#Example backend / 演示后端":"server/ is a layered dart:io demo backend exercising /login, /refresh, /logout, /me and /health. Run it with dart run bin/server.dart on port 8080. It issues real HMAC-SHA256 JWTs with refresh-token rotation and replay detection, and logs every request with its status and duration. The demo account is user / user.server/ 是一个分层的 dart:io 演示后端,提供 /login、/refresh、/logout、/me 与 /health。用 dart run bin/server.dart 在 8080 端口启动。它签发真实的 HMAC-SHA256 JWT,带刷新令牌轮换与重放检测,并记录每条请求的状态码与耗时。演示账号为 user / user。","next-steps--下一步#Next Steps / 下一步":"Token Store — Where sessions are persisted / 会话持久化到何处\nNetwork Integration — Attaching the bearer token / 附加 Bearer 令牌"}},"/Configuration":{"title":"Configuration / 配置","data":{"":"zero_auth is deliberately minimal: there is no global config object. Behavior is composed from the two boundaries you pass to AuthManager.zero_auth 刻意保持精简:没有全局配置对象。行为由你传给 AuthManager 的两个边界组合而成。","composing-authmanager--组合管理器#Composing AuthManager / 组合管理器":"final auth = AuthManager(\n strategy: MyAuthStrategy(), // how to talk to the backend / 如何与后端通信\n tokenStore: SecureTokenStore(), // where to persist tokens / 令牌持久化位置\n autoRefreshAhead: const Duration(minutes: 5), // proactive renewal / 主动续期\n autoRefreshRetryDelay: const Duration(seconds: 30), // re-arm after a failed renewal / 续期失败后重新排程\n autoRefreshMaxRetries: 3, // cap those retries / 重试上限\n autoRefreshMinInterval: const Duration(seconds: 5), // floor for an already-due renewal / 已到期续期的下限\n refreshFailurePolicy: defaultRefreshFailurePolicy, // sign-out rule / 登出规则\n clock: () => DateTime.now(), // time source (tests, skew) / 时间源(测试、时钟偏移)\n clockSkew: const Duration(seconds: 30), // treat tokens as expired this much earlier / 提前多久视为过期\n preserveSessionDetails: true, // keep identity across a tokens-only renewal / 只换令牌的续期保留身份\n onStateChanged: (state) => debugPrint('$state'), // observe every emission / 观察每次状态\n);\nKnob\tWhere\tEffect\tBackend endpoints & auth scheme\tAuthStrategy\tWhat login/refresh/… actually do / login/refresh 等的实际行为\tToken persistence\tTokenStore\tDisk / secure storage / in-memory / 磁盘/安全存储/内存\tRefresh timing\tAuthSession.expiresAt + autoRefreshAhead\tProactive renewal is scheduled that far before expiry / 在过期前该时长调度主动续期\tRenewal retry\tautoRefreshRetryDelay\tAfter a proactive renewal fails, it is re-armed this much later (default 30s) while a session still exists / 主动续期失败后按此时长重新排程(默认 30 秒),会话仍在才重试\tRenewal retry cap\tautoRefreshMaxRetries\tHow many failed proactive renewals to retry before giving up (default 3) / 主动续期失败最多重试几次(默认 3)\tRenewal floor\tautoRefreshMinInterval\tMinimum wait for a renewal that is already due (default 5s), so very short-lived tokens cannot cause a tight loop / 「已到期」续期的最小等待(默认 5 秒),极短寿命令牌不会造成紧密循环\tClock skew\tclockSkew\tHow much earlier a token counts as expired (default 30s); absorbs a device clock that runs ahead and the latency of the request itself. If your access tokens live under a minute, lower it (e.g. 5s) or almost every read renews first / 提前多久把令牌视为过期(默认 30 秒),用于吸收设备时钟偏快与请求自身的延迟。若访问令牌寿命不足一分钟,请调小(如 5 秒),否则几乎每次读取都会先续期\tIdentity on renewal\tpreserveSessionDetails\tCarry userId / displayName / claims over a renewal that returns tokens only (default true) / 只返回令牌的续期是否保留身份字段(默认 true)\tObservation\tonStateChanged\tOptional callback for every emitted state, for logging or analytics / 可选回调,每次发出状态时触发,便于日志或埋点\tRefresh failure handling\trefreshFailurePolicy\tWhether a failed refresh signs the user out / 刷新失败是否让用户登出\tTime source\tclock\tDrives expiry maths and proactive scheduling; inject one for deterministic tests or to tolerate device clock skew / 驱动过期计算与主动刷新调度;注入时钟可实现确定性测试或容忍设备时钟偏移\tToken injection\tAuthTokenSource / validAccessToken()\tHow the bearer token reaches HTTP clients, optionally renewing an expired token first / 令牌如何到达 HTTP 客户端,必要时先续期过期令牌","refresh-strategy--刷新策略#Refresh strategy / 刷新策略":"refresh() is single-flight by construction: concurrent callers share one in-flight request. You decide when to refresh:refresh() 天生单飞:并发调用方共享同一次进行中的请求。何时刷新由你决定:\nProactively, when session.isExpired is approaching, before a request. / 在请求前、当 session.isExpired 临近时主动刷新。\nReactively, on a 401 from your API (see Network Integration). / 响应式地,在 API 返回 401 时刷新。\nA failed refresh is always reported through AuthError (and the rethrown future),\nbut whether it ends the session is decided by refreshFailurePolicy. See\nRefresh failure policy below.刷新失败总会通过 AuthError(以及重新抛出的 future)上报,但是否终止会话由\nrefreshFailurePolicy 决定,详见下方「刷新失败策略」。","refresh-failure-policy--刷新失败策略#Refresh failure policy / 刷新失败策略":"The default policy signs the user out only for failures that can never succeed\nagain, keeping the session when the failure looks transient (so a network blip\ndoes not log people out):默认策略仅在失败「注定无法重试成功」时让用户登出;看起来像瞬时故障时保留会话(避免一次\n网络抖动就把人踢下线):\nFailure / 失败类型\tDefault outcome / 默认结果\tSessionExpiredException, InvalidCredentialsException\tStore cleared → Unauthenticated / 清空存储 → Unauthenticated\tAnything else (network, 5xx…) / 其它(网络、5xx 等)\tSession kept → back to Authenticated / 保留会话 → 回到 Authenticated\t\n// Never sign out on a failed refresh — useful when refresh tokens are\n// long-lived and your backend is occasionally flaky.\n// 刷新失败也绝不登出——适用于刷新令牌长期有效、后端偶尔不稳定的场景。\nfinal auth = AuthManager(\n strategy: strategy,\n refreshFailurePolicy: (AppException error) => false,\n);\nProactive (background) refresh failures are handled internally, so a scheduled\nrenewal can never surface as an unhandled async error.主动(后台)刷新的失败会在内部处理,因此定时续期永远不会以未处理异步错误的形式泄漏。","no-global-singletons--没有全局单例#No global singletons / 没有全局单例":"AuthManager is a plain object — create one per app, hold it in your DI container or a top-level variable. The core has no static mutable state.AuthManager 是一个普通对象——每个应用创建一个,放在 DI 容器或顶层变量中即可。内核没有静态可变状态。","next-steps--下一步#Next Steps / 下一步":"Usage — Putting it together / 综合使用\nFAQ — Common questions / 常见问题"}},"/Errors":{"title":"Errors / 错误","data":{"":"zero_auth never lets a raw Exception cross its public surface. Every failure is expressed through one of two mechanisms.zero_auth 绝不允许裸 Exception 越过公共边界。每个失败都通过下面两种机制之一表达。","appexception--异常类型#AppException / 异常类型":"All domain failures extend AppException:所有领域失败都继承 AppException:\nsealed class AppException implements Exception {\n final String code; // stable, machine-readable / 稳定、机器可读\n final String message; // human-readable / 人类可读\n final Object? cause; // original error, if any / 原始错误\n}\nfinal class AuthException extends AppException {\n // Base type for every auth failure / 所有认证失败的基类型\n}\n// Concrete subtypes — catch these to branch on what actually went wrong.\n// 具体子类型——捕获它们即可按实际错误分支处理。\nfinal class InvalidCredentialsException extends AuthException {} // invalid_credentials / 凭据无效\nfinal class SessionExpiredException extends AuthException {} // session_expired / 会话过期\nfinal class NoActiveSessionException extends AuthException {} // no_active_session / 无活动会话\nfinal class RefreshTokenMissingException extends AuthException {} // refresh_token_missing / 缺少刷新令牌\nfinal class UnexpectedAuthException extends AuthException {} // unexpected_auth_failure / 意外失败\nAuthStrategy authors opt into these subtypes simply by throwing an\nAuthException — or the bare AuthFail domain type — carrying the matching\ncode; mapAuthFailure performs the mapping, preserving any vocabulary it does\nnot recognise.AuthStrategy 实现者只需抛出携带对应 code 的 AuthException —— 或裸的领域类型\nAuthFail —— 即可参与映射;mapAuthFailure 负责转换,并保留它无法识别的自定义错误类型。\n// Both of these map to InvalidCredentialsException / 两者都会映射为 InvalidCredentialsException\nthrow AuthException('Wrong password', code: 'invalid_credentials');\nthrow const AuthFail('Wrong password', code: 'invalid_credentials');\ncode\tMapped type / 映射结果\tinvalid_credentials\tInvalidCredentialsException\tinvalid_grant, invalid_refresh_token, token_expired, session_expired\tSessionExpiredException\tno_active_session\tNoActiveSessionException\trefresh_token_missing\tRefreshTokenMissingException\tanything else / 其它\tpreserved as-is, or UnexpectedAuthException / 原样保留或包装为 UnexpectedAuthException\t\nCodes raised by the manager itself (not your strategy) / 由管理器自身产生的 code:\ncode\tRaised when / 何时触发\tauth_flow_in_progress\tAnother login / register / loginWith is already running / 已有登录流程在执行\tmanager_disposed\tAn operation is called after dispose() / dispose() 后又调用操作\t\nOverlapping authentication flows are rejected rather than racing: a second\nlogin() while one is in flight throws auth_flow_in_progress.重叠的登录流程会被拒绝而非争抢:进行中再次 login() 会抛 auth_flow_in_progress。\nAuthStrategy implementations should map transport/API errors into AuthException (or a custom AppException subclass) so callers get a stable code.AuthStrategy 实现方应将传输层/API 错误映射为 AuthException(或自定义 AppException 子类),让调用方拿到稳定的 code。","resultt--结果类型#Result / 结果类型":"For explicit, exception-free handling, use Result:如需显式、无异常的处理,可使用 Result:\nsealed class Result {\n const factory Result.ok(T value) = Ok;\n const factory Result.err(AppException error) = Err;\n}\nfinal result = await auth.tryLogin(user, pass); // returns Result\nswitch (result) {\n case Ok(:final value):\n /* authenticated / 已认证 */\n case Err(:final error):\n /* inspect error.code / 读取 error.code */\n}","how-failures-surface--失败如何呈现#How failures surface / 失败如何呈现":"Situation\tSurfaced as\tlogin/register fails\tAuthState becomes AuthError(error) and the future throws AppException / 状态变为 AuthError,且 future 抛出 AppException\trefresh fails\tAuthError(error) emitted, then Unauthenticated (unrecoverable) or back to Authenticated (transient) per the failure policy / 发出 AuthError,随后按失败策略转为 Unauthenticated(不可恢复)或回到 Authenticated(瞬时)\trestore() finds nothing\tStays Unauthenticated (not an error) / 保持 Unauthenticated(不算错误)\trestore() renewal fails transiently\tSession kept (the next validAccessToken() retries); only a terminal failure clears the store / 保留会话(下次 validAccessToken() 会重试);只有终局失败才清空存储\tTokenStore.load() throws\tMapped to AppException, treated as \"no session\" / 映射为 AppException,按\"无会话\"处理","next-steps--下一步#Next Steps / 下一步":"Backend Strategy — Map your API errors / 映射你的 API 错误\nConfiguration — Tuning refresh & retry / 调优刷新与重试"}},"/Getting-Started":{"title":"Getting Started / 快速开始","data":{"quick-start--快速开始#Quick Start / 快速开始":"zero_auth is a headless core: you bring the backend (AuthStrategy) and the persistence (TokenStore), and AuthManager drives the session lifecycle.zero_auth 是一个无头内核:你提供后端(AuthStrategy)与持久化(TokenStore),AuthManager 负责驱动会话生命周期。\nimport 'package:zero_auth/zero_auth.dart';\n// 1. Implement your backend (four methods only).\n// 1. 实现你的后端(只需四个方法)。\nfinal strategy = MyAuthStrategy();\n// 2. Pick a token store. InMemoryTokenStore ships in-core; for production use\n// the flutter_secure_storage reference in example/lib/secure_token_store.dart.\n// 2. 选择令牌存储。内核自带 InMemoryTokenStore;生产环境请用\n// example/lib/secure_token_store.dart 中的 flutter_secure_storage 实现。\nfinal store = InMemoryTokenStore();\n// 3. Create the manager and restore any persisted session.\n// 3. 创建管理器并恢复已持久化的会话。\nfinal auth = AuthManager(strategy: strategy, tokenStore: store);\nawait auth.restore();\nThen drive the UI from the state stream:随后用状态流驱动界面:\nauth.state.listen((state) {\n switch (state) {\n case Unauthenticated():\n showLoginScreen();\n case Authenticating():\n case LoggingOut():\n showSpinner();\n case Authenticated(:final session):\n showHome(session.userId);\n case Refreshing(:final session):\n // Still signed in: only the token is being renewed.\n // 仍处于已登录状态:只是令牌在续期。\n showHome(session.userId, renewing: true);\n case AuthError(:final error):\n showError(error);\n }\n});\nIf you prefer if/else over exhaustiveness, use state.isAuthenticated and\nstate.isBusy instead of state is Authenticated — isAuthenticated stays\ntrue during Refreshing, so your UI never bounces back to the login screen\nmid-refresh.若不想用穷举匹配,请用 state.isAuthenticated / state.isBusy 代替\nstate is Authenticated——isAuthenticated 在 Refreshing 期间仍为 true,\n界面不会在刷新途中退回登录页。","the-lifecycle--生命周期#The Lifecycle / 生命周期":"AuthManager broadcasts an explicit, sealed state machine:AuthManager 广播一个显式、密封的状态机:\nState\tMeaning\tUnauthenticated\tNo session / 无会话\tAuthenticating\tlogin/register in flight / 登录/注册进行中\tAuthenticated\tA valid session is present / 存在有效会话\tRefreshing\tSession renewal in flight; the previous session stays usable / 续期进行中,旧会话仍可用\tLoggingOut\tlogout() in flight / 登出进行中\tAuthError\tThe last operation failed / 上一次操作失败\t\nThe stream replays the latest value to new listeners, so a widget renders the correct screen on its first frame.状态流会向新订阅者重放最近的值,因此 widget 在首帧即可渲染正确界面。","next-steps--下一步#Next Steps / 下一步":"Installation — Detailed installation methods / 详细安装方式\nUsage — Full usage guide / 完整使用指南\nBackend Strategy — Implement AuthStrategy / 实现后端边界"}},"/Network-Integration":{"title":"Network Integration / 网络集成","data":{"":"AuthManager implements AuthTokenSource, so HTTP clients can read the current access token without depending on the manager object. This keeps your Dio setup decoupled from zero_auth.AuthManager 实现了 AuthTokenSource,因此 HTTP 客户端可在不依赖管理器对象的前提下读取当前访问令牌,使 Dio 配置与 zero_auth 解耦。","authtokensource--令牌源#AuthTokenSource / 令牌源":"abstract class AuthTokenSource {\n String? get accessToken; // may be expired / 可能已过期\n Future validAccessToken({Duration? leeway}); // renewed first / 先续期\n}\nAuthManager.accessToken returns the current token, or null when unauthenticated — and it may already be expired.AuthManager.accessToken 返回当前令牌;未认证时返回 null —— 而且它可能已经过期。validAccessToken() is the one to use before a request: it renews first (reusing\nthe single-flight refresh) and returns null only when there is nothing to send.\nleeway is how long the token must stay valid for, defaulting to the manager's\nclockSkew (30s), so a token that would die mid-request is renewed first.请求之前应当用 validAccessToken():它会在必要时先续期(复用单飞刷新),只有真的无令牌\n可发时才返回 null。leeway 表示令牌必须还能维持有效的时长,默认取管理器的\nclockSkew(30 秒),因此会在请求途中失效的令牌会被提前续期。","dio-interceptor--dio-拦截器#Dio interceptor / Dio 拦截器":"example/lib/dio_interceptor.dart ships a ready-to-use interceptor:example/lib/dio_interceptor.dart 提供了一个开箱即用的拦截器:\nclass AuthInterceptor extends Interceptor {\n AuthInterceptor(this.tokens); // an AuthTokenSource / 一个令牌源\n final AuthTokenSource tokens;\n @override\n void onRequest(RequestOptions o, RequestInterceptorHandler h) async {\n final t = await tokens.validAccessToken(); // renews first / 先续期\n if (t != null) o.headers['Authorization'] = 'Bearer $t';\n h.next(o);\n }\n}\nIf you only want the synchronous read, use tokens.accessToken — but remember\nit may hand you a token that has already expired.\n若只需要同步读取,可用 tokens.accessToken —— 但请记住它可能返回一个已过期的令牌。\nfinal dio = Dio()\n ..interceptors.add(AuthInterceptor(authManager));\nWhen a 401 is returned, refresh the session and retry; if refresh fails, the manager emits AuthError and your app routes back to login.当返回 401 时,刷新会话并重试;若刷新失败,管理器会发出 AuthError,应用随之跳转登录。example/lib/dio_interceptor.dart also ships AuthRetryInterceptor, which does\nexactly that — refreshes through the manager (single-flight) and replays the\nrequest once, so a retry loop cannot form:example/lib/dio_interceptor.dart 里还提供了 AuthRetryInterceptor,正是做这件事 ——\n通过管理器刷新(单飞)并把请求重试一次,因此不会形成重试风暴:\nfinal dio = Dio()\n ..interceptors.add(\n AuthRetryInterceptor(manager: authManager, dio: dio),\n );\nTwo more interceptor variants there / 那里还有另外两种拦截器:\nRefreshingAuthInterceptor — never sends an expired token: it renews first via\nvalidAccessToken() / 绝不发送过期令牌,先用 validAccessToken() 续期。\nAuthInterceptor — the plain AuthTokenSource version / 基础的令牌源版本。","other-http-clients--其它客户端#Other HTTP clients / 其它客户端":"Because AuthTokenSource is a plain interface, the same pattern works for package:http, chopper, or any client that lets you mutate request headers.由于 AuthTokenSource 是普通接口,同样的模式适用于 package:http、chopper 或任何允许修改请求头的客户端。","next-steps--下一步#Next Steps / 下一步":"Usage — Full integration walkthrough / 完整集成讲解\nErrors — Handling 401 and refresh failures / 处理 401 与刷新失败"}},"/Multi-Account":{"title":"Multiple Accounts / 多账号","data":{"first-which-kind-do-you-need--先分清你需要哪一种#First: which kind do you need? / 先分清你需要哪一种":"\"Multiple accounts\" means two very different things, and they do not need the\r\nsame solution:「多账号」其实指两种截然不同的需求,它们的解法不一样:\nShape\tDescription\tRecommendation\tSwitching / 账号切换\tSeveral accounts are remembered, but only one is used at a time (like Gmail's account picker) / 记住多个账号,但同时只用一个\tPlain AuthManager: logout() then login(). No new API. / 普通 AuthManager:登出再登录,不需要新 API\tConcurrent / 账号并存\tSeveral accounts are signed in at the same time and all can make requests / 多个账号同时登录,且都能发请求\tAuthManagerGroup — one AuthManager per account\t\nMost apps only need the first. Reach for AuthManagerGroup only when an account\r\nmust keep working while another one is in the foreground.多数应用只需要第一种。只有当「某个账号在后台也要继续工作」时,才需要 AuthManagerGroup。","shape-1-switching--形态一切换#Shape 1: switching / 形态一:切换":"AuthManager models one session. Switching accounts is just logging out and back\r\nin — the state machine stays honest, and there is no second source of truth:\n// Keep your own list of saved account identifiers (email, phone, …).\r\n// 自己维护已保存账号标识(邮箱、手机号等)的列表。\r\nfinal savedAccounts = ['alice@example.com', 'bob@example.com'];\r\n\r\nFuture switchTo(String account, String password) async {\r\n if (auth.current.isAuthenticated) await auth.logout();\r\n await auth.login(Credentials(username: account, password: password));\r\n}\nBecause username is just an opaque identifier, this works for email or phone\r\nlogins with no extra plumbing — see\r\nBackend Strategy for what the contract fixes.","shape-2-concurrent--形态二并存#Shape 2: concurrent / 形态二:并存":"AuthManagerGroup owns one AuthManager per account and tracks which is active.\nfinal group = AuthManagerGroup(\r\n // Both factories receive the account id; sharing one strategy instance is fine.\r\n // 两个工厂都会收到账号 id;共用一个策略实例也没问题。\r\n strategyFactory: (accountId) => MyAuthStrategy(),\r\n // CRITICAL: one store per account, so persisted sessions stay isolated.\r\n // 关键:每个账号一个存储,持久化会话才不会互相覆盖。\r\n storeFactory: (accountId) => SecureTokenStore(key: 'auth_$accountId'),\r\n // Optional: every AuthManager knob is forwarded to the managers it creates.\r\n // 可选:所有 AuthManager 调参都会转发给它创建的管理器。\r\n autoRefreshAhead: const Duration(minutes: 5),\r\n clockSkew: const Duration(seconds: 30),\r\n onStateChanged: (accountId, state) => debugPrint('$accountId → $state'),\r\n // Or build them yourself / 也可自行构建:\r\n // managerFactory: (id, strategy, store) => AuthManager(...),\r\n);\r\n\r\n// Sign in (or restore) each account independently.\r\n// 各账号独立登录(或恢复)。\r\nawait group.forAccount('alice').login(\r\n Credentials(username: 'alice@example.com', password: pw),\r\n);\r\nawait group.forAccount('bob').login(\r\n Credentials(username: 'bob@example.com', password: pw),\r\n);\r\n\r\n// Pick the active one — nothing is signed out by this call.\r\n// 选择激活账号 —— 此调用不会登出任何账号。\r\ngroup.switchTo('bob');","reading-the-active-account--读取激活账号#Reading the active account / 读取激活账号":"The group mirrors the active account and implements AuthTokenSource, so\r\ninterceptors keep depending on the narrow interface:\ngroup.current // active account's AuthState / 激活账号的状态\r\ngroup.currentSession // active account's session / 激活账号的会话\r\ngroup.accessToken // active account's token / 激活账号的令牌\r\ngroup.state // stream that follows the active account / 跟随激活账号的流\r\ngroup.activeIdChanges // stream of the active account id / 激活账号 id 的流\r\n\r\nawait group.validAccessToken(); // renewed token of the active account / 激活账号续期后的令牌\r\n\r\ndio.interceptors.add(RefreshingAuthInterceptor(group)); // renews then attaches / 先续期再附加","restoring-on-startup--启动时恢复#Restoring on startup / 启动时恢复":"// `knownIds` comes from your own saved-account list.\r\n// `knownIds` 来自你自己保存的账号列表。\r\nawait group.restoreAll(knownIds, activeId: lastUsedId);\nOne account failing to restore does not abandon the rest: every account is\r\nattempted and the first error is thrown at the end.某个账号恢复失败不会连累其余账号:所有账号都会被尝试,最后统一抛出第一个错误。","adding-accounts-explicitly--显式新增账号#Adding accounts explicitly / 显式新增账号":"forAccount creates on first use; addAccount is the same call with a name that\r\nreads better when you mean \"register an account here\":forAccount 首次使用时创建;addAccount 是同一个调用,只是名字在「这里注册账号」的\r\n语境下更好读:\nfinal alice = group.addAccount('alice');","removing-an-account--移除账号#Removing an account / 移除账号":"await group.remove('alice'); // logs out, disposes, forgets it","signing-everything-out--全部登出#Signing everything out / 全部登出":"await group.logoutAll(); // every account, in turn\nIf the removed account was active, the group becomes inactive and emits\r\nUnauthenticated.","teardown--释放#Teardown / 释放":"await group.disposeAll(); // disposes every manager and closes the stream","why-the-core-stays-single-session--为什么核心仍是单会话#Why the core stays single-session / 为什么核心仍是单会话":"AuthManager answers a singular question: who is logged in? Multi-account asks\r\na different one: of these signed-in identities, which is active?Folding the second into the first would mean a Map of sessions inside every\r\nstate, a Refreshing that needs an account id, and a breaking change for every\r\nexisting user. Keeping AuthManager single-session and adding an opt-in\r\ncoordinator gives you both without muddying either.把第二个问题塞进第一个,会导致每个状态里都带一个会话 Map、Refreshing 需要携带账号\r\nid,并且对所有现有用户构成破坏性变更。让 AuthManager 保持单会话、再提供一个可选的\r\n协调层,两者都能得到,且互不污染。","gotchas--注意点#Gotchas / 注意点":"Never share one TokenStore between accounts — the second save() would\r\noverwrite the first session. / 绝不要在多个账号间共用一个 TokenStore,第二次\r\nsave() 会覆盖第一个会话。\nSwitching does not sign anyone out; all managers keep refreshing in the\r\nbackground. / 切换不会登出任何账号,所有管理器仍会在后台续期。\nremove() also disposes that manager — do not use it afterwards. /\r\nremove() 会同时释放该管理器,之后不要再使用它。","next-steps--下一步#Next Steps / 下一步":"Auth State Machine — what each manager emits / 各管理器会发出什么\nToken Store — keying persisted sessions per account / 按账号隔离持久化会话\nBackend Strategy — what the contract fixes / 契约固定了什么"}},"/Third-Party-Login":{"title":"Third-Party Login / 第三方登录","data":{"the-key-idea--核心思路#The key idea / 核心思路":"zero_auth does not run the OAuth handshake — and that is deliberate. It is a\r\npure-Dart package, so it ships no platform code, no browser plumbing and no\r\nredirect URL handling (those live outside lib/).Instead, zero_auth manages what comes after the handshake: the session your\r\nown backend issues.zero_auth 不负责 OAuth 握手环节,这是刻意为之:它是纯 Dart 包,不含任何平台\r\n代码、浏览器通道或重定向回调处理(这些只能在 lib/ 之外)。它负责的是握手之后的事:你自己后端所签发的会话。\nprovider consent ──► code / id_token ──► YOUR BACKEND ──► your access + refresh tokens ──► zero_auth\r\n (outside) (any language) (this package)\nBecause the login method is invisible to the manager, password login and\r\nGoogle/Apple login end up sharing one token lifecycle. That is the whole\r\npoint.登录方式对管理器是透明的,因此密码登录与 Google / Apple 登录最终共享同一套\r\n令牌生命周期机制。这正是关键所在。","when-this-fits--适用场景#When this fits / 适用场景":"This pattern fits when your backend brokers the provider — you have a\r\nbackend, it verifies the provider token, then mints your own tokens.下面的模式适用于你的后端作为经纪方:你有后端,它校验提供方令牌,然后签发你自己的\r\n令牌。\nScenario\tFit\tOwn backend + Google/Apple/GitHub sign-in / 自有后端 + 第三方登录\t✅ Ideal / 理想\tOwn backend + password AND social logins / 自有后端,密码与社交登录并存\t✅ One shared session model / 共用一套会话模型\tFirebase/Supabase issues the tokens directly / 由 Firebase/Supabase 直接签发令牌\t⚠️ Use their SDK instead / 请改用其 SDK","wiring-it-up--接线步骤#Wiring it up / 接线步骤":"1. Run the handshake outside this package.\r\nUse whichever OAuth client fits your targets (flutter_web_auth, AppAuth, or a\r\nserver-side flow). This code lives in your app or a sibling Flutter package, never\r\nin lib/.1. 在本包之外完成握手。\r\n使用适合目标平台的 OAuth 客户端(flutter_web_auth、AppAuth 或服务端流程)。这部分\r\n代码放在你的应用或兄弟 Flutter 包里,绝不放在 lib/。2. Trade the provider credential for your tokens on your backend.2. 用提供方凭据到你的后端换取自有令牌。\nFuture signInWithGoogle(BuildContext context) async {\r\n // 1) Provider consent (outside zero_auth) → authorization code / id_token.\r\n // 提供方授权(零依赖,在 zero_auth 之外)→ 授权码 / id_token。\r\n final code = await myOAuthClient.authenticate();\r\n\r\n // 2) Your backend verifies it with Google and returns YOUR tokens.\r\n // 你的后端用它与 Google 校验,并返回「你自己的」令牌。\r\n final res = await myApi.post('/auth/google', data: {'code': code});\r\n\r\n return AuthSession(\r\n accessToken: res['accessToken'] as String,\r\n refreshToken: RefreshToken(res['refreshToken'] as String),\r\n expiresAt: DateTime.now().add(Duration(seconds: res['expiresIn'] as int)),\r\n userId: res['userId'] as String,\r\n );\r\n}\n3. Hand the session to the manager with loginWith.\r\nloginWith is the escape hatch for flows that are not login / register: the\r\nmanager still owns persistence, the state machine and proactive refresh.3. 用 loginWith 把会话交给管理器。\r\nloginWith 是为那些不符合 login / register 形态的流程准备的逃生口:持久化、状态机\r\n与主动刷新仍由管理器负责。\nfinal result = await auth.loginWith((strategy) => signInWithGoogle(context));\r\n// emits Authenticating → Authenticated, or AuthError + rethrow.\r\n// 发出 Authenticating → Authenticated;失败则发 AuthError 并重新抛出。\nKeep signInWithGoogle throwing AuthException with a meaningful code\r\n('invalid_credentials', 'session_expired', …) so failures map to the typed\r\nexceptions in Errors.让 signInWithGoogle 抛出带有实用 code('invalid_credentials'、\r\n'session_expired' 等)的 AuthException,这样失败就能映射为\r\nErrors 里的具体异常类型。","same-model-for-every-method--各登录方式共用一套模型#Same model for every method / 各登录方式共用一套模型":"// Password users and Google users end up with the same AuthSession shape.\r\n// 密码用户与 Google 用户最终得到的都是同一种 AuthSession。\r\nawait auth.login(const Credentials(username: user, password: pass));\r\nawait auth.loginWith((strategy) => signInWithGoogle(context));\nYour UI only ever reads state.isAuthenticated, and your network layer only ever\r\nreads auth.validAccessToken() — regardless of how the user signed in.你的界面只需要读 state.isAuthenticated,网络层只需要读 auth.validAccessToken()\r\n——无需关心用户是哪种方式登录的。","also-works-for--同样适用于#Also works for / 同样适用于":"loginWith covers any flow you drive yourself:loginWith 适用于任何由你驱动的流程:\nMagic links / email OTP / 魔法链接、邮箱验证码\nPasskeys / WebAuthn\nBiometric re-unlock (load a locked session and re-mint tokens) / 生物识别解锁\nMigrating from another auth SDK / 从其它认证 SDK 迁移","next-steps--下一步#Next Steps / 下一步":"Backend Strategy — what your strategy must implement / 策略需要实现什么\nAuth State Machine — the states this flow emits / 该流程发出的状态\nErrors — typed exceptions and codes / 类型化异常与 code"}},"/Token-Store":{"title":"Token Store / 令牌存储","data":{"":"TokenStore is the only persistence surface. The core never touches disk, secure storage, or SharedPreferences directly — you decide where tokens live.TokenStore 是唯一的持久化接口。内核绝不直接访问磁盘、安全存储或 SharedPreferences——由你决定令牌存放位置。","the-contract--契约#The contract / 契约":"abstract class TokenStore {\n Future save(AuthSession session);\n Future load();\n Future clear();\n}\nAuthManager calls load() during restore(), save() after every successful login/refresh, and clear() on logout or a failed refresh.AuthManager 在 restore() 时调用 load(),每次登录/刷新成功后调用 save(),在登出或刷新失败失败时调用 clear()。","in-memory-ships-in-core--内存实现内核自带#In-memory (ships in-core) / 内存实现(内核自带)":"InMemoryTokenStore is the default. It survives process restarts? No — it is in-memory only, so the session is lost on restart. Use it for tests and quick prototypes.InMemoryTokenStore 是默认实现,仅存在于内存,进程重启后会话丢失。适用于测试与原型。\nfinal auth = AuthManager(strategy: s, tokenStore: InMemoryTokenStore());","secure-storage-production--安全存储生产#Secure storage (production) / 安全存储(生产)":"example/lib/secure_token_store.dart shows a flutter_secure_storage-backed reference implementation. Adapt it for your app:example/lib/secure_token_store.dart 提供了基于 flutter_secure_storage 的参考实现,可据此改造:\nclass SecureTokenStore implements TokenStore {\n final _box = FlutterSecureStorage();\n @override\n Future save(AuthSession s) =>\n _box.write(key: 'zero_auth', value: jsonEncode(s.toJson()));\n @override\n Future load() async {\n final raw = await _box.read(key: 'zero_auth');\n // tryFromJson: corrupt data means \"not signed in\", not a crash.\n // tryFromJson:数据损坏代表「未登录」,而不是崩溃。\n return AuthSession.tryFromJson(raw == null ? null : jsonDecode(raw));\n }\n @override\n Future clear() => _box.delete(key: 'zero_auth');\n}\nPersist tokens only in OS-backed secure storage (Keychain / Keystore). Never store refresh tokens in plain SharedPreferences.令牌只应存放在操作系统级安全存储(Keychain / Keystore)中,切勿把刷新令牌明文存入 SharedPreferences。","next-steps--下一步#Next Steps / 下一步":"Session Persistence — persist sessions across restarts (serialization + reference stores) / 跨重启持久化会话(序列化与参考存储)\nBackend Strategy — The other boundary / 另一个边界\nErrors — What load() failures become / load() 失败会变成什么"}},"/Usage":{"title":"Usage / 使用指南","data":{"three-step-integration--三步集成#Three-step integration / 三步集成":"","1-implement-authstrategy--实现后端边界#1. Implement AuthStrategy / 实现后端边界":"class MyAuthStrategy implements AuthStrategy {\n @override\n Future login(Credentials credentials) => myApi.login(\n credentials.username, // an email or phone works too\n credentials.password,\n );\n @override\n Future register(RegistrationInput input) =>\n myApi.register(input.username, input.password);\n @override\n Future logout(SessionHandle handle) => myApi.logout(handle.userId);\n @override\n Future refresh(RefreshToken token) =>\n myApi.refresh(token.value);\n}","2-create-authmanager-and-restore--创建管理器并恢复#2. Create AuthManager and restore / 创建管理器并恢复":"final auth = AuthManager(\n strategy: MyAuthStrategy(),\n tokenStore: InMemoryTokenStore(),\n);\nawait auth.restore(); // rehydrate a persisted session / 恢复持久化会话","3-drive-the-ui-from-the-state-stream--用状态流驱动界面#3. Drive the UI from the state stream / 用状态流驱动界面":"await for (final state in auth.state) {\n // render based on state / 根据状态渲染\n}","operations--操作#Operations / 操作":"Method\tDescription\trestore()\tRehydrate the persisted session at startup / 启动时恢复会话\tlogin(username, password)\tAuthenticate and enter Authenticated / 登录并进入已认证\tregister(username, password)\tRegister and authenticate / 注册并认证\tlogout()\tClear the session / 清除会话\trefresh()\tSilent token refresh — concurrent calls share one flight / 静默刷新,并发共享单飞\ttokenSource\tAn AuthTokenSource for HTTP clients / 供 HTTP 客户端使用的令牌源","token-refresh--expiry--令牌刷新与过期#Token refresh & expiry / 令牌刷新与过期":"AuthSession exposes isExpired so callers can refresh proactively. refresh() is single-flight: if several callers request a refresh at once, only one network call is made and its result is shared.AuthSession 提供 isExpired,调用方可主动刷新。refresh() 为单飞:多个调用方同时请求刷新时,只发起一次网络调用并共享结果。 A failed refresh clears the session and emits AuthError.刷新失败会清空会话并发出 AuthError。","attaching-the-bearer-token--附加-bearer-令牌#Attaching the bearer token / 附加 Bearer 令牌":"AuthManager implements AuthTokenSource, so a Dio interceptor can read the current token without depending on the manager. See Network Integration.AuthManager 实现了 AuthTokenSource,Dio 拦截器可在不依赖管理器的情况下读取当前令牌。详见网络集成。","next-steps--下一步#Next Steps / 下一步":"Auth State Machine — State lifecycle details / 状态机细节\nBackend Strategy — Full AuthStrategy contract / 完整后端契约\nErrors — Exception & Result model / 异常与结果模型"}},"/Persistence":{"title":"Session Persistence / 会话持久化","data":{"":"InMemoryTokenStore loses the session on every restart. For a real app you must persist it — and zero_auth now ships the primitives that make this a one-liner: session (de)serialization plus two ready-to-copy reference stores.InMemoryTokenStore 会在每次重启时丢失会话。真实应用必须把它持久化——而 zero_auth 现在提供了让这件事变成「一行代码」的原语:会话(反)序列化,外加两个开箱即用的参考存储。","1-serialize-the-session--序列化会话#1. Serialize the session / 序列化会话":"AuthSession is JSON-safe out of the box. null fields are omitted, and claims round-trips as a plain map.AuthSession 天生就是 JSON 安全的:null 字段会被省略,claims 作为普通映射原样往返。\nfinal json = session.toJson(); // Map\nfinal restored = AuthSession.fromJson(json);\n// Prefer this inside TokenStore.load(): malformed data yields null (\"not signed\n// in\") instead of a FormatException / TypeError.\n// TokenStore.load() 里请优先用它:畸形数据返回 null(即「未登录」),而不是抛\n// FormatException / TypeError。\nfinal safe = AuthSession.tryFromJson(json);\nRefreshToken serializes to its raw string too, so a persisted session rehydrates losslessly.RefreshToken 也序列化为其原始字符串,因此持久化的会话可无损恢复。","2-flutter--encrypted-secure-storage--flutter--加密安全存储#2. Flutter — encrypted secure storage / Flutter —— 加密安全存储":"For mobile, persist into the OS keychain / keystore via flutter_secure_storage. A reference implementation lives at example/lib/secure_token_store.dart:在移动端,通过 flutter_secure_storage 把令牌存入系统钥匙串 / Keystore。参考实现位于 example/lib/secure_token_store.dart:\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: SecureTokenStore(), // from example/\n);\nawait auth.restore(); // rehydrate before the first frame\nNever store refresh tokens in plain SharedPreferences. Use OS-backed secure storage only.切勿把刷新令牌明文存入 SharedPreferences,只使用操作系统级安全存储。","3-server--cli--desktop--json-file--服务端--cli--桌面--json-文件#3. Server / CLI / desktop — JSON file / 服务端 / CLI / 桌面 —— JSON 文件":"On non-mobile Dart targets you can write the serialized session to a file. A reference implementation lives at example/lib/json_token_store.dart:在非移动端 Dart 目标上,可以把序列化后的会话写入文件。参考实现位于 example/lib/json_token_store.dart:\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: FileTokenStore(File('.zero_auth_session.json')),\n);\nThis is ideal for a backend that keeps a logged-in user across process restarts, or a CLI that stays authenticated between invocations.这非常适合「重启后仍保持登录」的后端,或「多次调用之间保持认证」的 CLI。","4-combine-with-auto-refresh--结合自动刷新#4. Combine with auto-refresh / 结合自动刷新":"When you enable proactive auto-refresh, the manager renews the token before it expires — and every renewal is persisted through save(), so the on-disk session always carries a fresh access token.当你开启主动自动刷新时,管理器会在令牌过期前续期——而每次续期都会通过 save() 落盘,因此磁盘上的会话始终持有崭新的访问令牌。\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: SecureTokenStore(),\n autoRefreshAhead: const Duration(minutes: 5),\n);","next-steps--下一步#Next Steps / 下一步":"Token Store — the save / load / clear contract / save / load / clear 契约\nConfiguration — enable proactive auto-refresh / 开启主动自动刷新"}},"/":{"title":"Zero Auth","data":{"":"A backend-agnostic auth state machine & session lifecycle core for Dart/Flutter. Pure Dart, headless, no HTTP/SDK/native code. Wire your own backend via AuthStrategy and your own persistence via TokenStore.一个后端无关的认证状态机与会话生命周期内核,面向 Dart/Flutter。纯 Dart、无头(headless),不含 HTTP、SDK 或原生代码。通过 AuthStrategy 接入自有后端,通过 TokenStore 接入自有持久化层。","-features--功能特性#✨ Features / 功能特性":"Feature\tDescription\tBackend-agnostic\tImplement only login/register/logout/refresh; REST, gRPC, Firebase or a private RPC are equally valid targets / 只需实现四个方法,REST、gRPC、Firebase 或私有 RPC 均可接入\tAuth State Machine\tSealed Unauthenticated / Authenticating / Authenticated / Refreshing / LoggingOut / AuthError on a Stream that replays the latest value to new listeners; use isAuthenticated / isBusy / 密封六态状态机 + 重放最近值的状态流,可用 isAuthenticated / isBusy\tSilent Restore\trestore() rehydrates the persisted session at startup / 启动时静默恢复持久化会话\tSingle-flight Refresh\tConcurrent refresh() calls share one in-flight request instead of stampeding the backend / 并发刷新共享同一次请求,避免冲击后端\tPluggable Persistence\tThe only persistence surface is TokenStore.save/load/clear; InMemoryTokenStore ships in-core / 唯一的持久化接口,内核自带内存实现\tTyped Session\tAuthSession carries access/refresh tokens, expiry (isExpired), user id, display name and raw claims / 强类型会话,无需手工解析令牌\tUnified Errors\tEvery failure maps to AppException (AuthException for auth cases) or a Result wrapper; raw exceptions never cross the public surface / 统一异常与结果,裸异常不越界\tNetwork Integration\tAuthManager itself is an AuthTokenSource, so a Dio interceptor can attach Authorization: Bearer without depending on the manager / 管理器即令牌源,Dio 拦截器零依赖附加令牌\tSession Serialization\tAuthSession.toJson / fromJson make persistence a one-liner, and tryFromJson returns null on malformed data; a file-based reference store ships for server/CLI / AuthSession.toJson / fromJson 让持久化一行搞定,tryFromJson 遇畸形数据返回 null,并附带面向服务端 / CLI 的文件参考存储\tProactive Auto-refresh\tPass autoRefreshAhead to renew tokens before expiry (single-flight) / 传入 autoRefreshAhead 在过期前自动续期(单飞)\tNever an Expired Token\tvalidAccessToken() renews first when the token has expired — or is about to, per clockSkew — so interceptors never send a dead bearer token / 令牌过期(或按 clockSkew 即将过期)时先续期,拦截器不会发出失效令牌\tBring Your Own Login\tloginWith adopts a session from any flow you drive: third-party OAuth, magic links, passkeys / loginWith 可接纳第三方 OAuth、魔法链接、Passkey 等自定义流程\tTyped Auth Exceptions\tInvalidCredentialsException, SessionExpiredException and friends, mapped from your strategy's code / InvalidCredentialsException、SessionExpiredException 等,由策略的 code 映射而来\tConfigurable Failure Policy\trefreshFailurePolicy decides whether a failed refresh signs the user out / refreshFailurePolicy 决定刷新失败是否登出\tRunnable Example\tA full Flutter demo app (Android/iOS/Web/Windows) plus a layered dart:io demo backend that issues real JWTs / 完整 Flutter 示例与一个签发真实 JWT 的分层演示后端\tMultiple Accounts\tOptional AuthManagerGroup keeps one manager per account / 可选 AuthManagerGroup,每账号一个管理器\tCross-platform\tPure Dart — runs anywhere Dart or Flutter runs / 纯 Dart,跨平台","-table-of-contents--目录#📚 Table of Contents / 目录":"Page\tDescription\tGetting Started\tQuick start guide / 快速开始\tInstallation\tHow to install / 安装方式\tUsage\tDetailed usage / 详细使用\tAuth State Machine\tState lifecycle & stream / 状态机与状态流\tBackend Strategy\tImplement AuthStrategy / 实现后端边界\tToken Store\tPersistence boundary / 持久化边界\tNetwork Integration\tDio interceptor & token source / 网络集成与拦截器\tErrors\tException & Result model / 异常与结果模型\tConfiguration\tConfiguration options / 配置说明\tSession Persistence\tRestoring and renewing a saved session / 会话持久化与恢复\tThird-Party Login\tOAuth / magic links via loginWith / 用 loginWith 接入第三方登录\tMultiple Accounts\tSwitching vs concurrent accounts / 账号切换与多账号并存\tFAQ\tFrequently asked questions / 常见问题","-links--链接#🔗 Links / 链接":"GitHub\nOfficial Website\npub.dev","-license--许可证#📄 License / 许可证":"This project is licensed under the Mozilla Public License 2.0 (MPL-2.0).本项目采用 Mozilla Public License 2.0 (MPL-2.0) 许可证。This package is provided \"as is\", without warranty of any kind. The author assumes no responsibility or liability for the functionality, security, or any consequences arising from the use of modified versions or derivative projects.本包按\"原样\"提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。"}},"/Installation":{"title":"Installation / 安装","data":{"from-pubdev-recommended--从-pubdev-安装推荐#From pub.dev (Recommended) / 从 pub.dev 安装(推荐)":"Add the following to your pubspec.yaml:在 pubspec.yaml 中添加以下依赖:\ndependencies:\n zero_auth: ^1.0.0\nThen run:然后运行:\ndart pub get","from-github--从-github-安装#From GitHub / 从 GitHub 安装":"Alternatively, install from GitHub:或者从 GitHub 安装:\ndependencies:\n zero_auth:\n git:\n url: https://github.com/zero-labsco/zero_auth.git\n ref: release/v1.0.0","import--导入#Import / 导入":"import 'package:zero_auth/zero_auth.dart';","requirements--环境要求#Requirements / 环境要求":"Requirement\tVersion\tDart SDK\t>= 3.4.0\tFlutter\t>= 3.0.0 (only a constraint; no Flutter runtime dependency)\t\nzero_auth is a pure-Dart package. Flutter is only listed because the example app uses it; the core has no Flutter dependency.zero_auth 是纯 Dart 包,仅因示例 App 用到 Flutter 才列出;内核本身不依赖 Flutter。","next-steps--下一步#Next Steps / 下一步":"Getting Started — Quick start guide / 快速开始\nUsage — Full usage guide / 完整使用指南"}},"/FAQ":{"title":"FAQ / 常见问题","data":{"does-zero_auth-ship-an-http-client--zero_auth-内置-http-客户端吗#Does zero_auth ship an HTTP client? / zero_auth 内置 HTTP 客户端吗?":"No. The core is backend-agnostic and ships no HTTP, SDK or native code. You implement AuthStrategy to call your backend.不。内核后端无关,不含任何 HTTP、SDK 或原生代码。你通过实现 AuthStrategy 来调用自己的后端。","does-it-depend-on-flutter--它依赖-flutter-吗#Does it depend on Flutter? / 它依赖 Flutter 吗?":"No. It is a pure-Dart package; flutter: is only a declared constraint because the example app uses Flutter. The runtime has no Flutter dependency.不。它是纯 Dart 包;仅因示例 App 用到 Flutter 才声明 flutter: 约束,运行时不依赖 Flutter。","where-are-tokens-stored--令牌存在哪里#Where are tokens stored? / 令牌存在哪里?":"Wherever your TokenStore puts them. The core ships InMemoryTokenStore (lost on restart). For production, use the flutter_secure_storage-backed reference in example/lib/secure_token_store.dart.取决于你的 TokenStore。内核自带 InMemoryTokenStore(重启即丢失)。生产请用 example/lib/secure_token_store.dart 中基于 flutter_secure_storage 的参考实现。","what-happens-on-a-failed-refresh--刷新失败会怎样#What happens on a failed refresh? / 刷新失败会怎样?":"The failure is always reported as AuthError and rethrown. What happens next\ndepends on refreshFailurePolicy: an unrecoverable failure (SessionExpiredException,\nInvalidCredentialsException) clears the store and lands on Unauthenticated,\nwhile a transient one (network, 5xx) keeps the previous session so a retry can\nsucceed. See Configuration.失败总会以 AuthError 上报并重新抛出。之后如何取决于 refreshFailurePolicy:\n不可恢复的失败(SessionExpiredException、InvalidCredentialsException)会清空存储\n并落到 Unauthenticated;瞬时故障(网络、5xx)保留上一个会话以便重试成功。参见\n配置。","is-token-refresh-concurrent-safe--令牌刷新是否并发安全#Is token refresh concurrent-safe? / 令牌刷新是否并发安全?":"Yes — refresh() is single-flight: many concurrent callers share one in-flight request and its result.是的——refresh() 为单飞:多个并发调用方共享同一次请求及其结果。","how-do-i-attach-the-bearer-token-to-requests--如何给请求附加-bearer-令牌#How do I attach the bearer token to requests? / 如何给请求附加 Bearer 令牌?":"AuthManager is an AuthTokenSource. Use the Dio interceptor in example/lib/dio_interceptor.dart, or read accessToken directly — but prefer validAccessToken() (or RefreshingAuthInterceptor) whenever the token reaches a server, since accessToken may already be expired.AuthManager 即 AuthTokenSource。可用 example/lib/dio_interceptor.dart 中的 Dio 拦截器,或直接读取 accessToken —— 但只要令牌要发到服务端,就请优先用 validAccessToken()(或 RefreshingAuthInterceptor),因为 accessToken 可能已经过期。","does-it-sync-sessions-across-devices--它会在多设备间同步会话吗#Does it sync sessions across devices? / 它会在多设备间同步会话吗?":"No — and by design. A session is local state plus whatever your backend decides;\npushing \"signed out elsewhere\" to a device needs server push (or polling), which\nis outside a headless state machine. Model it with your backend revoking the\ngrant: the next refresh fails, refreshFailurePolicy signs the device out, and\nAuthError explains why.不会 —— 这是有意的设计。会话是本地状态加上你后端的决定;把「在别处已登出」推送到某台\n设备需要服务端推送(或轮询),那超出了无头状态机的职责。可由后端吊销授权来实现:下一次\n刷新失败,refreshFailurePolicy 会让该设备登出,并由 AuthError 说明原因。","which-platforms-are-supported--支持哪些平台#Which platforms are supported? / 支持哪些平台?":"Any platform where Dart or Flutter runs (Android, iOS, Web, Windows, macOS, Linux).任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。"}}} \ No newline at end of file +{"/Backend-Strategy":{"title":"Backend Strategy / 后端策略","data":{"":"AuthStrategy is the only boundary the core needs from a backend. Implement four methods and zero_auth works with REST, gRPC, Firebase, or a private RPC.AuthStrategy 是内核向后台要求的唯一边界。实现四个方法后,zero_auth 即可对接 REST、gRPC、Firebase 或私有 RPC。","the-contract--契约#The contract / 契约":"abstract class AuthStrategy {\n Future login(Credentials credentials);\n Future register(RegistrationInput input);\n Future logout(SessionHandle handle);\n Future refresh(RefreshToken token);\n}\nMethod\tWhen called\tReceives\tMust return\tlogin\tAuthManager.login\tCredentials(username, password)\tA fresh AuthSession\tregister\tAuthManager.register\tRegistrationInput(username, password, displayName?, email?)\tA fresh AuthSession\tlogout\tAuthManager.logout\tSessionHandle(userId, refreshToken?)\tvoid (revoke server-side if applicable)\trefresh\tAuthManager.refresh\tRefreshToken(value)\tA new AuthSession (new tokens)\t\nusername is just a name — it is an opaque identifier, and the core never\nvalidates or interprets it. Put an email or a phone number in it and map it to\nwhatever field your API expects.username 只是字段名,它是个不透明标识符,内核从不校验或解析它。把邮箱或手机号放进去,\n再映射成你接口需要的字段即可。","what-is-fixed-and-where-the-escape-hatches-are--哪些是固定的逃生口在哪#What is fixed, and where the escape hatches are / 哪些是固定的,逃生口在哪":"The four signatures above are the only thing the contract fixes. Transport, URLs,\ntoken format and rotation are entirely yours.上面这四个签名是契约唯一固定的东西。传输方式、URL、令牌格式与轮换策略完全由你决定。\nYou need\tDo this\tLogin that is not username + password (SMS code, OAuth, passkey)\tAuthManager.loginWith — see Third-Party Login\tExtra fixed context (tenant id, app id, device id)\tHold it as a field on your strategy; do not squeeze it into Credentials\tExtra data returned by the backend (roles, tenant, permissions)\tPut it in AuthSession.claims\tSignalling why a call failed\tThrow AuthException with a code — see Errors\t\nGotchas / 注意点:\nAlways set userId when building sessions. logout receives\nuserId: session.userId ?? '', so a session without one hands your backend an\nempty string (the refresh token is passed too, when present).\n务必设置 userId:登出时收到的是 session.userId ?? '',没有它后端会拿到空\n字符串(有刷新令牌时会一并传入)。\nLogging in again does not sign the previous session out. The new session\nsimply replaces it locally; revoking the old one server-side is your call — or\nuse Multiple Accounts if both must survive.\n再次登录不会登出上一个会话:新会话只是本地替换;是否在服务端吊销旧的由你决定;\n若两者都要保留,请用多账号。","mapping-failures--映射失败#Mapping failures / 映射失败":"Every thrown error should be mapped to AppException (or AuthException for auth-specific cases). Raw Exceptions must never cross the public surface — the manager wraps unknowns.所有抛出的错误都应映射为 AppException(认证相关场景用 AuthException)。裸 Exception 不得越过公共边界——管理器会包裹未知异常。\n@override\nFuture login(Credentials credentials) async {\n try {\n final json = await myApi.post('/login', {\n 'account': credentials.username, // email, phone, whatever you accept\n 'password': credentials.password,\n });\n return AuthSession.fromJson(json as Map);\n } on MyApiAuthError catch (e) {\n throw AuthException(code: 'invalid_credentials', message: e.message);\n }\n}","building-the-session--构造会话#Building the session / 构造会话":"Return an AuthSession carrying at least the access token, the refresh token, and an expiry:返回 AuthSession,至少包含访问令牌、刷新令牌与过期时间:\nAuthSession(\n accessToken: json['access'],\n refreshToken: json['refresh'],\n expiresAt: DateTime.parse(json['expiresAt']),\n userId: json['userId'],\n displayName: json['displayName'],\n claims: json, // optional raw claims / 可选的原始 claims\n)","example-backend--演示后端#Example backend / 演示后端":"server/ is a layered dart:io demo backend exercising /login, /refresh, /logout, /me and /health. Run it with dart run bin/server.dart on port 8080. It issues real HMAC-SHA256 JWTs with refresh-token rotation and replay detection, and logs every request with its status and duration. The demo account is user / user.server/ 是一个分层的 dart:io 演示后端,提供 /login、/refresh、/logout、/me 与 /health。用 dart run bin/server.dart 在 8080 端口启动。它签发真实的 HMAC-SHA256 JWT,带刷新令牌轮换与重放检测,并记录每条请求的状态码与耗时。演示账号为 user / user。","next-steps--下一步#Next Steps / 下一步":"Token Store — Where sessions are persisted / 会话持久化到何处\nNetwork Integration — Attaching the bearer token / 附加 Bearer 令牌"}},"/Auth-State-Machine":{"title":"Auth State Machine / 认证状态机","data":{"states--状态#States / 状态":"AuthState is a sealed class with six subtypes:AuthState 是一个密封类,包含六个子类型:\nState\tFields\tMeaning\tUnauthenticated\t—\tNo session / 无会话\tAuthenticating\t—\tlogin/register in flight / 登录/注册进行中\tAuthenticated\tsession: AuthSession\tA valid session / 有效会话\tRefreshing\tsession: AuthSession\tRenewal in flight; the previous session is still usable / 续期进行中,旧会话仍可用\tLoggingOut\tsession: AuthSession\tlogout() in flight; that session is being discarded / 登出进行中,该会话即将被丢弃\tAuthError\terror: AppException\tLast operation failed / 上一次操作失败\t\nTwo helpers save you from spelling out every case:两个辅助属性可避免你手写全部分支:\nstate.isAuthenticated — true for Authenticated and Refreshing, so a\nrenewal never unmounts your signed-in UI. / 在 Authenticated 与 Refreshing\n下均为 true,续期不会卸载已登录界面。\nstate.isBusy — true while Authenticating, Refreshing or LoggingOut. /\n在 Authenticating、Refreshing、LoggingOut 期间为 true。\nstate.session — the session the state carries (Authenticated / Refreshing /\nLoggingOut), or null; no pattern-matching needed for the common case. /\n该状态携带的会话(Authenticated / Refreshing / LoggingOut)或 null,\n常见场景无需再做模式匹配。","the-stream--状态流#The stream / 状态流":"AuthManager.state is a Stream that replays the latest value to every new listener (BehaviorSubject-like). This means:AuthManager.state 是一个 Stream,会向每个新订阅者重放最近的值(类似于 BehaviorSubject)。这意味着:\nA freshly mounted widget renders the correct screen on its first frame / 新挂载的 widget 首帧即渲染正确界面。\nYou never need to read a separate \"current state\" field / 无需另读一个\"当前状态\"字段。\nauth.state.listen((state) {\n switch (state) {\n case Authenticated(:final session):\n /* ... */\n case AuthError(:final error):\n /* ... */\n default:\n }\n});","transitions--状态转移#Transitions / 状态转移":"login/register ──► Authenticating ──► Authenticated\n │ ▲\n refresh │ │ success\n ─────────► ▼ │\n Refreshing\n │\n failure │\n ▼\n AuthError ──policy: sign out──► Unauthenticated\n └──policy: keep session─► Authenticated (old session)\n Authenticated ──logout──► LoggingOut ──► Unauthenticated\n Unauthenticated ──restore() finds nothing / nothing persisted──► Unauthenticated\nrestore() restores a live session → Authenticated.\nrestore() restores an expired session → attempts Refreshing; if the\nrenewal fails transiently (network, 5xx) the persisted session is kept and\nactivated as-is, so the next request can retry. Only a terminal failure — or the\nabsence of a refresh token — clears the store and lands on Unauthenticated.\nrestore(refreshIfExpired: false) restores it verbatim instead.\nA failed login/register → AuthError, and the future rethrows.\nA failed refresh → AuthError, followed by Unauthenticated when the\nfailure policy considers the grant unrecoverable,\nor by Authenticated (previous session) when it looks transient.\nDuplicate consecutive emissions are suppressed, so listeners only rebuild on a\nreal change.\nrestore() 恢复未过期会话 → Authenticated。\nrestore() 恢复已过期会话 → 先走 Refreshing;若续期瞬时失败(网络、5xx),\n则保留持久化会话并按原样激活,下次请求可再试。只有终局失败(或没有刷新令牌)\n才会清空存储并落到 Unauthenticated。restore(refreshIfExpired: false) 则原样恢复。\nlogin/register 失败 → AuthError,同时 future 再次抛出错误。\nrefresh 失败 → AuthError;随后由失败策略决定:\n认为授权不可恢复则转 Unauthenticated,认为只是瞬时故障则回到 Authenticated。\n连续重复的状态会被抑制,监听器只会在真正变化时重建。","next-steps--下一步#Next Steps / 下一步":"Usage — How to drive the UI from the stream / 如何用状态流驱动界面\nBackend Strategy — What each operation calls / 各操作调用什么"}},"/Configuration":{"title":"Configuration / 配置","data":{"":"zero_auth is deliberately minimal: there is no global config object. Behavior is composed from the two boundaries you pass to AuthManager.zero_auth 刻意保持精简:没有全局配置对象。行为由你传给 AuthManager 的两个边界组合而成。","composing-authmanager--组合管理器#Composing AuthManager / 组合管理器":"final auth = AuthManager(\n strategy: MyAuthStrategy(), // how to talk to the backend / 如何与后端通信\n tokenStore: SecureTokenStore(), // where to persist tokens / 令牌持久化位置\n autoRefreshAhead: const Duration(minutes: 5), // proactive renewal / 主动续期\n autoRefreshRetryDelay: const Duration(seconds: 30), // re-arm after a failed renewal / 续期失败后重新排程\n autoRefreshMaxRetries: 3, // cap those retries / 重试上限\n autoRefreshMinInterval: const Duration(seconds: 5), // floor for an already-due renewal / 已到期续期的下限\n refreshFailurePolicy: defaultRefreshFailurePolicy, // sign-out rule / 登出规则\n clock: () => DateTime.now(), // time source (tests, skew) / 时间源(测试、时钟偏移)\n clockSkew: const Duration(seconds: 30), // treat tokens as expired this much earlier / 提前多久视为过期\n preserveSessionDetails: true, // keep identity across a tokens-only renewal / 只换令牌的续期保留身份\n onStateChanged: (state) => debugPrint('$state'), // observe every emission / 观察每次状态\n);\nKnob\tWhere\tEffect\tBackend endpoints & auth scheme\tAuthStrategy\tWhat login/refresh/… actually do / login/refresh 等的实际行为\tToken persistence\tTokenStore\tDisk / secure storage / in-memory / 磁盘/安全存储/内存\tRefresh timing\tAuthSession.expiresAt + autoRefreshAhead\tProactive renewal is scheduled that far before expiry / 在过期前该时长调度主动续期\tRenewal retry\tautoRefreshRetryDelay\tAfter a proactive renewal fails, it is re-armed this much later (default 30s) while a session still exists / 主动续期失败后按此时长重新排程(默认 30 秒),会话仍在才重试\tRenewal retry cap\tautoRefreshMaxRetries\tHow many failed proactive renewals to retry before giving up (default 3) / 主动续期失败最多重试几次(默认 3)\tRenewal floor\tautoRefreshMinInterval\tMinimum wait for a renewal that is already due (default 5s), so very short-lived tokens cannot cause a tight loop / 「已到期」续期的最小等待(默认 5 秒),极短寿命令牌不会造成紧密循环\tClock skew\tclockSkew\tHow much earlier a token counts as expired (default 30s); absorbs a device clock that runs ahead and the latency of the request itself. If your access tokens live under a minute, lower it (e.g. 5s) or almost every read renews first / 提前多久把令牌视为过期(默认 30 秒),用于吸收设备时钟偏快与请求自身的延迟。若访问令牌寿命不足一分钟,请调小(如 5 秒),否则几乎每次读取都会先续期\tIdentity on renewal\tpreserveSessionDetails\tCarry userId / displayName / claims over a renewal that returns tokens only (default true) / 只返回令牌的续期是否保留身份字段(默认 true)\tObservation\tonStateChanged\tOptional callback for every emitted state, for logging or analytics / 可选回调,每次发出状态时触发,便于日志或埋点\tRefresh failure handling\trefreshFailurePolicy\tWhether a failed refresh signs the user out / 刷新失败是否让用户登出\tTime source\tclock\tDrives expiry maths and proactive scheduling; inject one for deterministic tests or to tolerate device clock skew / 驱动过期计算与主动刷新调度;注入时钟可实现确定性测试或容忍设备时钟偏移\tToken injection\tAuthTokenSource / validAccessToken()\tHow the bearer token reaches HTTP clients, optionally renewing an expired token first / 令牌如何到达 HTTP 客户端,必要时先续期过期令牌","refresh-strategy--刷新策略#Refresh strategy / 刷新策略":"refresh() is single-flight by construction: concurrent callers share one in-flight request. You decide when to refresh:refresh() 天生单飞:并发调用方共享同一次进行中的请求。何时刷新由你决定:\nProactively, when session.isExpired is approaching, before a request. / 在请求前、当 session.isExpired 临近时主动刷新。\nReactively, on a 401 from your API (see Network Integration). / 响应式地,在 API 返回 401 时刷新。\nA failed refresh is always reported through AuthError (and the rethrown future),\nbut whether it ends the session is decided by refreshFailurePolicy. See\nRefresh failure policy below.刷新失败总会通过 AuthError(以及重新抛出的 future)上报,但是否终止会话由\nrefreshFailurePolicy 决定,详见下方「刷新失败策略」。","refresh-failure-policy--刷新失败策略#Refresh failure policy / 刷新失败策略":"The default policy signs the user out only for failures that can never succeed\nagain, keeping the session when the failure looks transient (so a network blip\ndoes not log people out):默认策略仅在失败「注定无法重试成功」时让用户登出;看起来像瞬时故障时保留会话(避免一次\n网络抖动就把人踢下线):\nFailure / 失败类型\tDefault outcome / 默认结果\tSessionExpiredException, InvalidCredentialsException\tStore cleared → Unauthenticated / 清空存储 → Unauthenticated\tAnything else (network, 5xx…) / 其它(网络、5xx 等)\tSession kept → back to Authenticated / 保留会话 → 回到 Authenticated\t\n// Never sign out on a failed refresh — useful when refresh tokens are\n// long-lived and your backend is occasionally flaky.\n// 刷新失败也绝不登出——适用于刷新令牌长期有效、后端偶尔不稳定的场景。\nfinal auth = AuthManager(\n strategy: strategy,\n refreshFailurePolicy: (AppException error) => false,\n);\nProactive (background) refresh failures are handled internally, so a scheduled\nrenewal can never surface as an unhandled async error.主动(后台)刷新的失败会在内部处理,因此定时续期永远不会以未处理异步错误的形式泄漏。","no-global-singletons--没有全局单例#No global singletons / 没有全局单例":"AuthManager is a plain object — create one per app, hold it in your DI container or a top-level variable. The core has no static mutable state.AuthManager 是一个普通对象——每个应用创建一个,放在 DI 容器或顶层变量中即可。内核没有静态可变状态。","next-steps--下一步#Next Steps / 下一步":"Usage — Putting it together / 综合使用\nFAQ — Common questions / 常见问题"}},"/Errors":{"title":"Errors / 错误","data":{"":"zero_auth never lets a raw Exception cross its public surface. Every failure is expressed through one of two mechanisms.zero_auth 绝不允许裸 Exception 越过公共边界。每个失败都通过下面两种机制之一表达。","appexception--异常类型#AppException / 异常类型":"All domain failures extend AppException:所有领域失败都继承 AppException:\nsealed class AppException implements Exception {\n final String code; // stable, machine-readable / 稳定、机器可读\n final String message; // human-readable / 人类可读\n final Object? cause; // original error, if any / 原始错误\n}\nfinal class AuthException extends AppException {\n // Base type for every auth failure / 所有认证失败的基类型\n}\n// Concrete subtypes — catch these to branch on what actually went wrong.\n// 具体子类型——捕获它们即可按实际错误分支处理。\nfinal class InvalidCredentialsException extends AuthException {} // invalid_credentials / 凭据无效\nfinal class SessionExpiredException extends AuthException {} // session_expired / 会话过期\nfinal class NoActiveSessionException extends AuthException {} // no_active_session / 无活动会话\nfinal class RefreshTokenMissingException extends AuthException {} // refresh_token_missing / 缺少刷新令牌\nfinal class UnexpectedAuthException extends AuthException {} // unexpected_auth_failure / 意外失败\nAuthStrategy authors opt into these subtypes simply by throwing an\nAuthException — or the bare AuthFail domain type — carrying the matching\ncode; mapAuthFailure performs the mapping, preserving any vocabulary it does\nnot recognise.AuthStrategy 实现者只需抛出携带对应 code 的 AuthException —— 或裸的领域类型\nAuthFail —— 即可参与映射;mapAuthFailure 负责转换,并保留它无法识别的自定义错误类型。\n// Both of these map to InvalidCredentialsException / 两者都会映射为 InvalidCredentialsException\nthrow AuthException('Wrong password', code: 'invalid_credentials');\nthrow const AuthFail('Wrong password', code: 'invalid_credentials');\ncode\tMapped type / 映射结果\tinvalid_credentials\tInvalidCredentialsException\tinvalid_grant, invalid_refresh_token, token_expired, session_expired\tSessionExpiredException\tno_active_session\tNoActiveSessionException\trefresh_token_missing\tRefreshTokenMissingException\tanything else / 其它\tpreserved as-is, or UnexpectedAuthException / 原样保留或包装为 UnexpectedAuthException\t\nCodes raised by the manager itself (not your strategy) / 由管理器自身产生的 code:\ncode\tRaised when / 何时触发\tauth_flow_in_progress\tAnother login / register / loginWith is already running / 已有登录流程在执行\tmanager_disposed\tAn operation is called after dispose() / dispose() 后又调用操作\t\nOverlapping authentication flows are rejected rather than racing: a second\nlogin() while one is in flight throws auth_flow_in_progress.重叠的登录流程会被拒绝而非争抢:进行中再次 login() 会抛 auth_flow_in_progress。\nAuthStrategy implementations should map transport/API errors into AuthException (or a custom AppException subclass) so callers get a stable code.AuthStrategy 实现方应将传输层/API 错误映射为 AuthException(或自定义 AppException 子类),让调用方拿到稳定的 code。","resultt--结果类型#Result / 结果类型":"For explicit, exception-free handling, use Result:如需显式、无异常的处理,可使用 Result:\nsealed class Result {\n const factory Result.ok(T value) = Ok;\n const factory Result.err(AppException error) = Err;\n}\nfinal result = await auth.tryLogin(user, pass); // returns Result\nswitch (result) {\n case Ok(:final value):\n /* authenticated / 已认证 */\n case Err(:final error):\n /* inspect error.code / 读取 error.code */\n}","how-failures-surface--失败如何呈现#How failures surface / 失败如何呈现":"Situation\tSurfaced as\tlogin/register fails\tAuthState becomes AuthError(error) and the future throws AppException / 状态变为 AuthError,且 future 抛出 AppException\trefresh fails\tAuthError(error) emitted, then Unauthenticated (unrecoverable) or back to Authenticated (transient) per the failure policy / 发出 AuthError,随后按失败策略转为 Unauthenticated(不可恢复)或回到 Authenticated(瞬时)\trestore() finds nothing\tStays Unauthenticated (not an error) / 保持 Unauthenticated(不算错误)\trestore() renewal fails transiently\tSession kept (the next validAccessToken() retries); only a terminal failure clears the store / 保留会话(下次 validAccessToken() 会重试);只有终局失败才清空存储\tTokenStore.load() throws\tMapped to AppException, treated as \"no session\" / 映射为 AppException,按\"无会话\"处理","next-steps--下一步#Next Steps / 下一步":"Backend Strategy — Map your API errors / 映射你的 API 错误\nConfiguration — Tuning refresh & retry / 调优刷新与重试"}},"/Getting-Started":{"title":"Getting Started / 快速开始","data":{"quick-start--快速开始#Quick Start / 快速开始":"zero_auth is a headless core: you bring the backend (AuthStrategy) and the persistence (TokenStore), and AuthManager drives the session lifecycle.zero_auth 是一个无头内核:你提供后端(AuthStrategy)与持久化(TokenStore),AuthManager 负责驱动会话生命周期。\nimport 'package:zero_auth/zero_auth.dart';\n// 1. Implement your backend (four methods only).\n// 1. 实现你的后端(只需四个方法)。\nfinal strategy = MyAuthStrategy();\n// 2. Pick a token store. InMemoryTokenStore ships in-core; for production use\n// the flutter_secure_storage reference in example/lib/secure_token_store.dart.\n// 2. 选择令牌存储。内核自带 InMemoryTokenStore;生产环境请用\n// example/lib/secure_token_store.dart 中的 flutter_secure_storage 实现。\nfinal store = InMemoryTokenStore();\n// 3. Create the manager and restore any persisted session.\n// 3. 创建管理器并恢复已持久化的会话。\nfinal auth = AuthManager(strategy: strategy, tokenStore: store);\nawait auth.restore();\nThen drive the UI from the state stream:随后用状态流驱动界面:\nauth.state.listen((state) {\n switch (state) {\n case Unauthenticated():\n showLoginScreen();\n case Authenticating():\n case LoggingOut():\n showSpinner();\n case Authenticated(:final session):\n showHome(session.userId);\n case Refreshing(:final session):\n // Still signed in: only the token is being renewed.\n // 仍处于已登录状态:只是令牌在续期。\n showHome(session.userId, renewing: true);\n case AuthError(:final error):\n showError(error);\n }\n});\nIf you prefer if/else over exhaustiveness, use state.isAuthenticated and\nstate.isBusy instead of state is Authenticated — isAuthenticated stays\ntrue during Refreshing, so your UI never bounces back to the login screen\nmid-refresh.若不想用穷举匹配,请用 state.isAuthenticated / state.isBusy 代替\nstate is Authenticated——isAuthenticated 在 Refreshing 期间仍为 true,\n界面不会在刷新途中退回登录页。","the-lifecycle--生命周期#The Lifecycle / 生命周期":"AuthManager broadcasts an explicit, sealed state machine:AuthManager 广播一个显式、密封的状态机:\nState\tMeaning\tUnauthenticated\tNo session / 无会话\tAuthenticating\tlogin/register in flight / 登录/注册进行中\tAuthenticated\tA valid session is present / 存在有效会话\tRefreshing\tSession renewal in flight; the previous session stays usable / 续期进行中,旧会话仍可用\tLoggingOut\tlogout() in flight / 登出进行中\tAuthError\tThe last operation failed / 上一次操作失败\t\nThe stream replays the latest value to new listeners, so a widget renders the correct screen on its first frame.状态流会向新订阅者重放最近的值,因此 widget 在首帧即可渲染正确界面。","next-steps--下一步#Next Steps / 下一步":"Installation — Detailed installation methods / 详细安装方式\nUsage — Full usage guide / 完整使用指南\nBackend Strategy — Implement AuthStrategy / 实现后端边界"}},"/Multi-Account":{"title":"Multiple Accounts / 多账号","data":{"first-which-kind-do-you-need--先分清你需要哪一种#First: which kind do you need? / 先分清你需要哪一种":"\"Multiple accounts\" means two very different things, and they do not need the\r\nsame solution:「多账号」其实指两种截然不同的需求,它们的解法不一样:\nShape\tDescription\tRecommendation\tSwitching / 账号切换\tSeveral accounts are remembered, but only one is used at a time (like Gmail's account picker) / 记住多个账号,但同时只用一个\tPlain AuthManager: logout() then login(). No new API. / 普通 AuthManager:登出再登录,不需要新 API\tConcurrent / 账号并存\tSeveral accounts are signed in at the same time and all can make requests / 多个账号同时登录,且都能发请求\tAuthManagerGroup — one AuthManager per account\t\nMost apps only need the first. Reach for AuthManagerGroup only when an account\r\nmust keep working while another one is in the foreground.多数应用只需要第一种。只有当「某个账号在后台也要继续工作」时,才需要 AuthManagerGroup。","shape-1-switching--形态一切换#Shape 1: switching / 形态一:切换":"AuthManager models one session. Switching accounts is just logging out and back\r\nin — the state machine stays honest, and there is no second source of truth:\n// Keep your own list of saved account identifiers (email, phone, …).\r\n// 自己维护已保存账号标识(邮箱、手机号等)的列表。\r\nfinal savedAccounts = ['alice@example.com', 'bob@example.com'];\r\n\r\nFuture switchTo(String account, String password) async {\r\n if (auth.current.isAuthenticated) await auth.logout();\r\n await auth.login(Credentials(username: account, password: password));\r\n}\nBecause username is just an opaque identifier, this works for email or phone\r\nlogins with no extra plumbing — see\r\nBackend Strategy for what the contract fixes.","shape-2-concurrent--形态二并存#Shape 2: concurrent / 形态二:并存":"AuthManagerGroup owns one AuthManager per account and tracks which is active.\nfinal group = AuthManagerGroup(\r\n // Both factories receive the account id; sharing one strategy instance is fine.\r\n // 两个工厂都会收到账号 id;共用一个策略实例也没问题。\r\n strategyFactory: (accountId) => MyAuthStrategy(),\r\n // CRITICAL: one store per account, so persisted sessions stay isolated.\r\n // 关键:每个账号一个存储,持久化会话才不会互相覆盖。\r\n storeFactory: (accountId) => SecureTokenStore(key: 'auth_$accountId'),\r\n // Optional: every AuthManager knob is forwarded to the managers it creates.\r\n // 可选:所有 AuthManager 调参都会转发给它创建的管理器。\r\n autoRefreshAhead: const Duration(minutes: 5),\r\n clockSkew: const Duration(seconds: 30),\r\n onStateChanged: (accountId, state) => debugPrint('$accountId → $state'),\r\n // Or build them yourself / 也可自行构建:\r\n // managerFactory: (id, strategy, store) => AuthManager(...),\r\n);\r\n\r\n// Sign in (or restore) each account independently.\r\n// 各账号独立登录(或恢复)。\r\nawait group.forAccount('alice').login(\r\n Credentials(username: 'alice@example.com', password: pw),\r\n);\r\nawait group.forAccount('bob').login(\r\n Credentials(username: 'bob@example.com', password: pw),\r\n);\r\n\r\n// Pick the active one — nothing is signed out by this call.\r\n// 选择激活账号 —— 此调用不会登出任何账号。\r\ngroup.switchTo('bob');","reading-the-active-account--读取激活账号#Reading the active account / 读取激活账号":"The group mirrors the active account and implements AuthTokenSource, so\r\ninterceptors keep depending on the narrow interface:\ngroup.current // active account's AuthState / 激活账号的状态\r\ngroup.currentSession // active account's session / 激活账号的会话\r\ngroup.accessToken // active account's token / 激活账号的令牌\r\ngroup.state // stream that follows the active account / 跟随激活账号的流\r\ngroup.activeIdChanges // stream of the active account id / 激活账号 id 的流\r\n\r\nawait group.validAccessToken(); // renewed token of the active account / 激活账号续期后的令牌\r\n\r\ndio.interceptors.add(RefreshingAuthInterceptor(group)); // renews then attaches / 先续期再附加","restoring-on-startup--启动时恢复#Restoring on startup / 启动时恢复":"// `knownIds` comes from your own saved-account list.\r\n// `knownIds` 来自你自己保存的账号列表。\r\nawait group.restoreAll(knownIds, activeId: lastUsedId);\nOne account failing to restore does not abandon the rest: every account is\r\nattempted and the first error is thrown at the end.某个账号恢复失败不会连累其余账号:所有账号都会被尝试,最后统一抛出第一个错误。","adding-accounts-explicitly--显式新增账号#Adding accounts explicitly / 显式新增账号":"forAccount creates on first use; addAccount is the same call with a name that\r\nreads better when you mean \"register an account here\":forAccount 首次使用时创建;addAccount 是同一个调用,只是名字在「这里注册账号」的\r\n语境下更好读:\nfinal alice = group.addAccount('alice');","removing-an-account--移除账号#Removing an account / 移除账号":"await group.remove('alice'); // logs out, disposes, forgets it","signing-everything-out--全部登出#Signing everything out / 全部登出":"await group.logoutAll(); // every account, in turn\nIf the removed account was active, the group becomes inactive and emits\r\nUnauthenticated.","teardown--释放#Teardown / 释放":"await group.disposeAll(); // disposes every manager and closes the stream","why-the-core-stays-single-session--为什么核心仍是单会话#Why the core stays single-session / 为什么核心仍是单会话":"AuthManager answers a singular question: who is logged in? Multi-account asks\r\na different one: of these signed-in identities, which is active?Folding the second into the first would mean a Map of sessions inside every\r\nstate, a Refreshing that needs an account id, and a breaking change for every\r\nexisting user. Keeping AuthManager single-session and adding an opt-in\r\ncoordinator gives you both without muddying either.把第二个问题塞进第一个,会导致每个状态里都带一个会话 Map、Refreshing 需要携带账号\r\nid,并且对所有现有用户构成破坏性变更。让 AuthManager 保持单会话、再提供一个可选的\r\n协调层,两者都能得到,且互不污染。","gotchas--注意点#Gotchas / 注意点":"Never share one TokenStore between accounts — the second save() would\r\noverwrite the first session. / 绝不要在多个账号间共用一个 TokenStore,第二次\r\nsave() 会覆盖第一个会话。\nSwitching does not sign anyone out; all managers keep refreshing in the\r\nbackground. / 切换不会登出任何账号,所有管理器仍会在后台续期。\nremove() also disposes that manager — do not use it afterwards. /\r\nremove() 会同时释放该管理器,之后不要再使用它。","next-steps--下一步#Next Steps / 下一步":"Auth State Machine — what each manager emits / 各管理器会发出什么\nToken Store — keying persisted sessions per account / 按账号隔离持久化会话\nBackend Strategy — what the contract fixes / 契约固定了什么"}},"/Network-Integration":{"title":"Network Integration / 网络集成","data":{"":"AuthManager implements AuthTokenSource, so HTTP clients can read the current access token without depending on the manager object. This keeps your Dio setup decoupled from zero_auth.AuthManager 实现了 AuthTokenSource,因此 HTTP 客户端可在不依赖管理器对象的前提下读取当前访问令牌,使 Dio 配置与 zero_auth 解耦。","authtokensource--令牌源#AuthTokenSource / 令牌源":"abstract class AuthTokenSource {\n String? get accessToken; // may be expired / 可能已过期\n Future validAccessToken({Duration? leeway}); // renewed first / 先续期\n}\nAuthManager.accessToken returns the current token, or null when unauthenticated — and it may already be expired.AuthManager.accessToken 返回当前令牌;未认证时返回 null —— 而且它可能已经过期。validAccessToken() is the one to use before a request: it renews first (reusing\nthe single-flight refresh) and returns null only when there is nothing to send.\nleeway is how long the token must stay valid for, defaulting to the manager's\nclockSkew (30s), so a token that would die mid-request is renewed first.请求之前应当用 validAccessToken():它会在必要时先续期(复用单飞刷新),只有真的无令牌\n可发时才返回 null。leeway 表示令牌必须还能维持有效的时长,默认取管理器的\nclockSkew(30 秒),因此会在请求途中失效的令牌会被提前续期。","dio-interceptor--dio-拦截器#Dio interceptor / Dio 拦截器":"example/lib/dio_interceptor.dart ships a ready-to-use interceptor:example/lib/dio_interceptor.dart 提供了一个开箱即用的拦截器:\nclass AuthInterceptor extends Interceptor {\n AuthInterceptor(this.tokens); // an AuthTokenSource / 一个令牌源\n final AuthTokenSource tokens;\n @override\n void onRequest(RequestOptions o, RequestInterceptorHandler h) async {\n final t = await tokens.validAccessToken(); // renews first / 先续期\n if (t != null) o.headers['Authorization'] = 'Bearer $t';\n h.next(o);\n }\n}\nIf you only want the synchronous read, use tokens.accessToken — but remember\nit may hand you a token that has already expired.\n若只需要同步读取,可用 tokens.accessToken —— 但请记住它可能返回一个已过期的令牌。\nfinal dio = Dio()\n ..interceptors.add(AuthInterceptor(authManager));\nWhen a 401 is returned, refresh the session and retry; if refresh fails, the manager emits AuthError and your app routes back to login.当返回 401 时,刷新会话并重试;若刷新失败,管理器会发出 AuthError,应用随之跳转登录。example/lib/dio_interceptor.dart also ships AuthRetryInterceptor, which does\nexactly that — refreshes through the manager (single-flight) and replays the\nrequest once, so a retry loop cannot form:example/lib/dio_interceptor.dart 里还提供了 AuthRetryInterceptor,正是做这件事 ——\n通过管理器刷新(单飞)并把请求重试一次,因此不会形成重试风暴:\nfinal dio = Dio()\n ..interceptors.add(\n AuthRetryInterceptor(manager: authManager, dio: dio),\n );\nTwo more interceptor variants there / 那里还有另外两种拦截器:\nRefreshingAuthInterceptor — never sends an expired token: it renews first via\nvalidAccessToken() / 绝不发送过期令牌,先用 validAccessToken() 续期。\nAuthInterceptor — the plain AuthTokenSource version / 基础的令牌源版本。","other-http-clients--其它客户端#Other HTTP clients / 其它客户端":"Because AuthTokenSource is a plain interface, the same pattern works for package:http, chopper, or any client that lets you mutate request headers.由于 AuthTokenSource 是普通接口,同样的模式适用于 package:http、chopper 或任何允许修改请求头的客户端。","next-steps--下一步#Next Steps / 下一步":"Usage — Full integration walkthrough / 完整集成讲解\nErrors — Handling 401 and refresh failures / 处理 401 与刷新失败"}},"/Persistence":{"title":"Session Persistence / 会话持久化","data":{"":"InMemoryTokenStore loses the session on every restart. For a real app you must persist it — and zero_auth now ships the primitives that make this a one-liner: session (de)serialization plus two ready-to-copy reference stores.InMemoryTokenStore 会在每次重启时丢失会话。真实应用必须把它持久化——而 zero_auth 现在提供了让这件事变成「一行代码」的原语:会话(反)序列化,外加两个开箱即用的参考存储。","1-serialize-the-session--序列化会话#1. Serialize the session / 序列化会话":"AuthSession is JSON-safe out of the box. null fields are omitted, and claims round-trips as a plain map.AuthSession 天生就是 JSON 安全的:null 字段会被省略,claims 作为普通映射原样往返。\nfinal json = session.toJson(); // Map\nfinal restored = AuthSession.fromJson(json);\n// Prefer this inside TokenStore.load(): malformed data yields null (\"not signed\n// in\") instead of a FormatException / TypeError.\n// TokenStore.load() 里请优先用它:畸形数据返回 null(即「未登录」),而不是抛\n// FormatException / TypeError。\nfinal safe = AuthSession.tryFromJson(json);\nRefreshToken serializes to its raw string too, so a persisted session rehydrates losslessly.RefreshToken 也序列化为其原始字符串,因此持久化的会话可无损恢复。","2-flutter--encrypted-secure-storage--flutter--加密安全存储#2. Flutter — encrypted secure storage / Flutter —— 加密安全存储":"For mobile, persist into the OS keychain / keystore via flutter_secure_storage. A reference implementation lives at example/lib/secure_token_store.dart:在移动端,通过 flutter_secure_storage 把令牌存入系统钥匙串 / Keystore。参考实现位于 example/lib/secure_token_store.dart:\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: SecureTokenStore(), // from example/\n);\nawait auth.restore(); // rehydrate before the first frame\nNever store refresh tokens in plain SharedPreferences. Use OS-backed secure storage only.切勿把刷新令牌明文存入 SharedPreferences,只使用操作系统级安全存储。","3-server--cli--desktop--json-file--服务端--cli--桌面--json-文件#3. Server / CLI / desktop — JSON file / 服务端 / CLI / 桌面 —— JSON 文件":"On non-mobile Dart targets you can write the serialized session to a file. A reference implementation lives at example/lib/json_token_store.dart:在非移动端 Dart 目标上,可以把序列化后的会话写入文件。参考实现位于 example/lib/json_token_store.dart:\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: FileTokenStore(File('.zero_auth_session.json')),\n);\nThis is ideal for a backend that keeps a logged-in user across process restarts, or a CLI that stays authenticated between invocations.这非常适合「重启后仍保持登录」的后端,或「多次调用之间保持认证」的 CLI。","4-combine-with-auto-refresh--结合自动刷新#4. Combine with auto-refresh / 结合自动刷新":"When you enable proactive auto-refresh, the manager renews the token before it expires — and every renewal is persisted through save(), so the on-disk session always carries a fresh access token.当你开启主动自动刷新时,管理器会在令牌过期前续期——而每次续期都会通过 save() 落盘,因此磁盘上的会话始终持有崭新的访问令牌。\nfinal auth = AuthManager(\n strategy: myStrategy,\n tokenStore: SecureTokenStore(),\n autoRefreshAhead: const Duration(minutes: 5),\n);","next-steps--下一步#Next Steps / 下一步":"Token Store — the save / load / clear contract / save / load / clear 契约\nConfiguration — enable proactive auto-refresh / 开启主动自动刷新"}},"/Third-Party-Login":{"title":"Third-Party Login / 第三方登录","data":{"the-key-idea--核心思路#The key idea / 核心思路":"zero_auth does not run the OAuth handshake — and that is deliberate. It is a\r\npure-Dart package, so it ships no platform code, no browser plumbing and no\r\nredirect URL handling (those live outside lib/).Instead, zero_auth manages what comes after the handshake: the session your\r\nown backend issues.zero_auth 不负责 OAuth 握手环节,这是刻意为之:它是纯 Dart 包,不含任何平台\r\n代码、浏览器通道或重定向回调处理(这些只能在 lib/ 之外)。它负责的是握手之后的事:你自己后端所签发的会话。\nprovider consent ──► code / id_token ──► YOUR BACKEND ──► your access + refresh tokens ──► zero_auth\r\n (outside) (any language) (this package)\nBecause the login method is invisible to the manager, password login and\r\nGoogle/Apple login end up sharing one token lifecycle. That is the whole\r\npoint.登录方式对管理器是透明的,因此密码登录与 Google / Apple 登录最终共享同一套\r\n令牌生命周期机制。这正是关键所在。","when-this-fits--适用场景#When this fits / 适用场景":"This pattern fits when your backend brokers the provider — you have a\r\nbackend, it verifies the provider token, then mints your own tokens.下面的模式适用于你的后端作为经纪方:你有后端,它校验提供方令牌,然后签发你自己的\r\n令牌。\nScenario\tFit\tOwn backend + Google/Apple/GitHub sign-in / 自有后端 + 第三方登录\t✅ Ideal / 理想\tOwn backend + password AND social logins / 自有后端,密码与社交登录并存\t✅ One shared session model / 共用一套会话模型\tFirebase/Supabase issues the tokens directly / 由 Firebase/Supabase 直接签发令牌\t⚠️ Use their SDK instead / 请改用其 SDK","wiring-it-up--接线步骤#Wiring it up / 接线步骤":"1. Run the handshake outside this package.\r\nUse whichever OAuth client fits your targets (flutter_web_auth, AppAuth, or a\r\nserver-side flow). This code lives in your app or a sibling Flutter package, never\r\nin lib/.1. 在本包之外完成握手。\r\n使用适合目标平台的 OAuth 客户端(flutter_web_auth、AppAuth 或服务端流程)。这部分\r\n代码放在你的应用或兄弟 Flutter 包里,绝不放在 lib/。2. Trade the provider credential for your tokens on your backend.2. 用提供方凭据到你的后端换取自有令牌。\nFuture signInWithGoogle(BuildContext context) async {\r\n // 1) Provider consent (outside zero_auth) → authorization code / id_token.\r\n // 提供方授权(零依赖,在 zero_auth 之外)→ 授权码 / id_token。\r\n final code = await myOAuthClient.authenticate();\r\n\r\n // 2) Your backend verifies it with Google and returns YOUR tokens.\r\n // 你的后端用它与 Google 校验,并返回「你自己的」令牌。\r\n final res = await myApi.post('/auth/google', data: {'code': code});\r\n\r\n return AuthSession(\r\n accessToken: res['accessToken'] as String,\r\n refreshToken: RefreshToken(res['refreshToken'] as String),\r\n expiresAt: DateTime.now().add(Duration(seconds: res['expiresIn'] as int)),\r\n userId: res['userId'] as String,\r\n );\r\n}\n3. Hand the session to the manager with loginWith.\r\nloginWith is the escape hatch for flows that are not login / register: the\r\nmanager still owns persistence, the state machine and proactive refresh.3. 用 loginWith 把会话交给管理器。\r\nloginWith 是为那些不符合 login / register 形态的流程准备的逃生口:持久化、状态机\r\n与主动刷新仍由管理器负责。\nfinal result = await auth.loginWith((strategy) => signInWithGoogle(context));\r\n// emits Authenticating → Authenticated, or AuthError + rethrow.\r\n// 发出 Authenticating → Authenticated;失败则发 AuthError 并重新抛出。\nKeep signInWithGoogle throwing AuthException with a meaningful code\r\n('invalid_credentials', 'session_expired', …) so failures map to the typed\r\nexceptions in Errors.让 signInWithGoogle 抛出带有实用 code('invalid_credentials'、\r\n'session_expired' 等)的 AuthException,这样失败就能映射为\r\nErrors 里的具体异常类型。","same-model-for-every-method--各登录方式共用一套模型#Same model for every method / 各登录方式共用一套模型":"// Password users and Google users end up with the same AuthSession shape.\r\n// 密码用户与 Google 用户最终得到的都是同一种 AuthSession。\r\nawait auth.login(const Credentials(username: user, password: pass));\r\nawait auth.loginWith((strategy) => signInWithGoogle(context));\nYour UI only ever reads state.isAuthenticated, and your network layer only ever\r\nreads auth.validAccessToken() — regardless of how the user signed in.你的界面只需要读 state.isAuthenticated,网络层只需要读 auth.validAccessToken()\r\n——无需关心用户是哪种方式登录的。","also-works-for--同样适用于#Also works for / 同样适用于":"loginWith covers any flow you drive yourself:loginWith 适用于任何由你驱动的流程:\nMagic links / email OTP / 魔法链接、邮箱验证码\nPasskeys / WebAuthn\nBiometric re-unlock (load a locked session and re-mint tokens) / 生物识别解锁\nMigrating from another auth SDK / 从其它认证 SDK 迁移","next-steps--下一步#Next Steps / 下一步":"Backend Strategy — what your strategy must implement / 策略需要实现什么\nAuth State Machine — the states this flow emits / 该流程发出的状态\nErrors — typed exceptions and codes / 类型化异常与 code"}},"/Token-Store":{"title":"Token Store / 令牌存储","data":{"":"TokenStore is the only persistence surface. The core never touches disk, secure storage, or SharedPreferences directly — you decide where tokens live.TokenStore 是唯一的持久化接口。内核绝不直接访问磁盘、安全存储或 SharedPreferences——由你决定令牌存放位置。","the-contract--契约#The contract / 契约":"abstract class TokenStore {\n Future save(AuthSession session);\n Future load();\n Future clear();\n}\nAuthManager calls load() during restore(), save() after every successful login/refresh, and clear() on logout or a failed refresh.AuthManager 在 restore() 时调用 load(),每次登录/刷新成功后调用 save(),在登出或刷新失败失败时调用 clear()。","in-memory-ships-in-core--内存实现内核自带#In-memory (ships in-core) / 内存实现(内核自带)":"InMemoryTokenStore is the default. It survives process restarts? No — it is in-memory only, so the session is lost on restart. Use it for tests and quick prototypes.InMemoryTokenStore 是默认实现,仅存在于内存,进程重启后会话丢失。适用于测试与原型。\nfinal auth = AuthManager(strategy: s, tokenStore: InMemoryTokenStore());","secure-storage-production--安全存储生产#Secure storage (production) / 安全存储(生产)":"example/lib/secure_token_store.dart shows a flutter_secure_storage-backed reference implementation. Adapt it for your app:example/lib/secure_token_store.dart 提供了基于 flutter_secure_storage 的参考实现,可据此改造:\nclass SecureTokenStore implements TokenStore {\n final _box = FlutterSecureStorage();\n @override\n Future save(AuthSession s) =>\n _box.write(key: 'zero_auth', value: jsonEncode(s.toJson()));\n @override\n Future load() async {\n final raw = await _box.read(key: 'zero_auth');\n // tryFromJson: corrupt data means \"not signed in\", not a crash.\n // tryFromJson:数据损坏代表「未登录」,而不是崩溃。\n return AuthSession.tryFromJson(raw == null ? null : jsonDecode(raw));\n }\n @override\n Future clear() => _box.delete(key: 'zero_auth');\n}\nPersist tokens only in OS-backed secure storage (Keychain / Keystore). Never store refresh tokens in plain SharedPreferences.令牌只应存放在操作系统级安全存储(Keychain / Keystore)中,切勿把刷新令牌明文存入 SharedPreferences。","next-steps--下一步#Next Steps / 下一步":"Session Persistence — persist sessions across restarts (serialization + reference stores) / 跨重启持久化会话(序列化与参考存储)\nBackend Strategy — The other boundary / 另一个边界\nErrors — What load() failures become / load() 失败会变成什么"}},"/Usage":{"title":"Usage / 使用指南","data":{"three-step-integration--三步集成#Three-step integration / 三步集成":"","1-implement-authstrategy--实现后端边界#1. Implement AuthStrategy / 实现后端边界":"class MyAuthStrategy implements AuthStrategy {\n @override\n Future login(Credentials credentials) => myApi.login(\n credentials.username, // an email or phone works too\n credentials.password,\n );\n @override\n Future register(RegistrationInput input) =>\n myApi.register(input.username, input.password);\n @override\n Future logout(SessionHandle handle) => myApi.logout(handle.userId);\n @override\n Future refresh(RefreshToken token) =>\n myApi.refresh(token.value);\n}","2-create-authmanager-and-restore--创建管理器并恢复#2. Create AuthManager and restore / 创建管理器并恢复":"final auth = AuthManager(\n strategy: MyAuthStrategy(),\n tokenStore: InMemoryTokenStore(),\n);\nawait auth.restore(); // rehydrate a persisted session / 恢复持久化会话","3-drive-the-ui-from-the-state-stream--用状态流驱动界面#3. Drive the UI from the state stream / 用状态流驱动界面":"await for (final state in auth.state) {\n // render based on state / 根据状态渲染\n}","operations--操作#Operations / 操作":"Method\tDescription\trestore()\tRehydrate the persisted session at startup / 启动时恢复会话\tlogin(username, password)\tAuthenticate and enter Authenticated / 登录并进入已认证\tregister(username, password)\tRegister and authenticate / 注册并认证\tlogout()\tClear the session / 清除会话\trefresh()\tSilent token refresh — concurrent calls share one flight / 静默刷新,并发共享单飞\ttokenSource\tAn AuthTokenSource for HTTP clients / 供 HTTP 客户端使用的令牌源","token-refresh--expiry--令牌刷新与过期#Token refresh & expiry / 令牌刷新与过期":"AuthSession exposes isExpired so callers can refresh proactively. refresh() is single-flight: if several callers request a refresh at once, only one network call is made and its result is shared.AuthSession 提供 isExpired,调用方可主动刷新。refresh() 为单飞:多个调用方同时请求刷新时,只发起一次网络调用并共享结果。 A failed refresh clears the session and emits AuthError.刷新失败会清空会话并发出 AuthError。","attaching-the-bearer-token--附加-bearer-令牌#Attaching the bearer token / 附加 Bearer 令牌":"AuthManager implements AuthTokenSource, so a Dio interceptor can read the current token without depending on the manager. See Network Integration.AuthManager 实现了 AuthTokenSource,Dio 拦截器可在不依赖管理器的情况下读取当前令牌。详见网络集成。","next-steps--下一步#Next Steps / 下一步":"Auth State Machine — State lifecycle details / 状态机细节\nBackend Strategy — Full AuthStrategy contract / 完整后端契约\nErrors — Exception & Result model / 异常与结果模型"}},"/":{"title":"Zero Auth","data":{"":"A backend-agnostic auth state machine & session lifecycle core for Dart/Flutter. Pure Dart, headless, no HTTP/SDK/native code. Wire your own backend via AuthStrategy and your own persistence via TokenStore.一个后端无关的认证状态机与会话生命周期内核,面向 Dart/Flutter。纯 Dart、无头(headless),不含 HTTP、SDK 或原生代码。通过 AuthStrategy 接入自有后端,通过 TokenStore 接入自有持久化层。","-features--功能特性#✨ Features / 功能特性":"Feature\tDescription\tBackend-agnostic\tImplement only login/register/logout/refresh; REST, gRPC, Firebase or a private RPC are equally valid targets / 只需实现四个方法,REST、gRPC、Firebase 或私有 RPC 均可接入\tAuth State Machine\tSealed Unauthenticated / Authenticating / Authenticated / Refreshing / LoggingOut / AuthError on a Stream that replays the latest value to new listeners; use isAuthenticated / isBusy / 密封六态状态机 + 重放最近值的状态流,可用 isAuthenticated / isBusy\tSilent Restore\trestore() rehydrates the persisted session at startup / 启动时静默恢复持久化会话\tSingle-flight Refresh\tConcurrent refresh() calls share one in-flight request instead of stampeding the backend / 并发刷新共享同一次请求,避免冲击后端\tPluggable Persistence\tThe only persistence surface is TokenStore.save/load/clear; InMemoryTokenStore ships in-core / 唯一的持久化接口,内核自带内存实现\tTyped Session\tAuthSession carries access/refresh tokens, expiry (isExpired), user id, display name and raw claims / 强类型会话,无需手工解析令牌\tUnified Errors\tEvery failure maps to AppException (AuthException for auth cases) or a Result wrapper; raw exceptions never cross the public surface / 统一异常与结果,裸异常不越界\tNetwork Integration\tAuthManager itself is an AuthTokenSource, so a Dio interceptor can attach Authorization: Bearer without depending on the manager / 管理器即令牌源,Dio 拦截器零依赖附加令牌\tSession Serialization\tAuthSession.toJson / fromJson make persistence a one-liner, and tryFromJson returns null on malformed data; a file-based reference store ships for server/CLI / AuthSession.toJson / fromJson 让持久化一行搞定,tryFromJson 遇畸形数据返回 null,并附带面向服务端 / CLI 的文件参考存储\tProactive Auto-refresh\tPass autoRefreshAhead to renew tokens before expiry (single-flight) / 传入 autoRefreshAhead 在过期前自动续期(单飞)\tNever an Expired Token\tvalidAccessToken() renews first when the token has expired — or is about to, per clockSkew — so interceptors never send a dead bearer token / 令牌过期(或按 clockSkew 即将过期)时先续期,拦截器不会发出失效令牌\tBring Your Own Login\tloginWith adopts a session from any flow you drive: third-party OAuth, magic links, passkeys / loginWith 可接纳第三方 OAuth、魔法链接、Passkey 等自定义流程\tTyped Auth Exceptions\tInvalidCredentialsException, SessionExpiredException and friends, mapped from your strategy's code / InvalidCredentialsException、SessionExpiredException 等,由策略的 code 映射而来\tConfigurable Failure Policy\trefreshFailurePolicy decides whether a failed refresh signs the user out / refreshFailurePolicy 决定刷新失败是否登出\tRunnable Example\tA full Flutter demo app (Android/iOS/Web/Windows) plus a layered dart:io demo backend that issues real JWTs / 完整 Flutter 示例与一个签发真实 JWT 的分层演示后端\tMultiple Accounts\tOptional AuthManagerGroup keeps one manager per account / 可选 AuthManagerGroup,每账号一个管理器\tCross-platform\tPure Dart — runs anywhere Dart or Flutter runs / 纯 Dart,跨平台","-table-of-contents--目录#📚 Table of Contents / 目录":"Page\tDescription\tGetting Started\tQuick start guide / 快速开始\tInstallation\tHow to install / 安装方式\tUsage\tDetailed usage / 详细使用\tAuth State Machine\tState lifecycle & stream / 状态机与状态流\tBackend Strategy\tImplement AuthStrategy / 实现后端边界\tToken Store\tPersistence boundary / 持久化边界\tNetwork Integration\tDio interceptor & token source / 网络集成与拦截器\tErrors\tException & Result model / 异常与结果模型\tConfiguration\tConfiguration options / 配置说明\tSession Persistence\tRestoring and renewing a saved session / 会话持久化与恢复\tThird-Party Login\tOAuth / magic links via loginWith / 用 loginWith 接入第三方登录\tMultiple Accounts\tSwitching vs concurrent accounts / 账号切换与多账号并存\tFAQ\tFrequently asked questions / 常见问题","-links--链接#🔗 Links / 链接":"GitHub\nOfficial Website\npub.dev","-license--许可证#📄 License / 许可证":"This project is licensed under the Mozilla Public License 2.0 (MPL-2.0).本项目采用 Mozilla Public License 2.0 (MPL-2.0) 许可证。This package is provided \"as is\", without warranty of any kind. The author assumes no responsibility or liability for the functionality, security, or any consequences arising from the use of modified versions or derivative projects.本包按\"原样\"提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。"}},"/FAQ":{"title":"FAQ / 常见问题","data":{"does-zero_auth-ship-an-http-client--zero_auth-内置-http-客户端吗#Does zero_auth ship an HTTP client? / zero_auth 内置 HTTP 客户端吗?":"No. The core is backend-agnostic and ships no HTTP, SDK or native code. You implement AuthStrategy to call your backend.不。内核后端无关,不含任何 HTTP、SDK 或原生代码。你通过实现 AuthStrategy 来调用自己的后端。","does-it-depend-on-flutter--它依赖-flutter-吗#Does it depend on Flutter? / 它依赖 Flutter 吗?":"No. It is a pure-Dart package; flutter: is only a declared constraint because the example app uses Flutter. The runtime has no Flutter dependency.不。它是纯 Dart 包;仅因示例 App 用到 Flutter 才声明 flutter: 约束,运行时不依赖 Flutter。","where-are-tokens-stored--令牌存在哪里#Where are tokens stored? / 令牌存在哪里?":"Wherever your TokenStore puts them. The core ships InMemoryTokenStore (lost on restart). For production, use the flutter_secure_storage-backed reference in example/lib/secure_token_store.dart.取决于你的 TokenStore。内核自带 InMemoryTokenStore(重启即丢失)。生产请用 example/lib/secure_token_store.dart 中基于 flutter_secure_storage 的参考实现。","what-happens-on-a-failed-refresh--刷新失败会怎样#What happens on a failed refresh? / 刷新失败会怎样?":"The failure is always reported as AuthError and rethrown. What happens next\ndepends on refreshFailurePolicy: an unrecoverable failure (SessionExpiredException,\nInvalidCredentialsException) clears the store and lands on Unauthenticated,\nwhile a transient one (network, 5xx) keeps the previous session so a retry can\nsucceed. See Configuration.失败总会以 AuthError 上报并重新抛出。之后如何取决于 refreshFailurePolicy:\n不可恢复的失败(SessionExpiredException、InvalidCredentialsException)会清空存储\n并落到 Unauthenticated;瞬时故障(网络、5xx)保留上一个会话以便重试成功。参见\n配置。","is-token-refresh-concurrent-safe--令牌刷新是否并发安全#Is token refresh concurrent-safe? / 令牌刷新是否并发安全?":"Yes — refresh() is single-flight: many concurrent callers share one in-flight request and its result.是的——refresh() 为单飞:多个并发调用方共享同一次请求及其结果。","how-do-i-attach-the-bearer-token-to-requests--如何给请求附加-bearer-令牌#How do I attach the bearer token to requests? / 如何给请求附加 Bearer 令牌?":"AuthManager is an AuthTokenSource. Use the Dio interceptor in example/lib/dio_interceptor.dart, or read accessToken directly — but prefer validAccessToken() (or RefreshingAuthInterceptor) whenever the token reaches a server, since accessToken may already be expired.AuthManager 即 AuthTokenSource。可用 example/lib/dio_interceptor.dart 中的 Dio 拦截器,或直接读取 accessToken —— 但只要令牌要发到服务端,就请优先用 validAccessToken()(或 RefreshingAuthInterceptor),因为 accessToken 可能已经过期。","does-it-sync-sessions-across-devices--它会在多设备间同步会话吗#Does it sync sessions across devices? / 它会在多设备间同步会话吗?":"No — and by design. A session is local state plus whatever your backend decides;\npushing \"signed out elsewhere\" to a device needs server push (or polling), which\nis outside a headless state machine. Model it with your backend revoking the\ngrant: the next refresh fails, refreshFailurePolicy signs the device out, and\nAuthError explains why.不会 —— 这是有意的设计。会话是本地状态加上你后端的决定;把「在别处已登出」推送到某台\n设备需要服务端推送(或轮询),那超出了无头状态机的职责。可由后端吊销授权来实现:下一次\n刷新失败,refreshFailurePolicy 会让该设备登出,并由 AuthError 说明原因。","which-platforms-are-supported--支持哪些平台#Which platforms are supported? / 支持哪些平台?":"Any platform where Dart or Flutter runs (Android, iOS, Web, Windows, macOS, Linux).任何能运行 Dart 或 Flutter 的平台(Android、iOS、Web、Windows、macOS、Linux)。"}},"/Installation":{"title":"Installation / 安装","data":{"from-pubdev-recommended--从-pubdev-安装推荐#From pub.dev (Recommended) / 从 pub.dev 安装(推荐)":"Add the following to your pubspec.yaml:在 pubspec.yaml 中添加以下依赖:\ndependencies:\n zero_auth: ^1.0.0\nThen run:然后运行:\ndart pub get","from-github--从-github-安装#From GitHub / 从 GitHub 安装":"Alternatively, install from GitHub:或者从 GitHub 安装:\ndependencies:\n zero_auth:\n git:\n url: https://github.com/zero-labsco/zero_auth.git\n ref: release/v1.0.0","import--导入#Import / 导入":"import 'package:zero_auth/zero_auth.dart';","requirements--环境要求#Requirements / 环境要求":"Requirement\tVersion\tDart SDK\t>= 3.4.0\tFlutter\t>= 3.0.0 (only a constraint; no Flutter runtime dependency)\t\nzero_auth is a pure-Dart package. Flutter is only listed because the example app uses it; the core has no Flutter dependency.zero_auth 是纯 Dart 包,仅因示例 App 用到 Flutter 才列出;内核本身不依赖 Flutter。","next-steps--下一步#Next Steps / 下一步":"Getting Started — Quick start guide / 快速开始\nUsage — Full usage guide / 完整使用指南"}}} \ No newline at end of file diff --git a/docs/_next/static/kjpeBAQZfIa0cAXLmehHB/_buildManifest.js b/docs/_next/static/snGQ1XTBh5zEItHPAKYhp/_buildManifest.js similarity index 100% rename from docs/_next/static/kjpeBAQZfIa0cAXLmehHB/_buildManifest.js rename to docs/_next/static/snGQ1XTBh5zEItHPAKYhp/_buildManifest.js diff --git a/docs/_next/static/kjpeBAQZfIa0cAXLmehHB/_ssgManifest.js b/docs/_next/static/snGQ1XTBh5zEItHPAKYhp/_ssgManifest.js similarity index 100% rename from docs/_next/static/kjpeBAQZfIa0cAXLmehHB/_ssgManifest.js rename to docs/_next/static/snGQ1XTBh5zEItHPAKYhp/_ssgManifest.js diff --git a/docs/index.html b/docs/index.html index 430c075..6c39d5f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,4 +1,4 @@ -
    🏠 Home

    Zero Auth

    +
    🏠 Home

    Zero Auth

    A backend-agnostic auth state machine & session lifecycle core for Dart/Flutter. Pure Dart, headless, no HTTP/SDK/native code. Wire your own backend via AuthStrategy and your own persistence via TokenStore.

    一个后端无关的认证状态机与会话生命周期内核,面向 Dart/Flutter。纯 Dart、无头(headless),不含 HTTP、SDK 或原生代码。通过 AuthStrategy 接入自有后端,通过 TokenStore 接入自有持久化层。

    ✨ Features / 功能特性

    @@ -153,4 +153,4 @@

    This project is licensed under the Mozilla Public License 2.0 (MPL-2.0).

    本项目采用 Mozilla Public License 2.0 (MPL-2.0) 许可证。

    This package is provided “as is”, without warranty of any kind. The author assumes no responsibility or liability for the functionality, security, or any consequences arising from the use of modified versions or derivative projects.

    -

    本包按”原样”提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。


    Zero Auth · MPL-2.0
    \ No newline at end of file +

    本包按”原样”提供,不提供任何担保。作者不对修改版或衍生项目的功能、安全性及任何使用后果承担责任。


    Zero Auth · MPL-2.0
    \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 2898867..ad43a1c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: zero_auth description: Backend-agnostic auth state machine & session lifecycle for Dart/Flutter. license: MPL-2.0 -version: 0.5.0 +version: 1.0.0 homepage: https://github.com/zero-labsco/zero_auth repository: https://github.com/zero-labsco/zero_auth documentation: https://zero-labsco.github.io/zero_auth/ From 9c879fd36b33a8fa314140c001d88d99bf45d550 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 21 Sep 2026 19:02:50 +0000 Subject: [PATCH 4/7] style: auto-format with dart format --- example/lib/main.dart | 154 ++++++++++++------------ example/lib/secure_token_store.dart | 2 +- lib/src/auth_manager.dart | 45 +++---- lib/src/auth_manager_group.dart | 12 +- lib/src/auth_session.dart | 72 +++++------ lib/src/auth_state.dart | 10 +- lib/src/exceptions.dart | 16 +-- server/lib/src/auth/auth_service.dart | 22 ++-- server/lib/src/auth/token_service.dart | 6 +- server/lib/src/auth/user_store.dart | 2 +- server/lib/src/config.dart | 5 +- server/lib/src/logging/logger.dart | 4 +- test/auth_manager_hardening_test.dart | 19 ++- test/auth_manager_lifecycle_test.dart | 50 ++++---- test/auth_manager_test.dart | 10 +- test/auth_manager_v1_test.dart | 160 ++++++++++++++----------- test/fake_strategy.dart | 12 +- 17 files changed, 302 insertions(+), 299 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index f3cd2aa..8ae6e9e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -49,8 +49,8 @@ class _DemoStrategy implements AuthStrategy { @override Future refresh(RefreshToken token) async => login( - const Credentials(username: _validUsername, password: _validPassword), - ); + const Credentials(username: _validUsername, password: _validPassword), + ); } /// Real HTTP backend strategy. Talks to the Dart server in `../../server`. @@ -64,14 +64,14 @@ class _HttpAuthStrategy implements AuthStrategy { res.data as Map; AuthSession _toSession(Map data) => AuthSession( - accessToken: data['accessToken'] as String, - refreshToken: RefreshToken(data['refreshToken'] as String), - expiresAt: data['expiresIn'] != null - ? DateTime.now().add(Duration(seconds: data['expiresIn'] as int)) - : null, - userId: data['userId'] as String, - displayName: data['displayName'] as String, - ); + accessToken: data['accessToken'] as String, + refreshToken: RefreshToken(data['refreshToken'] as String), + expiresAt: data['expiresIn'] != null + ? DateTime.now().add(Duration(seconds: data['expiresIn'] as int)) + : null, + userId: data['userId'] as String, + displayName: data['displayName'] as String, + ); @override Future login(Credentials credentials) async { @@ -194,10 +194,10 @@ class _DemoAppState extends State { } void _toggleBackend(bool value) => setState(() { - unawaited(_auth.dispose()); - _useBackend = value; - _init(); - }); + unawaited(_auth.dispose()); + _useBackend = value; + _init(); + }); /// Runs an auth action and swallows the rethrown error: [AuthManager] already /// surfaces it as an [AuthError] state, so there is nothing left to handle. @@ -271,51 +271,50 @@ class _DemoAppState extends State { @override Widget build(BuildContext context) => MaterialApp( - title: 'zero_auth demo', - theme: _theme(Brightness.light), - darkTheme: _theme(Brightness.dark), - home: Scaffold( - appBar: AppBar(title: const Text('zero_auth demo')), - body: SafeArea( - child: StreamBuilder( - initialData: _auth.current, - stream: _auth.state, - builder: (context, snapshot) => _DemoBody( - state: snapshot.data, - auth: _auth, - useBackend: _useBackend, - baseUrl: _baseUrl, - username: _username, - password: _password, - onToggleBackend: _toggleBackend, - onLogin: () => _invoke( - () => _auth.login( - Credentials( - username: _username.text, password: _password.text), - ), - ), - onRefresh: () => _invoke(() => _auth.refresh()), - onLogout: () => _invoke(() => _auth.logout()), - onCallMe: () => _callMe(context), - onExpireNow: () => _expireTokenNow(context), - onExpireSoon: () => _expireTokenSoon(context, 10), - onResetDebug: () => _resetDebug(context), + title: 'zero_auth demo', + theme: _theme(Brightness.light), + darkTheme: _theme(Brightness.dark), + home: Scaffold( + appBar: AppBar(title: const Text('zero_auth demo')), + body: SafeArea( + child: StreamBuilder( + initialData: _auth.current, + stream: _auth.state, + builder: (context, snapshot) => _DemoBody( + state: snapshot.data, + auth: _auth, + useBackend: _useBackend, + baseUrl: _baseUrl, + username: _username, + password: _password, + onToggleBackend: _toggleBackend, + onLogin: () => _invoke( + () => _auth.login( + Credentials(username: _username.text, password: _password.text), ), ), + onRefresh: () => _invoke(() => _auth.refresh()), + onLogout: () => _invoke(() => _auth.logout()), + onCallMe: () => _callMe(context), + onExpireNow: () => _expireTokenNow(context), + onExpireSoon: () => _expireTokenSoon(context, 10), + onResetDebug: () => _resetDebug(context), ), ), - ); + ), + ), + ); /// One seed colour drives the whole palette; widgets read shades from the /// theme instead of hardcoding colors. static ThemeData _theme(Brightness brightness) => ThemeData( - useMaterial3: true, - brightness: brightness, - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF00695C), - brightness: brightness, - ), - ); + useMaterial3: true, + brightness: brightness, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF00695C), + brightness: brightness, + ), + ); } /// The scrollable demo surface. Adapts to the viewport: a full-width column on @@ -579,17 +578,16 @@ class _Badge extends StatelessWidget { @override Widget build(BuildContext context) => Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: color, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: Text( - label, - style: - Theme.of(context).textTheme.labelMedium?.copyWith(color: onColor), - ), - ); + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith(color: onColor), + ), + ); } /// The complete access token, wrapped over as many lines as it needs. @@ -681,13 +679,13 @@ class _InfoRow extends StatelessWidget { @override Widget build(BuildContext context) => _LabeledRow( - label: label, - child: Text( - value, - style: Theme.of(context).textTheme.bodyMedium, - overflow: TextOverflow.ellipsis, - ), - ); + label: label, + child: Text( + value, + style: Theme.of(context).textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ); } /// Failure surface. Uses the theme's error container rather than literal red, @@ -753,15 +751,15 @@ class _BackendCard extends StatelessWidget { @override Widget build(BuildContext context) => Card( - child: SwitchListTile.adaptive( - value: useBackend, - onChanged: onChanged, - title: const Text('Live backend'), - subtitle: Text(useBackend ? baseUrl : 'offline double, no server'), - secondary: const Icon(Icons.cloud_outlined), - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - ), - ); + child: SwitchListTile.adaptive( + value: useBackend, + onChanged: onChanged, + title: const Text('Live backend'), + subtitle: Text(useBackend ? baseUrl : 'offline double, no server'), + secondary: const Icon(Icons.cloud_outlined), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + ), + ); } /// Sign-in form. The password can be revealed, and submitting from the keyboard diff --git a/example/lib/secure_token_store.dart b/example/lib/secure_token_store.dart index e04e9c5..5d8b5d9 100644 --- a/example/lib/secure_token_store.dart +++ b/example/lib/secure_token_store.dart @@ -7,7 +7,7 @@ import 'package:zero_auth/zero_auth.dart'; /// is required. Swap for your own codec as needed. final class SecureTokenStore implements TokenStore { SecureTokenStore([FlutterSecureStorage? storage]) - : _storage = storage ?? const FlutterSecureStorage(); + : _storage = storage ?? const FlutterSecureStorage(); final FlutterSecureStorage _storage; diff --git a/lib/src/auth_manager.dart b/lib/src/auth_manager.dart index 9c26f32..3239867 100644 --- a/lib/src/auth_manager.dart +++ b/lib/src/auth_manager.dart @@ -123,17 +123,17 @@ final class AuthManager implements AuthTokenSource { Duration? clockSkew, this.preserveSessionDetails = true, this.onStateChanged, - }) : tokenStore = tokenStore ?? InMemoryTokenStore(), - _autoRefreshAhead = autoRefreshAhead, - _autoRefreshRetryDelay = - autoRefreshRetryDelay ?? const Duration(seconds: 30), - _autoRefreshMaxRetries = autoRefreshMaxRetries ?? 3, - _autoRefreshMinInterval = - autoRefreshMinInterval ?? const Duration(seconds: 5), - refreshFailurePolicy = - refreshFailurePolicy ?? defaultRefreshFailurePolicy, - clock = clock ?? _systemClock, - clockSkew = clockSkew ?? const Duration(seconds: 30); + }) : tokenStore = tokenStore ?? InMemoryTokenStore(), + _autoRefreshAhead = autoRefreshAhead, + _autoRefreshRetryDelay = + autoRefreshRetryDelay ?? const Duration(seconds: 30), + _autoRefreshMaxRetries = autoRefreshMaxRetries ?? 3, + _autoRefreshMinInterval = + autoRefreshMinInterval ?? const Duration(seconds: 5), + refreshFailurePolicy = + refreshFailurePolicy ?? defaultRefreshFailurePolicy, + clock = clock ?? _systemClock, + clockSkew = clockSkew ?? const Duration(seconds: 30); final Duration? _autoRefreshAhead; @@ -220,10 +220,10 @@ final class AuthManager implements AuthTokenSource { /// 当前活动会话;未认证时为 `null`。在 [Authenticated] 与 [Refreshing] 下均可用 /// (续期中会话依然有效),但 [LoggingOut] 下为空。 AuthSession? get currentSession => switch (_state) { - Authenticated(:final session) => session, - Refreshing(:final session) => session, - _ => null, - }; + Authenticated(:final session) => session, + Refreshing(:final session) => session, + _ => null, + }; @override String? get accessToken => currentSession?.accessToken; @@ -255,8 +255,9 @@ final class AuthManager implements AuthTokenSource { _checkUsable(); final inFlight = _restoreInFlight; if (inFlight != null) return inFlight; - final attempt = - _restore(refreshIfExpired).whenComplete(() => _restoreInFlight = null); + final attempt = _restore( + refreshIfExpired, + ).whenComplete(() => _restoreInFlight = null); _restoreInFlight = attempt; return attempt; } @@ -646,11 +647,11 @@ final class AuthManager implements AuthTokenSource { /// being discarded during [LoggingOut]. /// 驱动进行中操作或已建立认证的会话,包含在 [LoggingOut] 期间正被丢弃的那个。 AuthSession? get _activeSession => switch (_state) { - Authenticated(:final session) => session, - Refreshing(:final session) => session, - LoggingOut(:final session) => session, - _ => null, - }; + Authenticated(:final session) => session, + Refreshing(:final session) => session, + LoggingOut(:final session) => session, + _ => null, + }; bool _isCurrent(int epoch) => !_disposed && epoch == _epoch; diff --git a/lib/src/auth_manager_group.dart b/lib/src/auth_manager_group.dart index 0e95eb6..5fc76dd 100644 --- a/lib/src/auth_manager_group.dart +++ b/lib/src/auth_manager_group.dart @@ -60,8 +60,8 @@ final class AuthManagerGroup implements AuthTokenSource { this.clockSkew, this.preserveSessionDetails = true, this.onStateChanged, - }) : _strategyFactory = strategyFactory, - _storeFactory = storeFactory; + }) : _strategyFactory = strategyFactory, + _storeFactory = storeFactory; /// Called once per account. Returning the same instance for every account is /// fine — and typical — since a strategy usually just talks to one backend. @@ -79,7 +79,8 @@ final class AuthManagerGroup implements AuthTokenSource { String accountId, AuthStrategy strategy, TokenStore store, - )? managerFactory; + )? + managerFactory; /// Forwarded to every manager this group creates — see [AuthManager.new]. /// 转发给分组创建的每个管理器 —— 参见 [AuthManager.new]。 @@ -156,8 +157,9 @@ final class AuthManagerGroup implements AuthTokenSource { clock: clock, clockSkew: clockSkew, preserveSessionDetails: preserveSessionDetails, - onStateChanged: - observer == null ? null : (state) => observer(accountId, state), + onStateChanged: observer == null + ? null + : (state) => observer(accountId, state), ); } diff --git a/lib/src/auth_session.dart b/lib/src/auth_session.dart index 2b24958..12398ce 100644 --- a/lib/src/auth_session.dart +++ b/lib/src/auth_session.dart @@ -94,15 +94,14 @@ final class AuthSession { String? userId, String? displayName, Map? claims, - }) => - AuthSession( - accessToken: accessToken ?? this.accessToken, - refreshToken: refreshToken ?? this.refreshToken, - expiresAt: expiresAt ?? this.expiresAt, - userId: userId ?? this.userId, - displayName: displayName ?? this.displayName, - claims: claims ?? this.claims, - ); + }) => AuthSession( + accessToken: accessToken ?? this.accessToken, + refreshToken: refreshToken ?? this.refreshToken, + expiresAt: expiresAt ?? this.expiresAt, + userId: userId ?? this.userId, + displayName: displayName ?? this.displayName, + claims: claims ?? this.claims, + ); /// How long until the access token expires, or `null` when there is no expiry. /// 距离访问令牌过期还有多久;无过期时间时为 `null`。 @@ -133,28 +132,28 @@ final class AuthSession { /// [claims] 会被原样写入,因此只能包含 JSON 安全的值(String、num、bool、null、 /// List、Map)。放入 `DateTime` 或自定义对象会让存储层的 `jsonEncode` 抛错。 Map toJson() => { - 'accessToken': accessToken, - if (refreshToken != null) 'refreshToken': refreshToken!.value, - if (expiresAt != null) 'expiresAt': expiresAt!.toIso8601String(), - if (userId != null) 'userId': userId, - if (displayName != null) 'displayName': displayName, - if (claims != null) 'claims': claims, - }; + 'accessToken': accessToken, + if (refreshToken != null) 'refreshToken': refreshToken!.value, + if (expiresAt != null) 'expiresAt': expiresAt!.toIso8601String(), + if (userId != null) 'userId': userId, + if (displayName != null) 'displayName': displayName, + if (claims != null) 'claims': claims, + }; /// Deserialize from a map produced by [toJson]. /// 从 [toJson] 生成的映射反序列化。 factory AuthSession.fromJson(Map json) => AuthSession( - accessToken: json['accessToken'] as String, - refreshToken: json['refreshToken'] == null - ? null - : RefreshToken(json['refreshToken'] as String), - expiresAt: json['expiresAt'] == null - ? null - : DateTime.parse(json['expiresAt'] as String), - userId: json['userId'] as String?, - displayName: json['displayName'] as String?, - claims: (json['claims'] as Map?)?.cast(), - ); + accessToken: json['accessToken'] as String, + refreshToken: json['refreshToken'] == null + ? null + : RefreshToken(json['refreshToken'] as String), + expiresAt: json['expiresAt'] == null + ? null + : DateTime.parse(json['expiresAt'] as String), + userId: json['userId'] as String?, + displayName: json['displayName'] as String?, + claims: (json['claims'] as Map?)?.cast(), + ); /// Deserialize from a map produced by [toJson], or return `null` when the map /// does not describe a valid session. @@ -196,8 +195,9 @@ final class AuthSession { return AuthSession( accessToken: accessToken, - refreshToken: - refreshToken == null ? null : RefreshToken(refreshToken as String), + refreshToken: refreshToken == null + ? null + : RefreshToken(refreshToken as String), expiresAt: expiresAt, userId: userId as String?, displayName: displayName as String?, @@ -217,13 +217,13 @@ final class AuthSession { @override int get hashCode => Object.hash( - accessToken, - refreshToken, - expiresAt, - userId, - displayName, - _claimsHash(claims), - ); + accessToken, + refreshToken, + expiresAt, + userId, + displayName, + _claimsHash(claims), + ); /// Claims participate in equality so a session whose *only* change is in /// `claims` still counts as new — otherwise a state emission could be diff --git a/lib/src/auth_state.dart b/lib/src/auth_state.dart index e9e9c3a..37bc4e5 100644 --- a/lib/src/auth_state.dart +++ b/lib/src/auth_state.dart @@ -33,11 +33,11 @@ sealed class AuthState { /// [Authenticated]、[Refreshing] 与 [LoggingOut] 都携带会话;当界面只需要会话时, /// 请优先使用此属性,而不是对三个子类分别做模式匹配。 AuthSession? get session => switch (this) { - Authenticated(:final session) => session, - Refreshing(:final session) => session, - LoggingOut(:final session) => session, - _ => null, - }; + Authenticated(:final session) => session, + Refreshing(:final session) => session, + LoggingOut(:final session) => session, + _ => null, + }; } /// No active session. diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart index b27d31f..96aae2a 100644 --- a/lib/src/exceptions.dart +++ b/lib/src/exceptions.dart @@ -15,12 +15,12 @@ class AuthException extends AppException { final AuthFail fail; AuthException.fromFail(this.fail) - : super(fail.message, code: fail.code, cause: fail.cause); + : super(fail.message, code: fail.code, cause: fail.cause); /// Convenience constructor for manager-internal failures. /// 供管理器内部失败使用的便捷构造。 AuthException(String message, {String? code, Object? cause}) - : this.fromFail(AuthFail(message, code: code, cause: cause)); + : this.fromFail(AuthFail(message, code: code, cause: cause)); } /// Credentials were rejected by the backend (wrong password, unknown user…). @@ -38,7 +38,7 @@ final class InvalidCredentialsException extends AuthException { /// 授权已不可用:会话 / 刷新令牌 / 访问令牌已过期或被吊销,只能重新登录。 final class SessionExpiredException extends AuthException { SessionExpiredException({String message = 'Session expired', Object? cause}) - : super(message, code: 'session_expired', cause: cause); + : super(message, code: 'session_expired', cause: cause); } /// An operation that requires an active session was called with none. @@ -99,10 +99,7 @@ AuthException mapAuthFailure(Object error) { switch (code) { case 'invalid_credentials': - return InvalidCredentialsException( - message: message, - cause: cause, - ); + return InvalidCredentialsException(message: message, cause: cause); case 'invalid_grant': case 'invalid_refresh_token': case 'token_expired': @@ -111,10 +108,7 @@ AuthException mapAuthFailure(Object error) { case 'no_active_session': return NoActiveSessionException(message: message, cause: cause); case 'refresh_token_missing': - return RefreshTokenMissingException( - message: message, - cause: cause, - ); + return RefreshTokenMissingException(message: message, cause: cause); default: // Unclassifiable: keep whatever vocabulary the author already threw. // 无法归类:保留作者原本抛出的错误类型。 diff --git a/server/lib/src/auth/auth_service.dart b/server/lib/src/auth/auth_service.dart index 7069daf..e3c9850 100644 --- a/server/lib/src/auth/auth_service.dart +++ b/server/lib/src/auth/auth_service.dart @@ -156,15 +156,15 @@ final class AuthService { } AuthSuccess _issue(UserRecord user, [String? refreshToken]) => AuthSuccess( - user: user, - accessToken: tokens.sign( - subject: user.id, - displayName: user.displayName, - type: TokenType.access, - ttl: accessTtl, - tokenId: tokens.newId(), - ), - refreshToken: refreshToken ?? refreshTokens.issue(user), - expiresIn: accessTtl.inSeconds, - ); + user: user, + accessToken: tokens.sign( + subject: user.id, + displayName: user.displayName, + type: TokenType.access, + ttl: accessTtl, + tokenId: tokens.newId(), + ), + refreshToken: refreshToken ?? refreshTokens.issue(user), + expiresIn: accessTtl.inSeconds, + ); } diff --git a/server/lib/src/auth/token_service.dart b/server/lib/src/auth/token_service.dart index 99e67fd..ea8d519 100644 --- a/server/lib/src/auth/token_service.dart +++ b/server/lib/src/auth/token_service.dart @@ -33,9 +33,9 @@ final class TokenService { required String secret, Duration clockSkew = const Duration(seconds: 1), Random? random, - }) : _secret = utf8.encode(secret), - _clockSkew = clockSkew, - _random = random ?? Random.secure(); + }) : _secret = utf8.encode(secret), + _clockSkew = clockSkew, + _random = random ?? Random.secure(); final List _secret; final Duration _clockSkew; diff --git a/server/lib/src/auth/user_store.dart b/server/lib/src/auth/user_store.dart index c2d5083..098acff 100644 --- a/server/lib/src/auth/user_store.dart +++ b/server/lib/src/auth/user_store.dart @@ -13,7 +13,7 @@ final class UserRecord { /// in the backend has to change. final class UserStore { UserStore({Map? credentials}) - : _credentials = credentials ?? _defaultCredentials; + : _credentials = credentials ?? _defaultCredentials; static const _defaultCredentials = {'user': 'user'}; diff --git a/server/lib/src/config.dart b/server/lib/src/config.dart index e6b1e62..76e9a1b 100644 --- a/server/lib/src/config.dart +++ b/server/lib/src/config.dart @@ -32,9 +32,8 @@ final class ServerConfig { static const _defaultSecret = 'demo-secret-change-me'; static Duration _durationFromEnv(String key, int defaultSeconds) => Duration( - seconds: - int.tryParse(Platform.environment[key] ?? '') ?? defaultSeconds, - ); + seconds: int.tryParse(Platform.environment[key] ?? '') ?? defaultSeconds, + ); final String host; final int port; diff --git a/server/lib/src/logging/logger.dart b/server/lib/src/logging/logger.dart index 21a3f9f..582b142 100644 --- a/server/lib/src/logging/logger.dart +++ b/server/lib/src/logging/logger.dart @@ -9,8 +9,8 @@ enum LogLevel { debug, info, warn, error } /// `LOG_LEVEL=debug` to also see per-route debug lines. final class Logger { Logger({LogLevel minimum = LogLevel.info, Stdout? output}) - : _minimum = minimum, - _out = output ?? stdout; + : _minimum = minimum, + _out = output ?? stdout; final LogLevel _minimum; final Stdout _out; diff --git a/test/auth_manager_hardening_test.dart b/test/auth_manager_hardening_test.dart index 4a79436..a215dd1 100644 --- a/test/auth_manager_hardening_test.dart +++ b/test/auth_manager_hardening_test.dart @@ -93,16 +93,15 @@ void main() { String token = 'access', String name = 'user@demo', bool expired = false, - }) => - AuthSession( - accessToken: token, - refreshToken: const RefreshToken('refresh'), - expiresAt: expired - ? now.subtract(const Duration(minutes: 1)) - : now.add(const Duration(minutes: 5)), - userId: 'user', - displayName: name, - ); + }) => AuthSession( + accessToken: token, + refreshToken: const RefreshToken('refresh'), + expiresAt: expired + ? now.subtract(const Duration(minutes: 1)) + : now.add(const Duration(minutes: 5)), + userId: 'user', + displayName: name, + ); group('restore hardening', () { test('a logout during restore does not resurrect the session', () async { diff --git a/test/auth_manager_lifecycle_test.dart b/test/auth_manager_lifecycle_test.dart index 693bbc9..3dd6e9d 100644 --- a/test/auth_manager_lifecycle_test.dart +++ b/test/auth_manager_lifecycle_test.dart @@ -37,15 +37,14 @@ void main() { bool expired = false, bool withRefresh = true, Duration ttl = const Duration(hours: 1), - }) => - AuthSession( - accessToken: accessToken, - refreshToken: withRefresh ? const RefreshToken('refresh') : null, - expiresAt: expired - ? fixedNow.subtract(const Duration(minutes: 5)) - : fixedNow.add(ttl), - userId: 'u1', - ); + }) => AuthSession( + accessToken: accessToken, + refreshToken: withRefresh ? const RefreshToken('refresh') : null, + expiresAt: expired + ? fixedNow.subtract(const Duration(minutes: 5)) + : fixedNow.add(ttl), + userId: 'u1', + ); /// Lets pending microtasks settle so stream emissions become observable. /// 让挂起的微任务执行完,使状态流的新值可被观察。 @@ -350,24 +349,21 @@ void main() { group('AuthManager — proactive refresh', () { test('failures never leak an unhandled async error', () async { final errors = []; - final zoneRun = runZonedGuarded>( - () async { - final strategy = FakeAuthStrategy( - session: buildSession(ttl: Duration.zero), - )..refreshError = SessionExpiredException(); - final manager = AuthManager( - strategy: strategy, - tokenStore: InMemoryTokenStore(), - autoRefreshAhead: const Duration(minutes: 5), - clock: () => fixedNow, - ); - await manager.login(credentials); - await pump(); - expect(manager.current, const Unauthenticated()); - await manager.dispose(); - }, - (error, stack) => errors.add(error), - ); + final zoneRun = runZonedGuarded>(() async { + final strategy = FakeAuthStrategy( + session: buildSession(ttl: Duration.zero), + )..refreshError = SessionExpiredException(); + final manager = AuthManager( + strategy: strategy, + tokenStore: InMemoryTokenStore(), + autoRefreshAhead: const Duration(minutes: 5), + clock: () => fixedNow, + ); + await manager.login(credentials); + await pump(); + expect(manager.current, const Unauthenticated()); + await manager.dispose(); + }, (error, stack) => errors.add(error)); await (zoneRun ?? Future.value()); expect(errors, isEmpty); }); diff --git a/test/auth_manager_test.dart b/test/auth_manager_test.dart index ccf12cc..dcfb62d 100644 --- a/test/auth_manager_test.dart +++ b/test/auth_manager_test.dart @@ -13,11 +13,11 @@ final class ExtendingAuthStrategy implements AuthStrategy { int refreshCount = 0; AuthSession _session(DateTime expiresAt) => AuthSession( - accessToken: 'access', - refreshToken: const RefreshToken('refresh'), - expiresAt: expiresAt, - userId: 'u1', - ); + accessToken: 'access', + refreshToken: const RefreshToken('refresh'), + expiresAt: expiresAt, + userId: 'u1', + ); @override Future login(Credentials credentials) async => diff --git a/test/auth_manager_v1_test.dart b/test/auth_manager_v1_test.dart index b6b2b4e..52fc380 100644 --- a/test/auth_manager_v1_test.dart +++ b/test/auth_manager_v1_test.dart @@ -44,8 +44,8 @@ final class _FailingStore implements TokenStore { this.failSaveAfter = 0, this.failClear = false, AuthSession? initial, - }) : value = initial, - _allowedSaves = failSaveAfter; + }) : value = initial, + _allowedSaves = failSaveAfter; final int failSaveAfter; final bool failClear; @@ -167,10 +167,10 @@ final class _ShortTtlStrategy implements AuthStrategy { } AuthSession _issue() => AuthSession( - accessToken: 'access-$refreshCount', - refreshToken: const RefreshToken('refresh'), - expiresAt: DateTime.now().add(const Duration(milliseconds: 300)), - ); + accessToken: 'access-$refreshCount', + refreshToken: const RefreshToken('refresh'), + expiresAt: DateTime.now().add(const Duration(milliseconds: 300)), + ); } /// A strategy that keeps handing out sessions that are already due, to prove the @@ -197,10 +197,10 @@ final class _ShortLivedStrategy implements AuthStrategy { } AuthSession _issued() => AuthSession( - accessToken: 'access-$refreshCount', - refreshToken: const RefreshToken('refresh'), - expiresAt: clock().add(const Duration(seconds: 1)), - ); + accessToken: 'access-$refreshCount', + refreshToken: const RefreshToken('refresh'), + expiresAt: clock().add(const Duration(seconds: 1)), + ); } final class _StaticTokenSource implements AuthTokenSource { @@ -219,15 +219,14 @@ AuthSession _session( String? userId, String? displayName, Map? claims, -}) => - AuthSession( - accessToken: token, - refreshToken: const RefreshToken('refresh'), - expiresAt: expiresAt, - userId: userId, - displayName: displayName, - claims: claims, - ); +}) => AuthSession( + accessToken: token, + refreshToken: const RefreshToken('refresh'), + expiresAt: expiresAt, + userId: userId, + displayName: displayName, + claims: claims, +); const _credentials = Credentials(username: 'user', password: 'user'); @@ -235,32 +234,34 @@ void main() { final now = DateTime.utc(2026, 1, 1, 12); group('restore — transient renewal failures', () { - test('keeps the persisted session when the renewal fails transiently', - () async { - final store = _MemStore( - _session( - 'old', - expiresAt: now.subtract(const Duration(minutes: 5)), - userId: 'u1', - ), - ); - final manager = AuthManager( - strategy: _FailingRefreshStrategy( - AuthException('offline', code: 'network_unreachable'), - session: _session('unused'), - ), - tokenStore: store, - clock: () => now, - ); + test( + 'keeps the persisted session when the renewal fails transiently', + () async { + final store = _MemStore( + _session( + 'old', + expiresAt: now.subtract(const Duration(minutes: 5)), + userId: 'u1', + ), + ); + final manager = AuthManager( + strategy: _FailingRefreshStrategy( + AuthException('offline', code: 'network_unreachable'), + session: _session('unused'), + ), + tokenStore: store, + clock: () => now, + ); - await manager.restore(); + await manager.restore(); - // The whole point of the failure policy: a transient error must not - // destroy a session that a later attempt could still renew. - expect(store.value, isNotNull); - expect(manager.currentSession?.accessToken, 'old'); - expect(manager.current, isA()); - }); + // The whole point of the failure policy: a transient error must not + // destroy a session that a later attempt could still renew. + expect(store.value, isNotNull); + expect(manager.currentSession?.accessToken, 'old'); + expect(manager.current, isA()); + }, + ); test('still clears the session when the failure is terminal', () async { final store = _MemStore( @@ -373,8 +374,10 @@ void main() { group('a stale refresh can never win', () { test('a refresh in flight does not overwrite a newer login', () async { final gate = Completer(); - final strategy = - _GatedStrategy(gate: gate, refreshed: _session('refreshed')); + final strategy = _GatedStrategy( + gate: gate, + refreshed: _session('refreshed'), + ); final store = _MemStore(); final manager = AuthManager( strategy: strategy, @@ -395,8 +398,10 @@ void main() { test('a refresh in flight does not overwrite an updated session', () async { final gate = Completer(); - final strategy = - _GatedStrategy(gate: gate, refreshed: _session('refreshed')); + final strategy = _GatedStrategy( + gate: gate, + refreshed: _session('refreshed'), + ); final store = _MemStore(); final manager = AuthManager( strategy: strategy, @@ -417,11 +422,12 @@ void main() { expect(manager.currentSession?.accessToken, 'login-1'); }); - test('a new refresh does not join one started in a previous epoch', - () async { + test('a new refresh does not join one started in a previous epoch', () async { final gate = Completer(); - final strategy = - _GatedStrategy(gate: gate, refreshed: _session('refreshed')); + final strategy = _GatedStrategy( + gate: gate, + refreshed: _session('refreshed'), + ); final manager = AuthManager( strategy: strategy, tokenStore: _MemStore(), @@ -466,32 +472,36 @@ void main() { expect(manager.currentSession!.claims, {'plan': 'pro'}); }); - test('preserveSessionDetails: false keeps the backend answer verbatim', - () async { - final manager = AuthManager( - strategy: _FixedStrategy( - session: _session('a', userId: 'u1', displayName: 'Ada'), - refreshed: _session('b'), - ), - tokenStore: _MemStore(), - clock: () => now, - preserveSessionDetails: false, - ); + test( + 'preserveSessionDetails: false keeps the backend answer verbatim', + () async { + final manager = AuthManager( + strategy: _FixedStrategy( + session: _session('a', userId: 'u1', displayName: 'Ada'), + refreshed: _session('b'), + ), + tokenStore: _MemStore(), + clock: () => now, + preserveSessionDetails: false, + ); - await manager.login(_credentials); - await manager.refresh(); + await manager.login(_credentials); + await manager.refresh(); - expect(manager.currentSession!.userId, isNull); - expect(manager.currentSession!.displayName, isNull); - }); + expect(manager.currentSession!.userId, isNull); + expect(manager.currentSession!.displayName, isNull); + }, + ); }); group('clock skew', () { test('renews a token that would expire in flight', () async { final manager = AuthManager( strategy: _FixedStrategy( - session: - _session('a', expiresAt: now.add(const Duration(seconds: 10))), + session: _session( + 'a', + expiresAt: now.add(const Duration(seconds: 10)), + ), refreshed: _session( 'b', expiresAt: now.add(const Duration(minutes: 5)), @@ -511,8 +521,10 @@ void main() { test('validAccessToken(leeway:) overrides the manager default', () async { final manager = AuthManager( strategy: _FixedStrategy( - session: - _session('a', expiresAt: now.add(const Duration(seconds: 10))), + session: _session( + 'a', + expiresAt: now.add(const Duration(seconds: 10)), + ), refreshed: _session( 'b', expiresAt: now.add(const Duration(minutes: 5)), @@ -639,8 +651,10 @@ void main() { 'a', expiresAt: now.subtract(const Duration(minutes: 1)), ), - refreshed: - _session('b', expiresAt: now.add(const Duration(minutes: 5))), + refreshed: _session( + 'b', + expiresAt: now.add(const Duration(minutes: 5)), + ), ), tokenStore: _MemStore(), clock: () => now, diff --git a/test/fake_strategy.dart b/test/fake_strategy.dart index 93e6db8..14267b0 100644 --- a/test/fake_strategy.dart +++ b/test/fake_strategy.dart @@ -17,14 +17,14 @@ final class FakeAuthStrategy implements AuthStrategy { bool logoutCalled = false; FakeAuthStrategy({AuthSession? session}) - : nextSession = session ?? _default(); + : nextSession = session ?? _default(); static AuthSession _default() => const AuthSession( - accessToken: 'access', - refreshToken: RefreshToken('refresh'), - userId: 'u1', - displayName: 'User', - ); + accessToken: 'access', + refreshToken: RefreshToken('refresh'), + userId: 'u1', + displayName: 'User', + ); @override Future login(Credentials credentials) async { From fbf06fad840be7850db96e9c55022908a546c8ef Mon Sep 17 00:00:00 2001 From: AmisKwok Date: Tue, 22 Sep 2026 03:13:43 +0800 Subject: [PATCH 5/7] style: apply dart format across the package, example and server The root command skips sub-packages that own a pubspec, so server/ and example/ were never formatted before. Also re-runs the formatter with the current toolchain, which indents switch-expression cases like the rest of the code base. No behaviour change: dart analyze and all 129 tests still pass. --- example/lib/main.dart | 154 +++++++++++++------------ example/lib/secure_token_store.dart | 2 +- lib/src/auth_manager.dart | 40 +++---- lib/src/auth_manager_group.dart | 12 +- lib/src/auth_session.dart | 72 ++++++------ lib/src/auth_state.dart | 10 +- lib/src/exceptions.dart | 6 +- server/lib/src/auth/auth_service.dart | 22 ++-- server/lib/src/auth/token_service.dart | 6 +- server/lib/src/auth/user_store.dart | 2 +- server/lib/src/config.dart | 5 +- server/lib/src/logging/logger.dart | 4 +- test/auth_manager_hardening_test.dart | 19 +-- test/auth_manager_lifecycle_test.dart | 50 ++++---- test/auth_manager_test.dart | 10 +- test/auth_manager_v1_test.dart | 40 ++++--- test/fake_strategy.dart | 12 +- 17 files changed, 237 insertions(+), 229 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 8ae6e9e..f3cd2aa 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -49,8 +49,8 @@ class _DemoStrategy implements AuthStrategy { @override Future refresh(RefreshToken token) async => login( - const Credentials(username: _validUsername, password: _validPassword), - ); + const Credentials(username: _validUsername, password: _validPassword), + ); } /// Real HTTP backend strategy. Talks to the Dart server in `../../server`. @@ -64,14 +64,14 @@ class _HttpAuthStrategy implements AuthStrategy { res.data as Map; AuthSession _toSession(Map data) => AuthSession( - accessToken: data['accessToken'] as String, - refreshToken: RefreshToken(data['refreshToken'] as String), - expiresAt: data['expiresIn'] != null - ? DateTime.now().add(Duration(seconds: data['expiresIn'] as int)) - : null, - userId: data['userId'] as String, - displayName: data['displayName'] as String, - ); + accessToken: data['accessToken'] as String, + refreshToken: RefreshToken(data['refreshToken'] as String), + expiresAt: data['expiresIn'] != null + ? DateTime.now().add(Duration(seconds: data['expiresIn'] as int)) + : null, + userId: data['userId'] as String, + displayName: data['displayName'] as String, + ); @override Future login(Credentials credentials) async { @@ -194,10 +194,10 @@ class _DemoAppState extends State { } void _toggleBackend(bool value) => setState(() { - unawaited(_auth.dispose()); - _useBackend = value; - _init(); - }); + unawaited(_auth.dispose()); + _useBackend = value; + _init(); + }); /// Runs an auth action and swallows the rethrown error: [AuthManager] already /// surfaces it as an [AuthError] state, so there is nothing left to handle. @@ -271,50 +271,51 @@ class _DemoAppState extends State { @override Widget build(BuildContext context) => MaterialApp( - title: 'zero_auth demo', - theme: _theme(Brightness.light), - darkTheme: _theme(Brightness.dark), - home: Scaffold( - appBar: AppBar(title: const Text('zero_auth demo')), - body: SafeArea( - child: StreamBuilder( - initialData: _auth.current, - stream: _auth.state, - builder: (context, snapshot) => _DemoBody( - state: snapshot.data, - auth: _auth, - useBackend: _useBackend, - baseUrl: _baseUrl, - username: _username, - password: _password, - onToggleBackend: _toggleBackend, - onLogin: () => _invoke( - () => _auth.login( - Credentials(username: _username.text, password: _password.text), + title: 'zero_auth demo', + theme: _theme(Brightness.light), + darkTheme: _theme(Brightness.dark), + home: Scaffold( + appBar: AppBar(title: const Text('zero_auth demo')), + body: SafeArea( + child: StreamBuilder( + initialData: _auth.current, + stream: _auth.state, + builder: (context, snapshot) => _DemoBody( + state: snapshot.data, + auth: _auth, + useBackend: _useBackend, + baseUrl: _baseUrl, + username: _username, + password: _password, + onToggleBackend: _toggleBackend, + onLogin: () => _invoke( + () => _auth.login( + Credentials( + username: _username.text, password: _password.text), + ), + ), + onRefresh: () => _invoke(() => _auth.refresh()), + onLogout: () => _invoke(() => _auth.logout()), + onCallMe: () => _callMe(context), + onExpireNow: () => _expireTokenNow(context), + onExpireSoon: () => _expireTokenSoon(context, 10), + onResetDebug: () => _resetDebug(context), ), ), - onRefresh: () => _invoke(() => _auth.refresh()), - onLogout: () => _invoke(() => _auth.logout()), - onCallMe: () => _callMe(context), - onExpireNow: () => _expireTokenNow(context), - onExpireSoon: () => _expireTokenSoon(context, 10), - onResetDebug: () => _resetDebug(context), ), ), - ), - ), - ); + ); /// One seed colour drives the whole palette; widgets read shades from the /// theme instead of hardcoding colors. static ThemeData _theme(Brightness brightness) => ThemeData( - useMaterial3: true, - brightness: brightness, - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF00695C), - brightness: brightness, - ), - ); + useMaterial3: true, + brightness: brightness, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF00695C), + brightness: brightness, + ), + ); } /// The scrollable demo surface. Adapts to the viewport: a full-width column on @@ -578,16 +579,17 @@ class _Badge extends StatelessWidget { @override Widget build(BuildContext context) => Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: color, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith(color: onColor), - ), - ); + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Text( + label, + style: + Theme.of(context).textTheme.labelMedium?.copyWith(color: onColor), + ), + ); } /// The complete access token, wrapped over as many lines as it needs. @@ -679,13 +681,13 @@ class _InfoRow extends StatelessWidget { @override Widget build(BuildContext context) => _LabeledRow( - label: label, - child: Text( - value, - style: Theme.of(context).textTheme.bodyMedium, - overflow: TextOverflow.ellipsis, - ), - ); + label: label, + child: Text( + value, + style: Theme.of(context).textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ); } /// Failure surface. Uses the theme's error container rather than literal red, @@ -751,15 +753,15 @@ class _BackendCard extends StatelessWidget { @override Widget build(BuildContext context) => Card( - child: SwitchListTile.adaptive( - value: useBackend, - onChanged: onChanged, - title: const Text('Live backend'), - subtitle: Text(useBackend ? baseUrl : 'offline double, no server'), - secondary: const Icon(Icons.cloud_outlined), - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - ), - ); + child: SwitchListTile.adaptive( + value: useBackend, + onChanged: onChanged, + title: const Text('Live backend'), + subtitle: Text(useBackend ? baseUrl : 'offline double, no server'), + secondary: const Icon(Icons.cloud_outlined), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + ), + ); } /// Sign-in form. The password can be revealed, and submitting from the keyboard diff --git a/example/lib/secure_token_store.dart b/example/lib/secure_token_store.dart index 5d8b5d9..e04e9c5 100644 --- a/example/lib/secure_token_store.dart +++ b/example/lib/secure_token_store.dart @@ -7,7 +7,7 @@ import 'package:zero_auth/zero_auth.dart'; /// is required. Swap for your own codec as needed. final class SecureTokenStore implements TokenStore { SecureTokenStore([FlutterSecureStorage? storage]) - : _storage = storage ?? const FlutterSecureStorage(); + : _storage = storage ?? const FlutterSecureStorage(); final FlutterSecureStorage _storage; diff --git a/lib/src/auth_manager.dart b/lib/src/auth_manager.dart index 3239867..8e0e66c 100644 --- a/lib/src/auth_manager.dart +++ b/lib/src/auth_manager.dart @@ -123,17 +123,17 @@ final class AuthManager implements AuthTokenSource { Duration? clockSkew, this.preserveSessionDetails = true, this.onStateChanged, - }) : tokenStore = tokenStore ?? InMemoryTokenStore(), - _autoRefreshAhead = autoRefreshAhead, - _autoRefreshRetryDelay = - autoRefreshRetryDelay ?? const Duration(seconds: 30), - _autoRefreshMaxRetries = autoRefreshMaxRetries ?? 3, - _autoRefreshMinInterval = - autoRefreshMinInterval ?? const Duration(seconds: 5), - refreshFailurePolicy = - refreshFailurePolicy ?? defaultRefreshFailurePolicy, - clock = clock ?? _systemClock, - clockSkew = clockSkew ?? const Duration(seconds: 30); + }) : tokenStore = tokenStore ?? InMemoryTokenStore(), + _autoRefreshAhead = autoRefreshAhead, + _autoRefreshRetryDelay = + autoRefreshRetryDelay ?? const Duration(seconds: 30), + _autoRefreshMaxRetries = autoRefreshMaxRetries ?? 3, + _autoRefreshMinInterval = + autoRefreshMinInterval ?? const Duration(seconds: 5), + refreshFailurePolicy = + refreshFailurePolicy ?? defaultRefreshFailurePolicy, + clock = clock ?? _systemClock, + clockSkew = clockSkew ?? const Duration(seconds: 30); final Duration? _autoRefreshAhead; @@ -220,10 +220,10 @@ final class AuthManager implements AuthTokenSource { /// 当前活动会话;未认证时为 `null`。在 [Authenticated] 与 [Refreshing] 下均可用 /// (续期中会话依然有效),但 [LoggingOut] 下为空。 AuthSession? get currentSession => switch (_state) { - Authenticated(:final session) => session, - Refreshing(:final session) => session, - _ => null, - }; + Authenticated(:final session) => session, + Refreshing(:final session) => session, + _ => null, + }; @override String? get accessToken => currentSession?.accessToken; @@ -647,11 +647,11 @@ final class AuthManager implements AuthTokenSource { /// being discarded during [LoggingOut]. /// 驱动进行中操作或已建立认证的会话,包含在 [LoggingOut] 期间正被丢弃的那个。 AuthSession? get _activeSession => switch (_state) { - Authenticated(:final session) => session, - Refreshing(:final session) => session, - LoggingOut(:final session) => session, - _ => null, - }; + Authenticated(:final session) => session, + Refreshing(:final session) => session, + LoggingOut(:final session) => session, + _ => null, + }; bool _isCurrent(int epoch) => !_disposed && epoch == _epoch; diff --git a/lib/src/auth_manager_group.dart b/lib/src/auth_manager_group.dart index 5fc76dd..0e95eb6 100644 --- a/lib/src/auth_manager_group.dart +++ b/lib/src/auth_manager_group.dart @@ -60,8 +60,8 @@ final class AuthManagerGroup implements AuthTokenSource { this.clockSkew, this.preserveSessionDetails = true, this.onStateChanged, - }) : _strategyFactory = strategyFactory, - _storeFactory = storeFactory; + }) : _strategyFactory = strategyFactory, + _storeFactory = storeFactory; /// Called once per account. Returning the same instance for every account is /// fine — and typical — since a strategy usually just talks to one backend. @@ -79,8 +79,7 @@ final class AuthManagerGroup implements AuthTokenSource { String accountId, AuthStrategy strategy, TokenStore store, - )? - managerFactory; + )? managerFactory; /// Forwarded to every manager this group creates — see [AuthManager.new]. /// 转发给分组创建的每个管理器 —— 参见 [AuthManager.new]。 @@ -157,9 +156,8 @@ final class AuthManagerGroup implements AuthTokenSource { clock: clock, clockSkew: clockSkew, preserveSessionDetails: preserveSessionDetails, - onStateChanged: observer == null - ? null - : (state) => observer(accountId, state), + onStateChanged: + observer == null ? null : (state) => observer(accountId, state), ); } diff --git a/lib/src/auth_session.dart b/lib/src/auth_session.dart index 12398ce..2b24958 100644 --- a/lib/src/auth_session.dart +++ b/lib/src/auth_session.dart @@ -94,14 +94,15 @@ final class AuthSession { String? userId, String? displayName, Map? claims, - }) => AuthSession( - accessToken: accessToken ?? this.accessToken, - refreshToken: refreshToken ?? this.refreshToken, - expiresAt: expiresAt ?? this.expiresAt, - userId: userId ?? this.userId, - displayName: displayName ?? this.displayName, - claims: claims ?? this.claims, - ); + }) => + AuthSession( + accessToken: accessToken ?? this.accessToken, + refreshToken: refreshToken ?? this.refreshToken, + expiresAt: expiresAt ?? this.expiresAt, + userId: userId ?? this.userId, + displayName: displayName ?? this.displayName, + claims: claims ?? this.claims, + ); /// How long until the access token expires, or `null` when there is no expiry. /// 距离访问令牌过期还有多久;无过期时间时为 `null`。 @@ -132,28 +133,28 @@ final class AuthSession { /// [claims] 会被原样写入,因此只能包含 JSON 安全的值(String、num、bool、null、 /// List、Map)。放入 `DateTime` 或自定义对象会让存储层的 `jsonEncode` 抛错。 Map toJson() => { - 'accessToken': accessToken, - if (refreshToken != null) 'refreshToken': refreshToken!.value, - if (expiresAt != null) 'expiresAt': expiresAt!.toIso8601String(), - if (userId != null) 'userId': userId, - if (displayName != null) 'displayName': displayName, - if (claims != null) 'claims': claims, - }; + 'accessToken': accessToken, + if (refreshToken != null) 'refreshToken': refreshToken!.value, + if (expiresAt != null) 'expiresAt': expiresAt!.toIso8601String(), + if (userId != null) 'userId': userId, + if (displayName != null) 'displayName': displayName, + if (claims != null) 'claims': claims, + }; /// Deserialize from a map produced by [toJson]. /// 从 [toJson] 生成的映射反序列化。 factory AuthSession.fromJson(Map json) => AuthSession( - accessToken: json['accessToken'] as String, - refreshToken: json['refreshToken'] == null - ? null - : RefreshToken(json['refreshToken'] as String), - expiresAt: json['expiresAt'] == null - ? null - : DateTime.parse(json['expiresAt'] as String), - userId: json['userId'] as String?, - displayName: json['displayName'] as String?, - claims: (json['claims'] as Map?)?.cast(), - ); + accessToken: json['accessToken'] as String, + refreshToken: json['refreshToken'] == null + ? null + : RefreshToken(json['refreshToken'] as String), + expiresAt: json['expiresAt'] == null + ? null + : DateTime.parse(json['expiresAt'] as String), + userId: json['userId'] as String?, + displayName: json['displayName'] as String?, + claims: (json['claims'] as Map?)?.cast(), + ); /// Deserialize from a map produced by [toJson], or return `null` when the map /// does not describe a valid session. @@ -195,9 +196,8 @@ final class AuthSession { return AuthSession( accessToken: accessToken, - refreshToken: refreshToken == null - ? null - : RefreshToken(refreshToken as String), + refreshToken: + refreshToken == null ? null : RefreshToken(refreshToken as String), expiresAt: expiresAt, userId: userId as String?, displayName: displayName as String?, @@ -217,13 +217,13 @@ final class AuthSession { @override int get hashCode => Object.hash( - accessToken, - refreshToken, - expiresAt, - userId, - displayName, - _claimsHash(claims), - ); + accessToken, + refreshToken, + expiresAt, + userId, + displayName, + _claimsHash(claims), + ); /// Claims participate in equality so a session whose *only* change is in /// `claims` still counts as new — otherwise a state emission could be diff --git a/lib/src/auth_state.dart b/lib/src/auth_state.dart index 37bc4e5..e9e9c3a 100644 --- a/lib/src/auth_state.dart +++ b/lib/src/auth_state.dart @@ -33,11 +33,11 @@ sealed class AuthState { /// [Authenticated]、[Refreshing] 与 [LoggingOut] 都携带会话;当界面只需要会话时, /// 请优先使用此属性,而不是对三个子类分别做模式匹配。 AuthSession? get session => switch (this) { - Authenticated(:final session) => session, - Refreshing(:final session) => session, - LoggingOut(:final session) => session, - _ => null, - }; + Authenticated(:final session) => session, + Refreshing(:final session) => session, + LoggingOut(:final session) => session, + _ => null, + }; } /// No active session. diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart index 96aae2a..5ea929a 100644 --- a/lib/src/exceptions.dart +++ b/lib/src/exceptions.dart @@ -15,12 +15,12 @@ class AuthException extends AppException { final AuthFail fail; AuthException.fromFail(this.fail) - : super(fail.message, code: fail.code, cause: fail.cause); + : super(fail.message, code: fail.code, cause: fail.cause); /// Convenience constructor for manager-internal failures. /// 供管理器内部失败使用的便捷构造。 AuthException(String message, {String? code, Object? cause}) - : this.fromFail(AuthFail(message, code: code, cause: cause)); + : this.fromFail(AuthFail(message, code: code, cause: cause)); } /// Credentials were rejected by the backend (wrong password, unknown user…). @@ -38,7 +38,7 @@ final class InvalidCredentialsException extends AuthException { /// 授权已不可用:会话 / 刷新令牌 / 访问令牌已过期或被吊销,只能重新登录。 final class SessionExpiredException extends AuthException { SessionExpiredException({String message = 'Session expired', Object? cause}) - : super(message, code: 'session_expired', cause: cause); + : super(message, code: 'session_expired', cause: cause); } /// An operation that requires an active session was called with none. diff --git a/server/lib/src/auth/auth_service.dart b/server/lib/src/auth/auth_service.dart index e3c9850..7069daf 100644 --- a/server/lib/src/auth/auth_service.dart +++ b/server/lib/src/auth/auth_service.dart @@ -156,15 +156,15 @@ final class AuthService { } AuthSuccess _issue(UserRecord user, [String? refreshToken]) => AuthSuccess( - user: user, - accessToken: tokens.sign( - subject: user.id, - displayName: user.displayName, - type: TokenType.access, - ttl: accessTtl, - tokenId: tokens.newId(), - ), - refreshToken: refreshToken ?? refreshTokens.issue(user), - expiresIn: accessTtl.inSeconds, - ); + user: user, + accessToken: tokens.sign( + subject: user.id, + displayName: user.displayName, + type: TokenType.access, + ttl: accessTtl, + tokenId: tokens.newId(), + ), + refreshToken: refreshToken ?? refreshTokens.issue(user), + expiresIn: accessTtl.inSeconds, + ); } diff --git a/server/lib/src/auth/token_service.dart b/server/lib/src/auth/token_service.dart index ea8d519..99e67fd 100644 --- a/server/lib/src/auth/token_service.dart +++ b/server/lib/src/auth/token_service.dart @@ -33,9 +33,9 @@ final class TokenService { required String secret, Duration clockSkew = const Duration(seconds: 1), Random? random, - }) : _secret = utf8.encode(secret), - _clockSkew = clockSkew, - _random = random ?? Random.secure(); + }) : _secret = utf8.encode(secret), + _clockSkew = clockSkew, + _random = random ?? Random.secure(); final List _secret; final Duration _clockSkew; diff --git a/server/lib/src/auth/user_store.dart b/server/lib/src/auth/user_store.dart index 098acff..c2d5083 100644 --- a/server/lib/src/auth/user_store.dart +++ b/server/lib/src/auth/user_store.dart @@ -13,7 +13,7 @@ final class UserRecord { /// in the backend has to change. final class UserStore { UserStore({Map? credentials}) - : _credentials = credentials ?? _defaultCredentials; + : _credentials = credentials ?? _defaultCredentials; static const _defaultCredentials = {'user': 'user'}; diff --git a/server/lib/src/config.dart b/server/lib/src/config.dart index 76e9a1b..e6b1e62 100644 --- a/server/lib/src/config.dart +++ b/server/lib/src/config.dart @@ -32,8 +32,9 @@ final class ServerConfig { static const _defaultSecret = 'demo-secret-change-me'; static Duration _durationFromEnv(String key, int defaultSeconds) => Duration( - seconds: int.tryParse(Platform.environment[key] ?? '') ?? defaultSeconds, - ); + seconds: + int.tryParse(Platform.environment[key] ?? '') ?? defaultSeconds, + ); final String host; final int port; diff --git a/server/lib/src/logging/logger.dart b/server/lib/src/logging/logger.dart index 582b142..21a3f9f 100644 --- a/server/lib/src/logging/logger.dart +++ b/server/lib/src/logging/logger.dart @@ -9,8 +9,8 @@ enum LogLevel { debug, info, warn, error } /// `LOG_LEVEL=debug` to also see per-route debug lines. final class Logger { Logger({LogLevel minimum = LogLevel.info, Stdout? output}) - : _minimum = minimum, - _out = output ?? stdout; + : _minimum = minimum, + _out = output ?? stdout; final LogLevel _minimum; final Stdout _out; diff --git a/test/auth_manager_hardening_test.dart b/test/auth_manager_hardening_test.dart index a215dd1..4a79436 100644 --- a/test/auth_manager_hardening_test.dart +++ b/test/auth_manager_hardening_test.dart @@ -93,15 +93,16 @@ void main() { String token = 'access', String name = 'user@demo', bool expired = false, - }) => AuthSession( - accessToken: token, - refreshToken: const RefreshToken('refresh'), - expiresAt: expired - ? now.subtract(const Duration(minutes: 1)) - : now.add(const Duration(minutes: 5)), - userId: 'user', - displayName: name, - ); + }) => + AuthSession( + accessToken: token, + refreshToken: const RefreshToken('refresh'), + expiresAt: expired + ? now.subtract(const Duration(minutes: 1)) + : now.add(const Duration(minutes: 5)), + userId: 'user', + displayName: name, + ); group('restore hardening', () { test('a logout during restore does not resurrect the session', () async { diff --git a/test/auth_manager_lifecycle_test.dart b/test/auth_manager_lifecycle_test.dart index 3dd6e9d..693bbc9 100644 --- a/test/auth_manager_lifecycle_test.dart +++ b/test/auth_manager_lifecycle_test.dart @@ -37,14 +37,15 @@ void main() { bool expired = false, bool withRefresh = true, Duration ttl = const Duration(hours: 1), - }) => AuthSession( - accessToken: accessToken, - refreshToken: withRefresh ? const RefreshToken('refresh') : null, - expiresAt: expired - ? fixedNow.subtract(const Duration(minutes: 5)) - : fixedNow.add(ttl), - userId: 'u1', - ); + }) => + AuthSession( + accessToken: accessToken, + refreshToken: withRefresh ? const RefreshToken('refresh') : null, + expiresAt: expired + ? fixedNow.subtract(const Duration(minutes: 5)) + : fixedNow.add(ttl), + userId: 'u1', + ); /// Lets pending microtasks settle so stream emissions become observable. /// 让挂起的微任务执行完,使状态流的新值可被观察。 @@ -349,21 +350,24 @@ void main() { group('AuthManager — proactive refresh', () { test('failures never leak an unhandled async error', () async { final errors = []; - final zoneRun = runZonedGuarded>(() async { - final strategy = FakeAuthStrategy( - session: buildSession(ttl: Duration.zero), - )..refreshError = SessionExpiredException(); - final manager = AuthManager( - strategy: strategy, - tokenStore: InMemoryTokenStore(), - autoRefreshAhead: const Duration(minutes: 5), - clock: () => fixedNow, - ); - await manager.login(credentials); - await pump(); - expect(manager.current, const Unauthenticated()); - await manager.dispose(); - }, (error, stack) => errors.add(error)); + final zoneRun = runZonedGuarded>( + () async { + final strategy = FakeAuthStrategy( + session: buildSession(ttl: Duration.zero), + )..refreshError = SessionExpiredException(); + final manager = AuthManager( + strategy: strategy, + tokenStore: InMemoryTokenStore(), + autoRefreshAhead: const Duration(minutes: 5), + clock: () => fixedNow, + ); + await manager.login(credentials); + await pump(); + expect(manager.current, const Unauthenticated()); + await manager.dispose(); + }, + (error, stack) => errors.add(error), + ); await (zoneRun ?? Future.value()); expect(errors, isEmpty); }); diff --git a/test/auth_manager_test.dart b/test/auth_manager_test.dart index dcfb62d..ccf12cc 100644 --- a/test/auth_manager_test.dart +++ b/test/auth_manager_test.dart @@ -13,11 +13,11 @@ final class ExtendingAuthStrategy implements AuthStrategy { int refreshCount = 0; AuthSession _session(DateTime expiresAt) => AuthSession( - accessToken: 'access', - refreshToken: const RefreshToken('refresh'), - expiresAt: expiresAt, - userId: 'u1', - ); + accessToken: 'access', + refreshToken: const RefreshToken('refresh'), + expiresAt: expiresAt, + userId: 'u1', + ); @override Future login(Credentials credentials) async => diff --git a/test/auth_manager_v1_test.dart b/test/auth_manager_v1_test.dart index 52fc380..397700b 100644 --- a/test/auth_manager_v1_test.dart +++ b/test/auth_manager_v1_test.dart @@ -44,8 +44,8 @@ final class _FailingStore implements TokenStore { this.failSaveAfter = 0, this.failClear = false, AuthSession? initial, - }) : value = initial, - _allowedSaves = failSaveAfter; + }) : value = initial, + _allowedSaves = failSaveAfter; final int failSaveAfter; final bool failClear; @@ -167,10 +167,10 @@ final class _ShortTtlStrategy implements AuthStrategy { } AuthSession _issue() => AuthSession( - accessToken: 'access-$refreshCount', - refreshToken: const RefreshToken('refresh'), - expiresAt: DateTime.now().add(const Duration(milliseconds: 300)), - ); + accessToken: 'access-$refreshCount', + refreshToken: const RefreshToken('refresh'), + expiresAt: DateTime.now().add(const Duration(milliseconds: 300)), + ); } /// A strategy that keeps handing out sessions that are already due, to prove the @@ -197,10 +197,10 @@ final class _ShortLivedStrategy implements AuthStrategy { } AuthSession _issued() => AuthSession( - accessToken: 'access-$refreshCount', - refreshToken: const RefreshToken('refresh'), - expiresAt: clock().add(const Duration(seconds: 1)), - ); + accessToken: 'access-$refreshCount', + refreshToken: const RefreshToken('refresh'), + expiresAt: clock().add(const Duration(seconds: 1)), + ); } final class _StaticTokenSource implements AuthTokenSource { @@ -219,14 +219,15 @@ AuthSession _session( String? userId, String? displayName, Map? claims, -}) => AuthSession( - accessToken: token, - refreshToken: const RefreshToken('refresh'), - expiresAt: expiresAt, - userId: userId, - displayName: displayName, - claims: claims, -); +}) => + AuthSession( + accessToken: token, + refreshToken: const RefreshToken('refresh'), + expiresAt: expiresAt, + userId: userId, + displayName: displayName, + claims: claims, + ); const _credentials = Credentials(username: 'user', password: 'user'); @@ -422,7 +423,8 @@ void main() { expect(manager.currentSession?.accessToken, 'login-1'); }); - test('a new refresh does not join one started in a previous epoch', () async { + test('a new refresh does not join one started in a previous epoch', + () async { final gate = Completer(); final strategy = _GatedStrategy( gate: gate, diff --git a/test/fake_strategy.dart b/test/fake_strategy.dart index 14267b0..93e6db8 100644 --- a/test/fake_strategy.dart +++ b/test/fake_strategy.dart @@ -17,14 +17,14 @@ final class FakeAuthStrategy implements AuthStrategy { bool logoutCalled = false; FakeAuthStrategy({AuthSession? session}) - : nextSession = session ?? _default(); + : nextSession = session ?? _default(); static AuthSession _default() => const AuthSession( - accessToken: 'access', - refreshToken: RefreshToken('refresh'), - userId: 'u1', - displayName: 'User', - ); + accessToken: 'access', + refreshToken: RefreshToken('refresh'), + userId: 'u1', + displayName: 'User', + ); @override Future login(Credentials credentials) async { From 546c00a3eb00009ee020a219d840400ad1adb6df Mon Sep 17 00:00:00 2001 From: AmisKwok Date: Tue, 22 Sep 2026 03:13:52 +0800 Subject: [PATCH 6/7] chore(server): give the demo backend its own analysis options It has no analysis_options.yaml, so it inherited the root one, which includes package:flutter_lints - not a dependency here. Every dart command in server/ therefore printed a package-resolution warning. The new file mirrors the root rule set without the Flutter-only bits. --- server/analysis_options.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 server/analysis_options.yaml diff --git a/server/analysis_options.yaml b/server/analysis_options.yaml new file mode 100644 index 0000000..0cce0f1 --- /dev/null +++ b/server/analysis_options.yaml @@ -0,0 +1,28 @@ +# The demo backend is a standalone package, so it must not inherit the root +# options: those `include: package:flutter_lints/flutter.yaml`, which is not a +# dependency here (resolving it fails and floods every `dart` command with a +# package-resolution warning). The rules below mirror the root set, minus the +# Flutter-only ones. +# 演示后端是独立 package,因此不能继承根目录配置:根配置 +# `include: package:flutter_lints/flutter.yaml`,而本包并未依赖它(解析失败会让每条 +# `dart` 命令都刷一遍包解析告警)。下面的规则与根包保持一致,去掉了仅 Flutter 适用的项。 + +analyzer: + language: + strict-casts: true + strict-raw-types: true + +linter: + rules: + - always_declare_return_types + - annotate_overrides + - avoid_empty_else + - avoid_relative_lib_imports + - avoid_returning_this + - avoid_types_on_closure_parameters + - curly_braces_in_flow_control_structures + - prefer_single_quotes + - require_trailing_commas + - unawaited_futures + - use_rethrow_when_possible + - void_checks From 7b8e15d2a84469825d7953b841a288a011c86448 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 21 Sep 2026 19:15:00 +0000 Subject: [PATCH 7/7] style: auto-format with dart format --- example/lib/main.dart | 154 ++++++++++++------------- example/lib/secure_token_store.dart | 2 +- lib/src/auth_manager.dart | 40 +++---- lib/src/auth_manager_group.dart | 12 +- lib/src/auth_session.dart | 72 ++++++------ lib/src/auth_state.dart | 10 +- lib/src/exceptions.dart | 6 +- server/lib/src/auth/auth_service.dart | 22 ++-- server/lib/src/auth/token_service.dart | 6 +- server/lib/src/auth/user_store.dart | 2 +- server/lib/src/config.dart | 5 +- server/lib/src/logging/logger.dart | 4 +- test/auth_manager_hardening_test.dart | 19 ++- test/auth_manager_lifecycle_test.dart | 50 ++++---- test/auth_manager_test.dart | 10 +- test/auth_manager_v1_test.dart | 40 +++---- test/fake_strategy.dart | 12 +- 17 files changed, 229 insertions(+), 237 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index f3cd2aa..8ae6e9e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -49,8 +49,8 @@ class _DemoStrategy implements AuthStrategy { @override Future refresh(RefreshToken token) async => login( - const Credentials(username: _validUsername, password: _validPassword), - ); + const Credentials(username: _validUsername, password: _validPassword), + ); } /// Real HTTP backend strategy. Talks to the Dart server in `../../server`. @@ -64,14 +64,14 @@ class _HttpAuthStrategy implements AuthStrategy { res.data as Map; AuthSession _toSession(Map data) => AuthSession( - accessToken: data['accessToken'] as String, - refreshToken: RefreshToken(data['refreshToken'] as String), - expiresAt: data['expiresIn'] != null - ? DateTime.now().add(Duration(seconds: data['expiresIn'] as int)) - : null, - userId: data['userId'] as String, - displayName: data['displayName'] as String, - ); + accessToken: data['accessToken'] as String, + refreshToken: RefreshToken(data['refreshToken'] as String), + expiresAt: data['expiresIn'] != null + ? DateTime.now().add(Duration(seconds: data['expiresIn'] as int)) + : null, + userId: data['userId'] as String, + displayName: data['displayName'] as String, + ); @override Future login(Credentials credentials) async { @@ -194,10 +194,10 @@ class _DemoAppState extends State { } void _toggleBackend(bool value) => setState(() { - unawaited(_auth.dispose()); - _useBackend = value; - _init(); - }); + unawaited(_auth.dispose()); + _useBackend = value; + _init(); + }); /// Runs an auth action and swallows the rethrown error: [AuthManager] already /// surfaces it as an [AuthError] state, so there is nothing left to handle. @@ -271,51 +271,50 @@ class _DemoAppState extends State { @override Widget build(BuildContext context) => MaterialApp( - title: 'zero_auth demo', - theme: _theme(Brightness.light), - darkTheme: _theme(Brightness.dark), - home: Scaffold( - appBar: AppBar(title: const Text('zero_auth demo')), - body: SafeArea( - child: StreamBuilder( - initialData: _auth.current, - stream: _auth.state, - builder: (context, snapshot) => _DemoBody( - state: snapshot.data, - auth: _auth, - useBackend: _useBackend, - baseUrl: _baseUrl, - username: _username, - password: _password, - onToggleBackend: _toggleBackend, - onLogin: () => _invoke( - () => _auth.login( - Credentials( - username: _username.text, password: _password.text), - ), - ), - onRefresh: () => _invoke(() => _auth.refresh()), - onLogout: () => _invoke(() => _auth.logout()), - onCallMe: () => _callMe(context), - onExpireNow: () => _expireTokenNow(context), - onExpireSoon: () => _expireTokenSoon(context, 10), - onResetDebug: () => _resetDebug(context), + title: 'zero_auth demo', + theme: _theme(Brightness.light), + darkTheme: _theme(Brightness.dark), + home: Scaffold( + appBar: AppBar(title: const Text('zero_auth demo')), + body: SafeArea( + child: StreamBuilder( + initialData: _auth.current, + stream: _auth.state, + builder: (context, snapshot) => _DemoBody( + state: snapshot.data, + auth: _auth, + useBackend: _useBackend, + baseUrl: _baseUrl, + username: _username, + password: _password, + onToggleBackend: _toggleBackend, + onLogin: () => _invoke( + () => _auth.login( + Credentials(username: _username.text, password: _password.text), ), ), + onRefresh: () => _invoke(() => _auth.refresh()), + onLogout: () => _invoke(() => _auth.logout()), + onCallMe: () => _callMe(context), + onExpireNow: () => _expireTokenNow(context), + onExpireSoon: () => _expireTokenSoon(context, 10), + onResetDebug: () => _resetDebug(context), ), ), - ); + ), + ), + ); /// One seed colour drives the whole palette; widgets read shades from the /// theme instead of hardcoding colors. static ThemeData _theme(Brightness brightness) => ThemeData( - useMaterial3: true, - brightness: brightness, - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF00695C), - brightness: brightness, - ), - ); + useMaterial3: true, + brightness: brightness, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF00695C), + brightness: brightness, + ), + ); } /// The scrollable demo surface. Adapts to the viewport: a full-width column on @@ -579,17 +578,16 @@ class _Badge extends StatelessWidget { @override Widget build(BuildContext context) => Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: color, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: Text( - label, - style: - Theme.of(context).textTheme.labelMedium?.copyWith(color: onColor), - ), - ); + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith(color: onColor), + ), + ); } /// The complete access token, wrapped over as many lines as it needs. @@ -681,13 +679,13 @@ class _InfoRow extends StatelessWidget { @override Widget build(BuildContext context) => _LabeledRow( - label: label, - child: Text( - value, - style: Theme.of(context).textTheme.bodyMedium, - overflow: TextOverflow.ellipsis, - ), - ); + label: label, + child: Text( + value, + style: Theme.of(context).textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ); } /// Failure surface. Uses the theme's error container rather than literal red, @@ -753,15 +751,15 @@ class _BackendCard extends StatelessWidget { @override Widget build(BuildContext context) => Card( - child: SwitchListTile.adaptive( - value: useBackend, - onChanged: onChanged, - title: const Text('Live backend'), - subtitle: Text(useBackend ? baseUrl : 'offline double, no server'), - secondary: const Icon(Icons.cloud_outlined), - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - ), - ); + child: SwitchListTile.adaptive( + value: useBackend, + onChanged: onChanged, + title: const Text('Live backend'), + subtitle: Text(useBackend ? baseUrl : 'offline double, no server'), + secondary: const Icon(Icons.cloud_outlined), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + ), + ); } /// Sign-in form. The password can be revealed, and submitting from the keyboard diff --git a/example/lib/secure_token_store.dart b/example/lib/secure_token_store.dart index e04e9c5..5d8b5d9 100644 --- a/example/lib/secure_token_store.dart +++ b/example/lib/secure_token_store.dart @@ -7,7 +7,7 @@ import 'package:zero_auth/zero_auth.dart'; /// is required. Swap for your own codec as needed. final class SecureTokenStore implements TokenStore { SecureTokenStore([FlutterSecureStorage? storage]) - : _storage = storage ?? const FlutterSecureStorage(); + : _storage = storage ?? const FlutterSecureStorage(); final FlutterSecureStorage _storage; diff --git a/lib/src/auth_manager.dart b/lib/src/auth_manager.dart index 8e0e66c..3239867 100644 --- a/lib/src/auth_manager.dart +++ b/lib/src/auth_manager.dart @@ -123,17 +123,17 @@ final class AuthManager implements AuthTokenSource { Duration? clockSkew, this.preserveSessionDetails = true, this.onStateChanged, - }) : tokenStore = tokenStore ?? InMemoryTokenStore(), - _autoRefreshAhead = autoRefreshAhead, - _autoRefreshRetryDelay = - autoRefreshRetryDelay ?? const Duration(seconds: 30), - _autoRefreshMaxRetries = autoRefreshMaxRetries ?? 3, - _autoRefreshMinInterval = - autoRefreshMinInterval ?? const Duration(seconds: 5), - refreshFailurePolicy = - refreshFailurePolicy ?? defaultRefreshFailurePolicy, - clock = clock ?? _systemClock, - clockSkew = clockSkew ?? const Duration(seconds: 30); + }) : tokenStore = tokenStore ?? InMemoryTokenStore(), + _autoRefreshAhead = autoRefreshAhead, + _autoRefreshRetryDelay = + autoRefreshRetryDelay ?? const Duration(seconds: 30), + _autoRefreshMaxRetries = autoRefreshMaxRetries ?? 3, + _autoRefreshMinInterval = + autoRefreshMinInterval ?? const Duration(seconds: 5), + refreshFailurePolicy = + refreshFailurePolicy ?? defaultRefreshFailurePolicy, + clock = clock ?? _systemClock, + clockSkew = clockSkew ?? const Duration(seconds: 30); final Duration? _autoRefreshAhead; @@ -220,10 +220,10 @@ final class AuthManager implements AuthTokenSource { /// 当前活动会话;未认证时为 `null`。在 [Authenticated] 与 [Refreshing] 下均可用 /// (续期中会话依然有效),但 [LoggingOut] 下为空。 AuthSession? get currentSession => switch (_state) { - Authenticated(:final session) => session, - Refreshing(:final session) => session, - _ => null, - }; + Authenticated(:final session) => session, + Refreshing(:final session) => session, + _ => null, + }; @override String? get accessToken => currentSession?.accessToken; @@ -647,11 +647,11 @@ final class AuthManager implements AuthTokenSource { /// being discarded during [LoggingOut]. /// 驱动进行中操作或已建立认证的会话,包含在 [LoggingOut] 期间正被丢弃的那个。 AuthSession? get _activeSession => switch (_state) { - Authenticated(:final session) => session, - Refreshing(:final session) => session, - LoggingOut(:final session) => session, - _ => null, - }; + Authenticated(:final session) => session, + Refreshing(:final session) => session, + LoggingOut(:final session) => session, + _ => null, + }; bool _isCurrent(int epoch) => !_disposed && epoch == _epoch; diff --git a/lib/src/auth_manager_group.dart b/lib/src/auth_manager_group.dart index 0e95eb6..5fc76dd 100644 --- a/lib/src/auth_manager_group.dart +++ b/lib/src/auth_manager_group.dart @@ -60,8 +60,8 @@ final class AuthManagerGroup implements AuthTokenSource { this.clockSkew, this.preserveSessionDetails = true, this.onStateChanged, - }) : _strategyFactory = strategyFactory, - _storeFactory = storeFactory; + }) : _strategyFactory = strategyFactory, + _storeFactory = storeFactory; /// Called once per account. Returning the same instance for every account is /// fine — and typical — since a strategy usually just talks to one backend. @@ -79,7 +79,8 @@ final class AuthManagerGroup implements AuthTokenSource { String accountId, AuthStrategy strategy, TokenStore store, - )? managerFactory; + )? + managerFactory; /// Forwarded to every manager this group creates — see [AuthManager.new]. /// 转发给分组创建的每个管理器 —— 参见 [AuthManager.new]。 @@ -156,8 +157,9 @@ final class AuthManagerGroup implements AuthTokenSource { clock: clock, clockSkew: clockSkew, preserveSessionDetails: preserveSessionDetails, - onStateChanged: - observer == null ? null : (state) => observer(accountId, state), + onStateChanged: observer == null + ? null + : (state) => observer(accountId, state), ); } diff --git a/lib/src/auth_session.dart b/lib/src/auth_session.dart index 2b24958..12398ce 100644 --- a/lib/src/auth_session.dart +++ b/lib/src/auth_session.dart @@ -94,15 +94,14 @@ final class AuthSession { String? userId, String? displayName, Map? claims, - }) => - AuthSession( - accessToken: accessToken ?? this.accessToken, - refreshToken: refreshToken ?? this.refreshToken, - expiresAt: expiresAt ?? this.expiresAt, - userId: userId ?? this.userId, - displayName: displayName ?? this.displayName, - claims: claims ?? this.claims, - ); + }) => AuthSession( + accessToken: accessToken ?? this.accessToken, + refreshToken: refreshToken ?? this.refreshToken, + expiresAt: expiresAt ?? this.expiresAt, + userId: userId ?? this.userId, + displayName: displayName ?? this.displayName, + claims: claims ?? this.claims, + ); /// How long until the access token expires, or `null` when there is no expiry. /// 距离访问令牌过期还有多久;无过期时间时为 `null`。 @@ -133,28 +132,28 @@ final class AuthSession { /// [claims] 会被原样写入,因此只能包含 JSON 安全的值(String、num、bool、null、 /// List、Map)。放入 `DateTime` 或自定义对象会让存储层的 `jsonEncode` 抛错。 Map toJson() => { - 'accessToken': accessToken, - if (refreshToken != null) 'refreshToken': refreshToken!.value, - if (expiresAt != null) 'expiresAt': expiresAt!.toIso8601String(), - if (userId != null) 'userId': userId, - if (displayName != null) 'displayName': displayName, - if (claims != null) 'claims': claims, - }; + 'accessToken': accessToken, + if (refreshToken != null) 'refreshToken': refreshToken!.value, + if (expiresAt != null) 'expiresAt': expiresAt!.toIso8601String(), + if (userId != null) 'userId': userId, + if (displayName != null) 'displayName': displayName, + if (claims != null) 'claims': claims, + }; /// Deserialize from a map produced by [toJson]. /// 从 [toJson] 生成的映射反序列化。 factory AuthSession.fromJson(Map json) => AuthSession( - accessToken: json['accessToken'] as String, - refreshToken: json['refreshToken'] == null - ? null - : RefreshToken(json['refreshToken'] as String), - expiresAt: json['expiresAt'] == null - ? null - : DateTime.parse(json['expiresAt'] as String), - userId: json['userId'] as String?, - displayName: json['displayName'] as String?, - claims: (json['claims'] as Map?)?.cast(), - ); + accessToken: json['accessToken'] as String, + refreshToken: json['refreshToken'] == null + ? null + : RefreshToken(json['refreshToken'] as String), + expiresAt: json['expiresAt'] == null + ? null + : DateTime.parse(json['expiresAt'] as String), + userId: json['userId'] as String?, + displayName: json['displayName'] as String?, + claims: (json['claims'] as Map?)?.cast(), + ); /// Deserialize from a map produced by [toJson], or return `null` when the map /// does not describe a valid session. @@ -196,8 +195,9 @@ final class AuthSession { return AuthSession( accessToken: accessToken, - refreshToken: - refreshToken == null ? null : RefreshToken(refreshToken as String), + refreshToken: refreshToken == null + ? null + : RefreshToken(refreshToken as String), expiresAt: expiresAt, userId: userId as String?, displayName: displayName as String?, @@ -217,13 +217,13 @@ final class AuthSession { @override int get hashCode => Object.hash( - accessToken, - refreshToken, - expiresAt, - userId, - displayName, - _claimsHash(claims), - ); + accessToken, + refreshToken, + expiresAt, + userId, + displayName, + _claimsHash(claims), + ); /// Claims participate in equality so a session whose *only* change is in /// `claims` still counts as new — otherwise a state emission could be diff --git a/lib/src/auth_state.dart b/lib/src/auth_state.dart index e9e9c3a..37bc4e5 100644 --- a/lib/src/auth_state.dart +++ b/lib/src/auth_state.dart @@ -33,11 +33,11 @@ sealed class AuthState { /// [Authenticated]、[Refreshing] 与 [LoggingOut] 都携带会话;当界面只需要会话时, /// 请优先使用此属性,而不是对三个子类分别做模式匹配。 AuthSession? get session => switch (this) { - Authenticated(:final session) => session, - Refreshing(:final session) => session, - LoggingOut(:final session) => session, - _ => null, - }; + Authenticated(:final session) => session, + Refreshing(:final session) => session, + LoggingOut(:final session) => session, + _ => null, + }; } /// No active session. diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart index 5ea929a..96aae2a 100644 --- a/lib/src/exceptions.dart +++ b/lib/src/exceptions.dart @@ -15,12 +15,12 @@ class AuthException extends AppException { final AuthFail fail; AuthException.fromFail(this.fail) - : super(fail.message, code: fail.code, cause: fail.cause); + : super(fail.message, code: fail.code, cause: fail.cause); /// Convenience constructor for manager-internal failures. /// 供管理器内部失败使用的便捷构造。 AuthException(String message, {String? code, Object? cause}) - : this.fromFail(AuthFail(message, code: code, cause: cause)); + : this.fromFail(AuthFail(message, code: code, cause: cause)); } /// Credentials were rejected by the backend (wrong password, unknown user…). @@ -38,7 +38,7 @@ final class InvalidCredentialsException extends AuthException { /// 授权已不可用:会话 / 刷新令牌 / 访问令牌已过期或被吊销,只能重新登录。 final class SessionExpiredException extends AuthException { SessionExpiredException({String message = 'Session expired', Object? cause}) - : super(message, code: 'session_expired', cause: cause); + : super(message, code: 'session_expired', cause: cause); } /// An operation that requires an active session was called with none. diff --git a/server/lib/src/auth/auth_service.dart b/server/lib/src/auth/auth_service.dart index 7069daf..e3c9850 100644 --- a/server/lib/src/auth/auth_service.dart +++ b/server/lib/src/auth/auth_service.dart @@ -156,15 +156,15 @@ final class AuthService { } AuthSuccess _issue(UserRecord user, [String? refreshToken]) => AuthSuccess( - user: user, - accessToken: tokens.sign( - subject: user.id, - displayName: user.displayName, - type: TokenType.access, - ttl: accessTtl, - tokenId: tokens.newId(), - ), - refreshToken: refreshToken ?? refreshTokens.issue(user), - expiresIn: accessTtl.inSeconds, - ); + user: user, + accessToken: tokens.sign( + subject: user.id, + displayName: user.displayName, + type: TokenType.access, + ttl: accessTtl, + tokenId: tokens.newId(), + ), + refreshToken: refreshToken ?? refreshTokens.issue(user), + expiresIn: accessTtl.inSeconds, + ); } diff --git a/server/lib/src/auth/token_service.dart b/server/lib/src/auth/token_service.dart index 99e67fd..ea8d519 100644 --- a/server/lib/src/auth/token_service.dart +++ b/server/lib/src/auth/token_service.dart @@ -33,9 +33,9 @@ final class TokenService { required String secret, Duration clockSkew = const Duration(seconds: 1), Random? random, - }) : _secret = utf8.encode(secret), - _clockSkew = clockSkew, - _random = random ?? Random.secure(); + }) : _secret = utf8.encode(secret), + _clockSkew = clockSkew, + _random = random ?? Random.secure(); final List _secret; final Duration _clockSkew; diff --git a/server/lib/src/auth/user_store.dart b/server/lib/src/auth/user_store.dart index c2d5083..098acff 100644 --- a/server/lib/src/auth/user_store.dart +++ b/server/lib/src/auth/user_store.dart @@ -13,7 +13,7 @@ final class UserRecord { /// in the backend has to change. final class UserStore { UserStore({Map? credentials}) - : _credentials = credentials ?? _defaultCredentials; + : _credentials = credentials ?? _defaultCredentials; static const _defaultCredentials = {'user': 'user'}; diff --git a/server/lib/src/config.dart b/server/lib/src/config.dart index e6b1e62..76e9a1b 100644 --- a/server/lib/src/config.dart +++ b/server/lib/src/config.dart @@ -32,9 +32,8 @@ final class ServerConfig { static const _defaultSecret = 'demo-secret-change-me'; static Duration _durationFromEnv(String key, int defaultSeconds) => Duration( - seconds: - int.tryParse(Platform.environment[key] ?? '') ?? defaultSeconds, - ); + seconds: int.tryParse(Platform.environment[key] ?? '') ?? defaultSeconds, + ); final String host; final int port; diff --git a/server/lib/src/logging/logger.dart b/server/lib/src/logging/logger.dart index 21a3f9f..582b142 100644 --- a/server/lib/src/logging/logger.dart +++ b/server/lib/src/logging/logger.dart @@ -9,8 +9,8 @@ enum LogLevel { debug, info, warn, error } /// `LOG_LEVEL=debug` to also see per-route debug lines. final class Logger { Logger({LogLevel minimum = LogLevel.info, Stdout? output}) - : _minimum = minimum, - _out = output ?? stdout; + : _minimum = minimum, + _out = output ?? stdout; final LogLevel _minimum; final Stdout _out; diff --git a/test/auth_manager_hardening_test.dart b/test/auth_manager_hardening_test.dart index 4a79436..a215dd1 100644 --- a/test/auth_manager_hardening_test.dart +++ b/test/auth_manager_hardening_test.dart @@ -93,16 +93,15 @@ void main() { String token = 'access', String name = 'user@demo', bool expired = false, - }) => - AuthSession( - accessToken: token, - refreshToken: const RefreshToken('refresh'), - expiresAt: expired - ? now.subtract(const Duration(minutes: 1)) - : now.add(const Duration(minutes: 5)), - userId: 'user', - displayName: name, - ); + }) => AuthSession( + accessToken: token, + refreshToken: const RefreshToken('refresh'), + expiresAt: expired + ? now.subtract(const Duration(minutes: 1)) + : now.add(const Duration(minutes: 5)), + userId: 'user', + displayName: name, + ); group('restore hardening', () { test('a logout during restore does not resurrect the session', () async { diff --git a/test/auth_manager_lifecycle_test.dart b/test/auth_manager_lifecycle_test.dart index 693bbc9..3dd6e9d 100644 --- a/test/auth_manager_lifecycle_test.dart +++ b/test/auth_manager_lifecycle_test.dart @@ -37,15 +37,14 @@ void main() { bool expired = false, bool withRefresh = true, Duration ttl = const Duration(hours: 1), - }) => - AuthSession( - accessToken: accessToken, - refreshToken: withRefresh ? const RefreshToken('refresh') : null, - expiresAt: expired - ? fixedNow.subtract(const Duration(minutes: 5)) - : fixedNow.add(ttl), - userId: 'u1', - ); + }) => AuthSession( + accessToken: accessToken, + refreshToken: withRefresh ? const RefreshToken('refresh') : null, + expiresAt: expired + ? fixedNow.subtract(const Duration(minutes: 5)) + : fixedNow.add(ttl), + userId: 'u1', + ); /// Lets pending microtasks settle so stream emissions become observable. /// 让挂起的微任务执行完,使状态流的新值可被观察。 @@ -350,24 +349,21 @@ void main() { group('AuthManager — proactive refresh', () { test('failures never leak an unhandled async error', () async { final errors = []; - final zoneRun = runZonedGuarded>( - () async { - final strategy = FakeAuthStrategy( - session: buildSession(ttl: Duration.zero), - )..refreshError = SessionExpiredException(); - final manager = AuthManager( - strategy: strategy, - tokenStore: InMemoryTokenStore(), - autoRefreshAhead: const Duration(minutes: 5), - clock: () => fixedNow, - ); - await manager.login(credentials); - await pump(); - expect(manager.current, const Unauthenticated()); - await manager.dispose(); - }, - (error, stack) => errors.add(error), - ); + final zoneRun = runZonedGuarded>(() async { + final strategy = FakeAuthStrategy( + session: buildSession(ttl: Duration.zero), + )..refreshError = SessionExpiredException(); + final manager = AuthManager( + strategy: strategy, + tokenStore: InMemoryTokenStore(), + autoRefreshAhead: const Duration(minutes: 5), + clock: () => fixedNow, + ); + await manager.login(credentials); + await pump(); + expect(manager.current, const Unauthenticated()); + await manager.dispose(); + }, (error, stack) => errors.add(error)); await (zoneRun ?? Future.value()); expect(errors, isEmpty); }); diff --git a/test/auth_manager_test.dart b/test/auth_manager_test.dart index ccf12cc..dcfb62d 100644 --- a/test/auth_manager_test.dart +++ b/test/auth_manager_test.dart @@ -13,11 +13,11 @@ final class ExtendingAuthStrategy implements AuthStrategy { int refreshCount = 0; AuthSession _session(DateTime expiresAt) => AuthSession( - accessToken: 'access', - refreshToken: const RefreshToken('refresh'), - expiresAt: expiresAt, - userId: 'u1', - ); + accessToken: 'access', + refreshToken: const RefreshToken('refresh'), + expiresAt: expiresAt, + userId: 'u1', + ); @override Future login(Credentials credentials) async => diff --git a/test/auth_manager_v1_test.dart b/test/auth_manager_v1_test.dart index 397700b..52fc380 100644 --- a/test/auth_manager_v1_test.dart +++ b/test/auth_manager_v1_test.dart @@ -44,8 +44,8 @@ final class _FailingStore implements TokenStore { this.failSaveAfter = 0, this.failClear = false, AuthSession? initial, - }) : value = initial, - _allowedSaves = failSaveAfter; + }) : value = initial, + _allowedSaves = failSaveAfter; final int failSaveAfter; final bool failClear; @@ -167,10 +167,10 @@ final class _ShortTtlStrategy implements AuthStrategy { } AuthSession _issue() => AuthSession( - accessToken: 'access-$refreshCount', - refreshToken: const RefreshToken('refresh'), - expiresAt: DateTime.now().add(const Duration(milliseconds: 300)), - ); + accessToken: 'access-$refreshCount', + refreshToken: const RefreshToken('refresh'), + expiresAt: DateTime.now().add(const Duration(milliseconds: 300)), + ); } /// A strategy that keeps handing out sessions that are already due, to prove the @@ -197,10 +197,10 @@ final class _ShortLivedStrategy implements AuthStrategy { } AuthSession _issued() => AuthSession( - accessToken: 'access-$refreshCount', - refreshToken: const RefreshToken('refresh'), - expiresAt: clock().add(const Duration(seconds: 1)), - ); + accessToken: 'access-$refreshCount', + refreshToken: const RefreshToken('refresh'), + expiresAt: clock().add(const Duration(seconds: 1)), + ); } final class _StaticTokenSource implements AuthTokenSource { @@ -219,15 +219,14 @@ AuthSession _session( String? userId, String? displayName, Map? claims, -}) => - AuthSession( - accessToken: token, - refreshToken: const RefreshToken('refresh'), - expiresAt: expiresAt, - userId: userId, - displayName: displayName, - claims: claims, - ); +}) => AuthSession( + accessToken: token, + refreshToken: const RefreshToken('refresh'), + expiresAt: expiresAt, + userId: userId, + displayName: displayName, + claims: claims, +); const _credentials = Credentials(username: 'user', password: 'user'); @@ -423,8 +422,7 @@ void main() { expect(manager.currentSession?.accessToken, 'login-1'); }); - test('a new refresh does not join one started in a previous epoch', - () async { + test('a new refresh does not join one started in a previous epoch', () async { final gate = Completer(); final strategy = _GatedStrategy( gate: gate, diff --git a/test/fake_strategy.dart b/test/fake_strategy.dart index 93e6db8..14267b0 100644 --- a/test/fake_strategy.dart +++ b/test/fake_strategy.dart @@ -17,14 +17,14 @@ final class FakeAuthStrategy implements AuthStrategy { bool logoutCalled = false; FakeAuthStrategy({AuthSession? session}) - : nextSession = session ?? _default(); + : nextSession = session ?? _default(); static AuthSession _default() => const AuthSession( - accessToken: 'access', - refreshToken: RefreshToken('refresh'), - userId: 'u1', - displayName: 'User', - ); + accessToken: 'access', + refreshToken: RefreshToken('refresh'), + userId: 'u1', + displayName: 'User', + ); @override Future login(Credentials credentials) async {