You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 55a4898
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec): declare the mobile sign-in hand-off surface (one-time-token hand-off and verify, registered app schemes) (#29804)
<!-- claude-projects-attribution: {"github_login":"os-warren"} -->
_Requested by **Warren** · [project
thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8PKyTDyCuaiBJ67xQB1FoTM)_Fixes#29736
Clause-②: yes
Metadata-protocol: added
Skills-docs: none — no file under `skills/**` or the hand-written docs
teaches `trustedOrigins`, the auth endpoint paths or a mobile sign-in,
so nothing taught becomes false and there is no page the new key belongs
on yet; the generated reference pages
(`content/docs/references/api/auth-endpoints.mdx`,
`system/auth-config.mdx`) carry it.
## 维护者速读
- 改了什么:在 `packages/spec` 声明移动端登录交接的契约:两条路由(`/one-time-token/handoff` 与
better-auth 自带的 `/one-time-token/verify`)及其请求/响应 schema,和新的可编写键
`AuthConfig.trustedAppSchemes`(允许回跳的原生 App scheme 列表)。
- 为什么改:部分身份提供方(如 Google)拒绝内嵌 WebView,移动端要走系统浏览器登录再把会话交回
WebView;契约先行,服务端卡 #29714 等本卡落地才转 ready。
- 风险与回滚:只加不减(新键可选、新导出),既有配置零变化;回滚即 revert 本 PR。最敏感处是 scheme
校验规则写严了(必须小写、含点、不带 `://`),错写的条目会被拒而不是被修正。
-
先看:`packages/spec/src/system/auth-config.zod.ts`(`TrustedAppSchemeSchema`
正则与 `trustedAppSchemes`
文档块)、`packages/spec/src/api/auth-endpoints.zod.ts`(交接 query 与 verify
绑定方式)、`.changeset/29736-mobile-sign-in-handoff-spec.md`。
- 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run
src/system/auth-config.test.ts src/api/auth-endpoints.test.ts`,预期全部通过,其中
`myapp`、`https`、`com.example.app://` 三种写法都被拒。
- 需要你做的:无,席位落地。
## What changes
The spec half of the mobile sign-in hand-off. The server half is the
#29714 card (draft until this lands). The shapes below were agreed with
that card's thread before writing. They were built against better-auth
1.7.12, the version `plugin-auth` pins, and measured from its
`dist/plugins/one-time-token`.
- **`AuthEndpointPaths.oneTimeTokenHandoff`** =
`/one-time-token/handoff` (GET, ObjectStack's own route) with
**`OneTimeTokenHandoffQuerySchema`**. It is a strict, closed set:
- `redirect_uri`;
- `code_challenge`, an RFC 7636 S256 challenge (43 base64url
characters);
- `code_challenge_method`, optional, only `S256`.
The docblock states the answers. On success, a `302` to `redirect_uri`
with a `token` query parameter, `no-store`, valid for one minute. A `400
VALIDATION_ERROR` with no redirect for an unknown or repeated parameter,
an unregistered scheme or a bad challenge. A `401` without a session.
- **`AuthEndpointPaths.oneTimeTokenVerify`** = `/one-time-token/verify`
(POST, better-auth's route name and body) with
**`OneTimeTokenVerifyRequestSchema`** and
**`OneTimeTokenVerifyResponseSchema`**.
- The interception binding rides inside better-auth's own `token` field:
the token, a dot, then the RFC 7636 verifier. The stock client keeps
working, and a callback intercepted by another app cannot be redeemed
without the verifier. The vendor token alphabet (`a-z A-Z 0-9 - _`,
measured from `generateRandomString`) never contains a dot, so the
server splits on the first one.
- The response is better-auth's unwrapped `{ session, user }`, reusing
`SessionSchema` / `SessionUserSchema` from `api/auth.zod.ts`.
- better-auth's `GET /one-time-token/generate` is mounted but refuses
client requests (`disableClientRequest`), so it is deliberately not
declared.
- **`AuthConfig.trustedAppSchemes`**: a new authorable key, an optional
array of **`TrustedAppSchemeSchema`**. The rule is strict and never
normalised: a lowercase RFC 3986 scheme written bare, with at least one
dot. RFC 8252 section 7.1 requires a private-use scheme to be a reverse
domain name. That requirement also rules out, by construction, every web
scheme (`http`, `https`, `ws`, `wss`, `javascript`, `data`, `file`,
`blob`, `about`, `vbscript`), because none of them contains a dot. A bad
entry is refused, never dropped. The docblock states that a hand-off to
an unregistered scheme is refused with `400` and no redirect. The server
half refuses the same entries at boot, since `plugin-auth` receives
plain objects.
- **No `/auth/config` feature flag.** Nothing in the tree reads one.
`PUBLIC_AUTH_FEATURES` precedent (passkeys withdrawn) says not to
advertise ahead of a consumer, so `kernel/public-auth-features.ts` is
untouched.
- Regenerated: `api-surface/`, `export-origins/`, `declaration-map/`,
`authorable-surface/`, `json-schema.manifest/`, the strictness-ledger
counts and two reference pages. Three isomorphic alias pins were added
(ADR-0122), 819 → 822.
**Key-name precedent (the seat's call under the veto window):**
`trustedAppSchemes` sits beside `trustedOrigins` on `AuthConfigSchema`
and follows its `trusted…` + plural-noun form. It is a separate key on
purpose. better-auth trusts an origin in `trustedOrigins` for every
redirect it makes, while a scheme here is trusted only as the hand-off's
destination. The query parameter names are the OAuth 2.0 / RFC 7636 wire
names, kept verbatim.
## Why
Some identity providers (Google among them) refuse embedded WebViews, so
a mobile user signing in with one cannot sign in to the app today. The
maintainer ruled option B: reuse better-auth's one-time-token plugin.
Triage's first touch on the server card asks for the public surface to
be declared in `packages/spec` first.
## Tests
- `src/api/auth-endpoints.test.ts`, a new `mobile sign-in hand-off
(one-time token)` block:
- the two paths are declared and generate is not;
- the hand-off query is accepted with and without the method;
- an unknown parameter is refused as `unrecognized_keys`;
- `plain`, a short challenge, a padded challenge and a missing challenge
are refused at their path;
- the verify body is accepted with the binding (including a verifier
with dots and tildes) and refused without it or with a short verifier;
- the vendor verify body parses.
- `src/system/auth-config.test.ts`: reverse-domain schemes are accepted
and the key is absent by default. A table refuses eight shapes at
`['trustedAppSchemes', 1]`: `https`, `javascript`, `myapp`,
`com.example.app://`, `com.example.app:`, `Com.Example.App`, `com..app`
and `com.example.`.
- `src/type-alias-convention.pin.test.ts`: three new `Iso_*` pins and
the count.
## Acceptance notes
- No ablation: the diff adds schemas and changes no tested behaviour.
## Verification
All readings below were taken on head `b73cfb24d`
(objectstack-ai/objectstack), after the final commit.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 102 families from the change set (19
paths at derivation, under the 3,000-line threshold). All 102 exit 0.
`dispatch-gates --ran` reconciles: 102 run, 0 NOT MEASURED.
- Also run, outside the derivation: `pnpm --filter @objectstack/spec
test` (803 files, 23811 tests passed, 1 todo), `pnpm --filter
@objectstack/spec typecheck` (exit 0) and `pnpm lint` (exit 0).
- `pnpm --filter @objectstack/spec check:generated` exit 0: every
artifact is current.
- The first pass on `4b54dade` was red on `check:spec-parsed-alias` (the
three new aliases needed `Iso_*` pins) and on `check:generated`
(declaration map). Both were fixed in this PR. Four gates answered exit
3 (prerequisite: unbuilt packages, or a pinned commit outside the
shallow clone) and were then run on the final head after building
`formula`, `lint`, `objectql`, `client` and `client-react` and fetching
the fixture commit. All four are green there.
<details><summary>Regeneration commands</summary>
`pnpm --filter @objectstack/spec check:generated --fix` regenerated
`api-surface/`, `export-origins/`, `declaration-map/`,
`content/docs/references/**` and the strictness-ledger counts.
`gen:schema` (via `check:authorable-surface`) wrote
`authorable-surface/` and `json-schema.manifest/`.
`authorable-surface.base.json` is untouched.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind (the snake_case
query keys are the RFC wire names, stated above).
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01JQwxbXghRkYmSR5dVh8PFG)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(spec): declare the mobile sign-in hand-off surface (one-time-token hand-off and verify, and the registered app-scheme list)
6
+
7
+
Clause-②: yes
8
+
Metadata-protocol: added
9
+
10
+
Some identity providers refuse embedded WebViews, so a mobile app signs in through the system browser and needs the session handed back to its WebView. This declares the contract the auth plugin serves for that, before the plugin enables it:
11
+
12
+
-**`AuthEndpointPaths.oneTimeTokenHandoff`** (`GET /one-time-token/handoff`) with `OneTimeTokenHandoffQuerySchema`: a closed query of `redirect_uri` (an absolute URI), an RFC 7636 S256 `code_challenge` and an optional `code_challenge_method` (only `S256`). A signed-in session is redirected to `redirect_uri` with a single-use `token` that expires after one minute. An unregistered scheme, an unknown or repeated parameter, or a bad challenge is refused with `400 VALIDATION_ERROR` and no redirect.
13
+
-**`AuthEndpointPaths.oneTimeTokenVerify`** (`POST /one-time-token/verify`, better-auth's own route) with `OneTimeTokenVerifyRequestSchema` and `OneTimeTokenVerifyResponseSchema`. The body is `{ token }`, where the token is followed by a dot and the code verifier. A callback intercepted by another app cannot be redeemed without the verifier. The response is better-auth's unwrapped `{ session, user }` with the session cookie. better-auth's `GET /one-time-token/generate` refuses client requests, so it is not declared.
14
+
-**`AuthConfig.trustedAppSchemes`** (new authorable key, beside `trustedOrigins`): the native-app schemes the hand-off may redirect to. Each entry is a `TrustedAppSchemeSchema`: a lowercase reverse-domain scheme written bare: two or more non-empty labels joined by single dots, the first starting with a letter (RFC 8252 section 7.1), such as `com.example.app`. A malformed entry is refused, not dropped or rewritten. That includes a `://` suffix, an uppercase letter, a dotless name such as `myapp`, and every web scheme such as `https`. If the list is absent or empty, the hand-off is not offered.
15
+
16
+
No `/auth/config` feature flag is added, because nothing reads one yet.
17
+
18
+
Nothing to do for existing configurations. To offer the hand-off, list the app's scheme in `trustedAppSchemes`.
@@ -215,3 +215,56 @@ This schema accepts one of the following structures:
215
215
216
216
---
217
217
218
+
## OneTimeTokenHandoffQuery
219
+
220
+
### Properties
221
+
222
+
| Property | Type | Required | Description |
223
+
| :--- | :--- | :--- | :--- |
224
+
|**redirect_uri**|`string`| ✅ | Where to send the token: an absolute URI whose scheme is registered in AuthConfig.trustedAppSchemes (e.g. com.example.app://auth/callback); any other scheme is refused with 400 and no redirect |
225
+
|**code_challenge**|`string`| ✅ | RFC 7636 S256 code challenge: the unpadded base64url SHA-256 of the verifier the app keeps (43 characters) |
226
+
|**code_challenge_method**|`'S256'`| optional | RFC 7636 challenge method; only S256 is accepted (plain is refused), and absent means S256 |
227
+
228
+
229
+
---
230
+
231
+
## OneTimeTokenVerifyRequest
232
+
233
+
### Properties
234
+
235
+
| Property | Type | Required | Description |
236
+
| :--- | :--- | :--- | :--- |
237
+
|**token**|`string`| ✅ | The hand-off token, a dot, then the RFC 7636 code verifier (43-128 characters) whose S256 challenge was sent to the hand-off |
238
+
239
+
240
+
---
241
+
242
+
## OneTimeTokenVerifyResponse
243
+
244
+
### Properties
245
+
246
+
| Property | Type | Required | Description |
247
+
| :--- | :--- | :--- | :--- |
248
+
|**session**|`{ id: string; expiresAt: string; token?: string; ipAddress?: string; … }`| ✅ | The session the token was minted from |
249
+
|**user**|`{ id: string; email: string; emailVerified?: boolean; name: string; … }`| ✅ | The signed-in user |
|**roles**|`string[]`| optional (default: `[]`) | Assigned role IDs |
262
+
|**tenantId**|`string`| optional | Current tenant ID |
263
+
|**language**|`never`| optional |[REMOVED]`SessionUser.language` was removed in @objectstack/spec 17.4.0 (ADR-0049 enforce-or-remove) — it was declared with a permanent default of `'en'` and never produced by any session endpoint nor read by any client, so a reader keying on it saw a constant, not the user's language. Delete the key. Read the signed-in user's language from `GET /auth/me/localization` (`locale`: the user's own `sys_user.locale` when set → the request's `Accept-Language` → the deployment default). |
|[Automation Protocol](/docs/references/automation)| 15 | 78 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
26
26
|[Data Protocol](/docs/references/data)| 31 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
Copy file name to clipboardExpand all lines: content/docs/references/system/auth-config.mdx
+13-3Lines changed: 13 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,7 +1,7 @@
1
1
---
2
2
title: Auth Config schema — System Protocol reference
3
3
navTitle: Auth Config
4
-
description: "Auth Config schemas of the ObjectStack System Protocol: AdvancedAuthConfig and 10 more — each property with its type, default and a TypeScript example."
4
+
description: "Auth Config schemas of the ObjectStack System Protocol: AdvancedAuthConfig and 11 more — each property with its type, default and a TypeScript example."
5
5
---
6
6
7
7
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -13,8 +13,8 @@ description: "Auth Config schemas of the ObjectStack System Protocol: AdvancedAu
|**session**|`{ expiresIn?: number; updateAge?: number }`| optional ||
75
75
|**trustedOrigins**|`string[]`| optional | Trusted origins for CSRF protection. Supports wildcards (e.g. "https://*.example.com"). The baseUrl origin is always trusted implicitly. |
76
+
|**trustedAppSchemes**|`string[]`| optional | Native-app URI schemes the mobile sign-in hand-off may redirect a one-time token to (e.g. ["com.example.app"]). A hand-off to any other scheme is refused with 400 VALIDATION_ERROR and no redirect; absent or empty means the hand-off is not offered |
|**oidcProviders**|`{ providerId: string; name?: string; discoveryUrl?: string; issuer?: string; … }[]`| optional | List of OIDC/OAuth2 providers for enterprise SSO. Product or enterprise packages can pass this directly or contribute it through auth:configure. |
@@ -339,3 +340,12 @@ Social/OAuth provider map forwarded to better-auth socialProviders. Keys are pro
339
340
340
341
---
341
342
343
+
## TrustedAppScheme
344
+
345
+
A registered native-app URI scheme: lowercase, reverse-domain (two or more non-empty labels joined by single dots, the first starting with a letter), written bare (e.g. com.example.app)
0 commit comments