Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,24 @@ but its runtime adapter and all Session/Turn delivery remain future work.

The four `OPENTAG_BOOTSTRAP_*` values are inputs to this one-time command only; the running server does not read them.
The bootstrap email is Account profile data, not an email/password credential. The Account login-code flow resolves a
stable user ID and then uses the provider-neutral token issuer. Future Google or OIDC identity resolvers can join at that
boundary without changing JWT claims. Internal grants are still loaded from PostgreSQL as a Phase 2 compatibility seam;
they are not exposed as Admin membership.
stable user ID and then uses the provider-neutral token issuer. Internal grants are still loaded from PostgreSQL as a
Phase 2 compatibility seam; they are not exposed as Admin membership.

That issuer now hands out a Better Auth session rather than a signed access/refresh pair, so a CLI credential is a row
the server can withdraw instead of a signature it can only wait out. The exchange response keeps its four fields and
`accessToken` and `refreshToken` carry the same session token, which is why a CLI built before the cutover keeps working
unchanged. `OPENTAG_SESSION_TTL_SECONDS` is that credential's whole lifetime, defaulted to what the refresh token's was
because it replaces the same thing: how long a client may be idle and still be signed in. Refreshing rotates — the
replacement is issued, then the presented token is withdrawn — so a copy taken before the last refresh stops working
rather than running to its own expiry.

One consequence is worth stating plainly: a disclosed credential is now usable for the session lifetime rather than the
old fifteen-minute access window. What made that window necessary was that its thirty-day refresh partner could not be
revoked at all; a session can be, immediately, which is the trade this makes.

Credentials issued before the cutover still verify, and `OPENTAG_ACCESS_TOKEN_TTL_SECONDS` and
`OPENTAG_REFRESH_TOKEN_TTL_SECONDS` govern only those. A browser holding one moves onto a session the next time it
refreshes; nothing is issued against them again.

