mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-16 07:13:12 -06:00
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:
parent
d4b8d7dad4
commit
9155370ae8
12
README.md
12
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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('')}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue