diff --git a/package-lock.json b/package-lock.json index 3c59be3..6f300a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "v0.0.1", "license": "Apache-2.0", "dependencies": { - "@ory/client": ">=0.0.1-alpha.21", + "@ory/client": ">=0.0.1-alpha.49", "cookie": "^0.4.1", "istextorbinary": "^6.0.0", "next": ">=12.0.10", @@ -29,6 +29,7 @@ "@types/request": "^2.48.7", "@types/set-cookie-parser": "^2.4.1", "@types/supertest": "^2.0.11", + "@types/tldjs": "^2.3.1", "babel-jest": "^27.2.0", "esbuild": "^0.12.28", "express": "^4.17.1", @@ -37,6 +38,7 @@ "rollup-plugin-dts": "^4.0.0", "rollup-plugin-esbuild": "^4.5.0", "supertest": "^6.1.6", + "tldjs": "^2.3.1", "typescript": "^4.4.3" }, "peerDependencies": { @@ -2925,11 +2927,11 @@ } }, "node_modules/@ory/client": { - "version": "0.0.1-alpha.21", - "resolved": "https://registry.npmjs.org/@ory/client/-/client-0.0.1-alpha.21.tgz", - "integrity": "sha512-SqjjcZ4uV767AGLzOEnSVGe0SSGMubg1NL33NEqqDKA96Xr08UUg+RlbrKpncHi8hznYYHI9iwlRskHJA2/xkQ==", + "version": "0.0.1-alpha.105", + "resolved": "https://registry.npmjs.org/@ory/client/-/client-0.0.1-alpha.105.tgz", + "integrity": "sha512-VwpKQTk+gBfE60hC12qg9YsXkxZ0f9KIqdePqgKn6FDv8RayIlrDp/TpWMQG5fR/XvmEn6rgEEEVo52dp7AcOw==", "dependencies": { - "axios": "^0.21.1" + "axios": "^0.21.4" } }, "node_modules/@rollup/pluginutils": { @@ -3216,6 +3218,12 @@ "@types/superagent": "*" } }, + "node_modules/@types/tldjs": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@types/tldjs/-/tldjs-2.3.1.tgz", + "integrity": "sha512-BQR04zLE0ve2eNrqxXw/Qp/f6LxvNrj/4A8ZgdQi3SzbBqxFhleI7N4DS/mSjDnODrUaEGgoWg4grAZR1kVj8w==", + "dev": true + }, "node_modules/@types/tough-cookie": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.1.tgz", @@ -7922,6 +7930,12 @@ "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + }, "node_modules/qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", @@ -8778,6 +8792,19 @@ "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==", "dev": true }, + "node_modules/tldjs": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tldjs/-/tldjs-2.3.1.tgz", + "integrity": "sha512-W/YVH/QczLUxVjnQhFC61Iq232NWu3TqDdO0S/MtXVz4xybejBov4ud+CIwN9aYqjOecEqIy0PscGkwpG9ZyTw==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "punycode": "^1.4.1" + }, + "engines": { + "node": ">= 4" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -11347,11 +11374,11 @@ "optional": true }, "@ory/client": { - "version": "0.0.1-alpha.21", - "resolved": "https://registry.npmjs.org/@ory/client/-/client-0.0.1-alpha.21.tgz", - "integrity": "sha512-SqjjcZ4uV767AGLzOEnSVGe0SSGMubg1NL33NEqqDKA96Xr08UUg+RlbrKpncHi8hznYYHI9iwlRskHJA2/xkQ==", + "version": "0.0.1-alpha.105", + "resolved": "https://registry.npmjs.org/@ory/client/-/client-0.0.1-alpha.105.tgz", + "integrity": "sha512-VwpKQTk+gBfE60hC12qg9YsXkxZ0f9KIqdePqgKn6FDv8RayIlrDp/TpWMQG5fR/XvmEn6rgEEEVo52dp7AcOw==", "requires": { - "axios": "^0.21.1" + "axios": "^0.21.4" } }, "@rollup/pluginutils": { @@ -11631,6 +11658,12 @@ "@types/superagent": "*" } }, + "@types/tldjs": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@types/tldjs/-/tldjs-2.3.1.tgz", + "integrity": "sha512-BQR04zLE0ve2eNrqxXw/Qp/f6LxvNrj/4A8ZgdQi3SzbBqxFhleI7N4DS/mSjDnODrUaEGgoWg4grAZR1kVj8w==", + "dev": true + }, "@types/tough-cookie": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.1.tgz", @@ -15188,6 +15221,12 @@ "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + }, "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", @@ -15830,6 +15869,15 @@ "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==", "dev": true }, + "tldjs": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tldjs/-/tldjs-2.3.1.tgz", + "integrity": "sha512-W/YVH/QczLUxVjnQhFC61Iq232NWu3TqDdO0S/MtXVz4xybejBov4ud+CIwN9aYqjOecEqIy0PscGkwpG9ZyTw==", + "dev": true, + "requires": { + "punycode": "^1.4.1" + } + }, "tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index 8f9dc94..6fc4c12 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@types/request": "^2.48.7", "@types/set-cookie-parser": "^2.4.1", "@types/supertest": "^2.0.11", + "@types/tldjs": "^2.3.1", "babel-jest": "^27.2.0", "esbuild": "^0.12.28", "express": "^4.17.1", @@ -35,6 +36,7 @@ "rollup-plugin-dts": "^4.0.0", "rollup-plugin-esbuild": "^4.5.0", "supertest": "^6.1.6", + "tldjs": "^2.3.1", "typescript": "^4.4.3" }, "peerDependencies": { @@ -42,7 +44,7 @@ "next": ">=12.0.10" }, "dependencies": { - "@ory/client": ">=0.0.1-alpha.21", + "@ory/client": ">=0.0.1-alpha.49", "cookie": "^0.4.1", "istextorbinary": "^6.0.0", "next": ">=12.0.10", diff --git a/src/next-edge/index.test.ts b/src/next-edge/index.test.ts index 1ef8537..e5dc7d5 100644 --- a/src/next-edge/index.test.ts +++ b/src/next-edge/index.test.ts @@ -1,4 +1,8 @@ -import { createApiHandler, CreateApiHandlerOptions } from './index' +import { + createApiHandler, + CreateApiHandlerOptions, + guessCookieDomain +} from './index' import express from 'express' import { NextApiRequest, NextApiResponse } from 'next' import supertest from 'supertest' @@ -60,6 +64,7 @@ describe('NextJS handler', () => { .get( '/?paths=api&paths=kratos&paths=public&paths=self-service&paths=login&paths=browser' ) + .set('Host', 'www.example.org') .expect(303) .then((res) => { expect(res.headers['set-cookie']).toBeDefined() @@ -70,7 +75,7 @@ describe('NextJS handler', () => { ).toBeDefined() cookies.forEach(({ domain, secure }) => { - expect(domain).toBeUndefined() + expect(domain).toEqual('example.org') expect(secure).toBeFalsy() }) @@ -79,6 +84,29 @@ describe('NextJS handler', () => { .catch(done) }) + test('sets the appropriate cookie domain based on headers', (done) => { + app = createApp({ + apiBaseUrlOverride: 'https://playground.projects.oryapis.com' + }) + + supertest(app.app) + .get( + '/?paths=api&paths=kratos&paths=public&paths=self-service&paths=login&paths=browser' + ) + .set('Host', 'www.example.org') + .set('X-Forwarded-Host', 'www.example.bar') + .expect(303) + .then((res) => { + const cookies = parse(res.headers['set-cookie']) + cookies.forEach(({ domain, secure }) => { + expect(domain).toEqual('example.bar') + }) + + done() + }) + .catch(done) + }) + test('sets secure true if a TLS connection', (done) => { app = createApp({ apiBaseUrlOverride: 'https://playground.projects.oryapis.com' @@ -253,3 +281,73 @@ describe('NextJS handler', () => { ) }) }) + +describe('cookie guesser', () => { + test('uses force domain', async () => { + expect( + guessCookieDomain('https://localhost', { + forceCookieDomain: 'some-domain' + }) + ).toEqual('some-domain') + }) + + test('does not use any guessing domain', async () => { + expect( + guessCookieDomain('https://localhost', { + dontUseTldForCookieDomain: true + }) + ).toEqual(undefined) + }) + + test('is not confused by invalid data', async () => { + expect( + guessCookieDomain('5qw5tare4g', { + dontUseTldForCookieDomain: true + }) + ).toEqual(undefined) + expect( + guessCookieDomain('https://123.123.123.123.123', { + dontUseTldForCookieDomain: true + }) + ).toEqual(undefined) + }) + + test('is not confused by IP', async () => { + expect( + guessCookieDomain('https://123.123.123.123', { + dontUseTldForCookieDomain: true + }) + ).toEqual(undefined) + expect( + guessCookieDomain('https://2001:0db8:0000:0000:0000:ff00:0042:8329', { + dontUseTldForCookieDomain: true + }) + ).toEqual(undefined) + }) + + test('uses TLD', async () => { + expect(guessCookieDomain('https://foo.localhost', {})).toEqual( + 'foo.localhost' + ) + + expect(guessCookieDomain('https://foo.localhost:1234', {})).toEqual( + 'foo.localhost' + ) + + expect( + guessCookieDomain( + 'https://spark-public.s3.amazonaws.com/dataanalysis/loansData.csv', + {} + ) + ).toEqual('spark-public.s3.amazonaws.com') + + expect(guessCookieDomain('spark-public.s3.amazonaws.com', {})).toEqual( + 'spark-public.s3.amazonaws.com' + ) + + expect(guessCookieDomain('https://localhost/123', {})).toEqual('localhost') + expect(guessCookieDomain('https://localhost:1234/123', {})).toEqual( + 'localhost' + ) + }) +}) diff --git a/src/next-edge/index.ts b/src/next-edge/index.ts index 5855529..6e7f6fd 100644 --- a/src/next-edge/index.ts +++ b/src/next-edge/index.ts @@ -5,8 +5,7 @@ import parse from 'set-cookie-parser' import { IncomingHttpHeaders } from 'http' import { Buffer } from 'buffer' import { isText } from 'istextorbinary' -import { NextResponse } from 'next/server' -import type { NextMiddleware } from 'next/server' +import tldjs from 'tldjs' const forwardedHeaders = [ 'accept', @@ -74,9 +73,19 @@ export interface CreateApiHandlerOptions { * * If you are running this app on a subdomain and you want the session and CSRF cookies * to be valid for the whole TLD, you can use this setting to force a cookie domain. + * + * Please be aware that his method disables the `dontUseTldForCookieDomain` option. */ forceCookieDomain?: string + /** + * Per default the cookie will be set on the hosts top-level-domain. If the app + * runs on www.example.org, the cookie domain will be set automatically to example.org. + * + * Set this option to true to disable that behaviour. + */ + dontUseTldForCookieDomain?: boolean + /** * If set to true will set the "Secure" flag for all cookies. This might come in handy when you deploy * not on Vercel. @@ -157,10 +166,18 @@ export function createApiHandler(options: CreateApiHandlerOptions) { options.forceCookieSecure === undefined ? isTls : options.forceCookieSecure + + const forwarded = req.rawHeaders.findIndex( + (h) => h.toLowerCase() === 'x-forwarded-host' + ) + const host = + forwarded > -1 ? req.rawHeaders[forwarded + 1] : req.headers.host + const domain = guessCookieDomain(host, options) + res.headers['set-cookie'] = parse(res) .map((cookie) => ({ ...cookie, - domain: options.forceCookieDomain, + domain, secure, encode })) @@ -202,3 +219,28 @@ export function createApiHandler(options: CreateApiHandlerOptions) { }) } } + +export function guessCookieDomain( + url: string | undefined, + options: CreateApiHandlerOptions +) { + if (!url || options.forceCookieDomain) { + return options.forceCookieDomain + } + + if (options.dontUseTldForCookieDomain) { + return undefined + } + + const parsed = tldjs.parse(url || '') + + if (!parsed.isValid || parsed.isIp) { + return undefined + } + + if (!parsed.domain) { + return parsed.hostname + } + + return parsed.domain +}