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
14 changes: 14 additions & 0 deletions .changeset/25111-sso-domain-verification-instructed-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/plugin-auth': patch
---

fix(plugin-auth): SSO domain verification accepts the DNS TXT record that request-domain-verification tells the operator to publish (#25111)

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.
Metadata-protocol: none

`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`.

- **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.
- **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.
- **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.
38 changes: 38 additions & 0 deletions packages/plugins/plugin-auth/src/internal-field-readback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,44 @@ describe('[#24061] a column the read was keyed on is answered from the lookup',
});
});

describe('[#25111] sys_verification.identifier — @better-auth/sso verify-domain reads it off the row', () => {
// `findVerificationValue(identifier)` is `findMany(verification, where
// identifier = …)`; the vendor then builds the TXT record it accepts as
// `${row.identifier}=${row.value}`. The real-handler pin is
// `sso-domain-verification-instructed-record.test.ts`; this pins the seam.
const byIdentifier = (value: string) => [
{ field: 'identifier', value, operator: 'eq', connector: 'AND', mode: 'sensitive' },
];

it('a stripped row (the findMany shape, no lookup) gets BOTH columns back — one batched call per column', async () => {
const resolveInternalField = resolver({ v1: { value: 'TOKEN', identifier: '_better-auth-token-acme' } });
const rows: any[] = [{ id: 'v1', expires_at: '2099-01-01' }];
await reattachInternalFieldsOnRead({ resolveInternalField }, 'sys_verification', rows);
expect(rows[0].value).toBe('TOKEN');
expect(rows[0].identifier).toBe('_better-auth-token-acme');
expect(resolveInternalField).toHaveBeenCalledTimes(2);
expect(resolveInternalField).toHaveBeenCalledWith('sys_verification', ['v1'], 'identifier');
});

it('a read keyed on identifier answers it from the lookup — only `value` costs a privileged read', async () => {
const resolveInternalField = resolver({ v1: { value: 'TOKEN', identifier: 'never-read' } });
const row: any = { id: 'v1' };
await reattachInternalFieldsOnRead(
{ resolveInternalField }, 'sys_verification', row, undefined, byIdentifier('_better-auth-token-acme'),
);
expect(row.identifier).toBe('_better-auth-token-acme');
expect(row.value).toBe('TOKEN');
expect(resolveInternalField).toHaveBeenCalledTimes(1);
expect(resolveInternalField).toHaveBeenCalledWith('sys_verification', ['v1'], 'value');
});

it('fails closed on a row the strip emptied of identifier when the engine offers no accessor', async () => {
// `required: true`, so absence can only be the strip — the `value` posture.
const row: any = { id: 'v1', value: 'TOKEN' };
await expect(reattachInternalFieldsOnRead({}, 'sys_verification', row)).rejects.toThrow(/'identifier'/);
});
});

describe('#7987 reattachInternalFieldsOnRead — sys_account OAuth columns', () => {
const ACCOUNT_ROW = () => ({ id: 'a1', user_id: 'u1', provider_id: 'google' });

Expand Down
36 changes: 26 additions & 10 deletions packages/plugins/plugin-auth/src/internal-field-readback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ const READBACK_FIELDS: Readonly<Record<string, readonly ReadbackColumn[]>> = {
// mirror that honours the same flag — carries it, and each is here because a
// better-auth route reads it back OFF a row its storage adapter returned
// (traced in the then-installed better-auth / @better-auth/* dist, 1.7.3;
// ⚠️ [#25096] NOT re-stamped for 1.7.12 — see the `identifier` note below):
// ⚠️ [#25096] NOT re-stamped for 1.7.12 as a whole — only the
// `sys_verification` row below was re-traced there, by #25111):
//
// - `sys_jwks.private_key` — the jwt plugin's key read (`findMany`, no
// projection) feeds signing. MEASURED before this row existed: the flag
Expand All @@ -205,13 +206,24 @@ const READBACK_FIELDS: Readonly<Record<string, readonly ReadbackColumn[]>> = {
// - `sys_verification.value` — reset-password, email/phone OTP, magic link
// and OIDC code redemption read it off the found or CONSUMED row (the
// adapter's `consumeOne` re-attaches through this table too).
// `required: true`. `identifier` is deliberately absent: no flow reads it
// back off a row — it is only ever the lookup key.
// ⚠️ [#25096] That census is wrong, on 1.7.3 and on 1.7.12 alike:
// `@better-auth/sso`'s `/sso/verify-domain` builds the TXT record it
// expects from `identifier` read back off the row, so on this store it
// expects `undefined=TOKEN`. Measured and reported on #25096; nothing
// here is changed by that card.
// `required: true`.
// - `sys_verification.identifier` [#25111] — `@better-auth/sso`'s
// `/sso/verify-domain` (traced on 1.7.12, the same code on 1.7.3) builds
// the TXT record it accepts as `${activeVerification.identifier}=${value}`
// from the row `findVerificationValue` read back, beside the bare value.
// Stripped, that row made it expect `undefined=TOKEN`: the record
// `request-domain-verification` tells the operator to publish
// (`_better-auth-token-PROVIDER=TOKEN`, `register-sso-provider.ts`)
// answered 502 DOMAIN_VERIFICATION_FAILED, and a record spelling
// `undefined=TOKEN` verified. It is the ONLY reader of the column off a
// row in better-auth, @better-auth/sso, /oauth-provider and /scim 1.7.12;
// every other flow uses it as the lookup key alone. It hands the vendor
// nothing it does not already hold: every verification read better-auth
// issues is keyed on `identifier` (`findVerificationValue`,
// `consumeVerificationValue`), a `findOne` keyed on it is answered from
// the lookup with no privileged read (#24061), and no vendor route echoes
// a verification row to its caller. The generic exits still strip it.
// `required: true` ⇒ absence can only be the strip.
// - `sys_two_factor.secret` (`required: true`) and `.backup_codes` (empty
// until generated) — TOTP and backup-code verification read both off the
// enrolment row.
Expand All @@ -222,7 +234,10 @@ const READBACK_FIELDS: Readonly<Record<string, readonly ReadbackColumn[]>> = {
// digest off the client row to verify a presented secret; empty on a
// public client.
[SystemObjectName.JWKS]: [{ field: 'private_key', absenceProvesStrip: true }],
[SystemObjectName.VERIFICATION]: [{ field: 'value', absenceProvesStrip: true }],
[SystemObjectName.VERIFICATION]: [
{ field: 'value', absenceProvesStrip: true },
{ field: 'identifier', absenceProvesStrip: true },
],
[SystemObjectName.TWO_FACTOR]: [
{ field: 'secret', absenceProvesStrip: true },
{ field: 'backup_codes', absenceProvesStrip: false },
Expand Down Expand Up @@ -252,7 +267,8 @@ const FAIL_CLOSED_CONSEQUENCE: Readonly<Record<string, string>> = {
+ '(no set-auth-jwt header, no OIDC id_token)',
[SystemObjectName.VERIFICATION]:
'better-auth one-time verification flows (password reset, OTP, magic link, OIDC code redemption) '
+ 'would find no stored value on such rows and refuse every outstanding code',
+ 'would find no stored value on such rows and refuse every outstanding code, and SSO domain '
+ 'verification would refuse the DNS record request-domain-verification names',
[SystemObjectName.TWO_FACTOR]:
'better-auth two-factor verification would find no TOTP secret on such rows and refuse every code',
};
Expand Down
Loading
Loading