Skip to content

Commit 0d56c86

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-25015-digest-heading-locale
Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
2 parents 490c664 + 425c743 commit 0d56c86

4 files changed

Lines changed: 409 additions & 10 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): SSO domain verification accepts the DNS TXT record that request-domain-verification tells the operator to publish (#25111)
6+
7+
Clause-②: no — the verify door now accepts the record the platform already names, as `@better-auth/sso` designed it; the one record it stops accepting is `undefined=TOKEN`, which only the defect produced and nothing instructs.
8+
Metadata-protocol: none
9+
10+
`POST /api/v1/auth/admin/sso/request-domain-verification` tells the operator to publish a TXT record named `_better-auth-token-PROVIDER.DOMAIN` with the value `_better-auth-token-PROVIDER=TOKEN`. `POST /api/v1/auth/admin/sso/verify-domain` (and the vendor route behind it, `POST /api/v1/auth/sso/verify-domain`) refused exactly that record with `502 DOMAIN_VERIFICATION_FAILED`. The vendor builds the record it accepts from the verification row's `identifier`, and the storage adapter handed it that row without the column (`sys_verification.identifier` is `internal`), so it expected `undefined=TOKEN`.
11+
12+
- **Now.** The adapter hands better-auth the row's `identifier` the same way it already hands back `value`. The instructed record verifies on both doors and marks the provider verified.
13+
- **Still refused.** A wrong token, the token under another provider's label, the record published under another provider's name, and `undefined=TOKEN`: each answers `502 DOMAIN_VERIFICATION_FAILED` and leaves the provider unverified.
14+
- **Unchanged.** A record holding only the token is still accepted, as the vendor designed it. The generic data API still omits `identifier` from every `sys_verification` row.

‎packages/plugins/plugin-auth/src/internal-field-readback.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,44 @@ describe('[#24061] a column the read was keyed on is answered from the lookup',
184184
});
185185
});
186186