An Account email is stored lowercased, and one address identifies at most one Account. The identity resolver enforces
that by serializing on the address before deciding whether to create or attach, so it holds without a database
Expand Down Expand Up @@ -287,10 +302,11 @@ export OPENTAG_DEV_AUTH_EMAIL=admin@example.com
```

Both `OPENTAG_HOST` and `OPENTAG_PUBLIC_URL` must remain loopback addresses. The login page then shows
`Dev: bypass Google`. The callback resolves exactly one existing user by case-insensitive email and issues the normal
browser session; it never creates an Account or internal compatibility records and still rejects suspended Accounts or
Accounts without the required internal grant. Missing or duplicate email matches fail closed. The server refuses this configuration in `staging` and
`prod`.
`Dev: bypass Google`. The callback resolves exactly one existing user by case-insensitive email and then issues the
normal browser session through Better Auth, so it is the same revocable session a Google sign-in produces and signing
out ends it. Which Account it signs in is fixed from configuration, not taken from the request. It never creates an
Account or internal compatibility records and still rejects suspended Accounts; a missing or duplicate email match
fails closed. The server refuses this configuration in `staging` and `prod`.

`OPENTAG_ENV` is the only OpenTag environment and release-channel selector. `dev` selects local development behavior and
the `opentag-dev` binary, `staging` selects `open-tag-staging` / `opentag-staging`, and `prod` selects
Expand Down Expand Up @@ -379,8 +395,9 @@ processes.
| `OPENTAG_OTEL_HEADERS` | empty | Secret OTLP headers in comma-separated `key=value` form |
| `OPENTAG_OTEL_ENVIRONMENT` | `OPENTAG_ENV` | Trace deployment environment label |
| `OPENTAG_OTEL_SAMPLE_RATE` | `1` | Global trace head sample rate from `0` to `1` |
| `OPENTAG_ACCESS_TOKEN_TTL_SECONDS` | `900` | Access-token lifetime |
| `OPENTAG_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | Refresh-JWT lifetime |
| `OPENTAG_SESSION_TTL_SECONDS` | `2592000` | Account session lifetime, browser and CLI alike |
| `OPENTAG_ACCESS_TOKEN_TTL_SECONDS` | `900` | Access-JWT lifetime; only credentials issued before the Better Auth cutover |
| `OPENTAG_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | Refresh-JWT lifetime; only credentials issued before the Better Auth cutover |
| `OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID` | empty | Staging-only Account UUID allowed to reset the [Onboarding Lab](./docs/staging-onboarding-lab.md) Account; Scenario Preview needs no configuration |
| `OPENTAG_HOME` | channel-specific | Root for lifecycle-separated `config/`, `data/`, `state/`, and `logs/` (`~/.opentag-dev` in source) |

Expand Down
27 changes: 20 additions & 7 deletions DEVELOPMENT.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,19 @@ delivery 仍属于后续工作。

这四个 `OPENTAG_BOOTSTRAP_*` 值仅作为一次性命令的输入,运行中的 Server 不会读取它们。
bootstrap email 是 Account 资料,不是邮箱密码凭据。Account 登录 code 流程先解析稳定的 user ID,再进入与 provider
无关的 token 颁发边界。未来 Google 或 OIDC identity resolver 可以接入这个边界,无需改变 JWT claims。内部 grant
仍会从 PostgreSQL 读取,作为 Phase 2 前的兼容 seam;产品不把它暴露为 Admin 成员关系。
无关的 token 颁发边界。内部 grant 仍会从 PostgreSQL 读取,作为 Phase 2 前的兼容 seam;产品不把它暴露为 Admin 成员关系。

该边界现在签发的是 Better Auth session,而不是签名的 access/refresh 对:CLI 凭据成为服务端可以撤销的一行记录,
而不再是只能等它过期的一段签名。兑换响应仍是原来的四个字段,`accessToken` 与 `refreshToken` 携带同一个 session
token,因此切换前构建的 CLI 无需升级即可继续工作。`OPENTAG_SESSION_TTL_SECONDS` 就是这个凭据的完整有效期,
默认值取自原 refresh token 的有效期,因为它替代的正是同一件事:客户端可以闲置多久仍保持登录。refresh 采用轮换——
先签发替代凭据,再撤销所呈现的那个——因此上次 refresh 之前被复制走的副本会立即失效,而不是继续有效到自身过期。

有一处代价需要明说:凭据一旦泄露,可用时长从原先 15 分钟的 access 窗口变成整个 session 有效期。而当初之所以需要
这个短窗口,正是因为与之配对的 30 天 refresh token 根本无法吊销;session 则可以随时吊销,这就是这次取舍。

切换前签发的凭据仍可通过校验,`OPENTAG_ACCESS_TOKEN_TTL_SECONDS` 与 `OPENTAG_REFRESH_TOKEN_TTL_SECONDS` 只对它们
生效。持有此类凭据的浏览器会在下一次 refresh 时换成 session;系统不会再基于它们签发任何新凭据。

Account email 以小写存储,且一个地址最多对应一个 Account。这由 identity resolver 保证:它在决定新建还是挂载之前先对该地址
串行化,因此不依赖数据库约束也成立;`users_email_unique` 索引作为兜底,用于防范绕过 resolver 的写入方,并且只在没有任何
Expand Down Expand Up @@ -275,9 +286,10 @@ export OPENTAG_DEV_AUTH_EMAIL=admin@example.com
```

`OPENTAG_HOST` 与 `OPENTAG_PUBLIC_URL` 都必须保持为 loopback 地址。登录页随后会显示
`Dev: bypass Google`。callback 会按不区分大小写的 email 精确解析唯一一个已有用户并签发正常浏览器 session;
它不会创建 Account 或内部兼容记录,且仍会拒绝 suspended Account 或缺少所需内部 grant 的 Account。email 不存在或有重复匹配时
会 fail closed。Server 会在 `staging` 和 `prod` 环境拒绝这组配置。
`Dev: bypass Google`。callback 会按不区分大小写的 email 精确解析唯一一个已有用户,再通过 Better Auth 签发正常浏览器
session,因此它与 Google 登录产生的是同一种可吊销 session,登出即可结束它。签入哪个 Account 由配置固定,不取自请求。
它不会创建 Account 或内部兼容记录,且仍会拒绝 suspended Account;email 不存在或有重复匹配时会 fail closed。
Server 会在 `staging` 和 `prod` 环境拒绝这组配置。

