SSO: TXT only for domain proof, drop the CNAME form

The CNAME alternative pointed at `<token>.verify.screentinker.com`. Making that work
means operating a wildcard DNS zone that answers for every token ever issued — which
this project does not have, so half the published instructions described a check that
could never pass. Documenting a verification path that cannot succeed is worse than
offering one form.

TXT needs nothing outside the customer's own zone, and the dedicated `_`-prefixed name
keeps it away from the apex where SPF and DMARC live. A wildcard `*.example.com` cannot
be mistaken for a proof either way: it answers with its own value, never the token, so
it lands in "exists but does not match".

Also simplifies check() — one lookup, no Promise.allSettled, and NXDOMAIN is reported
as "not published yet" rather than as an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
This commit is contained in:
ScreenTinker 2026-08-10 20:27:03 -05:00
parent d4b8d7dad4
commit 9155370ae8
5 changed files with 40 additions and 47 deletions

View file

@ -420,16 +420,20 @@ deny a public domain to everyone else.
A claimed domain **routes nobody and authenticates nobody until DNS proves the organization controls
it.** Typing a domain into a form reserves the name and nothing more.
Publish either record — whichever the domain's DNS will accept — then press **Verify**:
Publish this record, then press **Verify**:
```
_screentinker-verify.example.com. IN TXT "st-verify=<token>"
_screentinker-verify.example.com. IN CNAME <token>.verify.screentinker.com.
_screentinker-verify.example.com. IN TXT "st-verify=<token>"
```
The token is unique per domain, so publishing one proof cannot be replayed to claim a second. A
dedicated `_`-prefixed name is used rather than the apex, where a careless edit would sit alongside
SPF and DMARC and break mail.
SPF and DMARC and break mail — and where a wildcard `*.example.com` could not be confused for a
proof, since a wildcard answers with its own value and never with the token.
TXT is the only accepted form. A CNAME alternative would have to point at a wildcard zone this
project operates, answering for every token ever issued; documenting one without running it would
describe a check that can never pass.
**An unverified claim lapses after 8 hours**, and lapsing rotates the token. This is what stops
squatting: a tenant cannot type a company's domain and hold it against the real owner, and a record

View file