187+
describe('[#25111] sys_verification.identifier — @better-auth/sso verify-domain reads it off the row', () => {
188+
// `findVerificationValue(identifier)` is `findMany(verification, where
189+
// identifier = …)`; the vendor then builds the TXT record it accepts as
190+
// `${row.identifier}=${row.value}`. The real-handler pin is
191+
// `sso-domain-verification-instructed-record.test.ts`; this pins the seam.
192+
const byIdentifier = (value: string) => [
193+
{ field: 'identifier', value, operator: 'eq', connector: 'AND', mode: 'sensitive' },
194+
];
195+
196+
it('a stripped row (the findMany shape, no lookup) gets BOTH columns back — one batched call per column', async () => {
197+
const resolveInternalField = resolver({ v1: { value: 'TOKEN', identifier: '_better-auth-token-acme' } });
198+
const rows: any[] = [{ id: 'v1', expires_at: '2099-01-01' }];
199+
await reattachInternalFieldsOnRead({ resolveInternalField }, 'sys_verification', rows);
200+
expect(rows[0].value).toBe('TOKEN');
201+
expect(rows[0].identifier).toBe('_better-auth-token-acme');
202+
expect(resolveInternalField).toHaveBeenCalledTimes(2);
203+
expect(resolveInternalField).toHaveBeenCalledWith('sys_verification', ['v1'], 'identifier');
204+
});
205+
206+
it('a read keyed on identifier answers it from the lookup — only `value` costs a privileged read', async () => {
207+
const resolveInternalField = resolver({ v1: { value: 'TOKEN', identifier: 'never-read' } });
208+
const row: any = { id: 'v1' };
209+
await reattachInternalFieldsOnRead(
210+
{ resolveInternalField }, 'sys_verification', row, undefined, byIdentifier('_better-auth-token-acme'),
211+
);
212+
expect(row.identifier).toBe('_better-auth-token-acme');
213+
expect(row.value).toBe('TOKEN');
214+
expect(resolveInternalField).toHaveBeenCalledTimes(1);
215+
expect(resolveInternalField).toHaveBeenCalledWith('sys_verification', ['v1'], 'value');
216+
});
217+
218+
it('fails closed on a row the strip emptied of identifier when the engine offers no accessor', async () => {
219+
// `required: true`, so absence can only be the strip — the `value` posture.
220+
const row: any = { id: 'v1', value: 'TOKEN' };
221+
await expect(reattachInternalFieldsOnRead({}, 'sys_verification', row)).rejects.toThrow(/'identifier'/);
222+
});
223+
});
224+
187225
describe('#7987 reattachInternalFieldsOnRead — sys_account OAuth columns', () => {
188226
const ACCOUNT_ROW = () => ({ id: 'a1', user_id: 'u1', provider_id: 'google' });
189227

‎packages/plugins/plugin-auth/src/internal-field-readback.ts‎

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,8 @@ const READBACK_FIELDS: Readonly<Record<string, readonly ReadbackColumn[]>> = {
195195
// mirror that honours the same flag — carries it, and each is here because a
196196
// better-auth route reads it back OFF a row its storage adapter returned
197197
// (traced in the then-installed better-auth / @better-auth/* dist, 1.7.3;
198-
// ⚠️ [#25096] NOT re-stamped for 1.7.12 — see the `identifier` note below):
198+
// ⚠️ [#25096] NOT re-stamped for 1.7.12 as a whole — only the
199+
// `sys_verification` row below was re-traced there, by #25111):
199200
//
200201
// - `sys_jwks.private_key` — the jwt plugin's key read (`findMany`, no
201202
// projection) feeds signing. MEASURED before this row existed: the flag
@@ -205,13 +206,24 @@ const READBACK_FIELDS: Readonly<Record<string, readonly ReadbackColumn[]>> = {
205206
// - `sys_verification.value` — reset-password, email/phone OTP, magic link
206207
// and OIDC code redemption read it off the found or CONSUMED row (the
207208
// adapter's `consumeOne` re-attaches through this table too).
208-
// `required: true`. `identifier` is deliberately absent: no flow reads it
209-
// back off a row — it is only ever the lookup key.
210-
// ⚠️ [#25096] That census is wrong, on 1.7.3 and on 1.7.12 alike:
211-
// `@better-auth/sso`'s `/sso/verify-domain` builds the TXT record it
212-
// expects from `identifier` read back off the row, so on this store it
213-
// expects `undefined=TOKEN`. Measured and reported on #25096; nothing
214-
// here is changed by that card.
209+
// `required: true`.
210+
// - `sys_verification.identifier` [#25111] — `@better-auth/sso`'s
211+
// `/sso/verify-domain` (traced on 1.7.12, the same code on 1.7.3) builds
212+
// the TXT record it accepts as `${activeVerification.identifier}=${value}`
213+
// from the row `findVerificationValue` read back, beside the bare value.
214+
// Stripped, that row made it expect `undefined=TOKEN`: the record
215+
// `request-domain-verification` tells the operator to publish
216+
// (`_better-auth-token-PROVIDER=TOKEN`, `register-sso-provider.ts`)
217+
// answered 502 DOMAIN_VERIFICATION_FAILED, and a record spelling
218+
// `undefined=TOKEN` verified. It is the ONLY reader of the column off a
219+
// row in better-auth, @better-auth/sso, /oauth-provider and /scim 1.7.12;
220+
// every other flow uses it as the lookup key alone. It hands the vendor
221+
// nothing it does not already hold: every verification read better-auth
222+
// issues is keyed on `identifier` (`findVerificationValue`,
223+
// `consumeVerificationValue`), a `findOne` keyed on it is answered from
224+
// the lookup with no privileged read (#24061), and no vendor route echoes
225+
// a verification row to its caller. The generic exits still strip it.
226+
// `required: true` ⇒ absence can only be the strip.
215227
// - `sys_two_factor.secret` (`required: true`) and `.backup_codes` (empty
216228
// until generated) — TOTP and backup-code verification read both off the
217229
// enrolment row.
@@ -222,7 +234,10 @@ const READBACK_FIELDS: Readonly<Record<string, readonly ReadbackColumn[]>> = {
222234
// digest off the client row to verify a presented secret; empty on a
223235
// public client.
224236
[SystemObjectName.JWKS]: [{ field: 'private_key', absenceProvesStrip: true }],
225-
[SystemObjectName.VERIFICATION]: [{ field: 'value', absenceProvesStrip: true }],
237+
[SystemObjectName.VERIFICATION]: [
238+
{ field: 'value', absenceProvesStrip: true },
239+
{ field: 'identifier', absenceProvesStrip: true },
240+
],
226241
[SystemObjectName.TWO_FACTOR]: [
227242
{ field: 'secret', absenceProvesStrip: true },
228243
{ field: 'backup_codes', absenceProvesStrip: false },
@@ -252,7 +267,8 @@ const FAIL_CLOSED_CONSEQUENCE: Readonly<Record<string, string>> = {
252267
+ '(no set-auth-jwt header, no OIDC id_token)',
253268
[SystemObjectName.VERIFICATION]:
254269
'better-auth one-time verification flows (password reset, OTP, magic link, OIDC code redemption) '
255-
+ 'would find no stored value on such rows and refuse every outstanding code',
270+
+ 'would find no stored value on such rows and refuse every outstanding code, and SSO domain '
271+
+ 'verification would refuse the DNS record request-domain-verification names',
256272
[SystemObjectName.TWO_FACTOR]:
257273
'better-auth two-factor verification would find no TOTP secret on such rows and refuse every code',
258274
};

0 commit comments

Comments
 (0)