`OPENTAG_ENV` 是 OpenTag 唯一的环境与发布 channel 选择器。`dev` 对应本地开发行为与 `opentag-dev` binary,
`staging` 对应 `open-tag-staging` / `opentag-staging`,`prod` 对应 `open-tag` / `opentag`。托管 Node.js 进程的
Expand Down Expand Up @@ -359,8 +371,9 @@ setup attempt 并记录结果,然后把一条已授权的 binding 写入数据
| `OPENTAG_OTEL_HEADERS` | 空 | 逗号分隔 `key=value` 格式的 secret OTLP headers |
| `OPENTAG_OTEL_ENVIRONMENT` | `OPENTAG_ENV` | Trace deployment environment 标签 |
| `OPENTAG_OTEL_SAMPLE_RATE` | `1` | `0` 到 `1` 的全局 trace head sample rate |
| `OPENTAG_ACCESS_TOKEN_TTL_SECONDS` | `900` | access token 有效期 |
| `OPENTAG_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | refresh JWT 有效期 |
| `OPENTAG_SESSION_TTL_SECONDS` | `2592000` | Account session 有效期,浏览器与 CLI 相同 |
| `OPENTAG_ACCESS_TOKEN_TTL_SECONDS` | `900` | access JWT 有效期;仅适用于 Better Auth 切换前签发的凭据 |
| `OPENTAG_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | refresh JWT 有效期;仅适用于 Better Auth 切换前签发的凭据 |
| `OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID` | 空 | 仅限 staging,允许 reset [Onboarding Lab](./docs/zh-CN/staging-onboarding-lab.md) Account 的 Account UUID;Scenario Preview 不需要配置 |
| `OPENTAG_HOME` | 随 channel 而定 | 按生命周期分层的 `config/`、`data/`、`state/`、`logs/` 根目录(源码默认为 `~/.opentag-dev`) |

Expand Down
26 changes: 21 additions & 5 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export class ApiError extends Error {
}

export class BrowserApi {
private refreshInFlight?: Promise<Response>;

constructor(readonly fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis)) {}

me(): Promise<MeResponse> {
Expand Down Expand Up @@ -316,11 +318,7 @@ export class BrowserApi {
private async fetchWithRefresh(path: string, init: RequestInit = {}, retry = true): Promise<Response> {
const response = await this.fetchImpl(path, { ...init, credentials: "same-origin" });
if (response.status !== 401 || !retry || !this.csrfToken()) return response;
const refreshed = await this.fetchImpl("/api/v1/auth/browser/refresh", {
method: "POST",
credentials: "same-origin",
headers: this.csrfHeaders(),
});
const refreshed = await this.refreshOnce();
if (!refreshed.ok) return response;
const headers = new Headers(init.headers);
const csrf = this.csrfToken();
Expand All @@ -330,6 +328,24 @@ export class BrowserApi {
return this.fetchWithRefresh(path, { ...init, headers }, false);
}

/**
* Collapses concurrent refreshes into one.
*
* Several requests can meet a `401` at once — the page loads more than one resource — and each would otherwise send
* the same cookie to an endpoint that exchanges it. The server converges those on one session regardless; this keeps
* the browser from asking it to.
*/
private refreshOnce(): Promise<Response> {
this.refreshInFlight ??= this.fetchImpl("/api/v1/auth/browser/refresh", {
method: "POST",
credentials: "same-origin",
headers: this.csrfHeaders(),
}).finally(() => {
this.refreshInFlight = undefined;
});
return this.refreshInFlight;
}

private apiError(response: Response, body: unknown): ApiError {
const parsed = ErrorEnvelopeSchema.safeParse(body);
if (!parsed.success) return new ApiError(response.status, "Request failed");
Expand Down
7 changes: 7 additions & 0 deletions packages/server/drizzle/0023_motionless_gideon.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
CREATE TABLE "account_legacy_upgrades" (
"token_hash" text PRIMARY KEY NOT NULL,
"session_token" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
Loading