@ -159,8 +159,7 @@ export default {
'sso.verifying': 'Checking DNS…',
'sso.verify_failed': 'Could not verify that domain.',
'sso.domain_verified_toast': '{domain} is verified.',
'sso.dns_instructions': 'Publish ONE of these records in this domain\u2019s DNS, then click Verify. Claims expire after 8 hours.',
'sso.dns_or_cname': 'or, if your DNS will not take a TXT record there:',
'sso.dns_instructions': 'Publish this TXT record in this domain\u2019s DNS, then click Verify. Claims expire after 8 hours.',
'sso.callback_label': 'Redirect URI — add this to your provider',
'sso.f_name': 'Display name',
'sso.f_issuer': 'Issuer URL',

View file

@ -743,8 +743,6 @@ export async function render(container) {
${d.verified ? '' : `
<div style="margin-top:6px;color:var(--text-muted)">${esc(t('sso.dns_instructions'))}</div>
<code style="display:block;word-break:break-all;padding:6px;background:var(--bg-secondary);border-radius:4px;margin-top:4px">${esc(d.record_name)} TXT ${esc(d.txt_value)}</code>
<div style="margin-top:4px;color:var(--text-muted)">${esc(t('sso.dns_or_cname'))}</div>
<code style="display:block;word-break:break-all;padding:6px;background:var(--bg-secondary);border-radius:4px;margin-top:4px">${esc(d.record_name)} CNAME ${esc(d.cname_value)}</code>
${d.last_error ? `<div style="margin-top:4px;color:var(--danger,#b91c1c)">${esc(d.last_error)}</div>` : ''}`}
<div id="ssoVerify-${esc(p.id)}-${esc(d.domain.replace(/[^a-z0-9]/g, '-'))}" style="margin-top:4px"></div>
</div>`).join('')}

View file

@ -13,15 +13,19 @@
* sense that matters here. It is also the mechanism every other vendor uses, so the instructions
* are already familiar to the person who has to follow them.
*
* TWO RECORD FORMS, both at the same name, because organizations differ in what their DNS lets
* them add some providers refuse TXT at a subdomain, some refuse CNAME anywhere useful:
* ONE RECORD FORM a TXT record at a dedicated name:
*
* _screentinker-verify.example.com. IN TXT "st-verify=<token>"
* _screentinker-verify.example.com. IN CNAME <token>.verify.screentinker.com.
* _screentinker-verify.example.com. IN TXT "st-verify=<token>"
*
* A CNAME alternative was drafted and dropped. It would have pointed at
* `<token>.verify.screentinker.com`, which requires operating a wildcard DNS zone that answers for
* every token ever issued infrastructure this project does not have, so the instructions would
* have described a check that could never pass. TXT needs nothing but the customer's own zone.
*
* A dedicated `_`-prefixed name is used rather than the apex on purpose: an apex TXT record sits
* alongside SPF and DMARC, where a careless edit breaks mail, and it is the one record set an
* administrator is most reluctant to touch.
* administrator is most reluctant to touch. It also means a wildcard `*.example.com` cannot be
* mistaken for a proof a wildcard answers with ITS value, never with our token.
*/
const dns = require('dns').promises;
@ -29,7 +33,6 @@ const crypto = require('crypto');
const RECORD_PREFIX = '_screentinker-verify';
const TXT_PREFIX = 'st-verify=';
const CNAME_SUFFIX = '.verify.screentinker.com';
// A DNS answer that never arrives must not hold an HTTP request open. The resolver's own retries
// sit under this, so it is a ceiling on the whole lookup rather than on one query.
@ -68,7 +71,6 @@ function instructions(domain, token) {
return {
record_name: recordName(domain),
txt_value: `${TXT_PREFIX}${token}`,
cname_value: `${token}${CNAME_SUFFIX}`,
};
}
@ -94,45 +96,33 @@ function withTimeout(promise, ms) {
async function check(domain, token) {
const name = recordName(domain);
const wantTxt = `${TXT_PREFIX}${token}`;
const wantCname = `${token}${CNAME_SUFFIX}`;
const results = await Promise.allSettled([
withTimeout(dns.resolveTxt(name), LOOKUP_TIMEOUT_MS),
withTimeout(dns.resolveCname(name), LOOKUP_TIMEOUT_MS),
]);
const [txtRes, cnameRes] = results;
if (txtRes.status === 'fulfilled') {
// resolveTxt returns arrays of string chunks — a long value is split, so join before comparing.
for (const chunks of txtRes.value) {
if (chunks.join('').trim() === wantTxt) return { ok: true, via: 'TXT' };
}
}
if (cnameRes.status === 'fulfilled') {
for (const target of cnameRes.value) {
// DNS names are case-insensitive and may or may not carry the root dot.
if (target.replace(/\.$/, '').toLowerCase() === wantCname.toLowerCase()) return { ok: true, via: 'CNAME' };
}
let records;
try {
records = await withTimeout(dns.resolveTxt(name), LOOKUP_TIMEOUT_MS);
} catch (e) {
// NXDOMAIN and "no such record" are the ORDINARY answers here — an admin checking before the
// record has propagated — so they are "not found yet", not an error to be alarmed by.
if (/timed out/i.test(e.message)) return { ok: false, error: 'the DNS lookup timed out — try again shortly' };
return { ok: false, error: `no ${RECORD_PREFIX} record found for ${domain} yet (DNS can take a few minutes)` };
}
// Nothing matched. Say which of the two failure shapes it is, because the fixes differ: a record
// that is absent needs publishing, a record that is present but wrong needs correcting.
const found = [];
if (txtRes.status === 'fulfilled') found.push(...txtRes.value.map((c) => `TXT ${c.join('')}`));
if (cnameRes.status === 'fulfilled') found.push(...cnameRes.value.map((c) => `CNAME ${c}`));
if (found.length) {
return { ok: false, error: `${name} exists but does not match. Found: ${found.join('; ')}` };
// resolveTxt returns arrays of string chunks — a value over 255 bytes is split, so join first.
for (const chunks of records) {
if (chunks.join('').trim() === wantTxt) return { ok: true, via: 'TXT' };
}
const timedOut = results.some((r) => r.status === 'rejected' && /timed out/i.test(r.reason && r.reason.message));
if (timedOut) return { ok: false, error: 'the DNS lookup timed out — try again shortly' };
// Present but wrong is a different problem from absent, and the fixes differ: one needs
// correcting, the other needs publishing. A wildcard record lands here too, which is right —
// it answers with its own value, and that is not a proof of anything.
if (records.length) {
const found = records.map((c) => c.join('')).join('; ');
return { ok: false, error: `${name} exists but does not match. Found: ${found}` };
}
return { ok: false, error: `no ${RECORD_PREFIX} record found for ${domain} yet (DNS can take a few minutes)` };
}
module.exports = {
check, instructions, newToken, recordName, isClaimExpired,
CLAIM_TTL_S, RECORD_PREFIX, TXT_PREFIX, CNAME_SUFFIX,
CLAIM_TTL_S, RECORD_PREFIX, TXT_PREFIX,
};

View file

@ -588,7 +588,9 @@ test('the DNS record is per-domain and per-claim, so an old record proves nothin
const two = domainVerify.instructions('acme.test', b);
assert.equal(one.record_name, '_screentinker-verify.acme.test');
assert.notEqual(one.txt_value, two.txt_value, 'reissuing changes what must be published');
assert.notEqual(one.cname_value, two.cname_value);
// TXT is the only accepted form: a CNAME alternative would need a wildcard zone this project
// does not operate, so offering one would document a check that could never pass.
assert.equal(one.cname_value, undefined, 'no CNAME form is advertised');
// The record lives at a dedicated name, never the apex, where it would sit beside SPF and DMARC.
assert.ok(!domainVerify.instructions('acme.test', a).record_name.startsWith('acme.test'));
});