diff --git a/api/ssrApollo.js b/api/ssrApollo.js index a66bcde13..ed64f2bc1 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -183,7 +183,9 @@ export function getGetServerSideProps ( } if (authRequired && !me) { - let callback = process.env.NEXT_PUBLIC_URL + req.url + // if we're on a custom domain, use the domain header instead of the main domain + const origin = domain ? req.headers['x-stacker-news-domain'] : process.env.NEXT_PUBLIC_URL + let callback = origin + req.url // On client-side routing, the callback is a NextJS URL // so we need to remove the NextJS stuff. // Example: /_next/data/development/territory.json diff --git a/components/account.js b/components/account.js index 308a12d63..e5008031b 100644 --- a/components/account.js +++ b/components/account.js @@ -7,6 +7,7 @@ import useCookie from '@/components/use-cookie' import Link from 'next/link' import AddIcon from '@/svgs/add-fill.svg' import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_LIST, MULTI_AUTH_POINTER } from '@/lib/auth' +import { useDomain } from '@/components/territory-domains' const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8') @@ -94,6 +95,11 @@ const AccountListRow = ({ account, selected, ...props }) => { export const useIsLurker = () => { const accounts = useAccounts() + const { domain } = useDomain() + + // TODO, signup button for lurkers conflicts with one-click-login path + if (domain) return false + return accounts.length === 0 } diff --git a/components/login-button.js b/components/login-button.js index 8eab5a082..9ce080f99 100644 --- a/components/login-button.js +++ b/components/login-button.js @@ -3,6 +3,14 @@ import TwitterIcon from '@/svgs/twitter-fill.svg' import LightningIcon from '@/svgs/bolt.svg' import NostrIcon from '@/svgs/nostr.svg' import Button from 'react-bootstrap/Button' +import useCookie from './use-cookie' +import { cookieOptions, MULTI_AUTH_POINTER } from '@/lib/auth' +import { useAccounts } from './account' +import SNIcon from '@/svgs/sn.svg' +import { ButtonGroup, Dropdown } from 'react-bootstrap' +import styles from '@/lib/lexical/theme/editor.module.css' +import ArrowDownIcon from '@/svgs/editor/toolbar/arrow-down.svg' +import classNames from 'classnames' export default function LoginButton ({ text, type, className, onClick, disabled }) { let Icon, variant @@ -38,3 +46,56 @@ export default function LoginButton ({ text, type, className, onClick, disabled ) } + +// TODO: it's maybe better to select an account and give it to the sync endpoint instead of switching accounts here +export function LoginWithNymButton ({ className, onClick, disabled }) { + const accounts = useAccounts() + const [pointerCookie, setPointerCookie] = useCookie(MULTI_AUTH_POINTER) + + const account = accounts.find(account => account.id === Number(pointerCookie)) + if (!account) return null + + const title = `Log in with @${account?.name}` + + const mainButton = ( + + ) + + if (accounts.length === 1) return mainButton + + return ( + + {mainButton} + { e.preventDefault(); e.stopPropagation() }} + title='select account' + style={{ maxWidth: '42px' }} + > + + + + {accounts.map(account => ( + setPointerCookie(account.id, cookieOptions({ httpOnly: false }))} + className={classNames(styles.dropdownExtraItem, Number(account.id) === Number(pointerCookie) && styles.active)} + > + {account.name} + + ))} + + + ) +} diff --git a/components/login.js b/components/login.js index fe01a7f36..15bf48dea 100644 --- a/components/login.js +++ b/components/login.js @@ -6,13 +6,14 @@ import Alert from 'react-bootstrap/Alert' import { useRouter } from 'next/router' import { LightningAuthWithExplainer } from './lightning-auth' import { NostrAuthWithExplainer } from './nostr-auth' -import LoginButton from './login-button' +import LoginButton, { LoginWithNymButton } from './login-button' import { emailSchema } from '@/lib/validate' import { OverlayTrigger, Tooltip } from 'react-bootstrap' import { datePivot } from '@/lib/time' import * as cookie from 'cookie' -import { cookieOptions } from '@/lib/auth' +import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_POINTER } from '@/lib/auth' import Link from 'next/link' +import useCookie from './use-cookie' export function EmailLoginForm ({ text, callbackUrl, multiAuth }) { const disabled = multiAuth @@ -73,9 +74,18 @@ export function authErrorMessage (error, signin) { const multiAuthProviders = ['Lightning', 'Nostr'] -export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer, signin }) { +export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer, signin, syncSignup, domainData }) { const [errorMessage, setErrorMessage] = useState(authErrorMessage(error, signin)) const router = useRouter() + const [, setPointerCookie] = useCookie(MULTI_AUTH_POINTER) + + // we can't signup if we're already logged in to another account + // for signups with auth sync, we first need to switch to anon. + useEffect(() => { + if (syncSignup) { + setPointerCookie(MULTI_AUTH_ANON, cookieOptions({ httpOnly: false })) + } + }, [syncSignup, setPointerCookie]) multiAuth = typeof multiAuth === 'string' ? multiAuth === 'true' : !!multiAuth @@ -116,6 +126,21 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text, dismissible >{errorMessage} } + {/** custom domain auth sync button */} + {domainData && ( + { + // 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))