{
+ // TODO: unify with nextauth custom redirect
+ const redirectUri = callbackUrl?.startsWith('http')
+ ? new URL(callbackUrl).pathname
+ : callbackUrl || '/'
+ // port is only present in local dev; in prod domainData.port is null and we send the bare host.
+ const domain = domainData.port ? `${domainData.domainName}:${domainData.port}` : domainData.domainName
+ router.push({ pathname: '/api/auth/sync', query: { domain, redirectUri } })
+ }}
+ />
+ )}
{sortedProviders.map(provider => {
switch (provider.name) {
case 'Email':
diff --git a/components/nav/common.js b/components/nav/common.js
index 1864006f5..965b413bc 100644
--- a/components/nav/common.js
+++ b/components/nav/common.js
@@ -23,6 +23,7 @@ import SwitchAccountList, { nextAccount, useAccounts, useIsLurker } from '@/comp
import { useShowModal } from '@/components/modal'
import { ObstacleButtons } from '@/components/obstacle'
import { numWithUnits } from '@/lib/format'
+import { useDomain } from '@/components/territory-domains'
export function Brand ({ className }) {
return (
@@ -258,6 +259,7 @@ export default function LoginButton () {
function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const router = useRouter()
+ const { domain } = useDomain()
const handleLogout = async () => {
const next = await nextAccount()
@@ -275,7 +277,9 @@ function LogoutObstacle ({ onClose }) {
await togglePushSubscription().catch(console.error)
}
- await signOut({ callbackUrl: '/' })
+ onClose()
+ await signOut({ callbackUrl: '/', redirect: !domain })
+ domain && router.push('/')
}
return (
diff --git a/docs/dev/custom-domains.md b/docs/dev/custom-domains.md
index c2e85027d..b27b816f1 100644
--- a/docs/dev/custom-domains.md
+++ b/docs/dev/custom-domains.md
@@ -160,6 +160,19 @@ Every midnight, the `clearLongHeldDomains` job gets executed to remove domains t
A domain removal also means the certificate removal, which triggers **Ask ACM to delete certificate**.
+### Active Domain DNS Drift Check
+A pgboss cron `checkActiveDomainsDNS` runs every 5 minutes (`*/5 * * * *`) and, for each `ACTIVE` domain:
+- re-resolves the stored `CNAME` `DomainVerificationRecord` against live DNS via the same `verifyDNSRecord` helper used during initial verification
+- on a clean drift (record present but mismatched), flips the domain to `HOLD`
+- on a temporary resolver error (i.e. timeout), logs and skips
+
+Switching to `HOLD` cascades into:
+1. **Bump token version** — a db trigger on `Domain` increments `tokenVersion` whenever the domain switches from or to `ACTIVE`. [see token revocation via `tokenVersion`](#token-revocation-via-tokenversion).
+2. **Delete cert + verification records**
+3. **Ask ACM to delete certificate** — chained from the cert deletion
+
+The territory owner can re-verify and the domain returns to `ACTIVE`, but with a higher `tokenVersion` than any token issued before the drift.
+
### Update `DomainVerificationRecord` status
The `DomainVerification` job logs every step into `DomainVerificationAttempt`, when it comes to steps that involves DNS records like the `CNAME` record or ACM validation records, a connection between `DomainVerificationAttempt` and `DomainVerificationRecord` gets established.
@@ -180,3 +193,91 @@ Whenever a domain or domain certificate gets deleted, we run a job called `delet
It detaches the ACM certificate from our ALB listener and then deletes the ACM certificate from ACM.
It's a necessary step to ensure that we don't waste AWS resources and also provide safety regarding the custom domain access to Stacker News.
+
+# Auth Sync
+
+Cross-domain JWT authentication is a complex issue due to browser security restrictions, mainly because cookies:
+- are bound to specific domains
+
+and
+
+- can't be set for another domain
+- -- `stacker.news` <- cookie -> `pizza.com` 🚫
+
+Instead of fighting these restrictions, Auth Sync works with them by creating a whole new session:
+- user visits `pizza.com/login`
+- middleware redirects to auth sync **on the main domain** accessing that domain cookies
+- -- `https://stacker.news/api/auth/sync?domain=pizza.com&redirectUri=/items/212142`
+- checks if pizza.com is an **allowed domain**
+- checks if there's a session
+- -- if not: redirects to `stacker.news/login` with `/api/auth/sync` as callback to continue syncing
+- auth sync creates a short-lived verification token and redirects back to the custom domain with the `token` parameter
+- -- `https://pizza.com/?token=42424242&redirectUri=/items/212142`
+- middleware exchanges this token for a session, **setting the session cookie** on pizza.com
+- -- `POST: https://stacker.news/api/auth/sync; token: 42424242`
+
+
+The verification token is a one-time code that dies in **5 minutes** and has **256 bits** of entropy. The JWT is then generated server-side and applied to the final middleware response.
+
+### Token revocation via `domainId` + `tokenVersion`
+
+JWTs are stateless, so once a session cookie has been set on `pizza.com` we cannot un-issue it: the cookie remains valid in every browser that ever signed in until it expires (30 days by default). That is a problem the moment we suspect the domain itself is no longer trustworthy.
+
+Every custom-domain JWT carries two claims that together make it revocable without abandoning the JWT model:
+
+- **`domainId`** — the primary key of the `Domain` row the token was minted against. Pins the JWT to a specific *row lifetime*. If the row is deleted and recreated (owner removes and re-adds the domain, takeover, etc.), the replacement row has a fresh autoincrement `id` that no pre-existing JWT can reference.
+- **`domainVersion`** — this is the value of `Domain.tokenVersion` when the JWT was created. If the domain leaves and later returns to `ACTIVE`, `tokenVersion` increases. Old JWTs with a different version become invalid.
+
+A `BEFORE UPDATE` trigger on `Domain` (`bump_domain_token_version`) increments `tokenVersion` on **any transition to/from `ACTIVE`**. The trigger alone can't help across row lifetimes, which is exactly why `domainId` exists.
+
+##### Where `domainId` and `tokenVersion` are read
+
+Two sides read these, with different consistency requirements:
+
+- **Mint side** — `createEphemeralSessionToken` in [pages/api/auth/sync.js](../../pages/api/auth/sync.js) reads the row **directly from the DB** (uncached) and snapshots both `id` and `tokenVersion` into the JWT. Since the minted cookie lives for up to 30 days, any staleness here could mint a token against an outdated row identity or revoked reign.
+- **Verify side** — the next-auth `jwt` callback reads through `getDomainMapping`, which goes through `domainsMappingsCache` (same cache the proxy uses). This runs on every custom-domain request, so hitting the DB here would be expensive. Bounded staleness is acceptable because the mint side already guarantees that no *new* tokens can be minted with the old identity — the stale window only delays the rejection of pre-existing tokens.
+
+##### Enforcement
+
+The check happens once per request, in [pages/api/auth/[...nextauth].js](../../pages/api/auth/[...nextauth].js)'s `jwt` callback, after the existing same-domain check:
+
+```js
+if (token?.domainName) {
+ // ... same-domain check ...
+
+ const mapping = await getDomainMapping(token.domainName)
+ if (!mapping) return null // domain is not ACTIVE right now
+ if (mapping.id !== token.domainId) return null // row was deleted and recreated
+ if (mapping.tokenVersion !== token.domainVersion) return null // ACTIVE reign has changed
+}
+```
+
+`getDomainMapping` reads from `domainsMappingsCache` (the same cache the proxy uses). Both SSR (`getServerSession`) and `/api/graphql` go through `getAuthOptions` -> this callback.
+
+##### Why all three checks?
+
+They cover different failure modes:
+- `!mapping` — the domain is not `ACTIVE` **right now** (on HOLD, deleted, unknown).
+- `mapping.id !== token.domainId` — the row was deleted and recreated since the token was minted. A fresh row always has a strictly greater autoincrement `id`, so old tokens can never match the new row regardless of what `tokenVersion` happens to land on.
+- `mapping.tokenVersion !== token.domainVersion` — the domain has crossed the `ACTIVE` boundary at least once since the token was minted, within the same row lifetime.
+
+##### an attack scenario, prevented
+
+Two variants worth walking through, since they exercise different parts of the defense.
+
+**Variant A — DNS drift within a single row lifetime** (caught by `tokenVersion`):
+
+1. `pizza.com` is `ACTIVE` with `tokenVersion=3`. Alice signs in and gets a JWT carrying `{ domainName: 'pizza.com', domainId: 42, domainVersion: 3 }`.
+2. The attacker hijacks DNS for `pizza.com` and exfiltrates her cookie.
+3. Within ~5 minutes, `checkActiveDomainsDNS` notices the CNAME no longer matches and switches the domain to `HOLD`. The `ACTIVE -> HOLD` trigger bumps `tokenVersion` to `4`, and the on-HOLD trigger deletes the certificate and verification records.
+4. Next request from Alice's browser **or** the attacker's stolen cookie, once the verifier's cache refreshes past the bump: `!mapping` is true -> the request is rejected and the user is `anon`.
+5. The territory owner notices, fixes DNS, re-verifies. The domain goes back through `PENDING` and the `PENDING -> ACTIVE` trigger bumps `tokenVersion` again, to `5`.
+6. The domain is `ACTIVE` again, so `!mapping` passes and `domainId` still matches (the row was updated, not recreated). **But** the cached `tokenVersion` is `5` while the JWT snapshots `3`, so the version check rejects them: `5 !== 3` -> both have to sign in again.
+
+**Variant B — owner removes and re-adds the domain** (caught by `domainId`):
+
+1. `pizza.com` is `ACTIVE`, row `id=42`, `tokenVersion=1`. Alice signs in and gets a JWT carrying `{ domainName: 'pizza.com', domainId: 42, domainVersion: 1 }`. The attacker steals her cookie and keeps it warm (actively replaying so it gets re-encoded with the default 30-day session maxAge).
+2. The owner calls `setDomain(subName, null)`, which hard-deletes row `id=42` (cascading into cert cleanup). Alice's and the attacker's cookies start failing the `!mapping` check.
+3. Weeks later, the owner re-adds `pizza.com`. A fresh row is created, `id=43`, `tokenVersion` defaulted to `0`.
+4. Verification succeeds, the `PENDING -> ACTIVE` trigger bumps `tokenVersion` to `1`.
+5. The attacker tries their stolen cookie again. The domain is `ACTIVE` (so `!mapping` passes) and the new `tokenVersion=1` happens to collide with the stolen JWT's `domainVersion=1`. Without `domainId`, **this would resurrect the stolen token**. With `domainId` in place: `mapping.id` is `43`, the JWT claims `42`, `43 !== 42` -> rejected.
diff --git a/lib/domains.js b/lib/domains.js
index d0bb9f602..4ca9bdb11 100644
--- a/lib/domains.js
+++ b/lib/domains.js
@@ -13,8 +13,10 @@ export const domainsMappingsCache = cachedFetcher(async function fetchDomainsMap
try {
const domains = await prisma.domain.findMany({
select: {
+ id: true, // pins JWTs to a specific Domain row across delete/recreate cycles
domainName: true,
- subName: true
+ subName: true,
+ tokenVersion: true // jwt revocability within a single row lifetime
},
where: {
status: 'ACTIVE'
@@ -24,7 +26,7 @@ export const domainsMappingsCache = cachedFetcher(async function fetchDomainsMap
if (!domains.length) return null
return domains.reduce((acc, domain) => {
- acc[domain.domainName.toLowerCase()] = { domainName: domain.domainName, subName: domain.subName }
+ acc[domain.domainName.toLowerCase()] = domain
return acc
}, {})
} catch (error) {
diff --git a/lib/url.js b/lib/url.js
index 9b1c7c8f7..feae3a3c1 100644
--- a/lib/url.js
+++ b/lib/url.js
@@ -34,6 +34,21 @@ export function isHashLink (url) {
return url?.startsWith('#')
}
+// validates that a redirect URI is a same-origin path and cannot escape to
+// another origin via protocol-relative URLs ("//evil.com")
+export function isSafeRedirectPath (uri) {
+ if (typeof uri !== 'string' || uri.length === 0) return false
+ if (uri[0] !== '/') return false
+ if (uri[1] === '/' || uri[1] === '\\') return false
+ try {
+ // arbitrarily resolve against the main domain. if the origin changes, it's unsafe
+ const base = process.env.NEXT_PUBLIC_URL
+ return new URL(uri, base).origin === base
+ } catch {
+ return false
+ }
+}
+
export function isInternalLink (url) {
return isHashLink(url) || !isExternal(url)
}
diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js
index 3544f3558..cd6326505 100644
--- a/pages/api/auth/[...nextauth].js
+++ b/pages/api/auth/[...nextauth].js
@@ -12,6 +12,7 @@ import { schnorr } from '@noble/curves/secp256k1'
import { notifyReferral } from '@/lib/webPush'
import { hashEmail } from '@/lib/crypto'
import { multiAuthMiddleware, setMultiAuthCookies } from '@/lib/auth'
+import { getDomainMapping, normalizeDomain } from '@/lib/domains'
import { BECH32_CHARSET } from '@/lib/constants'
import { NodeNextRequest } from 'next/dist/server/base-http/node'
import * as cookie from 'cookie'
@@ -119,6 +120,27 @@ function getCallbacks (req, res) {
}
}
+ // custom domain session token validation
+ if (token?.domainName) {
+ try {
+ const { domainName: currentDomain } = normalizeDomain(req.headers.host)
+ // tokens created for a custom domain should only be used on that domain
+ if (currentDomain !== token.domainName) return null
+
+ const mapping = await getDomainMapping(token.domainName)
+ // token is valid only if:
+ // - the domain is still ACTIVE (mapping exists)
+ // - it's the same Domain row the token was minted against (domainId match)
+ // - ACTIVE status hasn't changed since the token was minted (tokenVersion match)
+ if (!mapping) return null
+ if (mapping.id !== token.domainId) return null
+ if (mapping.tokenVersion !== token.domainVersion) return null
+ } catch (error) {
+ console.error('cannot verify domain', error)
+ return null
+ }
+ }
+
if (token?.id) {
// HACK token.sub is used by nextjs v4 internally and is used like a userId
// setting it here allows us to link multiple auth method to an account
@@ -142,6 +164,30 @@ function getCallbacks (req, res) {
session.user.id = token.id
return session
+ },
+ // allow absolute callback URLs that point at an active custom domain.
+ async redirect ({ url, baseUrl }) {
+ if (url.startsWith('/')) return `${baseUrl}${url}`
+
+ try {
+ const parsed = new URL(url)
+ if (parsed.origin === baseUrl) return url
+
+ // redirect to the auth sync endpoint if on custom domain
+ // TODO: handle multi auth
+ const { domainName, domainPort } = normalizeDomain(parsed.host)
+ const mapping = await getDomainMapping(domainName)
+ if (mapping) {
+ const syncUrl = new URL('/api/auth/sync', baseUrl)
+ syncUrl.searchParams.set('domain', domainPort ? `${domainName}:${domainPort}` : domainName)
+ syncUrl.searchParams.set('redirectUri', (parsed.pathname || '/') + parsed.search + parsed.hash)
+ return syncUrl.href
+ }
+ } catch (error) {
+ console.error('[nextauth redirect] invalid callback URL', url, error)
+ }
+
+ return baseUrl
}
}
}
diff --git a/pages/api/auth/redirect.js b/pages/api/auth/redirect.js
new file mode 100644
index 000000000..e2ce4aa69
--- /dev/null
+++ b/pages/api/auth/redirect.js
@@ -0,0 +1,18 @@
+import { SN_MAIN_DOMAIN } from '@/lib/domains'
+
+// TODO: experimental, middleware proxy can't redirect to absolute MAIN DOMAIN URLs in local dev
+export default async function handler (req, res) {
+ const { domain, signup, callbackUrl } = req.query
+ if (!domain) {
+ return res.status(400).json({ status: 'ERROR', reason: 'domain is required' })
+ }
+
+ const redirectPath = signup ? '/signup' : '/login'
+ const redirectUrl = new URL(redirectPath, SN_MAIN_DOMAIN)
+ redirectUrl.searchParams.set('domain', domain)
+ if (callbackUrl) {
+ redirectUrl.searchParams.set('callbackUrl', callbackUrl)
+ }
+
+ res.redirect(302, redirectUrl.href)
+}
diff --git a/pages/api/auth/sync.js b/pages/api/auth/sync.js
new file mode 100644
index 000000000..859fc5e7c
--- /dev/null
+++ b/pages/api/auth/sync.js
@@ -0,0 +1,175 @@
+import models from '@/api/models'
+import { randomBytes } from 'node:crypto'
+import { encode as encodeJWT, getToken } from 'next-auth/jwt'
+import { validateSchema, customDomainSchema } from '@/lib/validate'
+import { SN_MAIN_DOMAIN, normalizeDomain } from '@/lib/domains'
+import { isSafeRedirectPath } from '@/lib/url'
+
+const SYNC_TOKEN_MAX_AGE = 60 * 5 // 5 minutes
+const VERIFICATION_TOKEN_EXPIRY = 1000 * 60 * 5 // 5 minutes in milliseconds
+
+export default async function handler (req, res) {
+ try {
+ if (req.method === 'POST') {
+ const { verificationToken, domainName } = req.body
+ if (!verificationToken || !domainName) {
+ return res.status(400).json({ status: 'ERROR', reason: 'verification token and domain name are required' })
+ }
+
+ const verificationResult = await consumeVerificationToken(verificationToken)
+ if (verificationResult.status === 'ERROR') {
+ return res.status(400).json(verificationResult)
+ }
+
+ const sessionTokenResult = await createEphemeralSessionToken(domainName, verificationResult.userId)
+ if (sessionTokenResult.status === 'ERROR') {
+ return res.status(500).json(sessionTokenResult)
+ }
+
+ return res.status(200).json({ status: 'OK', sessionToken: sessionTokenResult.sessionToken })
+ }
+
+ if (req.method === 'GET') {
+ const { domain, redirectUri = '/', signup } = req.query
+ if (!domain || !isSafeRedirectPath(redirectUri)) {
+ return res.status(400).json({ status: 'ERROR', reason: 'domain and a correct redirectUri are required' })
+ }
+
+ const domainValidation = await checkDomainValidity(domain)
+ if (domainValidation.status === 'ERROR') {
+ return res.status(400).json(domainValidation)
+ }
+
+ if (signup) {
+ return handleNoSession(res, domain, redirectUri, signup)
+ }
+
+ const sessionToken = await getToken({ req })
+ if (!sessionToken) {
+ return handleNoSession(res, domain, redirectUri)
+ }
+
+ const newVerificationToken = await createVerificationToken(sessionToken)
+ if (newVerificationToken.status === 'ERROR') {
+ return res.status(500).json(newVerificationToken)
+ }
+
+ return redirectToDomain(res, domain, newVerificationToken.token, redirectUri)
+ }
+ } catch (error) {
+ return res.status(500).json({ status: 'ERROR', reason: 'auth sync broke its legs' })
+ }
+}
+
+async function checkDomainValidity (receivedDomain) {
+ // the received domain can carry a port in local dev (e.g. pizza.com:3000);
+ // the Domain row stores bare hostnames, so we always normalize before lookup.
+ const { domainName } = normalizeDomain(receivedDomain)
+
+ try {
+ await validateSchema(customDomainSchema, { domainName })
+ const domain = await models.domain.findUnique({
+ where: { domainName, status: 'ACTIVE' }
+ })
+
+ if (!domain) {
+ return { status: 'ERROR', reason: 'domain not allowed' }
+ }
+
+ return { status: 'OK' }
+ } catch (error) {
+ console.error('[auth sync] domain is not valid', error)
+ return { status: 'ERROR', reason: 'domain is not valid' }
+ }
+}
+
+function handleNoSession (res, domainName, redirectUri, signup = false) {
+ const syncUrl = new URL('/api/auth/sync', SN_MAIN_DOMAIN)
+ syncUrl.searchParams.set('domain', domainName)
+ syncUrl.searchParams.set('redirectUri', redirectUri)
+
+ const loginRedirectUrl = new URL(signup ? '/signup' : '/login', SN_MAIN_DOMAIN)
+ if (signup) loginRedirectUrl.searchParams.set('syncSignup', 'true')
+ loginRedirectUrl.searchParams.set('callbackUrl', syncUrl.href)
+
+ res.redirect(302, loginRedirectUrl.href)
+}
+
+async function createVerificationToken (token) {
+ try {
+ const verificationToken = await models.verificationToken.create({
+ data: {
+ identifier: token.id.toString(),
+ token: randomBytes(32).toString('hex'),
+ expires: new Date(Date.now() + VERIFICATION_TOKEN_EXPIRY)
+ }
+ })
+ return { status: 'OK', token: verificationToken.token }
+ } catch (error) {
+ return { status: 'ERROR', reason: 'failed to create verification token' }
+ }
+}
+
+async function redirectToDomain (res, domainName, verificationToken, redirectUri) {
+ try {
+ const protocol = process.env.NODE_ENV === 'development' ? 'http' : 'https'
+ const target = new URL(`${protocol}://${domainName}`)
+
+ target.searchParams.set('sync_token', verificationToken)
+ target.searchParams.set('redirectUri', redirectUri)
+
+ res.redirect(302, target.href)
+ } catch (error) {
+ return { status: 'ERROR', reason: 'cannot construct the URL' }
+ }
+}
+
+async function consumeVerificationToken (verificationToken) {
+ try {
+ const identifier = await models.$transaction(async tx => {
+ const token = await tx.verificationToken.findFirst({
+ where: {
+ token: verificationToken,
+ expires: { gt: new Date() }
+ }
+ })
+ if (!token) throw new Error('invalid verification token')
+
+ await tx.verificationToken.delete({ where: { id: token.id } })
+
+ return token.identifier
+ })
+
+ return { status: 'OK', userId: Number(identifier) }
+ } catch (error) {
+ return { status: 'ERROR', reason: 'cannot validate verification token' }
+ }
+}
+
+async function createEphemeralSessionToken (domainName, userId) {
+ try {
+ const domain = await models.domain.findUnique({
+ where: { domainName, status: 'ACTIVE' },
+ select: { id: true, tokenVersion: true }
+ })
+ if (!domain) {
+ return { status: 'ERROR', reason: 'domain is no longer active' }
+ }
+
+ const sessionToken = await encodeJWT({
+ token: {
+ id: userId,
+ sub: userId,
+ domainName,
+ domainId: domain.id,
+ domainVersion: domain.tokenVersion
+ },
+ secret: process.env.NEXTAUTH_SECRET,
+ maxAge: SYNC_TOKEN_MAX_AGE
+ })
+
+ return { status: 'OK', sessionToken }
+ } catch (error) {
+ return { status: 'ERROR', reason: 'failed to create ephemeral session token' }
+ }
+}
diff --git a/pages/login.js b/pages/login.js
index fced474d3..04e04d072 100644
--- a/pages/login.js
+++ b/pages/login.js
@@ -6,33 +6,47 @@ import { StaticLayout } from '@/components/layout'
import Login from '@/components/login'
import { isExternal } from '@/lib/url'
import { MULTI_AUTH_ANON, MULTI_AUTH_POINTER } from '@/lib/auth'
+import { getDomainMapping, normalizeDomain } from '@/lib/domains'
-export async function getServerSideProps ({ req, res, query: { callbackUrl, multiAuth = false, error = null } }) {
+export async function getServerSideProps ({ req, res, query: { callbackUrl, multiAuth = false, syncSignup = null, domain = null, error = null } }) {
let session = await getServerSession(req, res, getAuthOptions(req))
// required to prevent infinite redirect loops if we switch to anon
// but are on a page that would redirect us to /signup.
// without this code, /signup would redirect us back to the callbackUrl.
- if (req.cookies[MULTI_AUTH_POINTER] === MULTI_AUTH_ANON) {
+ if (req.cookies[MULTI_AUTH_POINTER] === MULTI_AUTH_ANON || domain) {
session = null
}
+ // the ?domain= query param carries the custom domain's host as-is (with its port in local dev).
+ // we pass the port alongside the mapping so the client can redirect back through /api/auth/sync
+ const mapping = domain ? await getDomainMapping(domain) : null
+ const { domainPort } = domain ? normalizeDomain(domain) : { domainPort: null }
+ const domainData = mapping ? { ...mapping, port: domainPort } : null
+
// prevent open redirects. See https://github.com/stackernews/stacker.news/issues/264
// let undefined urls through without redirect ... otherwise this interferes with multiple auth linking
let external = true
+ let callbackHost = null
try {
- external = isExternal(decodeURIComponent(callbackUrl))
+ const decoded = decodeURIComponent(callbackUrl)
+ external = isExternal(decoded)
+ if (external) callbackHost = new URL(decoded).host
} catch (err) {
console.error('error decoding callback:', callbackUrl, err)
}
- if (external) {
+ // external callbackUrls are only allowed when they point at the custom domain
+ // we're syncing against (domainData). anything else is reset to avoid open redirects.
+ const matchesDomain = callbackHost && domainData &&
+ normalizeDomain(callbackHost).domainName === domainData.domainName
+ if (external && !matchesDomain) {
callbackUrl = '/'
}
- if (session && callbackUrl && !multiAuth) {
+ if (session && callbackUrl && !multiAuth && !syncSignup) {
// in the case of auth linking we want to pass the error back to settings
- // in the case of multi auth, don't redirect if there is already a session
+ // in the case of multi auth or auth sync signup, don't redirect if there is already a session
if (error) {
const url = new URL(callbackUrl, process.env.NEXT_PUBLIC_URL)
url.searchParams.set('error', error)
@@ -54,7 +68,9 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, mult
providers,
callbackUrl,
error,
- multiAuth
+ multiAuth,
+ syncSignup,
+ domainData
}
}
}
@@ -65,11 +81,11 @@ function LoginFooter ({ callbackUrl }) {
)
}
-function LoginHeader () {
+function LoginHeader ({ domainData }) {
return (
<>
- Log in
+ Log in {domainData && ` to ~${domainData.subName}`}
Nothing wrestles up a smile like a familiar face.
>
@@ -93,7 +109,7 @@ export default function LoginPage ({ multiAuth, ...props }) {
}
- Header={multiAuthBool ? () => : () => }
+ Header={multiAuthBool ? () => : () => }
text='Log in'
signin
multiAuth={multiAuth}
diff --git a/prisma/migrations/20260415170000_custom_domains_auth/migration.sql b/prisma/migrations/20260415170000_custom_domains_auth/migration.sql
new file mode 100644
index 000000000..e8d7e1151
--- /dev/null
+++ b/prisma/migrations/20260415170000_custom_domains_auth/migration.sql
@@ -0,0 +1,37 @@
+ALTER TABLE "Domain"
+ADD COLUMN "tokenVersion" INTEGER NOT NULL DEFAULT 0;
+
+-- bump tokenVersion on any domain state transition
+CREATE OR REPLACE FUNCTION bump_domain_token_version()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW."tokenVersion" = OLD."tokenVersion" + 1;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_bump_domain_token_version
+BEFORE UPDATE ON "Domain"
+FOR EACH ROW
+WHEN (
+ (NEW.status = 'ACTIVE' AND OLD.status IS DISTINCT FROM 'ACTIVE')
+ OR (OLD.status = 'ACTIVE' AND NEW.status IS DISTINCT FROM 'ACTIVE')
+)
+EXECUTE FUNCTION bump_domain_token_version();
+
+-- periodic DNS drift check for ACTIVE domains, every 5 minutes
+CREATE OR REPLACE FUNCTION schedule_check_active_domains_dns()
+RETURNS INTEGER
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ INSERT INTO pgboss.schedule (name, cron, timezone)
+ VALUES ('checkActiveDomainsDNS', '*/5 * * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
+ return 0;
+EXCEPTION WHEN OTHERS THEN
+ return 0;
+END;
+$$;
+
+SELECT schedule_check_active_domains_dns();
+DROP FUNCTION IF EXISTS schedule_check_active_domains_dns;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 939a39a5e..28ff55fe6 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -917,6 +917,7 @@ model Domain {
domainName String @unique @db.Citext
subName String @unique @db.Citext
status DomainStatus @default(PENDING)
+ tokenVersion Int @default(0)
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
attempts DomainVerificationAttempt[]
diff --git a/proxy.js b/proxy.js
index 4119c4492..39b40193d 100644
--- a/proxy.js
+++ b/proxy.js
@@ -1,6 +1,8 @@
import 'urlpattern-polyfill'
import { NextRequest, NextResponse } from 'next/server'
-import { getDomainMapping, createDomainsDebugLogger } from '@/lib/domains'
+import { SESSION_COOKIE, cookieOptions } from '@/lib/auth'
+import { getDomainMapping, createDomainsDebugLogger, SN_MAIN_DOMAIN, normalizeDomain } from '@/lib/domains'
+import { isSafeRedirectPath } from '@/lib/url'
const referrerPattern = new URLPattern({ pathname: ':pathname(*)/r/:referrer([\\w_]+)' })
const itemPattern = new URLPattern({ pathname: '/items/:id(\\d+){/:other(\\w+)}?' })
@@ -35,6 +37,13 @@ async function customDomainMiddleware (request, domain, subName) {
// log the original request path
const from = `${pathname}${url.search}`
+ // Auth Sync
+ if (pathname.startsWith('/login') || pathname.startsWith('/signup')) {
+ const signup = pathname.startsWith('/signup')
+ return redirectToAuth(searchParams, domain, signup)
+ }
+ if (searchParams.has('sync_token')) return syncAccount(request, searchParams, domain, reqHeaders)
+
// clean up the pathname from any subname
if (pathname.startsWith('/~')) {
url.pathname = pathname.replace(/^\/~[^/]+/, '') || '/'
@@ -61,6 +70,58 @@ async function customDomainMiddleware (request, domain, subName) {
return NextResponse.next({ request: { headers: reqHeaders } })
}
+async function redirectToAuth (searchParams, domain, signup) {
+ const loginUrl = new URL('/api/auth/redirect', SN_MAIN_DOMAIN)
+ loginUrl.searchParams.set('domain', domain)
+
+ if (signup) {
+ loginUrl.searchParams.set('signup', 'true')
+ }
+
+ if (searchParams.has('callbackUrl')) {
+ loginUrl.searchParams.set('callbackUrl', searchParams.get('callbackUrl'))
+ }
+
+ return NextResponse.redirect(loginUrl)
+}
+
+async function syncAccount (request, searchParams, domain, headers) {
+ const token = searchParams.get('sync_token')
+ const rawRedirectUri = searchParams.get('redirectUri')
+ const redirectUri = isSafeRedirectPath(rawRedirectUri) ? rawRedirectUri : '/'
+ const res = NextResponse.redirect(new URL(redirectUri, request.url))
+
+ const { domainName } = normalizeDomain(domain)
+
+ try {
+ const body = JSON.stringify({ verificationToken: token, domainName })
+ const fetchHeaders = new Headers(headers)
+ fetchHeaders.set('Content-Type', 'application/json')
+
+ const response = await fetch(`${SN_MAIN_DOMAIN.origin}/api/auth/sync`, {
+ method: 'POST',
+ headers: fetchHeaders,
+ body,
+ signal: AbortSignal.timeout(10000)
+ })
+
+ if (!response.ok) {
+ throw new Error(response.status)
+ }
+
+ const data = await response.json()
+ if (data.status === 'ERROR') {
+ throw new Error(data.reason)
+ }
+
+ res.cookies.set(SESSION_COOKIE, data.sessionToken, cookieOptions())
+ return res
+ } catch (error) {
+ console.error('[auth sync] cannot establish auth sync:', error.message)
+ return NextResponse.redirect(new URL('/error', request.url))
+ }
+}
+
function getContentReferrer (request, url) {
if (itemPattern.test(url)) {
let id = request.nextUrl.searchParams.get('commentId')
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 0cb825025..f69ceb71f 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -344,6 +344,64 @@ async function logAttempt ({ domain, models, record, stage, status, message }) {
})
}
+// Checks active domains for DNS drift. If record has drifted puts the domain on HOLD
+// a BEFORE UPDATE trigger on Domain bumps the tokenVersion,
+// retroactively revoking every JWT associated with the domain.
+export async function checkActiveDomainsDNS () {
+ const models = createPrisma({ connectionParams: { connection_limit: 1 } })
+ try {
+ const domains = await models.domain.findMany({
+ where: { status: 'ACTIVE' },
+ include: { records: { where: { type: 'CNAME' } } }
+ })
+
+ for (const domain of domains) {
+ const cname = domain.records[0]
+ if (!cname) continue
+
+ let drifted = false
+ let reason = null
+ try {
+ const result = await verifyDNSRecord('CNAME', cname.recordName, cname.recordValue)
+ if (!result.valid) {
+ drifted = true
+ reason = result.error?.message || 'CNAME record drifted'
+ }
+ } catch (error) {
+ // don't switch on temporary DNS errors
+ console.error(`[dns-drift] resolver error for ${domain.domainName}: ${error.message}`)
+ continue
+ }
+
+ if (!drifted) continue
+
+ console.log(`[dns-drift] ${domain.domainName} drifted (${reason}); switching to HOLD`)
+
+ // switching a domain to HOLD triggers:
+ // - bump_domain_token_version -> revokes every JWT associated with the domain (fires on any ACTIVE boundary crossing)
+ // - delete_certificate_and_verification_records_on_domain_hold -> deletes cert + records
+ // - ask_acm_to_delete_certificate -> asks ACM to delete the certificate
+ await models.domain.update({
+ where: { id: domain.id },
+ data: { status: 'HOLD' }
+ })
+
+ await logAttempt({
+ domain,
+ models,
+ stage: 'CNAME',
+ status: 'HOLD',
+ message: `DNS drift detected: ${reason}`
+ })
+ }
+ } catch (error) {
+ console.error(`[dns-drift] check failed: ${error.message}`)
+ throw error
+ } finally {
+ await models.$disconnect()
+ }
+}
+
// clear domains that have been on HOLD past the retention window
export async function clearLongHeldDomains () {
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
diff --git a/worker/index.js b/worker/index.js
index 5945b3713..d9d1e2472 100644
--- a/worker/index.js
+++ b/worker/index.js
@@ -42,6 +42,7 @@ import { postToSocial } from './socialPoster'
import {
domainVerification,
deleteCertificateExternal,
+ checkActiveDomainsDNS,
clearLongHeldDomains
} from './domainVerification.js'
import { untrackOldItems } from './untrackOldItems'
@@ -139,6 +140,7 @@ async function work () {
if (isServiceEnabled('domains')) {
await boss.work('domainVerification', jobWrapper(domainVerification))
await boss.work('deleteDomainCertificate', jobWrapper(deleteCertificateExternal))
+ await boss.work('checkActiveDomainsDNS', jobWrapper(checkActiveDomainsDNS))
await boss.work('clearLongHeldDomains', jobWrapper(clearLongHeldDomains))
}
await boss.work('weeklyPost-*', jobWrapper(weeklyPost))