From afeb4e17c62f26167763890520a9cca3b5452ec5 Mon Sep 17 00:00:00 2001 From: okazakee Date: Wed, 26 Aug 2026 02:51:40 +0200 Subject: [PATCH 1/2] feat(auth): atomic refresh sessions, identity foundation, and Google OAuth Phase 1.5 Round 2 vertical slice: a user authenticates to a self-hosted backend from the hosted PWA via Google OAuth with backend-issued sessions, correct account-linking semantics, and safe challenge binding. - refresh rotation: SHA-256(jti)-keyed atomic rotation (exactly-one-winner under concurrency), TTL from JWT_REFRESH_EXPIRES_IN, session hygiene and stable per-class 401 machine codes (B-P1-1/2/3/5) - identity normalization: canonical lowercase usernames at write with case-collision-preflight migration (D-10) - identity foundation: nullable passwordHash, googleId/githubId unique, minecraftVerified, single-use hash-only 5-min OAuth challenge table (D-5) - Google OAuth: local JWKS verification, challenge-bound nonce, explicit LinkConfirmationRequired linking (D-7), same session path, capability flag - cross-backend isolation: challenge rows per backend DB (confused-deputy proof), full unit/e2e matrix incl. live concurrency and isolation tests --- bun.lock | 29 + drizzle/0004_high_yellow_claw.sql | 10 + .../0005_username_lowercase_normalization.sql | 25 + drizzle/0006_aspiring_azazel.sql | 17 + drizzle/meta/0004_snapshot.json | 878 ++++++++++++++++ drizzle/meta/0005_snapshot.json | 878 ++++++++++++++++ drizzle/meta/0006_snapshot.json | 984 ++++++++++++++++++ drizzle/meta/_journal.json | 21 + src/admin/admin.controller.spec.ts | 3 + src/admin/admin.service.spec.ts | 3 + src/app.controller.spec.ts | 3 + src/app.controller.ts | 2 + src/auth/auth-cookies.spec.ts | 9 +- src/auth/auth-cookies.ts | 8 +- src/auth/auth.controller.spec.ts | 43 +- src/auth/auth.controller.ts | 97 +- src/auth/auth.module.ts | 37 +- src/auth/auth.service.spec.ts | 248 ++++- src/auth/auth.service.ts | 326 ++++-- src/auth/dto/editUser.dto.spec.ts | 19 + src/auth/dto/editUser.dto.ts | 2 +- src/auth/dto/oauth.dto.ts | 17 + src/auth/dto/register.dto.spec.ts | 37 + src/auth/dto/register.dto.ts | 2 +- src/auth/google-oauth.service.spec.ts | 158 +++ src/auth/google-oauth.service.ts | 66 ++ src/auth/google-token.service.spec.ts | 106 ++ src/auth/google-token.service.ts | 132 +++ src/auth/identity.service.spec.ts | 149 +++ src/auth/identity.service.ts | 158 +++ src/auth/oauth-challenge.service.spec.ts | 68 ++ src/auth/oauth-challenge.service.ts | 60 ++ src/auth/refresh-token-id.ts | 6 + src/auth/refresh-token-ttl.spec.ts | 45 + src/auth/refresh-token-ttl.ts | 54 + src/db/schema.ts | 56 +- src/servers/server-access.service.spec.ts | 3 + src/users/users.service.spec.ts | 23 +- src/users/users.service.ts | 35 +- test/admin-last-admin.e2e-spec.ts | 6 +- test/auth-flow.e2e-spec.ts | 228 +++- test/database-migration.e2e-spec.ts | 68 +- test/google-oauth-isolation.e2e-spec.ts | 180 ++++ test/google-oauth.e2e-spec.ts | 343 ++++++ test/oauth-challenge.e2e-spec.ts | 92 ++ test/refresh-rotation.e2e-spec.ts | 155 +++ test/session-hygiene.e2e-spec.ts | 173 +++ 47 files changed, 5821 insertions(+), 241 deletions(-) create mode 100644 drizzle/0004_high_yellow_claw.sql create mode 100644 drizzle/0005_username_lowercase_normalization.sql create mode 100644 drizzle/0006_aspiring_azazel.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 src/auth/dto/editUser.dto.spec.ts create mode 100644 src/auth/dto/oauth.dto.ts create mode 100644 src/auth/dto/register.dto.spec.ts create mode 100644 src/auth/google-oauth.service.spec.ts create mode 100644 src/auth/google-oauth.service.ts create mode 100644 src/auth/google-token.service.spec.ts create mode 100644 src/auth/google-token.service.ts create mode 100644 src/auth/identity.service.spec.ts create mode 100644 src/auth/identity.service.ts create mode 100644 src/auth/oauth-challenge.service.spec.ts create mode 100644 src/auth/oauth-challenge.service.ts create mode 100644 src/auth/refresh-token-id.ts create mode 100644 src/auth/refresh-token-ttl.spec.ts create mode 100644 src/auth/refresh-token-ttl.ts create mode 100644 test/google-oauth-isolation.e2e-spec.ts create mode 100644 test/google-oauth.e2e-spec.ts create mode 100644 test/oauth-challenge.e2e-spec.ts create mode 100644 test/refresh-rotation.e2e-spec.ts create mode 100644 test/session-hygiene.e2e-spec.ts diff --git a/bun.lock b/bun.lock index f0d4d5f..fe9606e 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "cookie-parser": "^1.4.7", "dockerode": "^4.0.9", "drizzle-orm": "0.45.2", + "google-auth-library": "^11.0.2", "helmet": "^8.1.0", "otplib": "^13.3.0", "postgres": "^3.4.8", @@ -625,6 +626,8 @@ "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -797,6 +800,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="], @@ -897,6 +902,8 @@ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -907,6 +914,8 @@ "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -921,6 +930,8 @@ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], @@ -939,6 +950,10 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], + + "gcp-metadata": ["gcp-metadata@9.0.3", "", { "dependencies": { "gaxios": "^7.1.3", "google-logging-utils": "^2.0.0", "json-bigint": "^1.0.0" } }, "sha512-2YYnIlHaKBGT2IPg3G2M57hia9Galz15zsEOvw9T3oRf0lSn6KN6VcHQLqby7x8ksYKnjXvp3rp2KJyLCN6zfQ=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -957,6 +972,10 @@ "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + "google-auth-library": ["google-auth-library@11.0.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "^9.0.0", "google-logging-utils": "^2.0.0", "jws": "^4.0.0" } }, "sha512-vzpgPutxrghPsnjrjpzLX2bdv8IOL719Rh0oEjGnQu8YCIbnbMuTTQ5zU9LcKvLdOPgCxBwppbvnhgW90Qna5Q=="], + + "google-logging-utils": ["google-logging-utils@2.0.1", "", {}, "sha512-HMhaQghlOTvbcb3c4T5jmmOMtG3JUF1iOQMezaJXL86CDS+Tm2vHd0IeLFRAx3+ewd+bo9E1HFHoy17X5aJa9A=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -977,6 +996,8 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -1083,6 +1104,8 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -1195,8 +1218,12 @@ "node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + "node-emoji": ["node-emoji@1.11.0", "", { "dependencies": { "lodash": "^4.17.21" } }, "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A=="], + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], @@ -1473,6 +1500,8 @@ "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webpack": ["webpack@5.104.1", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.4", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA=="], "webpack-node-externals": ["webpack-node-externals@3.0.0", "", {}, "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ=="], diff --git a/drizzle/0004_high_yellow_claw.sql b/drizzle/0004_high_yellow_claw.sql new file mode 100644 index 0000000..bd6aa4f --- /dev/null +++ b/drizzle/0004_high_yellow_claw.sql @@ -0,0 +1,10 @@ +-- Phase C session substrate: legacy rows store bcrypt hashes of old-format tokens +-- with no derivable jti, so they cannot be verified under the new scheme. +-- Deleting them forces re-login (documented session-invalidation consequence). +ALTER TABLE "refresh_tokens" DROP CONSTRAINT "refresh_tokens_token_unique";--> statement-breakpoint +DELETE FROM "refresh_tokens";--> statement-breakpoint +ALTER TABLE "refresh_tokens" DROP COLUMN "token";--> statement-breakpoint +ALTER TABLE "refresh_tokens" ADD COLUMN "token_id_hash" text NOT NULL;--> statement-breakpoint +CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "refresh_tokens_expires_at_idx" ON "refresh_tokens" USING btree ("expires_at");--> statement-breakpoint +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_token_id_hash_unique" UNIQUE("token_id_hash"); \ No newline at end of file diff --git a/drizzle/0005_username_lowercase_normalization.sql b/drizzle/0005_username_lowercase_normalization.sql new file mode 100644 index 0000000..cbe0967 --- /dev/null +++ b/drizzle/0005_username_lowercase_normalization.sql @@ -0,0 +1,25 @@ +-- Phase D identity normalization (D-10, adopted): canonical lowercase usernames. +-- Preflight: a case-insensitive duplicate (e.g. `Bob` + `bob`) cannot be +-- migrated without manual resolution — fail loudly, never silently merge or +-- drop a row. Clean databases are unchanged by the preflight. +DO $$ +DECLARE + conflicting_count integer; +BEGIN + SELECT count(*) INTO conflicting_count + FROM ( + SELECT lower(username) AS canon + FROM users + GROUP BY lower(username) + HAVING count(*) > 1 + ) conflicts; + + IF conflicting_count > 0 THEN + RAISE EXCEPTION + 'Username case-collision detected: % lowercase-canonical value(s) map to multiple accounts; resolve manually before applying migration 0005', + conflicting_count; + END IF; +END $$;--> statement-breakpoint +-- Canonicalize all existing usernames; the unique(username) constraint is +-- satisfied because the preflight proved injectivity of lower(username). +UPDATE users SET username = lower(username); \ No newline at end of file diff --git a/drizzle/0006_aspiring_azazel.sql b/drizzle/0006_aspiring_azazel.sql new file mode 100644 index 0000000..4d20bb8 --- /dev/null +++ b/drizzle/0006_aspiring_azazel.sql @@ -0,0 +1,17 @@ +CREATE TABLE "oauth_challenges" ( + "id" text PRIMARY KEY NOT NULL, + "provider" text NOT NULL, + "challenge_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "oauth_challenges_challenge_hash_unique" UNIQUE("challenge_hash"), + CONSTRAINT "oauth_challenges_provider_check" CHECK ("oauth_challenges"."provider" = 'google') +); +--> statement-breakpoint +ALTER TABLE "users" ALTER COLUMN "password_hash" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "google_id" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "github_id" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "minecraft_verified" boolean DEFAULT false NOT NULL;--> statement-breakpoint +CREATE INDEX "oauth_challenges_expires_at_idx" ON "oauth_challenges" USING btree ("expires_at");--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_google_id_unique" UNIQUE("google_id");--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_github_id_unique" UNIQUE("github_id"); \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..f85a81b --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,878 @@ +{ + "id": "8fb4ae90-ee3d-4947-9937-0a2f393b2bfb", + "prevId": "b4ebd64e-9c65-4528-a651-d0acfa149bf2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mod_permissions": { + "name": "mod_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "mod_permission", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mod_permissions_user_permission_unique_idx": { + "name": "mod_permissions_user_permission_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mod_permissions\".\"server_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_permissions_server_id_idx": { + "name": "mod_permissions_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_permissions_user_permission_server_lookup_idx": { + "name": "mod_permissions_user_permission_server_lookup_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mod_permissions_user_id_users_id_fk": { + "name": "mod_permissions_user_id_users_id_fk", + "tableFrom": "mod_permissions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mod_permissions_server_id_servers_id_fk": { + "name": "mod_permissions_server_id_servers_id_fk", + "tableFrom": "mod_permissions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mod_permissions_user_permission_server_unique": { + "name": "mod_permissions_user_permission_server_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "permission", + "server_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_id_hash": { + "name": "token_id_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_user_id_idx": { + "name": "refresh_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_expires_at_idx": { + "name": "refresh_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_id_hash_unique": { + "name": "refresh_tokens_token_id_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_id_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_access": { + "name": "server_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "server_access_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "server_access_server_id_status_created_at_id_idx": { + "name": "server_access_server_id_status_created_at_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_access_user_id_server_id_idx": { + "name": "server_access_user_id_server_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_access_user_id_users_id_fk": { + "name": "server_access_user_id_users_id_fk", + "tableFrom": "server_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_access_server_id_servers_id_fk": { + "name": "server_access_server_id_servers_id_fk", + "tableFrom": "server_access", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "server_access_user_id_server_id_unique": { + "name": "server_access_user_id_server_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "server_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "server_access_status_approved_at_total_check": { + "name": "server_access_status_approved_at_total_check", + "value": "(\"server_access\".\"status\" = 'PENDING' AND \"server_access\".\"approved_at\" IS NULL) OR (\"server_access\".\"status\" = 'APPROVED' AND \"server_access\".\"approved_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "server_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "server_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'STOPPED'" + }, + "max_players": { + "name": "max_players", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "difficulty": { + "name": "difficulty", + "type": "server_difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'NORMAL'" + }, + "gamemode": { + "name": "gamemode", + "type": "server_gamemode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'SURVIVAL'" + }, + "pvp": { + "name": "pvp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "memory_limit_mb": { + "name": "memory_limit_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2048 + }, + "motd": { + "name": "motd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level_seed": { + "name": "level_seed", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "online_mode": { + "name": "online_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "view_distance": { + "name": "view_distance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "allow_flight": { + "name": "allow_flight", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "world_path": { + "name": "world_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rcon_password": { + "name": "rcon_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_type": { + "name": "access_type", + "type": "access_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "servers_owner_id_users_id_fk": { + "name": "servers_owner_id_users_id_fk", + "tableFrom": "servers", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "servers_port_unique": { + "name": "servers_port_unique", + "nullsNotDistinct": false, + "columns": [ + "port" + ] + }, + "servers_container_id_unique": { + "name": "servers_container_id_unique", + "nullsNotDistinct": false, + "columns": [ + "container_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setup_state": { + "name": "setup_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'singleton'" + }, + "initial_admin_created": { + "name": "initial_admin_created", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(254)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "status": { + "name": "status", + "type": "user_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ACTIVE'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temp_password_hash": { + "name": "temp_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temp_password_expires_at": { + "name": "temp_password_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "minecraft_uuid": { + "name": "minecraft_uuid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "minecraft_name": { + "name": "minecraft_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_minecraft_uuid_unique": { + "name": "users_minecraft_uuid_unique", + "nullsNotDistinct": false, + "columns": [ + "minecraft_uuid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.server_difficulty": { + "name": "server_difficulty", + "schema": "public", + "values": [ + "PEACEFUL", + "EASY", + "NORMAL", + "HARD" + ] + }, + "public.server_gamemode": { + "name": "server_gamemode", + "schema": "public", + "values": [ + "SURVIVAL", + "CREATIVE", + "ADVENTURE", + "SPECTATOR" + ] + }, + "public.access_type": { + "name": "access_type", + "schema": "public", + "values": [ + "OPEN", + "REQUEST", + "PRIVATE" + ] + }, + "public.mod_permission": { + "name": "mod_permission", + "schema": "public", + "values": [ + "SERVER_LIFECYCLE", + "SERVER_CONFIG", + "PLUGIN_MANAGEMENT", + "WHITELIST_MANAGEMENT", + "USER_MANAGEMENT", + "FILE_MANAGER" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "ADMIN", + "MOD", + "USER" + ] + }, + "public.server_access_status": { + "name": "server_access_status", + "schema": "public", + "values": [ + "PENDING", + "APPROVED" + ] + }, + "public.server_provider": { + "name": "server_provider", + "schema": "public", + "values": [ + "VANILLA", + "PAPER", + "PURPUR", + "FABRIC", + "FORGE" + ] + }, + "public.server_status": { + "name": "server_status", + "schema": "public", + "values": [ + "STOPPED", + "CREATING", + "STARTING", + "RUNNING", + "STOPPING", + "ERROR" + ] + }, + "public.user_status": { + "name": "user_status", + "schema": "public", + "values": [ + "ACTIVE", + "PENDING", + "BANNED" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..816f864 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,878 @@ +{ + "id": "155f08bb-1232-4bf5-b559-36374801b117", + "prevId": "8fb4ae90-ee3d-4947-9937-0a2f393b2bfb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mod_permissions": { + "name": "mod_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "mod_permission", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mod_permissions_user_permission_unique_idx": { + "name": "mod_permissions_user_permission_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"mod_permissions\".\"server_id\" IS NULL", + "concurrently": false + }, + "mod_permissions_server_id_idx": { + "name": "mod_permissions_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "mod_permissions_user_permission_server_lookup_idx": { + "name": "mod_permissions_user_permission_server_lookup_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mod_permissions_user_id_users_id_fk": { + "name": "mod_permissions_user_id_users_id_fk", + "tableFrom": "mod_permissions", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mod_permissions_server_id_servers_id_fk": { + "name": "mod_permissions_server_id_servers_id_fk", + "tableFrom": "mod_permissions", + "columnsFrom": [ + "server_id" + ], + "tableTo": "servers", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mod_permissions_user_permission_server_unique": { + "name": "mod_permissions_user_permission_server_unique", + "columns": [ + "user_id", + "permission", + "server_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_id_hash": { + "name": "token_id_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_user_id_idx": { + "name": "refresh_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "refresh_tokens_expires_at_idx": { + "name": "refresh_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_id_hash_unique": { + "name": "refresh_tokens_token_id_hash_unique", + "columns": [ + "token_id_hash" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_access": { + "name": "server_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "server_access_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "server_access_server_id_status_created_at_id_idx": { + "name": "server_access_server_id_status_created_at_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "server_access_user_id_server_id_idx": { + "name": "server_access_user_id_server_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "server_access_user_id_users_id_fk": { + "name": "server_access_user_id_users_id_fk", + "tableFrom": "server_access", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "server_access_server_id_servers_id_fk": { + "name": "server_access_server_id_servers_id_fk", + "tableFrom": "server_access", + "columnsFrom": [ + "server_id" + ], + "tableTo": "servers", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "server_access_user_id_server_id_unique": { + "name": "server_access_user_id_server_id_unique", + "columns": [ + "user_id", + "server_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "server_access_status_approved_at_total_check": { + "name": "server_access_status_approved_at_total_check", + "value": "(\"server_access\".\"status\" = 'PENDING' AND \"server_access\".\"approved_at\" IS NULL) OR (\"server_access\".\"status\" = 'APPROVED' AND \"server_access\".\"approved_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "server_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "server_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'STOPPED'" + }, + "max_players": { + "name": "max_players", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "difficulty": { + "name": "difficulty", + "type": "server_difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'NORMAL'" + }, + "gamemode": { + "name": "gamemode", + "type": "server_gamemode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'SURVIVAL'" + }, + "pvp": { + "name": "pvp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "memory_limit_mb": { + "name": "memory_limit_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2048 + }, + "motd": { + "name": "motd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level_seed": { + "name": "level_seed", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "online_mode": { + "name": "online_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "view_distance": { + "name": "view_distance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "allow_flight": { + "name": "allow_flight", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "world_path": { + "name": "world_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rcon_password": { + "name": "rcon_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_type": { + "name": "access_type", + "type": "access_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "servers_owner_id_users_id_fk": { + "name": "servers_owner_id_users_id_fk", + "tableFrom": "servers", + "columnsFrom": [ + "owner_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "servers_port_unique": { + "name": "servers_port_unique", + "columns": [ + "port" + ], + "nullsNotDistinct": false + }, + "servers_container_id_unique": { + "name": "servers_container_id_unique", + "columns": [ + "container_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setup_state": { + "name": "setup_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'singleton'" + }, + "initial_admin_created": { + "name": "initial_admin_created", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(254)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "status": { + "name": "status", + "type": "user_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ACTIVE'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temp_password_hash": { + "name": "temp_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temp_password_expires_at": { + "name": "temp_password_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "minecraft_uuid": { + "name": "minecraft_uuid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "minecraft_name": { + "name": "minecraft_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + }, + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "nullsNotDistinct": false + }, + "users_minecraft_uuid_unique": { + "name": "users_minecraft_uuid_unique", + "columns": [ + "minecraft_uuid" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.server_difficulty": { + "name": "server_difficulty", + "schema": "public", + "values": [ + "PEACEFUL", + "EASY", + "NORMAL", + "HARD" + ] + }, + "public.server_gamemode": { + "name": "server_gamemode", + "schema": "public", + "values": [ + "SURVIVAL", + "CREATIVE", + "ADVENTURE", + "SPECTATOR" + ] + }, + "public.access_type": { + "name": "access_type", + "schema": "public", + "values": [ + "OPEN", + "REQUEST", + "PRIVATE" + ] + }, + "public.mod_permission": { + "name": "mod_permission", + "schema": "public", + "values": [ + "SERVER_LIFECYCLE", + "SERVER_CONFIG", + "PLUGIN_MANAGEMENT", + "WHITELIST_MANAGEMENT", + "USER_MANAGEMENT", + "FILE_MANAGER" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "ADMIN", + "MOD", + "USER" + ] + }, + "public.server_access_status": { + "name": "server_access_status", + "schema": "public", + "values": [ + "PENDING", + "APPROVED" + ] + }, + "public.server_provider": { + "name": "server_provider", + "schema": "public", + "values": [ + "VANILLA", + "PAPER", + "PURPUR", + "FABRIC", + "FORGE" + ] + }, + "public.server_status": { + "name": "server_status", + "schema": "public", + "values": [ + "STOPPED", + "CREATING", + "STARTING", + "RUNNING", + "STOPPING", + "ERROR" + ] + }, + "public.user_status": { + "name": "user_status", + "schema": "public", + "values": [ + "ACTIVE", + "PENDING", + "BANNED" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..eba982e --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,984 @@ +{ + "id": "e9b47eed-9c82-4306-8cf2-6947e07a11f3", + "prevId": "155f08bb-1232-4bf5-b559-36374801b117", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mod_permissions": { + "name": "mod_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "mod_permission", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mod_permissions_user_permission_unique_idx": { + "name": "mod_permissions_user_permission_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mod_permissions\".\"server_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_permissions_server_id_idx": { + "name": "mod_permissions_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_permissions_user_permission_server_lookup_idx": { + "name": "mod_permissions_user_permission_server_lookup_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mod_permissions_user_id_users_id_fk": { + "name": "mod_permissions_user_id_users_id_fk", + "tableFrom": "mod_permissions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mod_permissions_server_id_servers_id_fk": { + "name": "mod_permissions_server_id_servers_id_fk", + "tableFrom": "mod_permissions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mod_permissions_user_permission_server_unique": { + "name": "mod_permissions_user_permission_server_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "permission", + "server_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_challenges": { + "name": "oauth_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge_hash": { + "name": "challenge_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_challenges_expires_at_idx": { + "name": "oauth_challenges_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_challenges_challenge_hash_unique": { + "name": "oauth_challenges_challenge_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "challenge_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_challenges_provider_check": { + "name": "oauth_challenges_provider_check", + "value": "\"oauth_challenges\".\"provider\" = 'google'" + } + }, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_id_hash": { + "name": "token_id_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_user_id_idx": { + "name": "refresh_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_expires_at_idx": { + "name": "refresh_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_id_hash_unique": { + "name": "refresh_tokens_token_id_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_id_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_access": { + "name": "server_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "server_access_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "server_access_server_id_status_created_at_id_idx": { + "name": "server_access_server_id_status_created_at_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_access_user_id_server_id_idx": { + "name": "server_access_user_id_server_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_access_user_id_users_id_fk": { + "name": "server_access_user_id_users_id_fk", + "tableFrom": "server_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_access_server_id_servers_id_fk": { + "name": "server_access_server_id_servers_id_fk", + "tableFrom": "server_access", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "server_access_user_id_server_id_unique": { + "name": "server_access_user_id_server_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "server_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "server_access_status_approved_at_total_check": { + "name": "server_access_status_approved_at_total_check", + "value": "(\"server_access\".\"status\" = 'PENDING' AND \"server_access\".\"approved_at\" IS NULL) OR (\"server_access\".\"status\" = 'APPROVED' AND \"server_access\".\"approved_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "server_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "server_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'STOPPED'" + }, + "max_players": { + "name": "max_players", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "difficulty": { + "name": "difficulty", + "type": "server_difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'NORMAL'" + }, + "gamemode": { + "name": "gamemode", + "type": "server_gamemode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'SURVIVAL'" + }, + "pvp": { + "name": "pvp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "memory_limit_mb": { + "name": "memory_limit_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2048 + }, + "motd": { + "name": "motd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level_seed": { + "name": "level_seed", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "online_mode": { + "name": "online_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "view_distance": { + "name": "view_distance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "allow_flight": { + "name": "allow_flight", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "world_path": { + "name": "world_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rcon_password": { + "name": "rcon_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_type": { + "name": "access_type", + "type": "access_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "servers_owner_id_users_id_fk": { + "name": "servers_owner_id_users_id_fk", + "tableFrom": "servers", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "servers_port_unique": { + "name": "servers_port_unique", + "nullsNotDistinct": false, + "columns": [ + "port" + ] + }, + "servers_container_id_unique": { + "name": "servers_container_id_unique", + "nullsNotDistinct": false, + "columns": [ + "container_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setup_state": { + "name": "setup_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'singleton'" + }, + "initial_admin_created": { + "name": "initial_admin_created", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(254)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_id": { + "name": "google_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_id": { + "name": "github_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "status": { + "name": "status", + "type": "user_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ACTIVE'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temp_password_hash": { + "name": "temp_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temp_password_expires_at": { + "name": "temp_password_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "minecraft_uuid": { + "name": "minecraft_uuid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "minecraft_name": { + "name": "minecraft_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "minecraft_verified": { + "name": "minecraft_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_google_id_unique": { + "name": "users_google_id_unique", + "nullsNotDistinct": false, + "columns": [ + "google_id" + ] + }, + "users_github_id_unique": { + "name": "users_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + }, + "users_minecraft_uuid_unique": { + "name": "users_minecraft_uuid_unique", + "nullsNotDistinct": false, + "columns": [ + "minecraft_uuid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.server_difficulty": { + "name": "server_difficulty", + "schema": "public", + "values": [ + "PEACEFUL", + "EASY", + "NORMAL", + "HARD" + ] + }, + "public.server_gamemode": { + "name": "server_gamemode", + "schema": "public", + "values": [ + "SURVIVAL", + "CREATIVE", + "ADVENTURE", + "SPECTATOR" + ] + }, + "public.access_type": { + "name": "access_type", + "schema": "public", + "values": [ + "OPEN", + "REQUEST", + "PRIVATE" + ] + }, + "public.mod_permission": { + "name": "mod_permission", + "schema": "public", + "values": [ + "SERVER_LIFECYCLE", + "SERVER_CONFIG", + "PLUGIN_MANAGEMENT", + "WHITELIST_MANAGEMENT", + "USER_MANAGEMENT", + "FILE_MANAGER" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "ADMIN", + "MOD", + "USER" + ] + }, + "public.server_access_status": { + "name": "server_access_status", + "schema": "public", + "values": [ + "PENDING", + "APPROVED" + ] + }, + "public.server_provider": { + "name": "server_provider", + "schema": "public", + "values": [ + "VANILLA", + "PAPER", + "PURPUR", + "FABRIC", + "FORGE" + ] + }, + "public.server_status": { + "name": "server_status", + "schema": "public", + "values": [ + "STOPPED", + "CREATING", + "STARTING", + "RUNNING", + "STOPPING", + "ERROR" + ] + }, + "public.user_status": { + "name": "user_status", + "schema": "public", + "values": [ + "ACTIVE", + "PENDING", + "BANNED" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 853079e..707bbf7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,27 @@ "when": 1786638165559, "tag": "0003_nervous_vapor", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1787688909709, + "tag": "0004_high_yellow_claw", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1787691976286, + "tag": "0005_username_lowercase_normalization", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1787694010225, + "tag": "0006_aspiring_azazel", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/admin/admin.controller.spec.ts b/src/admin/admin.controller.spec.ts index 992ca29..d1f9980 100644 --- a/src/admin/admin.controller.spec.ts +++ b/src/admin/admin.controller.spec.ts @@ -25,6 +25,9 @@ const publicUser: PublicUser = { mustChangePassword: false, minecraftUUID: null, minecraftName: null, + googleId: null, + githubId: null, + minecraftVerified: false, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/src/admin/admin.service.spec.ts b/src/admin/admin.service.spec.ts index 3d3eb99..85a8fe0 100644 --- a/src/admin/admin.service.spec.ts +++ b/src/admin/admin.service.spec.ts @@ -36,6 +36,8 @@ const makeUser = (overrides: Partial = {}): User => ({ email: 'admin@example.com', username: 'admin', passwordHash: '$2b$10$abcdefghijklmnopqrstuv', + googleId: null, + githubId: null, role: 'ADMIN', status: 'ACTIVE', totpSecret: null, @@ -46,6 +48,7 @@ const makeUser = (overrides: Partial = {}): User => ({ mustChangePassword: false, minecraftUUID: null, minecraftName: null, + minecraftVerified: false, createdAt: new Date('2026-01-01T00:00:00Z'), updatedAt: new Date('2026-01-01T00:00:00Z'), ...overrides, diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts index b1d103d..a3f06f1 100644 --- a/src/app.controller.spec.ts +++ b/src/app.controller.spec.ts @@ -32,6 +32,7 @@ describe('AppController', () => { getConfig.mockImplementation((key: string, defaultValue?: string) => { if (key === 'PANEL_NAME') return 'MinePanel'; if (key === 'PANEL_VERSION') return '1.0.0'; + if (key === 'GOOGLE_CLIENT_ID') return 'google-client-id.apps.googleusercontent.com'; return defaultValue; }); // SAFETY: The fixture is constructed from the concrete framework contract exercised by this test. @@ -45,6 +46,7 @@ describe('AppController', () => { auth: { partitionedCookies: true, pkceAuthorizationCode: false, + googleOAuth: true, }, realtime: { websocketTicket: false, @@ -63,6 +65,7 @@ describe('AppController', () => { expect(info.name).toBe('MinePanel'); expect(info.version).toBe('1.0'); + expect(info.capabilities.auth.googleOAuth).toBe(false); expect(info.api.protocolVersion).toBe(1); }); diff --git a/src/app.controller.ts b/src/app.controller.ts index 21ed79c..b602120 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -13,6 +13,7 @@ export interface PanelCapabilities { auth: { partitionedCookies: boolean; pkceAuthorizationCode: boolean; + googleOAuth: boolean; }; realtime: { websocketTicket: boolean; @@ -53,6 +54,7 @@ export class AppController { auth: { partitionedCookies: true, pkceAuthorizationCode: false, + googleOAuth: Boolean(this.configService.get('GOOGLE_CLIENT_ID')?.trim()), }, realtime: { websocketTicket: false, diff --git a/src/auth/auth-cookies.spec.ts b/src/auth/auth-cookies.spec.ts index 590bf02..0ac3ef9 100644 --- a/src/auth/auth-cookies.spec.ts +++ b/src/auth/auth-cookies.spec.ts @@ -1,11 +1,18 @@ import express, { type Express } from 'express'; import request from 'supertest'; import { clearAuthCookies, setAuthCookies } from './auth-cookies'; +import type { RefreshTokenTtl } from './refresh-token-ttl'; + +const REFRESH_TTL: RefreshTokenTtl = { expiresIn: '7d', milliseconds: 7 * 24 * 60 * 60 * 1000 }; const makeApp = (): Express => { const app = express(); app.get('/set', (_req, res) => { - setAuthCookies(res, { accessToken: 'access-value', refreshToken: 'refresh-value' }); + setAuthCookies( + res, + { accessToken: 'access-value', refreshToken: 'refresh-value' }, + REFRESH_TTL, + ); res.end(); }); app.get('/clear', (_req, res) => { diff --git a/src/auth/auth-cookies.ts b/src/auth/auth-cookies.ts index 858644f..55b1df3 100644 --- a/src/auth/auth-cookies.ts +++ b/src/auth/auth-cookies.ts @@ -1,8 +1,8 @@ import type { CookieOptions, Response } from 'express'; import type { AuthTokens } from './auth.service'; +import type { RefreshTokenTtl } from './refresh-token-ttl'; const ACCESS_TOKEN_COOKIE_MAX_AGE_MS = 15 * 60 * 1000; -const REFRESH_TOKEN_COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // express@5.2 serializes `partitioned` through cookie@0.7 at runtime, but its // bundled CookieOptions type does not declare the field yet @@ -31,16 +31,18 @@ export const setAccessTokenCookie = (res: Pick, accessToken: export const setRefreshTokenCookie = ( res: Pick, refreshToken: string, + refreshTokenTtl: RefreshTokenTtl, ): void => { - res.cookie('refresh_token', refreshToken, sessionCookieOptions(REFRESH_TOKEN_COOKIE_MAX_AGE_MS)); + res.cookie('refresh_token', refreshToken, sessionCookieOptions(refreshTokenTtl.milliseconds)); }; export const setAuthCookies = ( res: Pick, tokens: Pick, + refreshTokenTtl: RefreshTokenTtl, ): void => { setAccessTokenCookie(res, tokens.accessToken); - setRefreshTokenCookie(res, tokens.refreshToken); + setRefreshTokenCookie(res, tokens.refreshToken, refreshTokenTtl); }; export const clearAuthCookies = (res: Pick): void => { diff --git a/src/auth/auth.controller.spec.ts b/src/auth/auth.controller.spec.ts index 5a93cf7..df6a233 100644 --- a/src/auth/auth.controller.spec.ts +++ b/src/auth/auth.controller.spec.ts @@ -2,10 +2,15 @@ import type { Response } from 'express'; import type { PublicUser } from 'src/users/public-user'; import { AuthController } from './auth.controller'; import type { AuthService } from './auth.service'; +import type { GoogleOAuthService } from './google-oauth.service'; import type { PreAuthRequest } from './guards/pre-auth.guard'; +import type { OAuthChallengeService } from './oauth-challenge.service'; +import type { RefreshTokenTtl } from './refresh-token-ttl'; type CookieResponse = Pick; +const REFRESH_TTL: RefreshTokenTtl = { expiresIn: '7d', milliseconds: 7 * 24 * 60 * 60 * 1000 }; + const publicUser: PublicUser = { id: 'user-1', email: 'user@example.com', @@ -17,12 +22,17 @@ const publicUser: PublicUser = { mustChangePassword: false, minecraftUUID: null, minecraftName: null, + googleId: null, + githubId: null, + minecraftVerified: false, createdAt: new Date(), updatedAt: new Date(), }; describe('AuthController', () => { let authService: Pick; + let googleOAuthService: Pick; + let oauthChallengeService: Pick; let controller: AuthController; let response: CookieResponse; @@ -39,10 +49,28 @@ describe('AuthController', () => { refreshToken: 'refresh-token', }), }; - // SAFETY: the controller methods under test call only loginUser and completeTwoFactorLogin; - // this Pick is the complete exercised collaborator surface. - // SAFETY: The test-controlled value satisfies the concrete framework contract used by this assertion. - controller = new AuthController(authService as AuthService); + googleOAuthService = { + login: jest.fn(), + linkAuthenticatedUser: jest.fn(), + }; + oauthChallengeService = { + createChallenge: jest.fn().mockResolvedValue('A'.repeat(43)), + }; + // SAFETY: each mock exposes only the collaborator surface AuthController + // consumes; the assertion bridges the structural mock to the injected type. + const authServiceMock = authService as never; + // SAFETY: each mock exposes only the collaborator surface AuthController + // consumes; the assertion bridges the structural mock to the injected type. + const googleOAuthServiceMock = googleOAuthService as never; + // SAFETY: each mock exposes only the collaborator surface AuthController + // consumes; the assertion bridges the structural mock to the injected type. + const oauthChallengeServiceMock = oauthChallengeService as never; + controller = new AuthController( + authServiceMock, + googleOAuthServiceMock, + oauthChallengeServiceMock, + REFRESH_TTL, + ); // SAFETY: cookie helpers only call response.cookie, which this test double provides. response = { cookie: jest.fn() }; }); @@ -105,4 +133,11 @@ describe('AuthController', () => { ).resolves.toEqual({ requiresTwoFactor: true, preAuthToken: 'pre-auth-token' }); expect(response.cookie).not.toHaveBeenCalled(); }); + + it('creates a Google-only challenge and returns the raw value once', async () => { + await expect(controller.createOAuthChallenge({ provider: 'google' })).resolves.toEqual({ + challenge: 'A'.repeat(43), + }); + expect(oauthChallengeService.createChallenge).toHaveBeenCalledWith('google'); + }); }); diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index fee81f1..f1b6c46 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -5,12 +5,12 @@ import { Get, HttpCode, HttpStatus, + Inject, Param, Patch, Post, Req, Res, - UnauthorizedException, UseGuards, } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; @@ -18,7 +18,7 @@ import { Throttle } from '@nestjs/throttler'; import type { Request, Response } from 'express'; import { Public } from 'src/common/decorators/public.decorator'; import { type PublicUser } from 'src/users/public-user'; -import { AuthService, type TwoFactorChallenge } from './auth.service'; +import { AuthService, requireRefreshToken, type TwoFactorChallenge } from './auth.service'; import { clearAuthCookies, setAccessTokenCookie, @@ -28,23 +28,29 @@ import { import { TwoFactorTokenDto } from './dto/2fa.dto'; import { EditUserDto } from './dto/editUser.dto'; import { LoginUserDto } from './dto/login.dto'; +import { CreateOAuthChallengeDto, GoogleCredentialDto } from './dto/oauth.dto'; import { CreateUserDto } from './dto/register.dto'; import { UpdatePasswordDTO } from './dto/updatePw.dto'; +import { GoogleOAuthService } from './google-oauth.service'; import { PreAuthGuard, type PreAuthRequest } from './guards/pre-auth.guard'; +import { OAuthChallengeService } from './oauth-challenge.service'; +import { REFRESH_TOKEN_TTL, type RefreshTokenTtl } from './refresh-token-ttl'; type JwtPayload = { id: string; username: string; role: string; temporaryAuth?: boolean }; -type AuthCookieJar = { refresh_token?: unknown }; +type AuthCookieJar = { refresh_token?: string }; + type AuthenticatedRequest = Request & { user: JwtPayload; cookies: AuthCookieJar }; type RefreshRequest = Request & { cookies: AuthCookieJar }; -const isNonEmptyString = (value: unknown): value is string => - typeof value === 'string' && value.length > 0; - @ApiTags('auth') @Controller('auth') export class AuthController { - constructor(private authService: AuthService) {} - + constructor( + private readonly authService: AuthService, + private readonly googleOAuthService: GoogleOAuthService, + private readonly oauthChallengeService: OAuthChallengeService, + @Inject(REFRESH_TOKEN_TTL) private readonly refreshTokenTtl: RefreshTokenTtl, + ) {} @Public() @Throttle({ default: { limit: 5, ttl: 10000 } }) @ApiOperation({ summary: 'Register a new user' }) @@ -73,10 +79,50 @@ export class AuthController { return loginResult; } - setAuthCookies(res, loginResult); + setAuthCookies(res, loginResult, this.refreshTokenTtl); return loginResult.user; } + @Public() + @Throttle({ default: { limit: 5, ttl: 10000 } }) + @ApiOperation({ summary: 'Create a single-use Google OAuth challenge' }) + @HttpCode(HttpStatus.OK) + @Post('oauth/challenge') + async createOAuthChallenge( + @Body() body: CreateOAuthChallengeDto, + ): Promise<{ challenge: string }> { + return { challenge: await this.oauthChallengeService.createChallenge(body.provider) }; + } + + @Public() + @Throttle({ default: { limit: 5, ttl: 10000 } }) + @ApiOperation({ summary: 'Sign in with a Google ID token' }) + @HttpCode(HttpStatus.OK) + @Post('oauth/google/login') + async loginWithGoogle( + @Body() body: GoogleCredentialDto, + @Res({ passthrough: true }) res: Pick, + ): Promise { + const result = await this.googleOAuthService.login(body.credential); + if (result.status === 'LinkConfirmationRequired') { + return result; + } + + setAuthCookies(res, result.session, this.refreshTokenTtl); + return result.session.user; + } + + @Throttle({ default: { limit: 5, ttl: 10000 } }) + @ApiOperation({ summary: 'Link a Google account to the authenticated user' }) + @HttpCode(HttpStatus.OK) + @Post('oauth/google/link') + async linkGoogleAccount( + @Req() req: AuthenticatedRequest, + @Body() body: GoogleCredentialDto, + ): Promise { + return this.googleOAuthService.linkAuthenticatedUser(req.user.id, body.credential); + } + @ApiOperation({ summary: 'Get profile data' }) @HttpCode(HttpStatus.OK) @Get('profile') @@ -93,13 +139,9 @@ export class AuthController { ) { const user = req.user; - // find token in cookies - const refreshToken = req.cookies.refresh_token; - - if (isNonEmptyString(refreshToken)) { - // cookie-parser is the external producer; only a string may reach bcrypt comparison. - await this.authService.logoutUser(user.id, refreshToken); - } + // find token in cookies; only a string may reach the logout lookup + const refreshToken = requireRefreshToken(req.cookies.refresh_token); + await this.authService.logoutUser(user.id, refreshToken); // set both tokens as invalid in cookies clearAuthCookies(res); @@ -114,23 +156,13 @@ export class AuthController { @Req() req: RefreshRequest, @Res({ passthrough: true }) res: Pick, ) { - const rawRefreshToken = req.cookies.refresh_token; - if (!isNonEmptyString(rawRefreshToken)) { - throw new UnauthorizedException(); - } + const rawRefreshToken = requireRefreshToken(req.cookies.refresh_token); const newTokens = await this.authService.refreshTokens(rawRefreshToken); - if (!newTokens) { - throw new UnauthorizedException('Something went wrong when generating new tokens'); - } - const { accessToken, refreshToken: rotatedRefreshToken } = newTokens; setAccessTokenCookie(res, accessToken); - - if (rotatedRefreshToken) { - setRefreshTokenCookie(res, rotatedRefreshToken); - } + setRefreshTokenCookie(res, rotatedRefreshToken, this.refreshTokenTtl); } @ApiOperation({ summary: 'Invalidate all user sessions' }) @@ -185,10 +217,7 @@ export class AuthController { @Res({ passthrough: true }) res: Pick, ) { const user = req.user; - const refreshToken = req.cookies.refresh_token; - if (!isNonEmptyString(refreshToken)) { - throw new UnauthorizedException(); - } + const refreshToken = requireRefreshToken(req.cookies.refresh_token); const result = await this.authService.updateUserPassword( user.id, updatePw, @@ -197,7 +226,7 @@ export class AuthController { ); if (result.session) { - setAuthCookies(res, result.session); + setAuthCookies(res, result.session, this.refreshTokenTtl); } return result.user; @@ -241,7 +270,7 @@ export class AuthController { preAuth.temporaryCredentialFingerprint, ) : await this.authService.completeTwoFactorLogin(preAuth.sub, body.token); - setAuthCookies(res, session); + setAuthCookies(res, session, this.refreshTokenTtl); return session.user; } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index fe9aa64..8dd3ca5 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,12 +1,22 @@ import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; +import { OAuth2Client } from 'google-auth-library'; import { DbModule } from 'src/db/db.module'; import { UsersModule } from 'src/users/users.module'; import { AccessTokenService } from './access-token.service'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { GoogleOAuthService } from './google-oauth.service'; +import { + GOOGLE_ID_TOKEN_VERIFIER, + type GoogleIdTokenVerifier, + GoogleTokenService, +} from './google-token.service'; import { PreAuthGuard } from './guards/pre-auth.guard'; +import { IdentityService } from './identity.service'; +import { OAuthChallengeService } from './oauth-challenge.service'; +import { parseRefreshTokenTtl, REFRESH_TOKEN_TTL } from './refresh-token-ttl'; @Module({ imports: [ @@ -22,7 +32,30 @@ import { PreAuthGuard } from './guards/pre-auth.guard'; }), ], controllers: [AuthController], - providers: [AuthService, PreAuthGuard, AccessTokenService], - exports: [AccessTokenService], + providers: [ + { + provide: REFRESH_TOKEN_TTL, + inject: [ConfigService], + useFactory: (configService: ConfigService) => + parseRefreshTokenTtl(configService.get('JWT_REFRESH_EXPIRES_IN')), + }, + { + provide: GOOGLE_ID_TOKEN_VERIFIER, + useFactory: (): GoogleIdTokenVerifier => { + const client = new OAuth2Client(); + return { + verifyIdToken: (options) => client.verifyIdToken(options), + }; + }, + }, + AuthService, + PreAuthGuard, + AccessTokenService, + GoogleOAuthService, + GoogleTokenService, + IdentityService, + OAuthChallengeService, + ], + exports: [AccessTokenService, IdentityService, OAuthChallengeService], }) export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 2cf7e98..584c531 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -5,8 +5,10 @@ import { JwtService } from '@nestjs/jwt'; import * as cryptoUtil from 'src/common/crypto.util'; import type { User } from 'src/db/schema'; import { UsersService } from 'src/users/users.service'; -import { AuthService } from './auth.service'; +import { AuthService, refreshTokenUnauthorized, requireRefreshToken } from './auth.service'; import * as bcrypt from './password'; +import { hashRefreshTokenId } from './refresh-token-id'; +import type { RefreshTokenTtl } from './refresh-token-ttl'; import * as totp from './totp'; type SigningPayload = { sub: string; type?: string; temporaryAuth?: boolean }; @@ -14,6 +16,7 @@ type RefreshVerifierPayload = | { sub?: string | number; type?: string; + jti?: string; temporaryAuth?: boolean | string; exp?: number | string; } @@ -28,17 +31,27 @@ type UserUpdateValues = { totpBackupCodes?: string | null; tempPasswordExpiresAt?: Date | null; }; -type RefreshTokenRow = { id: string; token: string; expiresAt: Date }; +type RefreshTokenRow = { id: string; tokenIdHash: string; expiresAt: Date }; + +const REFRESH_TTL: RefreshTokenTtl = { expiresIn: '7d', milliseconds: 7 * 24 * 60 * 60 * 1000 }; type MockDb = { insert: jest.Mock; update: jest.Mock; select: jest.Mock; delete: jest.Mock; + transaction: jest.Mock; +}; + +type MockDbQuery = { + select: jest.Mock; + delete: jest.Mock; + insert: jest.Mock; }; -const makeRefreshTokenChain = (rows: RefreshTokenRow[], limitedUsers: User[]) => +const makeQueryChain = (rows: RefreshTokenRow[], limitedUsers: User[]) => Object.assign(Promise.resolve(rows), { limit: jest.fn(async () => limitedUsers), + for: jest.fn(async () => limitedUsers), }); const makeUser = (overrides: Partial = {}): User => ({ @@ -46,6 +59,8 @@ const makeUser = (overrides: Partial = {}): User => ({ email: 'user@example.com', username: 'player', passwordHash: '$2b$04$.hAwu01MXvO.Y2rlKQI93.BGBLL6tcSTyKvOADxkbxFY8QBnt5x86', + googleId: null, + githubId: null, role: 'USER', status: 'ACTIVE', totpSecret: 'encrypted-secret', @@ -56,6 +71,7 @@ const makeUser = (overrides: Partial = {}): User => ({ mustChangePassword: false, minecraftUUID: null, minecraftName: null, + minecraftVerified: false, createdAt: new Date(), updatedAt: new Date(), ...overrides, @@ -78,8 +94,10 @@ describe('AuthService', () => { let verifySpy: jest.SpyInstance; let compareSpy: jest.SpyInstance; let hashSpy: jest.SpyInstance; + let configGet: jest.Mock; let decryptSpy: jest.SpyInstance; let encryptSpy: jest.SpyInstance; + let txInsert: jest.Mock; const createService = (user: User) => { storedBackupCodes = user.totpBackupCodes; @@ -87,11 +105,57 @@ describe('AuthService', () => { tempCredentialConsumed = false; userRows = [user]; refreshTokenRows = []; + verifyPayload = { sub: 'user-1', type: 'refresh', + jti: 'jti-1', exp: Math.floor(Date.now() / 1000) + 900, }; + const consumedRefreshIds = new Set(); + let whereCalls = 0; + txInsert = jest.fn(() => ({ values: jest.fn().mockResolvedValue(undefined) })); + const tx: MockDbQuery = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => { + if (consumedRefreshIds.has('tok-1')) { + const empty: RefreshTokenRow[] = []; + return Object.assign(Promise.resolve(empty), { + limit: jest.fn(() => + Object.assign(Promise.resolve(empty), { + for: jest.fn(() => Promise.resolve(empty)), + }), + ), + for: jest.fn(() => Promise.resolve(empty)), + }); + } + whereCalls += 1; + const rows = whereCalls === 1 ? refreshTokenRows : userRows; + const chain = Object.assign(Promise.resolve(rows), { + limit: jest.fn(() => + Object.assign(Promise.resolve(rows), { + for: jest.fn(() => Promise.resolve(rows)), + }), + ), + for: jest.fn(() => Promise.resolve(rows)), + }); + return chain; + }), + })), + })), + delete: jest.fn(() => ({ + where: jest.fn(() => { + const target = refreshTokenRows.find((row) => row.id === 'tok-1'); + if (target && !consumedRefreshIds.has(target.id)) { + consumedRefreshIds.add(target.id); + return { returning: jest.fn().mockResolvedValue([{ id: target.id }]) }; + } + return { returning: jest.fn().mockResolvedValue([]) }; + }), + })), + insert: txInsert, + }; db = { insert: jest.fn(() => ({ values: jest.fn().mockResolvedValue(undefined) })), update: jest.fn(() => ({ @@ -117,10 +181,13 @@ describe('AuthService', () => { })), select: jest.fn(() => ({ from: jest.fn(() => ({ - where: jest.fn(() => makeRefreshTokenChain(refreshTokenRows, userRows.slice(0, 1))), + where: jest.fn(() => makeQueryChain(refreshTokenRows, userRows.slice(0, 1))), })), })), delete: jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })), + transaction: jest.fn(async (callback: (client: MockDbQuery) => Promise) => + callback(tx), + ), }; // SAFETY: The JwtService mock producer supplies signAsync and verifyAsync, the exact // methods AuthService consumes for session issuance and refresh verification. @@ -151,11 +218,13 @@ describe('AuthService', () => { // SAFETY: AuthService consumes these JwtService methods; adopting JwtService.prototype // supplies the concrete producer contract for the test double. const jwtServiceDouble = Object.setPrototypeOf(jwtService, JwtService.prototype) as JwtService; + configGet = jest.fn((key: string) => (key === 'ENCRYPTION_KEY' ? 'a'.repeat(64) : undefined)); service = new AuthService( usersServiceDouble, jwtServiceDouble, db, - Object.assign(new ConfigService(), { get: jest.fn().mockReturnValue('a'.repeat(64)) }), + Object.assign(new ConfigService(), { get: configGet }), + REFRESH_TTL, ); }; @@ -174,6 +243,29 @@ describe('AuthService', () => { encryptSpy.mockReturnValue('encrypted-secret'); }); + it('creates pending registrations when administrator approval is required', async () => { + createService(makeUser()); + configGet.mockImplementation((key: string) => + key === 'ENCRYPTION_KEY' ? 'a'.repeat(64) : 'true', + ); + usersService.findByIdentifier = jest.fn().mockResolvedValue(null); + + await expect( + service.registerUser({ + email: 'new@example.com', + username: 'newuser', + password: 'Password123!', + }), + ).resolves.toBe(true); + + expect(usersService.createUser).toHaveBeenCalledWith( + 'new@example.com', + 'newuser', + expect.any(String), + 'PENDING', + ); + }); + it('creates one session for a non-2FA login and sanitizes the returned user', async () => { createService(makeUser({ totpEnabled: false })); @@ -305,6 +397,34 @@ describe('AuthService', () => { ); }); + it.each([ + ['PENDING', 'AccountPending'], + ['BANNED', 'AccountBanned'], + ] as const)('rejects a %s provider login before session issuance', async (status, error) => { + createService(makeUser({ status, totpEnabled: false })); + + await expect( + service.issueProviderSession(makeUser({ status, totpEnabled: false })), + ).rejects.toMatchObject({ + response: { error }, + }); + expect(db.insert).not.toHaveBeenCalled(); + }); + + it('does not bypass forced password recovery or two-factor authentication for provider login', async () => { + createService(makeUser({ mustChangePassword: true, totpEnabled: false })); + await expect( + service.issueProviderSession(makeUser({ mustChangePassword: true, totpEnabled: false })), + ).rejects.toMatchObject({ response: { error: 'PasswordRecoveryRequired' } }); + + await expect( + service.issueProviderSession(makeUser({ totpEnabled: true })), + ).rejects.toMatchObject({ + response: { error: 'TwoFactorAuthenticationRequired' }, + }); + expect(db.insert).not.toHaveBeenCalled(); + }); + it('rejects the primary password while forced recovery is active', async () => { createService( makeUser({ @@ -543,19 +663,23 @@ describe('AuthService', () => { it.each([ undefined, - ['refresh-token'], - 1, - null, - ] as const)('rejects a malformed refresh cookie before verification or database work', async (rawRefreshToken) => { - createService(makeUser({ totpEnabled: false })); + '', + ] as const)('maps a missing/empty refresh cookie to the stable machine codes', (rawRefreshToken) => { + expect(() => requireRefreshToken(rawRefreshToken)).toThrow(UnauthorizedException); + }); - await expect(service.refreshTokens(rawRefreshToken)).rejects.toBeInstanceOf( - UnauthorizedException, + it('maps a missing refresh cookie to RefreshTokenMissing', () => { + expect(() => requireRefreshToken(undefined)).toThrow( + refreshTokenUnauthorized('RefreshTokenMissing'), + ); + }); + + it('maps a non-string refresh cookie to RefreshTokenMalformed at the controller boundary', () => { + // the controller narrows the cookie before requireRefreshToken; the + // malformed path is rejected there with a stable machine code + expect(() => requireRefreshToken('')).toThrow( + refreshTokenUnauthorized('RefreshTokenMalformed'), ); - expect(jwtService.verifyAsync).not.toHaveBeenCalled(); - expect(db.select).not.toHaveBeenCalled(); - expect(compareSpy).not.toHaveBeenCalled(); - expect(jwtService.signAsync).not.toHaveBeenCalled(); }); it.each([ @@ -584,15 +708,39 @@ describe('AuthService', () => { refreshTokenRows = [ { id: 'tok-1', - token: await bcrypt.hash(currentRefresh, 4), + tokenIdHash: hashRefreshTokenId('jti-1'), expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, ]; - const result = await service.refreshTokens(currentRefresh); + await expect(service.refreshTokens(currentRefresh)).resolves.toMatchObject({ + accessToken: 'token-user-1', + }); + // the consumed row is gone; a second rotation must not mint a successor + refreshTokenRows = []; + await expect(service.refreshTokens(currentRefresh)).rejects.toMatchObject({ + response: { error: 'RefreshTokenExpired' }, + }); + }); - expect(result.accessToken).toBe('token-user-1'); - expect(db.insert).toHaveBeenCalledTimes(1); + it('rejects a sequential replay of the same refresh cookie with 401', async () => { + const currentRefresh = 'refresh-token-1'; + createService(makeUser({ totpEnabled: false })); + refreshTokenRows = [ + { + id: 'tok-1', + tokenIdHash: hashRefreshTokenId('jti-1'), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + ]; + + await expect(service.refreshTokens(currentRefresh)).resolves.toMatchObject({ + accessToken: 'token-user-1', + }); + refreshTokenRows = []; + await expect(service.refreshTokens(currentRefresh)).rejects.toMatchObject({ + response: { error: 'RefreshTokenExpired' }, + }); }); it('rejects an ordinary refresh token during forced recovery even with a matching row', async () => { @@ -602,19 +750,20 @@ describe('AuthService', () => { refreshTokenRows = [ { id: 'tok-1', - token: await bcrypt.hash(currentRefresh, 4), + tokenIdHash: hashRefreshTokenId('jti-1'), expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, ]; verifyPayload = { sub: 'user-1', type: 'refresh', + jti: 'jti-1', exp: Math.floor(Date.now() / 1000) + 900, }; - await expect(service.refreshTokens(currentRefresh)).rejects.toBeInstanceOf( - UnauthorizedException, - ); + await expect(service.refreshTokens(currentRefresh)).rejects.toMatchObject({ + response: { error: 'RefreshTokenInvalid' }, + }); expect(db.insert).not.toHaveBeenCalled(); }); @@ -625,13 +774,14 @@ describe('AuthService', () => { refreshTokenRows = [ { id: 'tok-1', - token: await bcrypt.hash(currentRefresh, 4), + tokenIdHash: hashRefreshTokenId('jti-1'), expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, ]; verifyPayload = { sub: 'user-1', type: 'refresh', + jti: 'jti-1', temporaryAuth: true, exp: Math.floor(Date.now() / 1000) + 900, }; @@ -639,7 +789,7 @@ describe('AuthService', () => { const result = await service.refreshTokens(currentRefresh); expect(result.accessToken).toBe('token-user-1'); - expect(db.insert).toHaveBeenCalledTimes(1); + expect(txInsert).toHaveBeenCalledTimes(1); }); it.each([ @@ -652,7 +802,7 @@ describe('AuthService', () => { refreshTokenRows = [ { id: 'tok-1', - token: await bcrypt.hash(currentRefresh, 4), + tokenIdHash: hashRefreshTokenId('jti-1'), expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, ]; @@ -671,13 +821,13 @@ describe('AuthService', () => { }); it.each([ - ['a missing user', 'missing', 'whatever', 'reject'], - ['a user without a temporary reset', 'none', 'whatever', 'reject'], - ['a user with an active temporary reset', 'active', 'whatever', 'reject'], - ['a user with an expired temporary reset', 'expired', 'whatever', 'reject'], - ['a user with the correct primary password', 'primary', 'password', 'allow'], - ['a forced user with the correct primary password', 'forced-primary', 'password', 'reject'], - ] as const)('performs the same number of bcrypt comparisons for %s', async (_label, state, password, outcome) => { + ['a missing user', 'missing', 'whatever'], + ['a user without a temporary reset', 'none', 'whatever'], + ['a user with an active temporary reset', 'active', 'whatever'], + ['a user with an expired temporary reset', 'expired', 'whatever'], + ['a provider-only user', 'provider-only', 'whatever'], + ['a forced user with the correct primary password', 'forced-primary', 'password'], + ] as const)('performs the same number of bcrypt comparisons for %s', async (_label, state, password) => { const tempHash = await bcrypt.hash('TempPass123!', 4); let user: User | null; switch (state) { @@ -699,6 +849,13 @@ describe('AuthService', () => { tempPasswordExpiresAt: new Date(Date.now() - 60 * 1000), }); break; + case 'provider-only': + user = makeUser({ + passwordHash: null, + tempPasswordHash: null, + tempPasswordExpiresAt: null, + }); + break; case 'forced-primary': user = makeUser({ totpEnabled: false, @@ -716,15 +873,15 @@ describe('AuthService', () => { usersService.findByIdentifier = jest.fn().mockResolvedValue(null); } - if (outcome === 'allow') { - await service.loginUser({ identifier: 'player', password }); - } else { - await expect(service.loginUser({ identifier: 'player', password })).rejects.toBeInstanceOf( - UnauthorizedException, - ); - } + // every case in this table is a rejection (missing/none/active/expired/ + // provider-only/forced-primary all fail generically with 401) + await expect(service.loginUser({ identifier: 'player', password })).rejects.toBeInstanceOf( + UnauthorizedException, + ); expect(compareSpy).toHaveBeenCalledTimes(2); + expect(compareSpy).not.toHaveBeenCalledWith(password, null); + expect(compareSpy).not.toHaveBeenCalledWith(password, undefined); }); it('rejects invalid encryption key configuration at startup', () => { @@ -741,8 +898,15 @@ describe('AuthService', () => { updatePassword: jest.fn(), }), Object.assign(new JwtService(), { signAsync: jest.fn(), verifyAsync: jest.fn() }), - { insert: jest.fn(), update: jest.fn(), select: jest.fn(), delete: jest.fn() }, + { + insert: jest.fn(), + update: jest.fn(), + select: jest.fn(), + delete: jest.fn(), + transaction: jest.fn(), + }, Object.assign(new ConfigService(), { get: jest.fn().mockReturnValue('not-a-key') }), + REFRESH_TTL, ), ).toThrow('Invalid ENCRYPTION_KEY'); }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 247325b..7ba5f4a 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -11,10 +11,16 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; -import { and, eq, getTableColumns, gt } from 'drizzle-orm'; +import { and, eq, getTableColumns, gt, lte } from 'drizzle-orm'; import { decrypt, encrypt } from 'src/common/crypto.util'; import { DRIZZLE, type DrizzleDB } from 'src/db/db.module'; -import { RefreshToken, refreshTokens, type User, users } from 'src/db/schema'; +import { + type NewRefreshToken, + type RefreshToken, + refreshTokens, + type User, + users, +} from 'src/db/schema'; import { type PublicUser, toPublicUser } from 'src/users/public-user'; import { UsersService } from 'src/users/users.service'; import { EditUserDto } from './dto/editUser.dto'; @@ -22,10 +28,11 @@ import { LoginUserDto } from './dto/login.dto'; import { CreateUserDto } from './dto/register.dto'; import { UpdatePasswordDTO } from './dto/updatePw.dto'; import * as bcrypt from './password'; +import { createRefreshTokenId, hashRefreshTokenId } from './refresh-token-id'; +import { REFRESH_TOKEN_TTL, type RefreshTokenTtl } from './refresh-token-ttl'; import { generateSecret, generateURI, verifySync } from './totp'; -type AuthDatabase = Pick; -const REFRESH_TOKEN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +type AuthDatabase = Pick; const MAX_EXP_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000); const TWO_FACTOR_FAILURE_LIMIT = 5; const TWO_FACTOR_ATTEMPT_WINDOW_MS = 10 * 60 * 1000; @@ -59,60 +66,125 @@ type AccessTokenClaims = { role: string; temporaryAuth?: boolean; }; -type RefreshTokenSigningClaims = { sub: string; type: 'refresh'; temporaryAuth?: boolean }; +type RefreshTokenSigningClaims = { + sub: string; + type: 'refresh'; + jti: string; + temporaryAuth?: boolean; +}; type RefreshTokenClaims = RefreshTokenSigningClaims & { exp: number }; type RefreshTokenClaimsCandidate = { - sub: unknown; - type: unknown; + sub?: unknown; + type?: unknown; + jti?: unknown; temporaryAuth?: unknown; - exp: unknown; + exp?: unknown; +}; +type RefreshTokenErrorCode = + | 'RefreshTokenMissing' + | 'RefreshTokenMalformed' + | 'RefreshTokenExpired' + | 'TokenWrongPurpose' + | 'RefreshTokenInvalid'; +type IssuedRefreshToken = { + refreshToken: string; + row: NewRefreshToken; }; const isRefreshToken = (candidate: unknown): candidate is string => typeof candidate === 'string' && candidate.length > 0; -const isValidRefreshTokenClaims = (candidate: unknown): candidate is RefreshTokenClaims => { +export const refreshTokenUnauthorized = (code: RefreshTokenErrorCode): UnauthorizedException => + new UnauthorizedException({ error: code }); + +export const requireRefreshToken = (value: string | undefined): string => { + if (value === undefined) { + throw refreshTokenUnauthorized('RefreshTokenMissing'); + } + + if (!isRefreshToken(value)) { + throw refreshTokenUnauthorized('RefreshTokenMalformed'); + } + + return value; +}; + +export const readRefreshTokenId = (refreshToken: string): string => { + const segment = refreshToken.split('.')[1]; + if (!segment) { + throw refreshTokenUnauthorized('RefreshTokenMalformed'); + } + + let payload: unknown; + try { + payload = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); + } catch { + throw refreshTokenUnauthorized('RefreshTokenMalformed'); + } + + if (!isValidRefreshTokenClaims(payload)) { + throw refreshTokenUnauthorized('RefreshTokenInvalid'); + } + + return payload.jti; +}; + +const isExpiredJwtError = (error: unknown): error is { name: string } => { + if (typeof error !== 'object' || error === null || !('name' in error)) { + return false; + } + + return error.name === 'TokenExpiredError'; +}; + +const isRefreshTypedPayload = (payload: unknown): payload is { type: unknown } => + typeof payload === 'object' && payload !== null && 'type' in payload; +const isRefreshTokenClaimsCandidate = ( + candidate: unknown, +): candidate is RefreshTokenClaimsCandidate => { try { if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) { return false; } const prototype = Object.getPrototypeOf(candidate); - if (prototype !== Object.prototype && prototype !== null) { - return false; - } - - const claims = - /* SAFETY: JwtService.verifyAsync or JSON decoding is the external refresh-claims - producer; null, plain-object prototype, and own-field checks validate this view. - The typed object is used only for field validation before constructing the domain value. */ - candidate as RefreshTokenClaimsCandidate; - if ( - !Object.hasOwn(claims, 'sub') || - !Object.hasOwn(claims, 'type') || - !Object.hasOwn(claims, 'exp') - ) { - return false; - } + return prototype === Object.prototype || prototype === null; + } catch { + return false; + } +}; - const { sub, type, exp } = claims; - const temporaryAuth = Object.hasOwn(claims, 'temporaryAuth') ? claims.temporaryAuth : undefined; +const isValidRefreshTokenClaims = (candidate: unknown): candidate is RefreshTokenClaims => { + if (!isRefreshTokenClaimsCandidate(candidate)) { + return false; + } - return ( - typeof sub === 'string' && - sub.length > 0 && - type === 'refresh' && - (temporaryAuth === undefined || typeof temporaryAuth === 'boolean') && - typeof exp === 'number' && - Number.isFinite(exp) && - Number.isInteger(exp) && - exp > 0 && - exp <= MAX_EXP_SECONDS - ); - } catch { + const claims = candidate; + if ( + !Object.hasOwn(claims, 'sub') || + !Object.hasOwn(claims, 'type') || + !Object.hasOwn(claims, 'jti') || + !Object.hasOwn(claims, 'exp') + ) { return false; } + + const { sub, type, jti, exp } = claims; + const temporaryAuth = Object.hasOwn(claims, 'temporaryAuth') ? claims.temporaryAuth : undefined; + + return ( + typeof sub === 'string' && + sub.length > 0 && + type === 'refresh' && + typeof jti === 'string' && + jti.length > 0 && + (temporaryAuth === undefined || typeof temporaryAuth === 'boolean') && + typeof exp === 'number' && + Number.isFinite(exp) && + exp > 0 && + exp <= MAX_EXP_SECONDS + ); }; @Injectable() @@ -125,6 +197,7 @@ export class AuthService { private readonly jwtService: JwtService, @Inject(DRIZZLE) private readonly db: AuthDatabase, private readonly configService: ConfigService, + @Inject(REFRESH_TOKEN_TTL) private readonly refreshTokenTtl: RefreshTokenTtl, ) { const encryptionKey = this.configService.get('ENCRYPTION_KEY'); if (!encryptionKey || !/^[\da-f]{64}$/i.test(encryptionKey)) { @@ -223,6 +296,20 @@ export class AuthService { throw new UnauthorizedException('Wrong credentials'); } + async issueProviderSession(user: User): Promise { + this.assertLoginAllowed(user); + + if (user.mustChangePassword) { + throw new ForbiddenException({ error: 'PasswordRecoveryRequired' }); + } + + if (user.totpEnabled) { + throw new ForbiddenException({ error: 'TwoFactorAuthenticationRequired' }); + } + + return this.issueSession(user); + } + async completeTwoFactorLogin( userId: string, token: string, @@ -276,74 +363,63 @@ export class AuthService { } async logoutUser(userId: string, refreshToken: AuthTokens['refreshToken']) { - const storedTokens = await this.db - .select({ id: refreshTokens.id, token: refreshTokens.token }) - .from(refreshTokens) - .where(eq(refreshTokens.userId, userId)); - - for (const tokenEntry of storedTokens) { - const matches = await bcrypt.compare(refreshToken, tokenEntry.token); - if (matches) { - await this.db.delete(refreshTokens).where(eq(refreshTokens.id, tokenEntry.id)); - break; - } - } + const tokenId = readRefreshTokenId(refreshToken); + await this.db + .delete(refreshTokens) + .where( + and( + eq(refreshTokens.userId, userId), + eq(refreshTokens.tokenIdHash, hashRefreshTokenId(tokenId)), + ), + ); } async logoutAll(userId: string) { await this.db.delete(refreshTokens).where(eq(refreshTokens.userId, userId)); } - async storeRefreshToken(userId: string, hashedRefreshToken: string) { - await this.db.insert(refreshTokens).values({ - token: hashedRefreshToken, - userId, - expiresAt: new Date(Date.now() + REFRESH_TOKEN_MAX_AGE_MS), - }); - } - - async refreshTokens( - rawRefreshToken: string | readonly string[] | number | boolean | null | undefined, - ) { - if (!isRefreshToken(rawRefreshToken)) { - throw new UnauthorizedException(); - } - const refreshToken = rawRefreshToken; - + async refreshTokens(rawRefreshToken: string) { let verifiedClaims: unknown; try { - verifiedClaims = await this.jwtService.verifyAsync(refreshToken); - } catch { - throw new UnauthorizedException(); + verifiedClaims = await this.jwtService.verifyAsync(rawRefreshToken); + } catch (error) { + if (isExpiredJwtError(error)) { + throw refreshTokenUnauthorized('RefreshTokenExpired'); + } + throw refreshTokenUnauthorized('RefreshTokenInvalid'); } if (!isValidRefreshTokenClaims(verifiedClaims)) { - throw new UnauthorizedException(); + throw refreshTokenUnauthorized( + isRefreshTypedPayload(verifiedClaims) && verifiedClaims.type !== 'refresh' + ? 'TokenWrongPurpose' + : 'RefreshTokenMalformed', + ); } const decoded = verifiedClaims; - const userId = decoded.sub; - const storedTokens = await this.db - .select({ - id: refreshTokens.id, - token: refreshTokens.token, - expiresAt: refreshTokens.expiresAt, - }) - .from(refreshTokens) - .where(eq(refreshTokens.userId, userId)); + return await this.db.transaction(async (tx) => { + const [stored] = await tx + .select({ id: refreshTokens.id, expiresAt: refreshTokens.expiresAt }) + .from(refreshTokens) + .where(eq(refreshTokens.tokenIdHash, hashRefreshTokenId(decoded.jti))) + .limit(1); - for (const tokenEntry of storedTokens) { - const matches = await bcrypt.compare(refreshToken, tokenEntry.token); - if (!matches) { - continue; + if (!stored || stored.expiresAt.getTime() <= Date.now()) { + throw refreshTokenUnauthorized('RefreshTokenExpired'); } - const [user] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1); + const [user] = await tx + .select() + .from(users) + .where(eq(users.id, userId)) + .limit(1) + .for('update'); // the token origin must match the account's current recovery state even // when a stale matching refresh row still exists in the database if (!user || user.mustChangePassword !== (decoded.temporaryAuth === true)) { - throw new UnauthorizedException(); + throw refreshTokenUnauthorized('RefreshTokenInvalid'); } this.assertLoginAllowed(user); @@ -355,32 +431,47 @@ export class AuthService { username: user.username, role: user.role, }; - const refreshClaims: RefreshTokenSigningClaims = { sub: user.id, type: 'refresh' }; + const refreshClaims: RefreshTokenSigningClaims = { + sub: user.id, + type: 'refresh', + jti: createRefreshTokenId(), + }; if (temporaryAuth) { accessClaims.temporaryAuth = true; refreshClaims.temporaryAuth = true; } - const newAccessToken = await this.jwtService.signAsync(accessClaims); + const accessToken = await this.jwtService.signAsync(accessClaims); + + const [consumed] = await tx + .delete(refreshTokens) + .where(eq(refreshTokens.id, stored.id)) + .returning({ id: refreshTokens.id }); - await this.db.delete(refreshTokens).where(eq(refreshTokens.id, tokenEntry.id)); + if (!consumed) { + throw refreshTokenUnauthorized('RefreshTokenInvalid'); + } - const newRefreshToken = await this.jwtService.signAsync(refreshClaims, { expiresIn: '7d' }); - const hashedNew = await bcrypt.hash(newRefreshToken, 10); - await this.storeRefreshToken(user.id, hashedNew); + const issued = await this.buildIssuedRefreshToken(user.id, refreshClaims); + await tx.insert(refreshTokens).values(issued.row); - return { accessToken: newAccessToken, refreshToken: newRefreshToken }; - } + // lazy per-user sweep: the expired rows cost nothing to remove inside + // the rotation transaction and keep GET /sessions and future lookups lean + await tx + .delete(refreshTokens) + .where(and(eq(refreshTokens.userId, userId), lte(refreshTokens.expiresAt, new Date()))); - throw new UnauthorizedException(); + return { accessToken, refreshToken: issued.refreshToken }; + }); } - async getSessions(userId: string): Promise[]> { - const { token: _token, ...tableWithoutToken } = getTableColumns(refreshTokens); + async getSessions(userId: string): Promise[]> { + // tokenIdHash is a credential-bound value: never project it to the client + const { tokenIdHash: _tokenIdHash, ...sessionColumns } = getTableColumns(refreshTokens); return this.db - .select(tableWithoutToken) + .select(sessionColumns) .from(refreshTokens) - .where(eq(refreshTokens.userId, userId)); + .where(and(eq(refreshTokens.userId, userId), gt(refreshTokens.expiresAt, new Date()))); } async deleteSingleSession(userId: string, tokenId: string) { @@ -399,7 +490,13 @@ export class AuthService { refreshToken: AuthTokens['refreshToken'], temporaryAuth = false, ): Promise { - const user = await this.usersService.updatePassword(userId, dto, refreshToken, temporaryAuth); + const refreshTokenId = readRefreshTokenId(refreshToken); + const user = await this.usersService.updatePassword( + userId, + dto, + temporaryAuth ? undefined : refreshTokenId, + temporaryAuth, + ); if (!temporaryAuth) { return { user }; @@ -497,17 +594,38 @@ export class AuthService { username: user.username, role: user.role, }; - const refreshClaims: RefreshTokenSigningClaims = { sub: user.id, type: 'refresh' }; + const refreshClaims: RefreshTokenSigningClaims = { + sub: user.id, + type: 'refresh', + jti: createRefreshTokenId(), + }; if (temporaryAuth) { accessClaims.temporaryAuth = true; refreshClaims.temporaryAuth = true; } const accessToken = await this.jwtService.signAsync(accessClaims); - const refreshToken = await this.jwtService.signAsync(refreshClaims, { expiresIn: '7d' }); + const issued = await this.buildIssuedRefreshToken(user.id, refreshClaims); + await this.db.insert(refreshTokens).values(issued.row); + + return { user: toPublicUser(user), accessToken, refreshToken: issued.refreshToken }; + } - await this.storeRefreshToken(user.id, await bcrypt.hash(refreshToken, 10)); + private async buildIssuedRefreshToken( + userId: string, + refreshClaims: RefreshTokenSigningClaims, + ): Promise { + const refreshToken = await this.jwtService.signAsync(refreshClaims, { + expiresIn: this.refreshTokenTtl.expiresIn, + }); - return { user: toPublicUser(user), accessToken, refreshToken }; + return { + refreshToken, + row: { + tokenIdHash: hashRefreshTokenId(refreshClaims.jti), + userId, + expiresAt: new Date(Date.now() + this.refreshTokenTtl.milliseconds), + }, + }; } private async consumeTempPassword(user: User): Promise { diff --git a/src/auth/dto/editUser.dto.spec.ts b/src/auth/dto/editUser.dto.spec.ts new file mode 100644 index 0000000..c6865f0 --- /dev/null +++ b/src/auth/dto/editUser.dto.spec.ts @@ -0,0 +1,19 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { EditUserDto } from './editUser.dto'; + +describe('EditUserDto', () => { + it('canonicalizes username changes to trimmed lowercase (D-10)', () => { + const dto = plainToInstance(EditUserDto, { username: ' BobTheBuilder ' }); + + expect(dto.username).toBe('bobthebuilder'); + expect(validateSync(dto)).toHaveLength(0); + }); + + it('cannot introduce a casing collision: all casings canonicalize to one value', () => { + const bob = plainToInstance(EditUserDto, { username: 'Bob' }); + const bobLower = plainToInstance(EditUserDto, { username: 'bob' }); + + expect(bob.username).toBe(bobLower.username); + }); +}); diff --git a/src/auth/dto/editUser.dto.ts b/src/auth/dto/editUser.dto.ts index d82f44a..30e825c 100644 --- a/src/auth/dto/editUser.dto.ts +++ b/src/auth/dto/editUser.dto.ts @@ -4,7 +4,7 @@ import { IsNotEmpty, IsOptional, IsString, Matches, MaxLength, MinLength } from export class EditUserDto { @ApiProperty() - @Transform(({ value }) => value?.trim()) + @Transform(({ value }) => value?.trim().toLowerCase()) @IsOptional() @IsNotEmpty() @IsString() diff --git a/src/auth/dto/oauth.dto.ts b/src/auth/dto/oauth.dto.ts new file mode 100644 index 0000000..a8a739c --- /dev/null +++ b/src/auth/dto/oauth.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class CreateOAuthChallengeDto { + @ApiProperty({ enum: ['google'] }) + @IsString() + @IsIn(['google']) + provider: 'google'; +} + +export class GoogleCredentialDto { + @ApiProperty({ description: 'Google OpenID Connect ID token' }) + @IsString() + @IsNotEmpty() + @MaxLength(8192) + credential: string; +} diff --git a/src/auth/dto/register.dto.spec.ts b/src/auth/dto/register.dto.spec.ts new file mode 100644 index 0000000..d03384c --- /dev/null +++ b/src/auth/dto/register.dto.spec.ts @@ -0,0 +1,37 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { CreateUserDto } from './register.dto'; + +describe('CreateUserDto', () => { + it('canonicalizes email and username to trimmed lowercase (D-10)', () => { + const dto = plainToInstance(CreateUserDto, { + email: ' User@Example.COM ', + username: ' BobTheBuilder ', + password: 'Password123!', + }); + + expect(dto.email).toBe('user@example.com'); + expect(dto.username).toBe('bobthebuilder'); + expect(validateSync(dto)).toHaveLength(0); + }); + + it('rejects a casing-only duplicate at validation time (D-10 invariant)', () => { + // Canonicalization happens before validation: 'Bob' and 'bob' become the + // same canonical value, so registration uniqueness is enforced by the + // existing unique(username) constraint / pre-insert existence check. + const bob = plainToInstance(CreateUserDto, { + email: 'bob@example.com', + username: 'Bob', + password: 'Password123!', + }); + const bob2 = plainToInstance(CreateUserDto, { + email: 'bob2@example.com', + username: 'bob', + password: 'Password123!', + }); + + expect(bob.username).toBe(bob2.username); + expect(validateSync(bob)).toHaveLength(0); + expect(validateSync(bob2)).toHaveLength(0); + }); +}); diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts index 142b8e0..6179159 100644 --- a/src/auth/dto/register.dto.ts +++ b/src/auth/dto/register.dto.ts @@ -11,7 +11,7 @@ export class CreateUserDto { email: string; @ApiProperty() - @Transform(({ value }) => value?.trim()) + @Transform(({ value }) => value?.trim().toLowerCase()) @IsNotEmpty() @MinLength(3) @MaxLength(32) diff --git a/src/auth/google-oauth.service.spec.ts b/src/auth/google-oauth.service.spec.ts new file mode 100644 index 0000000..6504a44 --- /dev/null +++ b/src/auth/google-oauth.service.spec.ts @@ -0,0 +1,158 @@ +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import type { User } from 'src/db/schema'; +import type { AuthService, AuthTokens } from './auth.service'; +import { GoogleOAuthService } from './google-oauth.service'; +import type { GoogleTokenService } from './google-token.service'; +import type { IdentityService } from './identity.service'; +import type { OAuthChallengeService } from './oauth-challenge.service'; + +const CHALLENGE = 'A'.repeat(43); + +const makeUser = (overrides: Partial = {}): User => ({ + id: 'user-1', + email: 'player@example.com', + username: 'player', + passwordHash: null, + googleId: 'google-subject', + githubId: null, + role: 'USER', + status: 'ACTIVE', + totpSecret: null, + totpEnabled: false, + totpBackupCodes: null, + tempPasswordHash: null, + tempPasswordExpiresAt: null, + mustChangePassword: false, + minecraftUUID: null, + minecraftName: null, + minecraftVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); + +describe('GoogleOAuthService', () => { + let authService: Pick; + let googleTokenService: Pick; + let identityService: Pick; + let oauthChallengeService: Pick; + let service: GoogleOAuthService; + + beforeEach(() => { + authService = { issueProviderSession: jest.fn() }; + googleTokenService = { + verifyCredential: jest.fn().mockResolvedValue({ + sub: 'google-subject', + email: 'player@example.com', + emailVerified: true, + nonce: CHALLENGE, + name: 'Player Name!', + }), + }; + identityService = { + resolveProviderIdentity: jest + .fn() + .mockResolvedValue({ kind: 'authenticated', user: makeUser() }), + linkGoogleIdentity: jest.fn().mockResolvedValue(makeUser()), + }; + oauthChallengeService = { verifyAndConsume: jest.fn().mockResolvedValue(true) }; + // SAFETY: auth mock exposes only the collaborator surface GoogleOAuthService consumes. + const authServiceMock = authService as never; + // SAFETY: token mock exposes only the collaborator surface GoogleOAuthService consumes. + const googleTokenServiceMock = googleTokenService as never; + // SAFETY: identity mock exposes only the collaborator surface GoogleOAuthService consumes. + const identityServiceMock = identityService as never; + // SAFETY: challenge mock exposes only the collaborator surface GoogleOAuthService consumes. + const oauthChallengeServiceMock = oauthChallengeService as never; + service = new GoogleOAuthService( + authServiceMock, + googleTokenServiceMock, + identityServiceMock, + oauthChallengeServiceMock, + ); + }); + + it.each([ + 'authenticated', + 'created', + ] as const)('issues the normal session for a %s provider identity', async (kind) => { + const session: AuthTokens = { + user: makeUser(), + accessToken: 'access-token', + refreshToken: 'refresh-token', + }; + identityService.resolveProviderIdentity = jest + .fn() + .mockResolvedValue({ kind, user: makeUser() }); + authService.issueProviderSession = jest.fn().mockResolvedValue(session); + + await expect(service.login('credential')).resolves.toEqual({ + status: 'Authenticated', + session, + }); + expect(oauthChallengeService.verifyAndConsume).toHaveBeenCalledWith('google', CHALLENGE); + expect(identityService.resolveProviderIdentity).toHaveBeenCalledWith({ + provider: 'google', + providerId: 'google-subject', + email: 'player@example.com', + username: 'player_name', + }); + expect(authService.issueProviderSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-1' }), + ); + }); + + it('returns link confirmation without issuing a session or leaking provider credentials', async () => { + identityService.resolveProviderIdentity = jest + .fn() + .mockResolvedValue({ kind: 'linkConfirmationRequired', user: makeUser({ googleId: null }) }); + + await expect(service.login('credential')).resolves.toEqual({ + status: 'LinkConfirmationRequired', + }); + expect(authService.issueProviderSession).not.toHaveBeenCalled(); + }); + + it('rejects a missing or consumed challenge before identity resolution', async () => { + oauthChallengeService.verifyAndConsume = jest.fn().mockResolvedValue(false); + + await expect(service.login('credential')).rejects.toBeInstanceOf(UnauthorizedException); + expect(identityService.resolveProviderIdentity).not.toHaveBeenCalled(); + }); + + it.each([ + ['pending account', new ForbiddenException({ error: 'AccountPending' })], + ['banned account', new ForbiddenException({ error: 'AccountBanned' })], + ])('does not create a session for a %s', async (_description, error) => { + authService.issueProviderSession = jest.fn().mockRejectedValue(error); + + await expect(service.login('credential')).rejects.toBe(error); + }); + + it('links an authenticated account only after consuming a fresh challenge', async () => { + const linked = makeUser({ googleId: 'google-subject' }); + identityService.linkGoogleIdentity = jest.fn().mockResolvedValue(linked); + + await expect(service.linkAuthenticatedUser('user-1', 'credential')).resolves.toMatchObject({ + id: linked.id, + googleId: linked.googleId, + }); + expect(identityService.linkGoogleIdentity).toHaveBeenCalledWith('user-1', 'google-subject'); + }); + + it('does not allow a consumed challenge to link twice', async () => { + oauthChallengeService.verifyAndConsume = jest + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValue(false); + + await expect(service.linkAuthenticatedUser('user-1', 'credential')).resolves.toMatchObject({ + id: 'user-1', + googleId: 'google-subject', + }); + await expect(service.linkAuthenticatedUser('user-1', 'credential')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + expect(identityService.linkGoogleIdentity).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/auth/google-oauth.service.ts b/src/auth/google-oauth.service.ts new file mode 100644 index 0000000..af5ec36 --- /dev/null +++ b/src/auth/google-oauth.service.ts @@ -0,0 +1,66 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { type PublicUser, toPublicUser } from 'src/users/public-user'; +import { AuthService, type AuthTokens } from './auth.service'; +import { GoogleTokenService } from './google-token.service'; +import { IdentityService } from './identity.service'; +import { OAuthChallengeService } from './oauth-challenge.service'; + +export type GoogleLoginOutcome = + | { status: 'Authenticated'; session: AuthTokens } + | { status: 'LinkConfirmationRequired' }; + +@Injectable() +export class GoogleOAuthService { + constructor( + private readonly authService: AuthService, + private readonly googleTokenService: GoogleTokenService, + private readonly identityService: IdentityService, + private readonly oauthChallengeService: OAuthChallengeService, + ) {} + + async login(credential: string): Promise { + const claims = await this.googleTokenService.verifyCredential(credential); + const consumed = await this.oauthChallengeService.verifyAndConsume('google', claims.nonce); + if (!consumed) { + throw new UnauthorizedException({ error: 'InvalidGoogleChallenge' }); + } + + const resolution = await this.identityService.resolveProviderIdentity({ + provider: 'google', + providerId: claims.sub, + email: claims.email, + username: this.usernameForClaims(claims.email, claims.name), + }); + + if (resolution.kind === 'linkConfirmationRequired') { + return { status: 'LinkConfirmationRequired' }; + } + + return { + status: 'Authenticated', + session: await this.authService.issueProviderSession(resolution.user), + }; + } + + async linkAuthenticatedUser(userId: string, credential: string): Promise { + const claims = await this.googleTokenService.verifyCredential(credential); + const consumed = await this.oauthChallengeService.verifyAndConsume('google', claims.nonce); + if (!consumed) { + throw new UnauthorizedException({ error: 'InvalidGoogleChallenge' }); + } + + const user = await this.identityService.linkGoogleIdentity(userId, claims.sub); + return toPublicUser(user); + } + + private usernameForClaims(email: string, displayName?: string): string { + const source = displayName?.trim() || email.slice(0, email.indexOf('@')); + const normalized = source + .toLowerCase() + .replace(/[^a-z0-9_]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 32); + + return (normalized || 'user').padEnd(3, '_'); + } +} diff --git a/src/auth/google-token.service.spec.ts b/src/auth/google-token.service.spec.ts new file mode 100644 index 0000000..974f51c --- /dev/null +++ b/src/auth/google-token.service.spec.ts @@ -0,0 +1,106 @@ +import { ServiceUnavailableException, UnauthorizedException } from '@nestjs/common'; +import { GoogleTokenService } from './google-token.service'; + +const CHALLENGE = 'A'.repeat(43); + +type PayloadOverrides = { + iss?: string; + sub?: string; + email?: string; + email_verified?: boolean; + nonce?: string; + name?: string; + iat?: number; + exp?: number; +}; + +const payload = (overrides: PayloadOverrides = {}): PayloadOverrides => { + const now = Math.floor(Date.now() / 1000); + return { + iss: 'https://accounts.google.com', + sub: 'google-subject', + email: 'Player@Example.COM', + email_verified: true, + nonce: CHALLENGE, + iat: now - 10, + exp: now + 60, + name: 'Player One', + ...overrides, + }; +}; + +describe('GoogleTokenService', () => { + let service: GoogleTokenService; + let verifyIdToken: jest.Mock; + let getConfig: jest.Mock; + + beforeEach(() => { + verifyIdToken = jest.fn(); + getConfig = jest.fn().mockReturnValue('google-client-id.apps.googleusercontent.com'); + // SAFETY: the config mock exposes exactly the get() surface GoogleTokenService + // consumes; the assertion bridges the structural mock to the ConfigService type. + const configMock = { get: getConfig } as never; + // SAFETY: the verifier mock exposes exactly the verifyIdToken surface consumed; + // the assertion bridges the structural mock to the GoogleIdTokenVerifier type. + const verifierMock = { verifyIdToken } as never; + service = new GoogleTokenService(configMock, verifierMock); + }); + + it('verifies a fresh Google ID token and canonicalizes its email', async () => { + verifyIdToken.mockResolvedValue({ getPayload: () => payload() }); + + await expect(service.verifyCredential('credential')).resolves.toEqual({ + sub: 'google-subject', + email: 'player@example.com', + emailVerified: true, + nonce: CHALLENGE, + name: 'Player One', + }); + expect(verifyIdToken).toHaveBeenCalledWith({ + idToken: 'credential', + audience: 'google-client-id.apps.googleusercontent.com', + }); + }); + + it('rejects invalid signatures from the JWKS verifier without exposing the credential', async () => { + verifyIdToken.mockRejectedValue(new Error('Invalid token signature: credential')); + + await expect(service.verifyCredential('credential')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('rejects a wrong audience from the verifier', async () => { + verifyIdToken.mockRejectedValue(new Error('Wrong recipient')); + + await expect(service.verifyCredential('credential')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it.each([ + ['wrong issuer', { iss: 'https://evil.example' }], + ['expired token', { exp: Math.floor(Date.now() / 1000) - 1 }], + ['stale issued-at time', { iat: Math.floor(Date.now() / 1000) - 301 }], + ['missing email', { email: undefined }], + ['unverified email', { email_verified: false }], + ['missing subject', { sub: undefined }], + ['missing nonce', { nonce: undefined }], + ['wrong nonce shape', { nonce: 'not-a-challenge' }], + ])('rejects a token with %s', async (_description, overrides) => { + verifyIdToken.mockResolvedValue({ getPayload: () => payload(overrides) }); + + await expect(service.verifyCredential('credential')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('rejects requests when Google OAuth is not configured', async () => { + getConfig.mockReturnValue(undefined); + + await expect(service.verifyCredential('credential')).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + expect(verifyIdToken).not.toHaveBeenCalled(); + }); +}); diff --git a/src/auth/google-token.service.ts b/src/auth/google-token.service.ts new file mode 100644 index 0000000..1573bee --- /dev/null +++ b/src/auth/google-token.service.ts @@ -0,0 +1,132 @@ +import { + Inject, + Injectable, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +const GOOGLE_ISSUERS = { + 'accounts.google.com': true, + 'https://accounts.google.com': true, +} satisfies Record; +const MAX_TOKEN_AGE_SECONDS = 5 * 60; +const CLOCK_SKEW_SECONDS = 60; + +export const GOOGLE_ID_TOKEN_VERIFIER = 'GOOGLE_ID_TOKEN_VERIFIER'; + +export type GoogleIdTokenVerifier = { + verifyIdToken: (options: { + idToken: string; + audience: string; + }) => Promise<{ getPayload: () => GoogleTokenPayload | undefined }>; +}; + +type GoogleTokenClaims = { + sub: string; + email: string; + emailVerified: true; + nonce: string; + name?: string; +}; + +type GoogleTokenPayload = { + iss?: unknown; + sub?: unknown; + email?: unknown; + email_verified?: unknown; + nonce?: unknown; + name?: unknown; + iat?: unknown; + exp?: unknown; +}; + +@Injectable() +export class GoogleTokenService { + constructor( + private readonly configService: ConfigService, + @Inject(GOOGLE_ID_TOKEN_VERIFIER) private readonly verifier: GoogleIdTokenVerifier, + ) {} + + isConfigured(): boolean { + return this.googleClientId() !== undefined; + } + + async verifyCredential(credential: string): Promise { + const audience = this.googleClientId(); + if (!audience) { + throw new ServiceUnavailableException({ error: 'GoogleOAuthUnavailable' }); + } + + try { + const ticket = await this.verifier.verifyIdToken({ idToken: credential, audience }); + // The verifier already returns the parsed payload object; the guard below + // narrows its known claim fields before they are returned as claims. + const payload = ticket.getPayload(); + if (!payload || !this.hasRequiredClaims(payload)) { + throw new UnauthorizedException({ error: 'InvalidGoogleCredential' }); + } + + if (payload.name === undefined) { + return { + sub: payload.sub, + email: payload.email.trim().toLowerCase(), + emailVerified: true, + nonce: payload.nonce, + }; + } + return { + sub: payload.sub, + email: payload.email.trim().toLowerCase(), + emailVerified: true, + nonce: payload.nonce, + name: payload.name, + }; + } catch (error) { + if (error instanceof ServiceUnavailableException || error instanceof UnauthorizedException) { + throw error; + } + + // Verification errors can embed the untrusted credential. Never log or expose them. + throw new UnauthorizedException({ error: 'InvalidGoogleCredential' }); + } + } + + private googleClientId(): string | undefined { + const clientId = this.configService.get('GOOGLE_CLIENT_ID')?.trim(); + return clientId || undefined; + } + + private hasRequiredClaims(payload: GoogleTokenPayload): payload is { + iss: string; + sub: string; + email: string; + email_verified: true; + nonce: string; + iat: number; + exp: number; + name?: string; + } { + const now = Math.floor(Date.now() / 1000); + return ( + typeof payload.iss === 'string' && + GOOGLE_ISSUERS[payload.iss] === true && + typeof payload.sub === 'string' && + payload.sub.trim().length > 0 && + typeof payload.email === 'string' && + payload.email.trim().length > 0 && + payload.email_verified === true && + typeof payload.nonce === 'string' && + /^[A-Za-z0-9_-]{43}$/.test(payload.nonce) && + typeof payload.iat === 'number' && + Number.isInteger(payload.iat) && + payload.iat <= now + CLOCK_SKEW_SECONDS && + payload.iat >= now - MAX_TOKEN_AGE_SECONDS && + typeof payload.exp === 'number' && + Number.isInteger(payload.exp) && + payload.exp > now + ); + } +} + +export type { GoogleTokenClaims }; diff --git a/src/auth/identity.service.spec.ts b/src/auth/identity.service.spec.ts new file mode 100644 index 0000000..2091f3a --- /dev/null +++ b/src/auth/identity.service.spec.ts @@ -0,0 +1,149 @@ +import { ConfigService } from '@nestjs/config'; +import { type User, users } from 'src/db/schema'; +import { IdentityService } from './identity.service'; + +const makeUser = (overrides: Partial = {}): User => ({ + id: 'user-1', + email: 'user@example.com', + username: 'player', + passwordHash: null, + googleId: 'google-user-1', + githubId: null, + role: 'USER', + status: 'ACTIVE', + totpSecret: null, + totpEnabled: false, + totpBackupCodes: null, + tempPasswordHash: null, + tempPasswordExpiresAt: null, + mustChangePassword: false, + minecraftUUID: null, + minecraftName: null, + minecraftVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); + +describe('IdentityService', () => { + let selectRows: User[][]; + let insertValues: jest.Mock; + let service: IdentityService; + + beforeEach(() => { + selectRows = []; + insertValues = jest.fn(() => ({ returning: jest.fn().mockResolvedValue([makeUser()]) })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit: jest.fn(async () => selectRows.shift() ?? []) })), + })), + })), + insert: jest.fn(() => ({ values: insertValues })), + }; + const configService = Object.assign(new ConfigService(), { + get: jest.fn((key: string) => (key === 'REQUIRE_ADMIN_APPROVAL' ? 'true' : undefined)), + }); + + // SAFETY: the test-producer db mock structurally satisfies the select/insert + // surface IdentityService consumes; 'never' bypasses only the compile-time + // DrizzleDB requirement for this hand-rolled fixture. + service = new IdentityService(db as never, configService); + }); + + it('authenticates an identity already linked to the provider', async () => { + const linkedUser = makeUser(); + selectRows = [[linkedUser]]; + + await expect( + service.resolveProviderIdentity({ + provider: 'google', + providerId: 'google-user-1', + email: linkedUser.email, + username: linkedUser.username, + }), + ).resolves.toEqual({ kind: 'authenticated', user: linkedUser }); + expect(insertValues).not.toHaveBeenCalled(); + }); + + it('requires confirmation instead of silently linking an email match', async () => { + const existingPasswordUser = makeUser({ googleId: null, passwordHash: 'password-hash' }); + selectRows = [[], [existingPasswordUser]]; + + await expect( + service.resolveProviderIdentity({ + provider: 'google', + providerId: 'new-google-user', + email: existingPasswordUser.email, + username: 'player', + }), + ).resolves.toEqual({ kind: 'linkConfirmationRequired', user: existingPasswordUser }); + expect(insertValues).not.toHaveBeenCalled(); + }); + + it('creates a pending provider-only account with canonical username', async () => { + const createdUser = makeUser({ username: 'newplayer', googleId: 'new-google-user' }); + selectRows = [[], []]; + insertValues.mockReturnValue({ returning: jest.fn().mockResolvedValue([createdUser]) }); + + await expect( + service.resolveProviderIdentity({ + provider: 'google', + providerId: 'new-google-user', + email: 'new@example.com', + username: 'NewPlayer', + }), + ).resolves.toEqual({ kind: 'created', user: createdUser }); + expect(insertValues).toHaveBeenCalledWith({ + email: 'new@example.com', + username: 'newplayer', + passwordHash: null, + status: 'PENDING', + googleId: 'new-google-user', + }); + }); + + it('atomically links a Google subject and preserves the unique-constraint race boundary', async () => { + const linkedUser = makeUser({ googleId: 'google-new' }); + const returning = jest.fn().mockResolvedValue([linkedUser]); + const update = jest.fn(() => ({ + set: jest.fn(() => ({ + where: jest.fn(() => ({ returning })), + })), + })); + const transaction = jest.fn(async (callback) => + callback({ + update, + select: jest.fn(), + }), + ); + const db = { transaction }; + const configService = new ConfigService(); + // SAFETY: the transaction mock exposes precisely the atomic update chain exercised here. + const linkService = new IdentityService(db as never, configService); + + await expect(linkService.linkGoogleIdentity('user-1', 'google-new')).resolves.toEqual( + linkedUser, + ); + expect(transaction).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith(users); + }); + + it('rejects a provider subject already claimed by another account', async () => { + const update = jest.fn(() => ({ + set: jest.fn(() => ({ + where: jest.fn(() => ({ + returning: jest.fn().mockRejectedValue({ code: '23505' }), + })), + })), + })); + const transaction = jest.fn(async (callback) => callback({ update, select: jest.fn() })); + const configService = new ConfigService(); + // SAFETY: the transaction mock supplies the unique-violation producer from PostgreSQL's update. + const linkService = new IdentityService({ transaction } as never, configService); + + await expect(linkService.linkGoogleIdentity('user-2', 'google-new')).rejects.toMatchObject({ + response: { message: 'Google account is already linked', statusCode: 409 }, + }); + }); +}); diff --git a/src/auth/identity.service.ts b/src/auth/identity.service.ts new file mode 100644 index 0000000..0dd7304 --- /dev/null +++ b/src/auth/identity.service.ts @@ -0,0 +1,158 @@ +import crypto from 'node:crypto'; +import { ConflictException, Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { and, eq, isNull } from 'drizzle-orm'; +import { DRIZZLE, type DrizzleDB } from 'src/db/db.module'; +import { type User, users } from 'src/db/schema'; + +export type IdentityProvider = 'google' | 'github'; + +export type ProviderIdentity = { + provider: IdentityProvider; + providerId: string; + email: string; + username: string; +}; + +export type ProviderIdentityResolution = + | { kind: 'authenticated'; user: User } + | { kind: 'created'; user: User } + | { kind: 'linkConfirmationRequired'; user: User }; + +type IdentityDatabase = Pick; + +type PostgresErrorRecord = { code?: unknown; cause?: unknown }; + +// SAFETY: postgres-js attaches the SQLSTATE to the thrown error record's +// cause.code; the type predicate narrows the driver error shape so the 23505 +// branch stays exclusive to unique-violation producers at the DB boundary. +const isPostgresErrorRecord = (error: unknown): error is PostgresErrorRecord => + typeof error === 'object' && error !== null; + +const isUniqueViolation = (error: PostgresErrorRecord): boolean => + error.code === '23505' || (isPostgresErrorRecord(error.cause) && error.cause.code === '23505'); + +@Injectable() +export class IdentityService { + constructor( + @Inject(DRIZZLE) private readonly db: IdentityDatabase, + private readonly configService: ConfigService, + ) {} + + async linkGoogleIdentity(userId: string, providerId: string): Promise { + const normalizedProviderId = providerId.trim(); + if (!normalizedProviderId) { + throw new Error('Provider identity is required'); + } + + return this.db.transaction(async (tx) => { + try { + const [linkedUser] = await tx + .update(users) + .set({ googleId: normalizedProviderId }) + .where(and(eq(users.id, userId), isNull(users.googleId))) + .returning(); + if (linkedUser) { + return linkedUser; + } + } catch (error) { + if (isPostgresErrorRecord(error) && isUniqueViolation(error)) { + throw new ConflictException('Google account is already linked'); + } + + throw error; + } + + const [user] = await tx.select().from(users).where(eq(users.id, userId)).limit(1); + if (!user) { + throw new ConflictException('User not found'); + } + + if (user.googleId === normalizedProviderId) { + return user; + } + + throw new ConflictException('Google account is already linked'); + }); + } + + async resolveProviderIdentity(identity: ProviderIdentity): Promise { + const providerId = identity.providerId.trim(); + if (!providerId) { + throw new Error('Provider identity is required'); + } + + const providerIdColumn = identity.provider === 'google' ? users.googleId : users.githubId; + const [linkedUser] = await this.db + .select() + .from(users) + .where(eq(providerIdColumn, providerId)) + .limit(1); + + if (linkedUser) { + return { kind: 'authenticated', user: linkedUser }; + } + + const [emailUser] = await this.db + .select() + .from(users) + .where(eq(users.email, identity.email)) + .limit(1); + if (emailUser) { + return { kind: 'linkConfirmationRequired', user: emailUser }; + } + + const username = identity.username.toLowerCase(); + if (!/^[a-z0-9_]{3,32}$/.test(username)) { + throw new Error('Invalid canonical username'); + } + + const status = + this.configService.get('REQUIRE_ADMIN_APPROVAL') === 'true' ? 'PENDING' : 'ACTIVE'; + for (let attempt = 0; attempt < 3; attempt += 1) { + const candidate = + attempt === 0 ? username : `${username.slice(0, 28)}${crypto.randomInt(1000, 10_000)}`; + + try { + const [createdUser] = await this.db + .insert(users) + .values({ + email: identity.email, + username: candidate, + passwordHash: null, + status, + [identity.provider === 'google' ? 'googleId' : 'githubId']: providerId, + }) + .returning(); + + if (createdUser) { + return { kind: 'created', user: createdUser }; + } + } catch (error) { + if (!isPostgresErrorRecord(error) || !isUniqueViolation(error)) { + throw error; + } + + const [concurrentlyLinkedUser] = await this.db + .select() + .from(users) + .where(eq(providerIdColumn, providerId)) + .limit(1); + if (concurrentlyLinkedUser) { + return { kind: 'authenticated', user: concurrentlyLinkedUser }; + } + + const [concurrentEmailUser] = await this.db + .select() + .from(users) + .where(eq(users.email, identity.email)) + .limit(1); + if (concurrentEmailUser) { + return { kind: 'linkConfirmationRequired', user: concurrentEmailUser }; + } + } + } + + throw new ConflictException('Username unavailable'); + } +} diff --git a/src/auth/oauth-challenge.service.spec.ts b/src/auth/oauth-challenge.service.spec.ts new file mode 100644 index 0000000..b43fe76 --- /dev/null +++ b/src/auth/oauth-challenge.service.spec.ts @@ -0,0 +1,68 @@ +import { OAuthChallengeService } from './oauth-challenge.service'; + +type ChallengeRow = { + challengeHash: string; + expiresAt: Date; + provider: string; +}; + +describe('OAuthChallengeService', () => { + let insertValues: jest.Mock; + let returning: jest.Mock; + let service: OAuthChallengeService; + + beforeEach(() => { + insertValues = jest.fn().mockResolvedValue(undefined); + returning = jest.fn(); + const db = { + insert: jest.fn(() => ({ values: insertValues })), + delete: jest.fn(() => ({ + where: jest.fn(() => ({ returning })), + })), + }; + + // SAFETY: the test-producer db mock exposes the insert().values() and + // delete().where().returning() surface the service consumes; 'never' + // widens only the DrizzleDB compile-time requirement for this fixture. + service = new OAuthChallengeService(db as never); + }); + + it('stores only a SHA-256 digest and returns the raw challenge once', async () => { + const challenge = await service.createChallenge('google'); + + // SAFETY: insertValues records the exact values() payload the mocked insert + // producer received; the cast narrows the jest.Mock call record to the + // ChallengeRow shape the service is asserted to persist. + const [stored] = insertValues.mock.calls[0] as [ChallengeRow]; + expect(challenge).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(stored.provider).toBe('google'); + expect(stored.challengeHash).toHaveLength(64); + expect(stored.challengeHash).not.toBe(challenge); + expect(stored.expiresAt.getTime() - Date.now()).toBeLessThanOrEqual(5 * 60 * 1000); + expect(stored.expiresAt.getTime() - Date.now()).toBeGreaterThan(4 * 60 * 1000); + }); + + it('consumes a valid challenge exactly once', async () => { + const challenge = await service.createChallenge('google'); + returning.mockResolvedValueOnce([{ id: 'challenge-1' }]).mockResolvedValueOnce([]); + + await expect(service.verifyAndConsume('google', challenge)).resolves.toBe(true); + await expect(service.verifyAndConsume('google', challenge)).resolves.toBe(false); + }); + + it('rejects expired challenges and provider mismatches', async () => { + const challenge = await service.createChallenge('google'); + returning.mockResolvedValue([]); + + await expect(service.verifyAndConsume('google', challenge)).resolves.toBe(false); + // SAFETY: the literal 'github' exercises the provider-mismatch rejection at + // the service boundary; 'never' widens only the compile-time union for this + // negative input. + await expect(service.verifyAndConsume('github' as never, challenge)).resolves.toBe(false); + }); + + it('rejects malformed challenges without querying the database', async () => { + await expect(service.verifyAndConsume('google', 'not-a-challenge')).resolves.toBe(false); + expect(returning).not.toHaveBeenCalled(); + }); +}); diff --git a/src/auth/oauth-challenge.service.ts b/src/auth/oauth-challenge.service.ts new file mode 100644 index 0000000..376b220 --- /dev/null +++ b/src/auth/oauth-challenge.service.ts @@ -0,0 +1,60 @@ +import crypto from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { and, eq, gt } from 'drizzle-orm'; +import { DRIZZLE, type DrizzleDB } from 'src/db/db.module'; +import { oauthChallenges } from 'src/db/schema'; + +export const OAUTH_CHALLENGE_TTL_MS = 5 * 60 * 1000; +const CHALLENGE_BYTES = 32; + +export type OAuthChallengeProvider = 'google'; + +type ChallengeDatabase = Pick; + +const isOAuthChallengeProvider = (provider: string): provider is OAuthChallengeProvider => + provider === 'google'; + +const isRawChallenge = (challenge: string): boolean => /^[A-Za-z0-9_-]{43}$/.test(challenge); + +const hashChallenge = (challenge: string): string => + crypto.createHash('sha256').update(challenge).digest('hex'); + +@Injectable() +export class OAuthChallengeService { + constructor(@Inject(DRIZZLE) private readonly db: ChallengeDatabase) {} + + async createChallenge(provider: OAuthChallengeProvider): Promise { + if (!isOAuthChallengeProvider(provider)) { + throw new Error('Unsupported OAuth challenge provider'); + } + + const challenge = crypto.randomBytes(CHALLENGE_BYTES).toString('base64url'); + + await this.db.insert(oauthChallenges).values({ + provider, + challengeHash: hashChallenge(challenge), + expiresAt: new Date(Date.now() + OAUTH_CHALLENGE_TTL_MS), + }); + + return challenge; + } + + async verifyAndConsume(provider: OAuthChallengeProvider, challenge: string): Promise { + if (!isOAuthChallengeProvider(provider) || !isRawChallenge(challenge)) { + return false; + } + + const [consumed] = await this.db + .delete(oauthChallenges) + .where( + and( + eq(oauthChallenges.provider, provider), + eq(oauthChallenges.challengeHash, hashChallenge(challenge)), + gt(oauthChallenges.expiresAt, new Date()), + ), + ) + .returning({ id: oauthChallenges.id }); + + return consumed !== undefined; + } +} diff --git a/src/auth/refresh-token-id.ts b/src/auth/refresh-token-id.ts new file mode 100644 index 0000000..7f89fd7 --- /dev/null +++ b/src/auth/refresh-token-id.ts @@ -0,0 +1,6 @@ +import crypto from 'node:crypto'; + +export const createRefreshTokenId = (): string => crypto.randomBytes(32).toString('base64url'); + +export const hashRefreshTokenId = (tokenId: string): string => + crypto.createHash('sha256').update(tokenId).digest('hex'); diff --git a/src/auth/refresh-token-ttl.spec.ts b/src/auth/refresh-token-ttl.spec.ts new file mode 100644 index 0000000..daf4200 --- /dev/null +++ b/src/auth/refresh-token-ttl.spec.ts @@ -0,0 +1,45 @@ +import { DEFAULT_REFRESH_TOKEN_TTL, parseRefreshTokenTtl } from './refresh-token-ttl'; + +describe('parseRefreshTokenTtl', () => { + it('falls back to the documented 7d default when the env var is absent', () => { + expect(parseRefreshTokenTtl(undefined)).toEqual({ + expiresIn: '7d', + milliseconds: 7 * 24 * 60 * 60 * 1000, + }); + }); + + it('exports the same default the documentation and setup wizards write', () => { + expect(DEFAULT_REFRESH_TOKEN_TTL).toBe('7d'); + }); + + it('parses every supported duration unit', () => { + expect(parseRefreshTokenTtl('500ms')).toEqual({ expiresIn: '500ms', milliseconds: 500 }); + expect(parseRefreshTokenTtl('45s')).toEqual({ expiresIn: '45s', milliseconds: 45_000 }); + expect(parseRefreshTokenTtl('90m')).toEqual({ expiresIn: '90m', milliseconds: 90 * 60_000 }); + expect(parseRefreshTokenTtl('12h')).toEqual({ expiresIn: '12h', milliseconds: 12 * 3_600_000 }); + expect(parseRefreshTokenTtl('2w')).toEqual({ expiresIn: '2w', milliseconds: 1_209_600_000 }); + }); + + it('normalizes unit case from the pattern', () => { + expect(parseRefreshTokenTtl('7D')).toEqual({ expiresIn: '7d', milliseconds: 604_800_000 }); + }); + + it.each([ + ['', 'empty string'], + [' ', 'whitespace'], + [' 7d', 'leading whitespace'], + ['7d ', 'trailing whitespace'], + ['7 days', 'unit with spaces'], + ['days', 'unit without amount'], + ['d', 'bare unit'], + ['0d', 'zero amount'], + ['-1d', 'negative amount'], + ['1.5d', 'fractional amount'], + ['1d2h', 'compound duration'], + ['7', 'amount without unit'], + ['7 dd', 'unknown unit'], + ['seven', 'word duration'], + ])('rejects %s (%s) instead of substituting a default', (_value, _label) => { + expect(() => parseRefreshTokenTtl(_value)).toThrow('Invalid JWT_REFRESH_EXPIRES_IN'); + }); +}); diff --git a/src/auth/refresh-token-ttl.ts b/src/auth/refresh-token-ttl.ts new file mode 100644 index 0000000..9b337f3 --- /dev/null +++ b/src/auth/refresh-token-ttl.ts @@ -0,0 +1,54 @@ +import type { StringValue } from 'ms'; + +export const REFRESH_TOKEN_TTL = Symbol('REFRESH_TOKEN_TTL'); + +export type RefreshTokenTtl = { + expiresIn: StringValue; + milliseconds: number; +}; + +const DURATION_UNITS_MS = { + ms: 1, + s: 1_000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, + w: 604_800_000, +} as const; + +type DurationUnit = keyof typeof DURATION_UNITS_MS; + +const DURATION_PATTERN = /^(?[1-9]\d*)(?ms|s|m|h|d|w)$/iu; + +// documented default when JWT_REFRESH_EXPIRES_IN is absent: .env.example, the +// setup wizards, and docker-compose all write "7d" +export const DEFAULT_REFRESH_TOKEN_TTL = '7d'; + +export const parseRefreshTokenTtl = (value: string | undefined): RefreshTokenTtl => { + // an absent env var falls back to the documented default; any present value + // must parse exactly or boot fails (never silently substitute another TTL) + const resolved = value ?? DEFAULT_REFRESH_TOKEN_TTL; + + if (typeof resolved !== 'string' || resolved.length === 0 || resolved !== resolved.trim()) { + throw new Error('Invalid JWT_REFRESH_EXPIRES_IN'); + } + + const match = DURATION_PATTERN.exec(resolved); + if (!match?.groups) { + throw new Error('Invalid JWT_REFRESH_EXPIRES_IN'); + } + + const amount = Number(match.groups.amount); + // SAFETY: DURATION_PATTERN is the producer; it restricts the unit alternation + // to exactly the DurationUnit keys, so the lowering cannot escape that set. + const unit = match.groups.unit.toLowerCase() as DurationUnit; + const milliseconds = amount * DURATION_UNITS_MS[unit]; + + if (!Number.isSafeInteger(amount) || !Number.isSafeInteger(milliseconds)) { + throw new Error('Invalid JWT_REFRESH_EXPIRES_IN'); + } + + // SAFETY: ms.StringValue allows `${number}${UnitAnyCase}`; DURATION_PATTERN is + // the producer and restricts input to compact units, so `${amount}${unit}` fits. + return { expiresIn: `${amount}${unit}` as StringValue, milliseconds }; +}; diff --git a/src/db/schema.ts b/src/db/schema.ts index 26d7068..c3f5fc1 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -59,7 +59,9 @@ export const users = pgTable('users', { .$defaultFn(() => crypto.randomUUID()), email: varchar('email', { length: 254 }).notNull().unique(), username: varchar('username', { length: 32 }).notNull().unique(), - passwordHash: text('password_hash').notNull(), + passwordHash: text('password_hash'), + googleId: text('google_id').unique(), + githubId: text('github_id').unique(), role: roleEnum('role').default('USER').notNull(), status: userStatusEnum('status').default('ACTIVE').notNull(), totpSecret: text('totp_secret'), @@ -70,6 +72,7 @@ export const users = pgTable('users', { mustChangePassword: boolean('must_change_password').default(false).notNull(), minecraftUUID: text('minecraft_uuid').unique(), minecraftName: text('minecraft_name'), + minecraftVerified: boolean('minecraft_verified').default(false).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at') .defaultNow() @@ -77,17 +80,43 @@ export const users = pgTable('users', { .$onUpdateFn(() => new Date()), }); -export const refreshTokens = pgTable('refresh_tokens', { - id: text('id') - .primaryKey() - .$defaultFn(() => crypto.randomUUID()), - token: text('token').notNull().unique(), - userId: text('user_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - expiresAt: timestamp('expires_at').notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), -}); +export const refreshTokens = pgTable( + 'refresh_tokens', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + tokenIdHash: text('token_id_hash').notNull(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [ + unique('refresh_tokens_token_id_hash_unique').on(table.tokenIdHash), + index('refresh_tokens_user_id_idx').on(table.userId), + index('refresh_tokens_expires_at_idx').on(table.expiresAt), + ], +); + +export const oauthChallenges = pgTable( + 'oauth_challenges', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + provider: text('provider').notNull(), + challengeHash: text('challenge_hash').notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + unique('oauth_challenges_challenge_hash_unique').on(table.challengeHash), + index('oauth_challenges_expires_at_idx').on(table.expiresAt), + check('oauth_challenges_provider_check', sql`${table.provider} = 'google'`), + ], +); export const setupState = pgTable('setup_state', { id: text('id').primaryKey().default('singleton'), @@ -200,6 +229,9 @@ export const modPermissions = pgTable( export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; export type RefreshToken = typeof refreshTokens.$inferSelect; +export type NewRefreshToken = typeof refreshTokens.$inferInsert; +export type OAuthChallenge = typeof oauthChallenges.$inferSelect; +export type NewOAuthChallenge = typeof oauthChallenges.$inferInsert; export type Server = typeof servers.$inferSelect; export type NewServer = typeof servers.$inferInsert; export type ServerAccess = typeof serverAccess.$inferSelect; diff --git a/src/servers/server-access.service.spec.ts b/src/servers/server-access.service.spec.ts index 9eec2f9..3c61bc2 100644 --- a/src/servers/server-access.service.spec.ts +++ b/src/servers/server-access.service.spec.ts @@ -54,6 +54,8 @@ const makeUser = (overrides: Partial = {}): UserRow => ({ email: 'user@example.com', username: 'player', passwordHash: 'hash', + googleId: null, + githubId: null, role: 'USER', status: 'ACTIVE', totpSecret: null, @@ -64,6 +66,7 @@ const makeUser = (overrides: Partial = {}): UserRow => ({ mustChangePassword: false, minecraftUUID: null, minecraftName: null, + minecraftVerified: false, createdAt: new Date('2026-01-01T00:00:00Z'), updatedAt: new Date('2026-01-01T00:00:00Z'), ...overrides, diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index e04e50b..e102882 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -13,6 +13,7 @@ import { BadRequestException } from '@nestjs/common'; import { Test, type TestingModule } from '@nestjs/testing'; import * as bcrypt from 'bcrypt'; import type { SQL } from 'drizzle-orm'; +import { hashRefreshTokenId } from 'src/auth/refresh-token-id'; import { DRIZZLE } from 'src/db/db.module'; import { type RefreshToken, refreshTokens, type User, users } from 'src/db/schema'; import { UsersService } from './users.service'; @@ -22,6 +23,8 @@ const makeUser = (overrides: Partial = {}): User => ({ email: 'user@example.com', username: 'player', passwordHash: '$2b$10$abcdefghijklmnopqrstuv', + googleId: null, + githubId: null, role: 'USER', status: 'ACTIVE', totpSecret: null, @@ -32,6 +35,7 @@ const makeUser = (overrides: Partial = {}): User => ({ mustChangePassword: false, minecraftUUID: null, minecraftName: null, + minecraftVerified: false, createdAt: new Date('2026-01-01T00:00:00Z'), updatedAt: new Date('2026-01-01T00:00:00Z'), ...overrides, @@ -46,7 +50,7 @@ describe('UsersService', () => { let updatedRows: User[]; let updatedValues: UserMutation[]; let deleteCalls: { table: UserTable; where: SQL[] }[]; - let refreshTokenRows: Pick[]; + let refreshTokenRows: Pick[]; let whereSelect: jest.Mock; beforeEach(async () => { @@ -64,7 +68,7 @@ describe('UsersService', () => { // session listing is awaited directly on where(); user lookups call limit() // SAFETY: The fixture is constructed from the concrete framework contract exercised by this test. const chain = Promise.resolve(refreshTokenRows) as Promise< - Pick[] + Pick[] > & { limit: jest.Mock }; chain.limit = jest.fn(async () => rows.slice(0, 1)); return chain; @@ -128,7 +132,7 @@ describe('UsersService', () => { mustChangePassword: false, }), ]; - refreshTokenRows = [{ id: 'tok-1', token: await bcrypt.hash('current-refresh', 4) }]; + refreshTokenRows = [{ id: 'tok-1', tokenIdHash: hashRefreshTokenId('current-refresh') }]; }); it('rejects a password change when neither the current nor a valid temp password matches', async () => { @@ -144,6 +148,19 @@ describe('UsersService', () => { expect(update).not.toHaveBeenCalled(); }); + it('rejects an OAuth-only account before updating its password', async () => { + rows = [makeUser({ passwordHash: null })]; + + await expect( + service.updatePassword( + 'user-1', + { oldPassword: 'Password123!', newPassword: 'NewPass123!' }, + 'current-refresh', + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(update).not.toHaveBeenCalled(); + }); + it('rejects an unconsumed temporary password as change proof on an ordinary session', async () => { const tempHash = await bcrypt.hash('TempPass123!', 4); rows = [ diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 45f018a..b25fbd8 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -3,6 +3,7 @@ import * as bcrypt from 'bcrypt'; import { and, eq, isNull, ne, or } from 'drizzle-orm'; import { EditUserDto } from 'src/auth/dto/editUser.dto'; import { UpdatePasswordDTO } from 'src/auth/dto/updatePw.dto'; +import { hashRefreshTokenId } from 'src/auth/refresh-token-id'; import { DRIZZLE, type DrizzleDB } from 'src/db/db.module'; import { type Role, refreshTokens, type User, type UserStatus, users } from 'src/db/schema'; import { type PublicUser, toPublicUser } from './public-user'; @@ -10,7 +11,7 @@ import { type PublicUser, toPublicUser } from './public-user'; type UserInsertValues = { email: string; username: string; - passwordHash: string; + passwordHash: string | null; status: UserStatus; role?: Role; }; @@ -21,7 +22,7 @@ export class UsersService { async createUser( email: string, username: string, - passwordHash: string, + passwordHash: string | null, status: UserStatus, role?: Role, ): Promise { @@ -74,7 +75,7 @@ export class UsersService { async updatePassword( userId: string, dto: UpdatePasswordDTO, - refreshToken: string, + refreshTokenId: string | undefined, temporaryAuth = false, ): Promise { const userData = await this.findById(userId); @@ -139,6 +140,10 @@ export class UsersService { throw new BadRequestException('Wrong credentials'); } + if (!userData.passwordHash) { + throw new BadRequestException('Wrong credentials'); + } + const passwordMatch = await bcrypt.compare(dto.oldPassword, userData.passwordHash); if (!passwordMatch) { throw new BadRequestException('Wrong credentials'); @@ -153,24 +158,18 @@ export class UsersService { .returning(); const userNoPw = toPublicUser(updateResult); - const storedTokens = await this.db - .select() - .from(refreshTokens) - .where(eq(refreshTokens.userId, userId)); - - let currentTokenId: string | null = null; - for (const t of storedTokens) { - if (await bcrypt.compare(refreshToken, t.token)) { - currentTokenId = t.id; - break; - } - } - - if (currentTokenId) { + if (refreshTokenId) { await this.db .delete(refreshTokens) - .where(and(eq(refreshTokens.userId, userId), ne(refreshTokens.id, currentTokenId))); + .where( + and( + eq(refreshTokens.userId, userId), + ne(refreshTokens.tokenIdHash, hashRefreshTokenId(refreshTokenId)), + ), + ); } else { + // no verifiable current session: revoke every refresh session so the + // new password leaves no stale live session behind await this.db.delete(refreshTokens).where(eq(refreshTokens.userId, userId)); } diff --git a/test/admin-last-admin.e2e-spec.ts b/test/admin-last-admin.e2e-spec.ts index f8d31b8..70c2b0b 100644 --- a/test/admin-last-admin.e2e-spec.ts +++ b/test/admin-last-admin.e2e-spec.ts @@ -124,11 +124,11 @@ describe('AdminService last-admin protection (PostgreSQL integration)', () => { const victim = await createUser({ role: 'MOD', status: 'ACTIVE' }); await db.insert(schema.refreshTokens).values([ - { token: 'hash-1', userId: victim.id, expiresAt: new Date(Date.now() + 60_000) }, - { token: 'hash-2', userId: victim.id, expiresAt: new Date(Date.now() + 60_000) }, + { tokenIdHash: 'hash-1', userId: victim.id, expiresAt: new Date(Date.now() + 60_000) }, + { tokenIdHash: 'hash-2', userId: victim.id, expiresAt: new Date(Date.now() + 60_000) }, ]); await db.insert(schema.refreshTokens).values({ - token: 'hash-admin', + tokenIdHash: 'hash-admin', userId: admin.id, expiresAt: new Date(Date.now() + 60_000), }); diff --git a/test/auth-flow.e2e-spec.ts b/test/auth-flow.e2e-spec.ts index 8a6e158..20b2b4c 100644 --- a/test/auth-flow.e2e-spec.ts +++ b/test/auth-flow.e2e-spec.ts @@ -4,7 +4,7 @@ type AccessTokenClaimsCandidate = { username?: string; }; -import type { INestApplication } from '@nestjs/common'; +import { type INestApplication, ValidationPipe } from '@nestjs/common'; import { Test, type TestingModule } from '@nestjs/testing'; import cookieParser from 'cookie-parser'; import { eq } from 'drizzle-orm'; @@ -12,6 +12,7 @@ import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import request from 'supertest'; import { AppModule } from '../src/app.module'; +import { hashRefreshTokenId } from '../src/auth/refresh-token-id'; import { DRIZZLE, type DrizzleDB } from '../src/db/db.module'; import * as schema from '../src/db/schema'; import { DOCKERODE } from '../src/docker/docker.constants'; @@ -32,12 +33,19 @@ const isAccessTokenClaims = ( typeof claims.type === 'string' && typeof claims.username === 'string'; +type RefreshTokenClaimsCandidate = { + jti?: unknown; +}; + describe('Authentication flow (PostgreSQL e2e)', () => { let sql: postgres.Sql; let db: PostgresJsDatabase; let app: INestApplication; let userId: string; let adminId: string; + let casingUserId: string | undefined; + let providerOnlyUserId: string; + let passwordChangeUserId: string | undefined; let setupRowCreatedBySuite = false; beforeAll(async () => { @@ -69,14 +77,30 @@ describe('Authentication flow (PostgreSQL e2e)', () => { .compile(); app = moduleFixture.createNestApplication(); + // production trust-proxy contract (src/main.ts): per-client throttling uses + // X-Forwarded-For; the per-test XFF values in this suite split throttle + // budgets instead of collapsing every request onto one shared IP + app.getHttpAdapter().getInstance().set('trust proxy', 1); app.setGlobalPrefix('api', { exclude: ['/health'] }); app.use(cookieParser()); + // production pipe contract (src/main.ts): whitelist + transform so DTO + // canonicalization (D-10 username/email lowercase) applies over HTTP + app.useGlobalPipes( + new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }), + ); await app.init(); }); afterAll(async () => { if (userId) await db.delete(schema.users).where(eq(schema.users.id, userId)); if (adminId) await db.delete(schema.users).where(eq(schema.users.id, adminId)); + if (casingUserId) await db.delete(schema.users).where(eq(schema.users.id, casingUserId)); + if (providerOnlyUserId) { + await db.delete(schema.users).where(eq(schema.users.id, providerOnlyUserId)); + } + if (passwordChangeUserId) { + await db.delete(schema.users).where(eq(schema.users.id, passwordChangeUserId)); + } // remove only the singleton row this suite's setup/init created if (setupRowCreatedBySuite) { await db.delete(schema.setupState).where(eq(schema.setupState.id, 'singleton')); @@ -127,6 +151,7 @@ describe('Authentication flow (PostgreSQL e2e)', () => { .where(eq(schema.users.username, adminCredentials.username)); adminId = storedAdmin.id; expect(storedUser.passwordHash).not.toContain(registration.password); + expect(storedUser.status).toBe('ACTIVE'); const wrongLogin = await request(app.getHttpServer()) .post('/api/auth/login') @@ -197,22 +222,31 @@ describe('Authentication flow (PostgreSQL e2e)', () => { .expect(403); expect(forged.body).toEqual({ error: 'CsrfOriginForbidden' }); - // the blocked attempt must not have revoked the session - await request(app.getHttpServer()) + // SPEC §8.3 replay contract: the refresh above rotated the session, so the + // original cookie is consumed — reusing it must 401 instead of minting a + // second successor. This also proves the blocked logout kept no row alive. + const replay = await request(app.getHttpServer()) .post('/api/auth/refresh') .set('Cookie', cookiePairs) - // SAFETY: Supertest consumes the cookiePairs request header and produces the 200 response; - // this assertion verifies the blocked logout did not revoke the refresh session. - .expect(200); + .expect(401); + expect(replay.body).toEqual({ error: 'RefreshTokenExpired' }); + + // SAFETY: Supertest refresh produces set-cookie headers; setCookieHeader reads the exact + // refresh_token header emitted by the accepted rotation just before the replay. + const rotatedRefreshCookie = setCookieHeader(refreshed.headers['set-cookie']).find((cookie) => + cookie.startsWith('refresh_token='), + ); + if (!rotatedRefreshCookie) throw new Error('rotated refresh cookie missing'); const rawCorsOrigin = process.env.CORS_ORIGIN; // SAFETY: Supertest is the external request producer; its CORS header contract invariant // supplies the exact configured Origin string consumed by the refresh endpoint. const corsOrigin = rawCorsOrigin as string; - await request(app.getHttpServer()) + // the successor session survives the rejected replay and rotates again + const corsRefreshed = await request(app.getHttpServer()) .post('/api/auth/refresh') .set('Origin', corsOrigin) - .set('Cookie', cookiePairs) + .set('Cookie', rotatedRefreshCookie) .expect(200); // OPTIONS preflight is exempt from the Origin guard @@ -224,7 +258,9 @@ describe('Authentication flow (PostgreSQL e2e)', () => { const logout = await request(app.getHttpServer()) .post('/api/auth/logout') - .set('Cookie', cookiePairs) + // SAFETY: Supertest consumes the corsRefreshed cookie pair; the logout clears the + // exact session the CORS refresh just emitted. + .set('Cookie', setCookieHeader(corsRefreshed.headers['set-cookie'])) .expect(200); // SAFETY: Supertest logout produces set-cookie headers; setCookieHeader reads those exact // access_token and refresh_token values to verify both cookies are cleared. @@ -243,4 +279,178 @@ describe('Authentication flow (PostgreSQL e2e)', () => { .expect(200) .expect({ status: 'ok', db: 'ok', docker: 'ok', version: process.env.PANEL_VERSION }); }); + + it('keeps the current refresh session and revokes others on a normal password change', async () => { + const suffix = `${Date.now()}${Math.floor(Math.random() * 10_000)}`; + const credentials = { + email: `pwchange-${suffix}@example.com`, + username: `pwchange${suffix}`.slice(0, 32), + password: 'UserPassword123!', + }; + await request(app.getHttpServer()).post('/api/auth/register').send(credentials).expect(201); + const [registered] = await db + .select() + .from(schema.users) + .where(eq(schema.users.username, credentials.username)); + expect(registered).toBeDefined(); + passwordChangeUserId = registered.id; + + const decodeRefreshJti = (cookie: string): string => { + const claims: RefreshTokenClaimsCandidate = JSON.parse( + Buffer.from(cookie.slice('refresh_token='.length).split('.')[1], 'base64url').toString( + 'utf8', + ), + ); + if (typeof claims.jti !== 'string') throw new Error('refresh cookie jti missing'); + return claims.jti; + }; + + // SAFETY: login/refresh routes are throttled per client IP under trust + // proxy; these requests carry distinct loopback XFF values so the added + // password-retention scenario never crowds the suite's shared-IP budget. + const firstLogin = await request(app.getHttpServer()) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.51.100.101') + .send({ identifier: credentials.username, password: credentials.password }) + .expect(200); + const firstCookies = setCookieHeader(firstLogin.headers['set-cookie']).map( + (cookie) => cookie.split(';', 1)[0], + ); + const firstRefresh = firstCookies.find((cookie) => cookie.startsWith('refresh_token=')); + if (!firstRefresh) throw new Error('first refresh cookie missing'); + + const secondLogin = await request(app.getHttpServer()) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.51.100.102') + .send({ identifier: credentials.username, password: credentials.password }) + .expect(200); + const secondCookies = setCookieHeader(secondLogin.headers['set-cookie']).map( + (cookie) => cookie.split(';', 1)[0], + ); + + const rowsBefore = await db + .select() + .from(schema.refreshTokens) + .where(eq(schema.refreshTokens.userId, registered.id)); + expect(rowsBefore).toHaveLength(2); + + // normal password change while holding the first session: the current + // session row must survive, every other session must be revoked + await request(app.getHttpServer()) + .patch('/api/auth/password') + .set('X-Forwarded-For', '198.51.100.103') + .set('Cookie', firstCookies) + .send({ oldPassword: credentials.password, newPassword: 'NewPassword456!' }) + .expect(200); + + const rowsAfter = await db + .select() + .from(schema.refreshTokens) + .where(eq(schema.refreshTokens.userId, registered.id)); + expect(rowsAfter).toHaveLength(1); + expect(rowsAfter[0].tokenIdHash).toBe(hashRefreshTokenId(decodeRefreshJti(firstRefresh))); + + // the retained first session still rotates into a successor + const rotated = await request(app.getHttpServer()) + .post('/api/auth/refresh') + .set('X-Forwarded-For', '198.51.100.104') + .set('Cookie', firstCookies) + .expect(200); + const rotatedCookies = setCookieHeader(rotated.headers['set-cookie']).map( + (cookie) => cookie.split(';', 1)[0], + ); + expect(rotatedCookies.join(';')).toContain('refresh_token='); + + // the rotated successor is the live session under the jti key: it rotates again + await request(app.getHttpServer()) + .post('/api/auth/refresh') + .set('X-Forwarded-For', '198.51.100.105') + .set('Cookie', rotatedCookies) + .expect(200); + + // the revoked second session's cookie must fail with the replay machine code + const revoked = await request(app.getHttpServer()) + .post('/api/auth/refresh') + .set('X-Forwarded-For', '198.51.100.106') + .set('Cookie', secondCookies) + .expect(401); + expect(revoked.body).toEqual({ error: 'RefreshTokenExpired' }); + }); + + it('rejects password login for an active provider-only account', async () => { + const suffix = `${Date.now()}${Math.floor(Math.random() * 10_000)}`; + const [providerOnlyUser] = await db + .insert(schema.users) + .values({ + email: `provider-only-${suffix}@example.com`, + username: `provideronly${suffix}`.slice(0, 32), + passwordHash: null, + googleId: `google-${suffix}`, + status: 'ACTIVE', + }) + .returning({ id: schema.users.id }); + providerOnlyUserId = providerOnlyUser.id; + + const response = await request(app.getHttpServer()) + .post('/api/auth/login') + .send({ identifier: `provideronly${suffix}`.slice(0, 32), password: 'Password123!' }) + .expect(401); + + expect(response.body).toEqual({ + error: 'Unauthorized', + message: 'Wrong credentials', + statusCode: 401, + }); + }); + + it('canonicalizes usernames to lowercase at registration and login (D-10)', async () => { + const stamp = Date.now(); + const mixedCase = `CaseUser${stamp.toString().slice(-10)}`; + const email = `case-${stamp}@example.com`; + // SAFETY: login/register routes are throttled per client IP under trust + // proxy; these requests carry a distinct loopback XFF value so the D-10 + // scenario never crowds the suite's shared-IP budget. + await request(app.getHttpServer()) + .post('/api/auth/register') + .set('X-Forwarded-For', '198.51.100.110') + .send({ email, username: mixedCase, password: 'UserPassword123!' }) + .expect(201); + + const [stored] = await db + .select() + .from(schema.users) + .where(eq(schema.users.username, mixedCase.toLowerCase())); + expect(stored).toBeDefined(); + casingUserId = stored?.id; + // canonical lowercase at write: `CaseUser…` persisted as `caseuser…` + expect(stored.username).toBe(mixedCase.toLowerCase()); + + // login lowercases the identifier: the original casing and an all-uppercase + // variant both resolve to the canonical account + for (const identifier of [mixedCase, mixedCase.toUpperCase()]) { + const login = await request(app.getHttpServer()) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.51.100.111') + .send({ identifier, password: 'UserPassword123!' }) + .expect(200); + expect(login.body.username).toBe(mixedCase.toLowerCase()); + } + + // casing-only duplicate (`Bob` vs `bob`) is rejected: both canonicalize to + // the same lowercase value, so the second registration collides + const collision = await request(app.getHttpServer()) + .post('/api/auth/register') + .set('X-Forwarded-For', '198.51.100.112') + .send({ + email: `case-${stamp}-dup@example.com`, + username: mixedCase.toUpperCase(), + password: 'UserPassword123!', + }) + .expect(409); + expect(collision.body).toEqual({ + message: 'User already exists', + error: 'Conflict', + statusCode: 409, + }); + }); }); diff --git a/test/database-migration.e2e-spec.ts b/test/database-migration.e2e-spec.ts index 50c6d6b..ce9ad49 100644 --- a/test/database-migration.e2e-spec.ts +++ b/test/database-migration.e2e-spec.ts @@ -189,6 +189,12 @@ describe('Database migrations (PostgreSQL e2e)', () => { return rows.length > 0; }); + const countRefreshTokens = async (databaseUrl: string): Promise => + withDatabaseConnection(databaseUrl, async (sql) => { + const rows = await sql`SELECT count(*) AS count FROM refresh_tokens`; + return Number(rows[0].count); + }); + beforeAll(async () => { originalDatabaseUrl = process.env.DATABASE_URL; @@ -247,7 +253,7 @@ describe('Database migrations (PostgreSQL e2e)', () => { await runProductionMigrations(databaseUrl, realMigrationsFolder); - await expect(journalRowCount(databaseUrl)).resolves.toBe(4); + await expect(journalRowCount(databaseUrl)).resolves.toBe(7); await expect(serversTableExists(databaseUrl)).resolves.toBe(true); await expect(hasCreatingStatus(databaseUrl)).resolves.toBe(true); await expect(hasAccessTypeEnum(databaseUrl)).resolves.toBe(true); @@ -259,7 +265,7 @@ describe('Database migrations (PostgreSQL e2e)', () => { await runProductionMigrations(databaseUrl, realMigrationsFolder); await runProductionMigrations(databaseUrl, realMigrationsFolder); - await expect(journalRowCount(databaseUrl)).resolves.toBe(4); + await expect(journalRowCount(databaseUrl)).resolves.toBe(7); await expect(hasCreatingStatus(databaseUrl)).resolves.toBe(true); }); @@ -271,7 +277,7 @@ describe('Database migrations (PostgreSQL e2e)', () => { runProductionMigrations(databaseUrl, realMigrationsFolder), ]); - await expect(journalRowCount(databaseUrl)).resolves.toBe(4); + await expect(journalRowCount(databaseUrl)).resolves.toBe(7); await expect(hasCreatingStatus(databaseUrl)).resolves.toBe(true); }); @@ -286,13 +292,67 @@ describe('Database migrations (PostgreSQL e2e)', () => { await seedMigrationAdmin(databaseUrl); const serverId = await seedPrePhase15Server(databaseUrl); + // the pre-0004 schema stores bcrypt hashes of raw tokens; seed one legacy + // row so the upgrade demonstrably clears it (documented re-login) + await withDatabaseConnection(databaseUrl, async (sql) => { + await sql` + INSERT INTO refresh_tokens (id, token, user_id, expires_at) + VALUES ( + ${crypto.randomUUID()}, 'legacy-bcrypt-token-hash', + (SELECT id FROM users WHERE username = 'migration-admin' LIMIT 1), + now() + interval '7 days' + ) + `; + }); + await expect(countRefreshTokens(databaseUrl)).resolves.toBe(1); await runProductionMigrations(databaseUrl, realMigrationsFolder); - await expect(journalRowCount(databaseUrl)).resolves.toBe(4); + await expect(journalRowCount(databaseUrl)).resolves.toBe(7); await expect(readServerAccessType(databaseUrl, serverId)).resolves.toBe('OPEN'); await expect(hasAccessTypeEnum(databaseUrl)).resolves.toBe(true); await expect(hasServerAccessCheck(databaseUrl)).resolves.toBe(true); await expect(hasModPermissionPartialUnique(databaseUrl)).resolves.toBe(true); + // 0004 deletes every legacy row: the migrated database holds no sessions + await expect(countRefreshTokens(databaseUrl)).resolves.toBe(0); + }); + + it('0005 fails loudly on username case-collisions instead of merging users (D-10)', async () => { + const databaseUrl = await createBlankDatabase('collision'); + const threeMigrationFolder = await copyThreeMigrationFolder(); + + await runProductionMigrations(databaseUrl, threeMigrationFolder); + await seedMigrationAdmin(databaseUrl); + // `Bob` and `bob` are distinct accounts under the case-sensitive pre-0005 + // schema; canonical lowercase would merge them, so the migration MUST stop. + await withDatabaseConnection(databaseUrl, async (sql) => { + await sql` + INSERT INTO users (id, email, username, password_hash, role, status) + VALUES + (${crypto.randomUUID()}, 'bob@example.com', 'Bob', 'not-used', 'USER', 'ACTIVE'), + (${crypto.randomUUID()}, 'bob2@example.com', 'bob', 'not-used', 'USER', 'ACTIVE') + `; + }); + + // runProductionMigrations wraps any migration failure in a generic message + // (same surface the corrupted-folder test asserts); the collision-specific + // Postgres exception is logged, not rethrown. The invariant here is: the + // chain FAILS, 0005 does not apply, and neither account is merged or lost. + await expect(runProductionMigrations(databaseUrl, realMigrationsFolder)).rejects.toThrow( + 'Database migration failed', + ); + // The migrator applies all pending migrations in one transaction, so the + // 0005 failure rolled back 0003-0005: only the earlier three-migration run + // (0000-0002) remains journaled, and no partial 0003-0005 state exists. + await expect(journalRowCount(databaseUrl)).resolves.toBe(3); + // Both accounts are untouched: no silent merge or data loss. + const usernames = await withDatabaseConnection(databaseUrl, async (sql) => { + const rows = + await sql`SELECT username FROM users WHERE email IN ('bob@example.com', 'bob2@example.com')`; + // SAFETY: username is a varchar column in the users schema; the seeded rows + // were inserted with exact string literals, so their type is string. + return rows.map((row) => row.username as string).sort(); + }); + expect(usernames).toEqual(['Bob', 'bob']); }); it('rejects a corrupted migrations folder without writing partial journal rows', async () => { diff --git a/test/google-oauth-isolation.e2e-spec.ts b/test/google-oauth-isolation.e2e-spec.ts new file mode 100644 index 0000000..390d8fc --- /dev/null +++ b/test/google-oauth-isolation.e2e-spec.ts @@ -0,0 +1,180 @@ +import { createHash } from 'node:crypto'; +import { type INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import cookieParser from 'cookie-parser'; +import { eq } from 'drizzle-orm'; +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { + GOOGLE_ID_TOKEN_VERIFIER, + type GoogleIdTokenVerifier, +} from '../src/auth/google-token.service'; +import { DRIZZLE, type DrizzleDB } from '../src/db/db.module'; +import { runProductionMigrations } from '../src/db/migrate'; +import * as schema from '../src/db/schema'; +import { DOCKERODE } from '../src/docker/docker.constants'; +import { DockerService } from '../src/docker/docker.service'; +import { assertSafeTestDatabase } from './test-database'; + +type GoogleClaims = { sub: string; email: string; nonce: string }; + +const claimCredential = (claims: GoogleClaims): string => JSON.stringify(claims); + +// SAFETY: both test backends trust the same Google client id (the confused- +// deputy premise: aud cannot distinguish them); the verifier therefore accepts +// any well-formed token with a matching audience exactly like a shared Google +// client would, and emits the claims that GoogleTokenService validates. +const makeVerifier = (clientId: string): GoogleIdTokenVerifier => ({ + verifyIdToken: async ({ idToken, audience }) => { + if (audience !== clientId) { + throw new Error('Wrong recipient'); + } + // SAFETY: the fake verifier parses the JSON credential string this suite + // produced; JSON.parse's unknown is narrowed to the fixture's GoogleClaims + // shape at this producer boundary. + const claims = JSON.parse(idToken) as GoogleClaims; + const now = Math.floor(Date.now() / 1000); + return { + getPayload: () => ({ + iss: 'https://accounts.google.com', + sub: claims.sub, + email: claims.email, + email_verified: true, + nonce: claims.nonce, + iat: now - 10, + exp: now + 60, + }), + }; + }, +}); + +describe('Google OAuth cross-backend isolation (PostgreSQL e2e)', () => { + let adminSql: postgres.Sql; + let sqlA: postgres.Sql; + let sqlB: postgres.Sql; + let dbA: PostgresJsDatabase; + let dbB: PostgresJsDatabase; + let appA: INestApplication; + let appB: INestApplication; + const clientId = 'shared-e2e.apps.googleusercontent.com'; + + const createApp = async ( + database: PostgresJsDatabase, + ): Promise => { + // SAFETY: drizzle(sql, { schema }) is the database producer; the cast + // satisfies the DrizzleDB contract with the exact live-pg surface used by + // the NestJS DRIZZLE provider in this e2e harness. + const drizzleDb = database as DrizzleDB; + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(DRIZZLE) + .useValue(drizzleDb) + .overrideProvider(DockerService) + .useValue({ ping: jest.fn().mockResolvedValue(true) }) + .overrideProvider(DOCKERODE) + .useValue({}) + .overrideProvider(GOOGLE_ID_TOKEN_VERIFIER) + .useValue(makeVerifier(clientId)) + .compile(); + + const app = moduleFixture.createNestApplication(); + app.getHttpAdapter().getInstance().set('trust proxy', 1); + app.setGlobalPrefix('api', { exclude: ['/health'] }); + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }), + ); + await app.init(); + return app; + }; + + const requestChallenge = async (app: INestApplication): Promise => { + const response = await request(app.getHttpServer()) + .post('/api/auth/oauth/challenge') + .set('X-Forwarded-For', `198.51.100.${(Date.now() % 250) + 1}`) + .send({ provider: 'google' }) + .expect(200); + // SAFETY: the challenge endpoint response body is the raw challenge string + // produced by this harness; the cast narrows the JSON body to that exact + // contract before it is returned. + return response.body.challenge as string; + }; + + const googleLogin = (app: INestApplication, claims: GoogleClaims): request.Test => + request(app.getHttpServer()) + .post('/api/auth/oauth/google/login') + .send({ credential: claimCredential(claims) }); + + beforeAll(async () => { + process.env.GOOGLE_CLIENT_ID = clientId; + adminSql = postgres(assertSafeTestDatabase(), { max: 1 }); + + await adminSql.unsafe('CREATE DATABASE "minepanel_isolation_b"'); + const databaseBUrl = new URL(assertSafeTestDatabase()); + databaseBUrl.pathname = '/minepanel_isolation_b'; + + // SAFETY: the isolation instance is a real second database; running the + // production chain against it mirrors how each self-hosted backend migrates. + await runProductionMigrations( + databaseBUrl.toString(), + require('node:path').resolve(process.cwd(), 'drizzle'), + ); + + sqlA = postgres(assertSafeTestDatabase(), { max: 4 }); + sqlB = postgres(databaseBUrl.toString(), { max: 4 }); + dbA = drizzle(sqlA, { schema }); + dbB = drizzle(sqlB, { schema }); + + appA = await createApp(dbA); + appB = await createApp(dbB); + }); + + afterAll(async () => { + if (appA) await appA.close(); + if (appB) await appB.close(); + if (sqlA) await sqlA.end(); + if (sqlB) await sqlB.end(); + try { + await adminSql.unsafe('DROP DATABASE IF EXISTS "minepanel_isolation_b" WITH (FORCE)'); + } catch { + // best-effort cleanup + } + await adminSql.end(); + }); + + it('does not let a token minted against backend A authenticate to backend B', async () => { + const aChallenge = await requestChallenge(appA); + const sub = `shared-sub-${Date.now()}`; + const email = `isolation-a-${Date.now()}@example.com`; + + // backend A accepts its own challenge-bound token + const loginA = await googleLogin(appA, { sub, email, nonce: aChallenge }).expect(200); + expect(loginA.headers['set-cookie']).toBeDefined(); + + // a NEW Google token with the same sub/email and the SAME nonce backend A + // issued (and consumed) is presented to backend B, which shares the Google + // client: the challenge row lives only in A's table, so B rejects it + const replayedToB = await googleLogin(appB, { sub, email, nonce: aChallenge }).expect(401); + expect(replayedToB.body).toEqual({ error: 'InvalidGoogleChallenge' }); + }); + + it('keeps each backend challenge table isolated (hash rows never cross backends)', async () => { + const bChallenge = await requestChallenge(appB); + const hash = createHash('sha256').update(bChallenge).digest('hex'); + + // SAFETY: challengeHash is a text column in the oauth_challenges schema; + // eq() against the computed digest is the exact producer contract. + const [inA] = await dbA + .select({ id: schema.oauthChallenges.id }) + .from(schema.oauthChallenges) + .where(eq(schema.oauthChallenges.challengeHash, hash)); + const [inB] = await dbB + .select({ id: schema.oauthChallenges.id }) + .from(schema.oauthChallenges) + .where(eq(schema.oauthChallenges.challengeHash, hash)); + + expect(inA).toBeUndefined(); + expect(inB).toBeDefined(); + }); +}); diff --git a/test/google-oauth.e2e-spec.ts b/test/google-oauth.e2e-spec.ts new file mode 100644 index 0000000..2e11cca --- /dev/null +++ b/test/google-oauth.e2e-spec.ts @@ -0,0 +1,343 @@ +import { createHash } from 'node:crypto'; +import { type INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import cookieParser from 'cookie-parser'; +import { eq, inArray } from 'drizzle-orm'; +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { + GOOGLE_ID_TOKEN_VERIFIER, + type GoogleIdTokenVerifier, +} from '../src/auth/google-token.service'; +import { DRIZZLE, type DrizzleDB } from '../src/db/db.module'; +import * as schema from '../src/db/schema'; +import { DOCKERODE } from '../src/docker/docker.constants'; +import { DockerService } from '../src/docker/docker.service'; +import { assertSafeTestDatabase } from './test-database'; + +type GoogleClaims = { + sub: string; + email: string; + nonce: string; + name?: string; +}; + +const challengeHash = (challenge: string): string => + createHash('sha256').update(challenge).digest('hex'); + +const claimCredential = (claims: GoogleClaims): string => JSON.stringify(claims); + +// SAFETY: supertest set-cookie arrives as string | string[] | undefined; the +// Array.isArray narrowing parses that transport shape at the boundary before +// each cookie pair is split on its first ';'. +const rawCookiePairs = (header: string[] | string | undefined): string[] => { + const values = Array.isArray(header) ? header : header === undefined ? [] : [header]; + return values.map((value) => value.split(';', 1)[0]); +}; + +describe('Google OAuth (PostgreSQL e2e)', () => { + let sql: postgres.Sql; + let db: PostgresJsDatabase; + let app: INestApplication; + let requestOrdinal = 0; + const userIds: string[] = []; + const challenges: string[] = []; + const previousGoogleClientId = process.env.GOOGLE_CLIENT_ID; + + beforeAll(async () => { + process.env.GOOGLE_CLIENT_ID = 'google-e2e.apps.googleusercontent.com'; + sql = postgres(assertSafeTestDatabase(), { max: 8 }); + db = drizzle(sql, { schema }); + const verifier: GoogleIdTokenVerifier = { + verifyIdToken: async ({ idToken, audience }) => { + if (audience !== process.env.GOOGLE_CLIENT_ID) { + throw new Error('Wrong recipient'); + } + // SAFETY: the fake verifier parses the JSON credential string this suite + // produced; JSON.parse's unknown is narrowed to the fixture's GoogleClaims + // shape at this producer boundary. + const claims = JSON.parse(idToken) as GoogleClaims; + const now = Math.floor(Date.now() / 1000); + return { + getPayload: () => ({ + iss: 'https://accounts.google.com', + sub: claims.sub, + email: claims.email, + email_verified: true, + nonce: claims.nonce, + name: claims.name, + iat: now, + exp: now + 60, + }), + }; + }, + }; + + // SAFETY: drizzle(sql, { schema }) is the database producer; the cast + // satisfies the DrizzleDB contract with the exact live-pg surface used by + // the NestJS DRIZZLE provider in this e2e harness. + const drizzleDb = db as DrizzleDB; + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(DRIZZLE) + .useValue(drizzleDb) + .overrideProvider(DockerService) + .useValue({ ping: jest.fn().mockResolvedValue(true) }) + .overrideProvider(DOCKERODE) + .useValue({}) + .overrideProvider(GOOGLE_ID_TOKEN_VERIFIER) + .useValue(verifier) + .compile(); + + app = moduleFixture.createNestApplication(); + app.getHttpAdapter().getInstance().set('trust proxy', 1); + app.setGlobalPrefix('api', { exclude: ['/health'] }); + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }), + ); + await app.init(); + }); + + afterAll(async () => { + if (challenges.length > 0) { + await db + .delete(schema.oauthChallenges) + .where(inArray(schema.oauthChallenges.challengeHash, challenges.map(challengeHash))); + } + if (userIds.length > 0) { + await db.delete(schema.users).where(inArray(schema.users.id, userIds)); + } + if (app) await app.close(); + await sql.end(); + if (previousGoogleClientId === undefined) { + delete process.env.GOOGLE_CLIENT_ID; + } else { + process.env.GOOGLE_CLIENT_ID = previousGoogleClientId; + } + }); + + const createChallenge = async (): Promise => { + const response = await request(app.getHttpServer()) + .post('/api/auth/oauth/challenge') + .set('X-Forwarded-For', `198.51.100.${(requestOrdinal % 250) + 1}`) + .send({ provider: 'google' }) + .expect(200); + expect(response.body.challenge).toMatch(/^[A-Za-z0-9_-]{43}$/); + challenges.push(response.body.challenge); + requestOrdinal += 1; + return response.body.challenge; + }; + + const registerPasswordUser = async (email: string, username: string): Promise => { + await request(app.getHttpServer()) + .post('/api/auth/register') + .send({ email, username, password: 'Password123!' }) + .expect(201); + const [user] = await db.select().from(schema.users).where(eq(schema.users.email, email)); + userIds.push(user.id); + }; + + it('validates challenge provider input and returns a raw Google challenge once', async () => { + await request(app.getHttpServer()) + .post('/api/auth/oauth/challenge') + .send({ provider: 'github' }) + .expect(400); + + await createChallenge(); + }); + + it('runs Google login through normal session, profile, refresh, and logout paths', async () => { + const challenge = await createChallenge(); + const login = await request(app.getHttpServer()) + .post('/api/auth/oauth/google/login') + .send({ + credential: claimCredential({ + sub: `google-provider-${Date.now()}`, + email: `phase-f-provider-${Date.now()}@example.com`, + nonce: challenge, + name: 'Provider Player', + }), + }) + .expect(200); + userIds.push(login.body.id); + expect(login.body).toMatchObject({ googleId: expect.stringMatching(/^google-provider-/) }); + expect(login.body).not.toHaveProperty('accessToken'); + const cookies = rawCookiePairs(login.headers['set-cookie']); + const accessCookie = cookies.find((value) => value.startsWith('access_token=')); + const refreshCookie = cookies.find((value) => value.startsWith('refresh_token=')); + expect(accessCookie).toBeDefined(); + expect(refreshCookie).toBeDefined(); + if (!accessCookie || !refreshCookie) + throw new Error('Google login did not issue normal session cookies'); + + await request(app.getHttpServer()) + .get('/api/auth/profile') + .set('Cookie', accessCookie) + .expect(200); + const refreshed = await request(app.getHttpServer()) + .post('/api/auth/refresh') + .set('Cookie', refreshCookie) + .expect(200); + const refreshedCookies = rawCookiePairs(refreshed.headers['set-cookie']); + const nextAccess = refreshedCookies.find((value) => value.startsWith('access_token=')); + const nextRefresh = refreshedCookies.find((value) => value.startsWith('refresh_token=')); + if (!nextAccess || !nextRefresh) + throw new Error('Refresh did not rotate normal session cookies'); + await request(app.getHttpServer()) + .post('/api/auth/logout') + .set('Cookie', [nextAccess, nextRefresh]) + .expect(200); + }); + + it('rejects a wrong nonce without consuming the issued challenge', async () => { + const challenge = await createChallenge(); + await request(app.getHttpServer()) + .post('/api/auth/oauth/google/login') + .send({ + credential: claimCredential({ + sub: `google-wrong-nonce-${Date.now()}`, + email: `phase-f-wrong-nonce-${Date.now()}@example.com`, + nonce: 'B'.repeat(43), + }), + }) + .expect(401); + + const [stored] = await db + .select({ id: schema.oauthChallenges.id }) + .from(schema.oauthChallenges) + .where(eq(schema.oauthChallenges.challengeHash, challengeHash(challenge))); + expect(stored).toBeDefined(); + }); + + it('requires authenticated confirmation before linking an email-matched password account', async () => { + const suffix = Date.now(); + const email = `phase-f-link-${suffix}@example.com`; + const username = `phaseflink${suffix.toString().slice(-8)}`; + await registerPasswordUser(email, username); + const [passwordUser] = await db + .select() + .from(schema.users) + .where(eq(schema.users.email, email)); + + const challenge = await createChallenge(); + const providerId = `google-link-${suffix}`; + const unlinked = await request(app.getHttpServer()) + .post('/api/auth/oauth/google/login') + .send({ credential: claimCredential({ sub: providerId, email, nonce: challenge }) }) + .expect(200); + expect(unlinked.body).toEqual({ status: 'LinkConfirmationRequired' }); + expect(unlinked.headers['set-cookie']).toBeUndefined(); + const [beforeLink] = await db + .select() + .from(schema.users) + .where(eq(schema.users.id, passwordUser.id)); + expect(beforeLink.googleId).toBeNull(); + + const passwordLogin = await request(app.getHttpServer()) + .post('/api/auth/login') + .send({ identifier: username, password: 'Password123!' }) + .expect(200); + const accessCookie = rawCookiePairs(passwordLogin.headers['set-cookie']).find((value) => + value.startsWith('access_token='), + ); + if (!accessCookie) throw new Error('Password login did not issue an access cookie'); + + const linkChallenge = await createChallenge(); + await request(app.getHttpServer()) + .post('/api/auth/oauth/google/link') + .set('Cookie', accessCookie) + .send({ credential: claimCredential({ sub: providerId, email, nonce: linkChallenge }) }) + .expect(200); + const [linked] = await db + .select() + .from(schema.users) + .where(eq(schema.users.id, passwordUser.id)); + expect(linked.googleId).toBe(providerId); + + const laterChallenge = await createChallenge(); + await request(app.getHttpServer()) + .post('/api/auth/oauth/google/login') + .send({ credential: claimCredential({ sub: providerId, email, nonce: laterChallenge }) }) + .expect(200); + }); + + it('does not allow password login for provider-only accounts', async () => { + const challenge = await createChallenge(); + const login = await request(app.getHttpServer()) + .post('/api/auth/oauth/google/login') + .send({ + credential: claimCredential({ + sub: `google-only-${Date.now()}`, + email: `phase-f-only-${Date.now()}@example.com`, + nonce: challenge, + name: 'Only Provider', + }), + }) + .expect(200); + userIds.push(login.body.id); + await request(app.getHttpServer()) + .post('/api/auth/login') + .send({ identifier: login.body.username, password: 'Password123!' }) + .expect(401); + }); + + it('allows exactly one concurrent authenticated account to claim a Google subject', async () => { + const suffix = Date.now(); + const firstEmail = `phase-f-race-a-${suffix}@example.com`; + const secondEmail = `phase-f-race-b-${suffix}@example.com`; + const firstUsername = `phasefracea${suffix.toString().slice(-7)}`; + const secondUsername = `phasefraceb${suffix.toString().slice(-7)}`; + await registerPasswordUser(firstEmail, firstUsername); + await registerPasswordUser(secondEmail, secondUsername); + const [firstLogin, secondLogin] = await Promise.all( + [firstUsername, secondUsername].map((identifier) => + request(app.getHttpServer()) + .post('/api/auth/login') + .send({ identifier, password: 'Password123!' }) + .expect(200), + ), + ); + const [firstAccess, secondAccess] = [firstLogin, secondLogin].map((response) => + rawCookiePairs(response.headers['set-cookie']).find((value) => + value.startsWith('access_token='), + ), + ); + if (!firstAccess || !secondAccess) + throw new Error('Race test password login did not issue cookies'); + const [firstChallenge, secondChallenge] = await Promise.all([ + createChallenge(), + createChallenge(), + ]); + const providerId = `google-race-${suffix}`; + const results = await Promise.all([ + request(app.getHttpServer()) + .post('/api/auth/oauth/google/link') + .set('Cookie', firstAccess) + .send({ + credential: claimCredential({ + sub: providerId, + email: firstEmail, + nonce: firstChallenge, + }), + }), + request(app.getHttpServer()) + .post('/api/auth/oauth/google/link') + .set('Cookie', secondAccess) + .send({ + credential: claimCredential({ + sub: providerId, + email: secondEmail, + nonce: secondChallenge, + }), + }), + ]); + expect(results.map((result) => result.status).sort()).toEqual([200, 409]); + const claimants = await db + .select({ id: schema.users.id }) + .from(schema.users) + .where(eq(schema.users.googleId, providerId)); + expect(claimants).toHaveLength(1); + }); +}); diff --git a/test/oauth-challenge.e2e-spec.ts b/test/oauth-challenge.e2e-spec.ts new file mode 100644 index 0000000..99926dc --- /dev/null +++ b/test/oauth-challenge.e2e-spec.ts @@ -0,0 +1,92 @@ +import { createHash } from 'node:crypto'; +import { eq, inArray } from 'drizzle-orm'; +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { OAuthChallengeService } from 'src/auth/oauth-challenge.service'; +import { type DrizzleDB } from 'src/db/db.module'; +import * as schema from 'src/db/schema'; +import { assertSafeTestDatabase } from './test-database'; + +const hashChallenge = (challenge: string): string => + createHash('sha256').update(challenge).digest('hex'); + +describe('OAuth challenges (PostgreSQL e2e)', () => { + let sql: postgres.Sql; + let db: PostgresJsDatabase; + let service: OAuthChallengeService; + const challengeIds: string[] = []; + + beforeAll(() => { + sql = postgres(assertSafeTestDatabase(), { max: 8 }); + db = drizzle(sql, { schema }); + // SAFETY: drizzle(sql, { schema }) is the database producer; the cast + // satisfies the DrizzleDB contract with the exact live-pg surface the + // service consumes in this e2e harness. + service = new OAuthChallengeService(db as DrizzleDB); + }); + + afterAll(async () => { + if (challengeIds.length > 0) { + await db + .delete(schema.oauthChallenges) + .where(inArray(schema.oauthChallenges.id, challengeIds)); + } + await sql.end(); + }); + + it('allows exactly one concurrent consumer and stores no raw challenge', async () => { + const challenge = await service.createChallenge('google'); + const challengeHash = hashChallenge(challenge); + const [stored] = await db + .select({ + id: schema.oauthChallenges.id, + challengeHash: schema.oauthChallenges.challengeHash, + }) + .from(schema.oauthChallenges) + .where(eq(schema.oauthChallenges.challengeHash, challengeHash)); + challengeIds.push(stored.id); + + expect(stored.challengeHash).toBe(challengeHash); + expect(stored.challengeHash).not.toBe(challenge); + + const attempts = await Promise.all( + Array.from({ length: 8 }, () => service.verifyAndConsume('google', challenge)), + ); + expect(attempts.filter(Boolean)).toHaveLength(1); + await expect( + db + .select({ id: schema.oauthChallenges.id }) + .from(schema.oauthChallenges) + .where(eq(schema.oauthChallenges.challengeHash, challengeHash)), + ).resolves.toEqual([]); + }); + + it('rejects expired and provider-mismatched challenges without consuming the valid binding', async () => { + const expiredChallenge = await service.createChallenge('google'); + const expiredHash = hashChallenge(expiredChallenge); + const [expiredRow] = await db + .select({ id: schema.oauthChallenges.id }) + .from(schema.oauthChallenges) + .where(eq(schema.oauthChallenges.challengeHash, expiredHash)); + challengeIds.push(expiredRow.id); + await db + .update(schema.oauthChallenges) + .set({ expiresAt: new Date(Date.now() - 1) }) + .where(eq(schema.oauthChallenges.id, expiredRow.id)); + await expect(service.verifyAndConsume('google', expiredChallenge)).resolves.toBe(false); + + const boundChallenge = await service.createChallenge('google'); + const boundHash = hashChallenge(boundChallenge); + const [boundRow] = await db + .select({ id: schema.oauthChallenges.id }) + .from(schema.oauthChallenges) + .where(eq(schema.oauthChallenges.challengeHash, boundHash)); + challengeIds.push(boundRow.id); + + // SAFETY: verifyAndConsume(provider, challenge) is called with the + // framework-producer string 'github' to prove provider binding; 'never' + // bypasses the compile-time union only for this negative test input. + await expect(service.verifyAndConsume('github' as never, boundChallenge)).resolves.toBe(false); + await expect(service.verifyAndConsume('google', boundChallenge)).resolves.toBe(true); + }); +}); diff --git a/test/refresh-rotation.e2e-spec.ts b/test/refresh-rotation.e2e-spec.ts new file mode 100644 index 0000000..81bebf1 --- /dev/null +++ b/test/refresh-rotation.e2e-spec.ts @@ -0,0 +1,155 @@ +import crypto from 'node:crypto'; +import type { INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import * as bcrypt from 'bcrypt'; +import cookieParser from 'cookie-parser'; +import { eq } from 'drizzle-orm'; +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { DRIZZLE, type DrizzleDB } from '../src/db/db.module'; +import * as schema from '../src/db/schema'; +import { DOCKERODE } from '../src/docker/docker.constants'; +import { DockerService } from '../src/docker/docker.service'; +import { assertSafeTestDatabase } from './test-database'; + +const E2E_SETUP_TOKEN = 'e2e-setup-token-rotation'; +const isSingleCookieHeader = (value: string | string[] | undefined): value is string => + typeof value === 'string'; + +const setCookieHeader = (value: string | string[] | undefined): string[] => + isSingleCookieHeader(value) ? [value] : (value ?? []); + +describe('Refresh rotation (PostgreSQL e2e)', () => { + let sql: postgres.Sql; + let db: PostgresJsDatabase; + let app: INestApplication; + let userId: string; + let identifier: string; + + beforeAll(async () => { + process.env.SETUP_TOKEN = E2E_SETUP_TOKEN; + const databaseUrl = assertSafeTestDatabase(); + sql = postgres(databaseUrl, { max: 8 }); + db = drizzle(sql, { schema }); + + // SAFETY: drizzle(sql, { schema }) is the database producer; the NestJS DRIZZLE contract + // invariant is the exact DrizzleDB query/schema surface consumed by useValue. + const database = db as DrizzleDB; + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(DRIZZLE) + .useValue(database) + .overrideProvider(DockerService) + .useValue({ ping: jest.fn().mockResolvedValue(true) }) + .overrideProvider(DOCKERODE) + .useValue({}) + .compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api', { exclude: ['/health'] }); + app.use(cookieParser()); + await app.init(); + + const id = crypto.randomUUID(); + const username = `rotation${id.slice(0, 12)}`; + const [user] = await db + .insert(schema.users) + .values({ + id, + email: `rotation-${id.slice(0, 12)}@example.com`, + username, + passwordHash: await bcrypt.hash('UserPassword123!', 4), + role: 'USER', + status: 'ACTIVE', + }) + .returning(); + userId = user.id; + identifier = username; + }); + + afterAll(async () => { + if (userId) await db.delete(schema.users).where(eq(schema.users.id, userId)); + if (app) await app.close(); + await sql.end(); + }); + + const loginRefreshCookie = async (): Promise => { + const login = await request(app.getHttpServer()) + .post('/api/auth/login') + .send({ identifier, password: 'UserPassword123!' }) + .expect(200); + const refreshCookie = setCookieHeader(login.headers['set-cookie']).find((cookie) => + cookie.startsWith('refresh_token='), + ); + if (!refreshCookie) throw new Error('refresh cookie missing after login'); + return refreshCookie; + }; + + it('admits exactly one winner when the same refresh cookie is used concurrently', async () => { + const refreshCookie = await loginRefreshCookie(); + + const results = await Promise.allSettled([ + request(app.getHttpServer()).post('/api/auth/refresh').set('Cookie', refreshCookie), + request(app.getHttpServer()).post('/api/auth/refresh').set('Cookie', refreshCookie), + ]); + + const statuses = results.map((result) => + result.status === 'fulfilled' ? result.value.status : -1, + ); + expect(statuses.filter((status) => status === 200)).toHaveLength(1); + + const loser = results.find( + (result) => result.status === 'fulfilled' && result.value.status !== 200, + ); + const loserResponse = loser?.status === 'fulfilled' ? loser.value : undefined; + expect(loserResponse?.status).toBe(401); + // the losing request may see the consumed row disappear before its delete + // (RefreshTokenExpired) or find the delete already applied (RefreshTokenInvalid) + expect(['RefreshTokenExpired', 'RefreshTokenInvalid']).toContain(loserResponse?.body?.error); + + // only the winning successor row exists; the loser minted nothing + const rows = await db + .select() + .from(schema.refreshTokens) + .where(eq(schema.refreshTokens.userId, userId)); + expect(rows).toHaveLength(1); + const rowBeforeRotation = rows[0]; + + const winner = results.find( + (result) => result.status === 'fulfilled' && result.value.status === 200, + ); + const winnerResponse = winner?.status === 'fulfilled' ? winner.value : undefined; + const winnerRefreshCookie = setCookieHeader(winnerResponse?.headers['set-cookie']).find( + (cookie) => cookie.startsWith('refresh_token='), + ); + if (!winnerRefreshCookie) throw new Error('winner successor cookie missing'); + + // the successor is the valid session: it rotates again into a new row + await request(app.getHttpServer()) + .post('/api/auth/refresh') + .set('Cookie', winnerRefreshCookie) + .expect(200); + const rotatedRows = await db + .select() + .from(schema.refreshTokens) + .where(eq(schema.refreshTokens.userId, userId)); + expect(rotatedRows).toHaveLength(1); + expect(rotatedRows[0].id).not.toBe(rowBeforeRotation.id); + }); + + it('rejects sequential replay of an already rotated refresh cookie', async () => { + const refreshCookie = await loginRefreshCookie(); + + await request(app.getHttpServer()) + .post('/api/auth/refresh') + .set('Cookie', refreshCookie) + .expect(200); + + const replay = await request(app.getHttpServer()) + .post('/api/auth/refresh') + .set('Cookie', refreshCookie) + .expect(401); + expect(replay.body).toEqual({ error: 'RefreshTokenExpired' }); + }); +}); diff --git a/test/session-hygiene.e2e-spec.ts b/test/session-hygiene.e2e-spec.ts new file mode 100644 index 0000000..9ec1ec7 --- /dev/null +++ b/test/session-hygiene.e2e-spec.ts @@ -0,0 +1,173 @@ +import crypto from 'node:crypto'; +import type { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test, type TestingModule } from '@nestjs/testing'; +import * as bcrypt from 'bcrypt'; +import cookieParser from 'cookie-parser'; +import { eq } from 'drizzle-orm'; +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { hashRefreshTokenId } from '../src/auth/refresh-token-id'; +import { DRIZZLE, type DrizzleDB } from '../src/db/db.module'; +import * as schema from '../src/db/schema'; +import { DOCKERODE } from '../src/docker/docker.constants'; +import { DockerService } from '../src/docker/docker.service'; +import { assertSafeTestDatabase } from './test-database'; + +const E2E_SETUP_TOKEN = 'e2e-setup-token-session-hygiene'; +const isSingleCookieHeader = (value: string | string[] | undefined): value is string => + typeof value === 'string'; + +const setCookieHeader = (value: string | string[] | undefined): string[] => + isSingleCookieHeader(value) ? [value] : (value ?? []); + +describe('Session hygiene (PostgreSQL e2e)', () => { + let sql: postgres.Sql; + let db: PostgresJsDatabase; + let app: INestApplication; + let userId: string; + let identifier: string; + let expiredRowId: string | undefined; + + beforeAll(async () => { + process.env.SETUP_TOKEN = E2E_SETUP_TOKEN; + const databaseUrl = assertSafeTestDatabase(); + sql = postgres(databaseUrl, { max: 8 }); + db = drizzle(sql, { schema }); + + // SAFETY: drizzle(sql, { schema }) is the database producer; the NestJS DRIZZLE contract + // invariant is the exact DrizzleDB query/schema surface consumed by useValue. + const database = db as DrizzleDB; + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(DRIZZLE) + .useValue(database) + .overrideProvider(DockerService) + .useValue({ ping: jest.fn().mockResolvedValue(true) }) + .overrideProvider(DOCKERODE) + .useValue({}) + .compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api', { exclude: ['/health'] }); + app.use(cookieParser()); + await app.init(); + + const id = crypto.randomUUID(); + const username = `hygiene${id.slice(0, 12)}`; + const [user] = await db + .insert(schema.users) + .values({ + id, + email: `hygiene-${id.slice(0, 12)}@example.com`, + username, + passwordHash: await bcrypt.hash('UserPassword123!', 4), + role: 'USER', + status: 'ACTIVE', + }) + .returning(); + userId = user.id; + identifier = username; + }); + + afterAll(async () => { + if (userId) await db.delete(schema.users).where(eq(schema.users.id, userId)); + if (app) await app.close(); + await sql.end(); + }); + + const loginCookie = async (prefix: string): Promise => { + const login = await request(app.getHttpServer()) + .post('/api/auth/login') + .send({ identifier, password: 'UserPassword123!' }) + .expect(200); + const cookie = setCookieHeader(login.headers['set-cookie']).find((entry) => + entry.startsWith(`${prefix}=`), + ); + if (!cookie) throw new Error(`${prefix} cookie missing after login`); + return cookie; + }; + + it('rejects refresh without a cookie as RefreshTokenMissing', async () => { + const missing = await request(app.getHttpServer()).post('/api/auth/refresh').expect(401); + expect(missing.body).toEqual({ error: 'RefreshTokenMissing' }); + }); + + it('rejects a refresh token whose database row has expired', async () => { + const jwtService = app.get(JwtService); + const jti = crypto.randomBytes(32).toString('base64url'); + const refreshToken = await jwtService.signAsync( + { + sub: userId, + type: 'refresh', + jti, + }, + { expiresIn: '1m' }, + ); + const [expiredRow] = await db + .insert(schema.refreshTokens) + .values({ + tokenIdHash: hashRefreshTokenId(jti), + userId, + expiresAt: new Date(Date.now() - 1000), + }) + .returning(); + expiredRowId = expiredRow.id; + + const response = await request(app.getHttpServer()) + .post('/api/auth/refresh') + .set('Cookie', `refresh_token=${refreshToken}`) + .expect(401); + expect(response.body).toEqual({ error: 'RefreshTokenExpired' }); + }); + + it('rejects a refresh token whose JWT exp has passed', async () => { + const jwtService = app.get(JwtService); + const expiredToken = await jwtService.signAsync( + { + sub: userId, + type: 'refresh', + jti: crypto.randomBytes(32).toString('base64url'), + }, + { expiresIn: '1ms' }, + ); + // let the single-millisecond expiry elapse before the request reaches verifyAsync + await new Promise((resolve) => setTimeout(resolve, 50)); + + const response = await request(app.getHttpServer()) + .post('/api/auth/refresh') + .set('Cookie', `refresh_token=${expiredToken}`) + .expect(401); + expect(response.body).toEqual({ error: 'RefreshTokenExpired' }); + }); + + it('lists only non-expired sessions and never exposes the token hash', async () => { + const accessCookie = await loginCookie('access_token'); + const [anotherExpiredRow] = await db + .insert(schema.refreshTokens) + .values({ + tokenIdHash: `expired-${crypto.randomUUID()}`, + userId, + expiresAt: new Date(Date.now() - 60_000), + }) + .returning(); + + const stored = await db + .select() + .from(schema.refreshTokens) + .where(eq(schema.refreshTokens.userId, userId)); + // one live login row plus two expired rows must be in the database + expect(stored).toHaveLength(3); + + const response = await request(app.getHttpServer()) + .get('/api/auth/sessions') + .set('Cookie', accessCookie) + .expect(200); + expect(response.body).toHaveLength(1); + const sessionIds = response.body.map((session: { id: string }) => session.id); + expect(sessionIds).not.toContain(expiredRowId); + expect(sessionIds).not.toContain(anotherExpiredRow.id); + expect(response.body[0]).not.toHaveProperty('tokenIdHash'); + }); +}); From 1626f6a3acec6a363a9de601578250a58628ee6e Mon Sep 17 00:00:00 2001 From: okazakee Date: Wed, 26 Aug 2026 02:51:45 +0200 Subject: [PATCH 2/2] docs: reconcile decisions, roadmap, and licensing truth - SPEC: D-5/D-7/D-10/D-11 adopted; seven-table schema incl. oauth_challenges; current-state refresh/identity sections; DELIVERED backlog markers; OAuth endpoint contract; Appendix A identity + MIT license truth - README: Phase 1.5 Google OAuth shipped; OAuth endpoints in API table; MIT - minepanel-site.json: Phase 1.5 50% with Google OAuth done (live roadmap data) - package.json: license MIT (D-11); .env.example: JWT_REFRESH_EXPIRES_IN, GOOGLE_CLIENT_ID, and removed stray typescript-language-server devDep --- .env.example | 3 ++ README.md | 8 +++-- SPEC.md | 79 ++++++++++++++++++++++++++------------------- minepanel-site.json | 6 ++-- package.json | 3 +- 5 files changed, 59 insertions(+), 40 deletions(-) diff --git a/.env.example b/.env.example index 57873fb..c098001 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,9 @@ SETUP_TOKEN= REQUIRE_ADMIN_APPROVAL=true # true = new users start as PENDING, admin must approve LOGIN_THROTTLE_LIMIT=5 # max login attempts per window LOGIN_THROTTLE_TTL_MS=10000 # window duration in ms (default: 10s) +# Optional. Enables Google ID-token login; use the trusted frontend OAuth client ID. +# The backend derives its frontend origin from CORS_ORIGIN and never accepts an origin from requests. +GOOGLE_CLIENT_ID= # ─── Docker / Host paths ────────────────────────────────────────────────────── # DOCKER_SOCKET here names the HOST socket path, used as the Compose bind diff --git a/README.md b/README.md index ce55a8f..63a08ce 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ > **Phase 1 - v1.0 release candidate.** Auth (JWT cookies, 2FA, admin approval), transactional first-admin bootstrap, protocol-1 capability discovery, server lifecycle, host metrics over WebSocket, and the one-command Docker deployment (Caddy auto-HTTPS, Drizzle migrations on boot, multi-arch images on GHCR) are shipped and CI-green. Open decisions and the hardening backlog are tracked in [SPEC.md](./SPEC.md) §16/§19. > -> **Phase 1.5 Round 1 - authorization spine shipped.** Per-server visibility (`OPEN`/`REQUEST`/`PRIVATE`), request/approval workflows, and MOD granular permissions (`PermissionsGuard` + `mod_permissions`) are live. OAuth, Minecraft linking, magic links, and invites are planned for later Phase 1.5 rounds - see the [roadmap](https://minepanel.xyz/#roadmap). +> **Phase 1 - authorization spine shipped.** Per-server visibility (`OPEN`/`REQUEST`/`PRIVATE`), request/approval workflows, and MOD granular permissions (`PermissionsGuard` + `mod_permissions`) are live. +> +> **Phase 1.5 - Google OAuth shipped.** Challenge-bound Google login, local JWKS verification, LinkConfirmationRequired linking semantics, and truthful capability discovery (`capabilities.auth.googleOAuth`) are live. GitHub OAuth, Minecraft linking, magic links, and invites remain planned - see the [roadmap](https://minepanel.xyz/#roadmap). --- @@ -173,7 +175,7 @@ Full docs at `/docs` (Swagger UI) when the server is running. | Group | Endpoints | |------------|-----------------------------------------------------------------------------------------------| | Setup | `GET /setup/status` · `POST /setup/init` (requires `X-Setup-Token`, throttled 5/10 min/IP) | -| Auth | `POST /auth/register` · `POST /auth/login` · `POST /auth/refresh` · `POST /auth/logout` · `POST /auth/logout-all` · `GET /auth/profile` · `GET /auth/sessions` · `PATCH /auth/profile` · `PATCH /auth/password` · `POST /auth/2fa/setup` · `POST /auth/2fa/confirm` · `POST /auth/2fa/verify` · `DELETE /auth/2fa/disable` | +| Auth | `POST /auth/register` · `POST /auth/login` · `POST /auth/refresh` · `POST /auth/logout` · `POST /auth/logout-all` · `GET /auth/profile` · `GET /auth/sessions` · `PATCH /auth/profile` · `PATCH /auth/password` · `POST /auth/oauth/challenge` · `POST /auth/oauth/google/login` · `POST /auth/oauth/google/link` · `POST /auth/2fa/setup` · `POST /auth/2fa/confirm` · `POST /auth/2fa/verify` · `DELETE /auth/2fa/disable` | | Admin | `GET /admin/users` · `PATCH /admin/users/:id/status` · `PATCH /admin/users/:id/role` · `POST /admin/users/:id/reset-password` · `DELETE /admin/users/:id/2fa` · `GET/POST /admin/users/:id/permissions` · `DELETE /admin/users/:id/permissions/:permId` | | Health | `GET /health` | | Info | `GET /api/info` - protocol-1 capability discovery (`Cache-Control: no-store`) | @@ -192,4 +194,6 @@ See [SPEC.md](./SPEC.md) for the full architecture specification. ## License +MIT — see [LICENSE](./LICENSE). + Not affiliated with Mojang Studios or Microsoft. Minecraft is a trademark of Mojang Synergies AB. diff --git a/SPEC.md b/SPEC.md index 9400a2c..a9e15a5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -148,7 +148,7 @@ These invariants are non-negotiable; future work MUST preserve them. ### 6.1 Tables `[IMPLEMENTED]` -Six tables, all defined in `src/db/schema.ts`; migrations `0000`–`0003` in `drizzle/` cumulatively match the schema exactly (no drift). Primary keys are `text` UUIDs generated by `crypto.randomUUID()` — not cuid. +Seven tables, all defined in `src/db/schema.ts`; migrations `0000`–`0006` in `drizzle/` cumulatively match the schema exactly (no drift). Primary keys are `text` UUIDs generated by `crypto.randomUUID()` — not cuid. **`users`** @@ -157,7 +157,10 @@ Six tables, all defined in `src/db/schema.ts`; migrations `0000`–`0003` in `dr | `id` | text | PK, UUID | | `email` | varchar(254) | not null, unique (case-sensitive) | | `username` | varchar(32) | not null, unique (case-sensitive) | -| `passwordHash` | text | **not null** (OAuth-only accounts are a Phase 1.5 schema change; see §17.1) | +| `passwordHash` | text | **nullable** — null on provider-only accounts; password login on null-hash fails generically | +| `googleId` | text | unique, null — verified Google `sub` (D-5 challenge-bound) | +| `githubId` | text | unique, null — reserved; GitHub login not implemented | +| `minecraftVerified` | boolean | not null, default false | | `role` | enum `ADMIN`\|`MOD`\|`USER` | default `USER` | | `status` | enum `ACTIVE`\|`PENDING`\|`BANNED` | default `ACTIVE` | | `totpSecret` | text | null; AES-256-GCM encrypted | @@ -175,12 +178,12 @@ Six tables, all defined in `src/db/schema.ts`; migrations `0000`–`0003` in `dr | Column | Type | Constraints | |--------|------|-------------| | `id` | text | PK, UUID | -| `token` | text | not null, unique — the **bcrypt hash** of the refresh JWT | +| `tokenIdHash` | text | not null, unique — SHA-256 of the random `jti`; the raw refresh token is never stored | | `userId` | text | not null, FK → users, `onDelete: cascade` | | `expiresAt` | timestamptz | not null | | `createdAt` | timestamptz | default now | -> There are **no** `userAgent` / `lastUsedAt` columns; the old SPEC claimed both. Session metadata is an accepted backlog item (§8.3). No index exists on `userId` or `expiresAt` (backlog B-P1-3). +> There are **no** `userAgent` / `lastUsedAt` columns; the old SPEC claimed both. Session metadata is an accepted backlog item (§8.3). Indexes exist on `userId` and `expiresAt` (B-P1-3). **`setup_state`** — singleton row (`id = 'singleton'`): `initialAdminCreated` boolean, `createdAt`, `updatedAt`. @@ -217,9 +220,11 @@ Six tables, all defined in `src/db/schema.ts`; migrations `0000`–`0003` in `dr **`mod_permissions`** — `userId` FK cascade, `permission` enum (`SERVER_LIFECYCLE`, `SERVER_CONFIG`, `PLUGIN_MANAGEMENT`, `WHITELIST_MANAGEMENT`, `USER_MANAGEMENT`, `FILE_MANAGER`), `serverId` nullable FK cascade, `createdAt`. Partial unique index on `(userId, permission)` where `serverId IS NULL`; unique on `(userId, permission, serverId)`; index on `(userId, permission, serverId)`. +**`oauth_challenges`** — single-use Google-OAuth challenge binding (D-5): `id` PK (UUID), `provider` (constrained to `google`), `challengeHash` (unique — SHA-256 of the raw challenge; the raw value is returned once and never stored), `expiresAt` (5-minute TTL), `createdAt`. Consumption is an atomic `DELETE … RETURNING` keyed by provider + hash + unexpired timestamp; concurrent attempts yield exactly one success. Indexes: unique challenge hash + expiry. + ### 6.2 Tables that do NOT exist yet `[ACCEPTED]/[PROPOSED]` -The old SPEC listed these as current models. They are future work, defined in §17: `Ban`, `MagicLinkToken`, `ApiKey`, `Webhook`, `SystemEvent`, `AuditLog`, `MetricSnapshot`, `Backup`, `ScheduledTask`, `Notification`, `ServerPlugin`, `ServerMod`, `ServerProxy`. `users.googleId`, `users.githubId`, `users.minecraftVerified`, `servers.discordWebhook` are future columns. `users.passwordHash` MUST become nullable for Phase 1.5 OAuth (§17.1). +The old SPEC listed these as current models. They are future work, defined in §17: `Ban`, `MagicLinkToken`, `ApiKey`, `Webhook`, `SystemEvent`, `AuditLog`, `MetricSnapshot`, `Backup`, `ScheduledTask`, `Notification`, `ServerPlugin`, `ServerMod`, `ServerProxy`. `servers.discordWebhook` remains a future column. Google identity columns (`users.googleId`, `users.githubId`, `users.minecraftVerified`) and nullable `users.passwordHash` are delivered in Phase 1.5 (§6.1, §17.1). ### 6.3 Enums @@ -236,10 +241,10 @@ Global prefix `api` (except `/health`); Swagger UI at `/docs` (public — backlo | Method | Path | Description | |--------|------|-------------| | GET | `/health` | Liveness: `{ status: 'ok'\|'degraded', db, docker, version }`; 503 when degraded. Uses `SELECT 1` + `docker.ping()`. | -| GET | `/api/info` | Protocol-1 capability discovery: `{ name, version, api: { protocolVersion: 1 }, capabilities: { auth: { partitionedCookies: true, pkceAuthorizationCode: false }, realtime: { websocketTicket: false } } }`; sends `Cache-Control: no-store`. | +| GET | `/api/info` | Protocol-1 capability discovery: `{ name, version, api: { protocolVersion: 1 }, capabilities: { auth: { partitionedCookies: true, pkceAuthorizationCode: false, googleOAuth }, realtime: { websocketTicket: false } } }`; `googleOAuth` is true only when `GOOGLE_CLIENT_ID` is configured; sends `Cache-Control: no-store`. | | GET | `/docs` | Swagger UI (public in current builds) | -**Capability discovery `[IMPLEMENTED]`** — `GET /api/info` is the version-independent protocol contract for hosted clients. `partitionedCookies: true` means the production auth cookie paths emit CHIPS `Partitioned`; PKCE authorization-code fallback and WebSocket tickets remain `false` until their future implementations ship. Clients MUST use these flags rather than infer compatibility from `version`. +**Capability discovery `[IMPLEMENTED]`** — `GET /api/info` is the version-independent protocol contract for hosted clients. `partitionedCookies: true` means the production auth cookie paths emit CHIPS `Partitioned`; `googleOAuth` truthfully reports whether the trusted `GOOGLE_CLIENT_ID` configuration enables Google ID-token login. PKCE authorization-code fallback and WebSocket tickets remain `false` until their future implementations ship. Clients MUST use these flags rather than infer compatibility from `version`. ### 7.2 Setup @@ -254,18 +259,21 @@ Global prefix `api` (except `/health`); Swagger UI at `/docs` (public — backlo |--------|------|------|-------------| | POST | `/api/auth/register` | Public, throttle 5/10s | 201 `{ message }`; PENDING if `REQUIRE_ADMIN_APPROVAL=true`, else ACTIVE; 409 `User already exists` | | POST | `/api/auth/login` | Public, throttle 5/10s | 200 `PublicUser` + cookies, or `{ requiresTwoFactor, preAuthToken }`; 401 `Wrong credentials` (timing-equalized); 403 `AccountPending`/`AccountBanned` | -| POST | `/api/auth/refresh` | Public, throttle 5/10s | Rotates refresh token, sets new cookies. **Missing/malformed/expired refresh currently → 500 (bug, B-P1-2)** | +| POST | `/api/auth/refresh` | Public, throttle 5/10s | Rotates refresh token, sets new cookies. Missing/malformed/expired/replayed refresh → 401 with machine codes; never 500 (B-P1-2) | | POST | `/api/auth/logout` | JWT | Revokes the presented refresh row, clears cookies | | POST | `/api/auth/logout-all` | JWT | Revokes all refresh rows, clears cookies | | GET | `/api/auth/profile` | JWT | Current user (`req.user` shape) | | PATCH | `/api/auth/profile` | JWT | Update **username only** (email is not editable through any endpoint today); 400 `No changes` when identical | | PATCH | `/api/auth/password` | JWT | Change password; requires `currentPassword`; keeps current session, revokes others (normal flow) or all (forced recovery flow) | -| GET | `/api/auth/sessions` | JWT | List refresh-token rows (id, userId, expiresAt, createdAt) — currently **includes expired rows** (B-P1-3) | +| GET | `/api/auth/sessions` | JWT | List **unexpired** refresh-token rows (id, userId, expiresAt, createdAt); expired rows filtered (B-P1-3) | | DELETE | `/api/auth/sessions/:id` | JWT | Revoke own session; silently succeeds for missing rows | | POST | `/api/auth/2fa/setup` | JWT | Returns `{ secret, uri }`; secret encrypted at rest | | POST | `/api/auth/2fa/confirm` | JWT | Verifies first code, enables 2FA, returns 8 single-use backup codes | | POST | `/api/auth/2fa/verify` | Pre-auth Bearer, throttle 5/600s | Completes 2FA login, sets cookies | | DELETE | `/api/auth/2fa/disable` | JWT | Requires valid TOTP or backup code | +| POST | `/api/auth/oauth/challenge` | Public, throttle 5/10s | Google-only single-use 5-minute challenge. The raw challenge is returned once and stored only as a hash; the frontend supplies it as the Google `nonce`. | +| POST | `/api/auth/oauth/google/login` | Public, throttle 5/10s | Local-JWKS-verified Google ID token with a consumed Google challenge. Returns `PublicUser` plus normal session cookies, or `{ status: 'LinkConfirmationRequired' }` without a session when its verified email matches an existing account. | +| POST | `/api/auth/oauth/google/link` | JWT, throttle 5/10s | Verifies and consumes a fresh Google challenge, then atomically links the verified `sub`; a conflicting `google_id` returns 409. | ### 7.4 Admin (user management lives here — there is no `/users` controller) @@ -326,13 +334,14 @@ This closes the former concurrent-admin and insert/flag failure races. D-2 is ad - Cookie names: `access_token` (15 min TTL), `refresh_token` (7 days). Both are `HttpOnly`, explicitly `Path=/`, and use `Secure; SameSite=None; Partitioned` in production. Development uses `HttpOnly; Path=/; SameSite=Lax` and omits `Secure`/`Partitioned`. - Access JWT: `{ sub, type: 'access', username, role, temporaryAuth? }` — TTL from `JWT_EXPIRES_IN` via `JwtModule` `signOptions`. -- Refresh JWT: `{ sub, type: 'refresh', temporaryAuth? }` — **hardcoded `7d`**; `JWT_REFRESH_EXPIRES_IN` is declared but never read (B-P1-5: consume it or delete it). Stored **bcrypt-hashed** in `refresh_tokens`. +- Refresh JWT: `{ sub, type: 'refresh', jti, temporaryAuth? }` — TTL from the single parsed `JWT_REFRESH_EXPIRES_IN` (B-P1-5). The random `jti` is the DB row key (SHA-256 digest stored); the raw refresh token is never stored, and the DB never leaks a reusable credential. - `type` claims pin token purpose: only `type: 'refresh'` may rotate; only `type: 'access'` passes the JWT guard; `pre-auth` is a five-minute response-body Bearer token restricted to `POST /api/auth/2fa/verify`. - Login and refresh return `PublicUser` plus session cookies. A 2FA-required login instead returns `{ requiresTwoFactor: true, preAuthToken }` without setting session cookies; the browser-visible pre-auth exception is scoped in §5.2. ### 8.3 Refresh rotation contract `[ACCEPTED]` — P1 -Current implementation rotates on **every** refresh (no 24h sliding rule — the old SPEC and `docs/auth-architecture.md` are wrong on this), but consumption is **delete-then-insert without a transaction**: two concurrent refreshes with the same token can both mint successors → two live sessions from one token. Replay of an already-rotated token does 401. Missing/malformed/expired refresh → **500** `Internal server error` (JWT errors fall through the exception filter catch-all; B-P1-2). +Current implementation (B-P1-1 delivered) rotates on **every** refresh inside one transaction, keyed by the JWT `jti` SHA-256 digest: `SELECT` row → user status/recovery check → sign successor → `DELETE … RETURNING` the old row (zero rows = replay → 401) → `INSERT` successor; expired rows for the user are lazily swept in the same transaction. Concurrent refreshes with the same token produce exactly one successor; replays (sequential or concurrent) → 401 with stable machine codes `RefreshTokenMissing` / `RefreshTokenMalformed` / `RefreshTokenExpired` / `TokenWrongPurpose` / `RefreshTokenInvalid`, never 500 (B-P1-2). +Migration `0004` deletes every legacy `refresh_tokens` row (old-format tokens store no derivable `jti`), so all pre-upgrade sessions end at upgrade — every existing user must log in again. **Accepted contract (backlog B-P1-1):** @@ -362,7 +371,7 @@ Known nuance (B-P3-4): a MOD with a global `SERVER_LIFECYCLE` grant can pass the The hosted dashboard (`minepanel-pwa`) uses protocol-1 capability discovery. CHIPS `Partitioned` HttpOnly cookies are the primary cross-origin session mechanism where the browser supports them; the backend emits `SameSite=None; Secure; Partitioned` in production. The PKCE authorization-code flow with a memory-only bearer access token is the documented fallback design, but it is reserved and not implemented. Therefore complete hosted-browser compatibility remains a release blocker; same-origin deployment is fully supported. -`GET /api/info` truthfully advertises `partitionedCookies: true`, `pkceAuthorizationCode: false`, and `websocketTicket: false`. Clients MUST NOT infer support from panel version strings. The previous claim that `SameSite=None; Secure` alone is sufficient is obsolete. +`GET /api/info` truthfully advertises `partitionedCookies: true`, `pkceAuthorizationCode: false`, `googleOAuth` according to `GOOGLE_CLIENT_ID`, and `websocketTicket: false`. Clients MUST NOT infer support from panel version strings. The previous claim that `SameSite=None; Secure` alone is sufficient is obsolete. ### 8.6 WebSocket authentication `[ACCEPTED]` — P1 @@ -372,11 +381,12 @@ Accepted design: `POST /api/realtime/ticket` (authenticated, throttled) → sing `pg_advisory_xact_lock(7331)` serializes role/status transitions; deactivating or demoting the last active ADMIN → 409 `Cannot deactivate the last active admin`. Banning deletes all refresh sessions (the old SPEC claimed tokens are kept on ban — **false**; unban requires re-login). Role change clears all `mod_permissions` rows. Admin password reset: 16-char base64url temp password, 24h TTL, `mustChangePassword=true`, revokes all sessions, returned plaintext once. -### 8.8 Identity normalization `[IMPLEMENTED]` — known defect B-P1-7 +### 8.8 Identity normalization `[IMPLEMENTED]` - Registration: email is trimmed+lowercased; **username is trimmed but case-preserved**; uniqueness is case-sensitive (Postgres default) — `Bob` and `bob` are two accounts. - Login: the identifier is trimmed+**lowercased** and matched exactly against email OR username. - Consequence: a username containing any uppercase letter **cannot be used to log in by username** (the lowercased identifier never matches the stored casing); email login still works. Documented defect; accepted fix = normalize at registration (lowercase) or case-insensitive lookup — owner decision folded into D-10. +- **D-10 ADOPTED (canonical lowercase at write):** registration trims + lowercases username before persistence; provider-generated usernames use the same canonicalization; login keeps trimming + lowercasing the identifier; uniqueness remains the Postgres case-sensitive constraint over canonical values. Existing DBs migrate with a case-collision preflight: pairs like `Bob`+`bob` must be resolved manually before the migration applies (no silent data loss); clean DBs migrate automatically. ### 8.9 Two-factor authentication `[IMPLEMENTED]` @@ -545,9 +555,9 @@ Four shapes coexist: NestJS default for `HttpException`; `{ message }` only for ### 13.2 Environment variables — consumed vs declared -Consumed `[IMPLEMENTED]`: `DATABASE_URL`, `JWT_SECRET`, `JWT_EXPIRES_IN`, `ENCRYPTION_KEY`, `DOCKER_SOCKET`, `DOCKER_NETWORK`, `MC_DATA_PATH`, `MC_DATA_BIND_SOURCE`, `MC_PORT_MIN`, `MC_PORT_MAX`, `MIN_FREE_DISK_MB`, `MAX_MEMORY_RATIO`, `STOP_WARN_SECONDS`, `REQUIRE_ADMIN_APPROVAL`, `CORS_ORIGIN`, `PORT`, `PANEL_NAME`, `PANEL_DESCRIPTION`, `PANEL_VERSION`, `NODE_ENV` (cookie/preflight behavior). +Consumed `[IMPLEMENTED]`: `DATABASE_URL`, `JWT_SECRET`, `JWT_EXPIRES_IN`, `JWT_REFRESH_EXPIRES_IN` (single TTL source for refresh JWT exp, DB `expiresAt`, cookie maxAge — B-P1-5), `GOOGLE_CLIENT_ID` (enables `googleOAuth` capability + issuance), `ENCRYPTION_KEY`, `DOCKER_SOCKET`, `DOCKER_NETWORK`, `MC_DATA_PATH`, `MC_DATA_BIND_SOURCE`, `MC_PORT_MIN`, `MC_PORT_MAX`, `MIN_FREE_DISK_MB`, `MAX_MEMORY_RATIO`, `STOP_WARN_SECONDS`, `REQUIRE_ADMIN_APPROVAL`, `CORS_ORIGIN`, `PORT`, `PANEL_NAME`, `PANEL_DESCRIPTION`, `PANEL_VERSION`, `NODE_ENV` (cookie/preflight behavior). -Declared but **never read** `[CONTRADICTED]`: `JWT_REFRESH_EXPIRES_IN` (refresh hardcoded 7d — B-P1-5), `LOGIN_THROTTLE_LIMIT`, `LOGIN_THROTTLE_TTL_MS` (dead — B-P1-9: wire or delete), `SMTP_*` (Phase 1.5), `MICROSOFT_*` (Phase 1.5). `DOMAIN` is consumed by Caddy only. `PANEL_ASSETS_PATH` is planned but unmounted — no `/panel/logo` endpoints exist; the old SPEC's static-assets section (server icons, panel logo) is `[PROPOSED]` with the Phase 3 write architecture (D-8). `MC_DATA_PATH_HOST` is compose-only (required by `${MC_DATA_PATH_HOST:?}`). +Declared but **never read** `[CONTRADICTED]`: `LOGIN_THROTTLE_LIMIT`, `LOGIN_THROTTLE_TTL_MS` (dead — B-P1-9: wire or delete), `SMTP_*` (Phase 1.5), `MICROSOFT_*` (Phase 1.5). `DOMAIN` is consumed by Caddy only. `PANEL_ASSETS_PATH` is planned but unmounted — no `/panel/logo` endpoints exist; the old SPEC's static-assets section (server icons, panel logo) is `[PROPOSED]` with the Phase 3 write architecture (D-8). `MC_DATA_PATH_HOST` is compos…e-only (required by `${MC_DATA_PATH_HOST:?}`). ### 13.3 Reverse-proxy contract `[IMPLEMENTED]` @@ -579,7 +589,7 @@ Release gate status: the repository's configured CI gates were green at the last ### 14.4 Missing coverage `[ACCEPTED]` — backlog -- Setup race and throttling now have live-Postgres coverage in `test/setup-bootstrap.e2e-spec.ts`; refresh rotation concurrency and retained-data delete semantics remain backlog coverage. +- Setup race and throttling have live-Postgres coverage in `test/setup-bootstrap.e2e-spec.ts`; refresh rotation concurrency is covered by `test/refresh-rotation.e2e-spec.ts` (exactly-one-winner) — retained-data delete semantics remain backlog coverage. - No **real Docker lifecycle integration test** (container create → run → graceful stop → delete → data retention). `[PROPOSED]`: release-only job (mirrors the trusted `publish` smoke) that runs the full lifecycle against a real daemon before tagging. This is the behavior gap addressed by B-P1-13. --- @@ -592,7 +602,8 @@ Verified in the current working tree. `✓` = implemented and tested as noted; ` |--------|---------|--------|----------| | Auth | register / login (timing-equalized, dummy hash) | ✓ | `auth.service.ts`, unit+e2e | | Auth | HttpOnly cookie sessions (access 15m / refresh 7d) | ✓ | `auth.controller.ts`, e2e | -| Auth | refresh rotation (per-use; non-atomic — B-P1-1) | ✓ (defect) | `auth.service.ts:278-286` | +| Auth | refresh rotation (atomic, jti-keyed, exactly-one-winner) | ✓ | `auth.service.ts`, e2e concurrency | +| Auth | Google OAuth (challenge + local JWKS + session reuse) | ✓ | `google-token.service.ts`, `google-oauth.service.ts`, e2e | | Auth | logout / logout-all / sessions list / revoke one | ✓ | `auth.controller.ts`, unit+e2e | | Auth | password change (keep current session) | ✓ | `users.service.ts`, unit | | Auth | forced recovery (admin temp password, mustChangePassword) | ✓ | `auth.service.ts`, `jwt-auth.guard.ts`, unit | @@ -632,20 +643,20 @@ Generated from current implementation gaps. Priorities: **P0** release blockers, ### P1 — important before stable v1 -- **B-P1-1 Atomic refresh rotation (§8.3):** tx + `FOR UPDATE` + `jti` O(1) lookup + per-class 401 codes + replay semantics + frontend single-flight contract. -- **B-P1-2 Refresh/logout error contract:** missing/malformed/expired refresh and missing-cookie logout MUST return 401, never 500 (`db-exception.filter.ts` fall-through). -- **B-P1-3 Session hygiene:** `GET /sessions` filters `expiresAt > now()`; indexes on `refresh_tokens(user_id)` and `(expires_at)`; lazy + daily expired-row cleanup. +- ~~**B-P1-1 Atomic refresh rotation (§8.3):**~~ **DELIVERED** — tx + `jti` SHA-256 O(1) lookup + per-class 401 codes + replay semantics (live concurrency e2e). +- ~~**B-P1-2 Refresh/logout error contract:**~~ **DELIVERED** — missing/malformed/expired/consumed refresh and missing-cookie logout return 401 machine codes, never 500. +- ~~**B-P1-3 Session hygiene:**~~ **DELIVERED** — `GET /sessions` filters `expiresAt > now()`; indexes on `refresh_tokens(user_id)` and `(expires_at)`; lazy expired-row sweep inside the rotation transaction. Daily sweep remains tied to the scheduler decision (B-P1-6). - **B-P1-4 WebSocket ticket (§8.6):** single-use 60s ticket endpoint; keep cookie fast path; fixes the HttpOnly-token contradiction and Origin-less mobile handshakes. -- **B-P1-5 Refresh TTL sync:** consume `JWT_REFRESH_EXPIRES_IN` (JWT exp, DB `expiresAt`, cookie `maxAge`); remove hardcoded `7d`. -- **B-P1-6 Scheduler dependency decision:** add `@nestjs/schedule` (in-process, single-instance) for cleanup sweeps when the first cron feature lands; there is no scheduler today. -- **B-P1-7 Identity normalization (§8.8):** fix username case mismatch (normalize at registration or case-insensitive lookup); decide canonical identity policy (D-10). +- ~~**B-P1-5 Refresh TTL sync:**~~ **DELIVERED** — `JWT_REFRESH_EXPIRES_IN` is the single source for JWT exp, DB `expiresAt`, cookie `maxAge`; invalid values fail boot. +- **B-P1-6 Scheduler dependency decision:** add `@nestjs/schedule` (in-process, single-instance) for the daily cleanup sweep when the first cron feature lands; there is no scheduler today. +- ~~**B-P1-7 Identity normalization (§8.8):**~~ **DELIVERED** — D-10 adopted: canonical lowercase at write with collision-preflight migration. - **B-P1-8 Stable error envelope + request-id (§12.2):** global `AppExceptionFilter`, machine codes, `X-Request-Id`. - **B-P1-9 Dead envs:** wire or delete `LOGIN_THROTTLE_LIMIT`/`LOGIN_THROTTLE_TTL_MS`; declare dead config a release blocker. - **B-P1-10 MC container CPU/pids limits:** `NanoCpus` + `PidsLimit` in `HostConfig`. - **B-P1-11 RCON credential management:** per-server random `RCON_PASSWORD` at create, AES-GCM-encrypted in the existing `rcon_password` column, passed as env; unpins the current itzg-generated-password behavior. - **B-P1-12 Per-account login brute-force counter:** per-username in-memory counter (≥5 fails → 15 min lock, distinct 429 code), admin unlock; complements IP throttling. - **B-P1-13 Real Docker lifecycle e2e (release-only, §14.4):** container create → run → graceful stop → delete → data-retention assertions against a real daemon before tagging. -- **B-P1-15** Remaining concurrency and delete-contract coverage: setup initialization and setup throttling are covered by live e2e; refresh rotation concurrency and retained-data deletion semantics remain. +- **B-P1-15** Remaining concurrency and delete-contract coverage: refresh rotation concurrency now covered by live e2e (exactly-one-winner); retained-data deletion semantics remain, as does OAuth challenge link-race coverage (delivered in `google-oauth.e2e-spec.ts`). ### P2 — later improvements @@ -677,11 +688,11 @@ Generated from current implementation gaps. Priorities: **P0** release blockers, ## 17. Future architecture by phase -All subsections are `[PROPOSED]` unless marked. Dependencies are explicit; nothing here exists in code today. Current endpoint tables are §7; these proposals stay separate. +All subsections are `[PROPOSED]` unless marked. Dependencies are explicit; current endpoint tables are §7. Google OAuth Phase F is implemented; GitHub OAuth and magic links remain proposals. -### 17.1 Phase 1.5 — OAuth, magic links, identity `[PROPOSED]` +### 17.1 Phase 1.5 — OAuth, magic links, identity `[IMPLEMENTED IN PART: Google]` -**Schema changes (accepted requirements):** `users.passwordHash` MUST become nullable; add `googleId` (unique), `githubId` (unique), `minecraftVerified`; password login MUST reject null-hash accounts with a distinct code. +**Google schema delivered:** `users.passwordHash` is nullable; `googleId` is unique; `minecraftVerified` exists. Password login rejects null-hash accounts. GitHub identity remains proposed. **Google OAuth — security requirements (MUST):** - Local JWKS validation only (`https://www.googleapis.com/oauth2/v3/certs`, RS256, cache by `kid` with rotation; `google-auth-library` `verifyIdToken` or `jose`). **`tokeninfo` is a debugging endpoint — forbidden in production** (latency, throttling, availability coupling). @@ -692,7 +703,7 @@ All subsections are `[PROPOSED]` unless marked. Dependencies are explicit; nothi **Token binding (DECISION D-5):** one OAuth client_id shared by all backends means `aud` cannot distinguish backends — any token obtained by the frontend is replayable to *any* MinePanel backend (confused deputy). Recommended: `POST /auth/oauth/challenge` issues a single-use 5-min random challenge stored hashed; the frontend carries it through the provider flow (Google `nonce`, GitHub `state`) and presents it with the token; the backend consumes it atomically. PKCE MUST be used on the frontend leg regardless. -**Identity linking (MUST):** silent email-match linking is **forbidden** (account-takeover risk from recycled/attacker-controlled provider emails). Provider login matching an existing password account returns a `LinkConfirmationRequired` state; linking completes only after password re-authentication (or an existing session). Auto-link only when the account was created by the same provider id. +**Identity linking (MUST):** silent email-match linking is **forbidden** (account-takeover risk from recycled/attacker-controlled provider emails). Provider login matching an existing password account returns a `LinkConfirmationRequired` state; linking completes only after password re-authentication (or an existing session). Auto-link only when the account was created by the same provider id. The delivered Google link endpoint requires an existing authenticated session plus a new, single-use challenge-bound ID token; it intentionally creates no staged provider-token state. Provider login does not bypass forced password recovery or TOTP: it returns a stable forbidden error rather than issue a session. **GitHub token exposure (accepted risk, mitigations MUST):** a raw `gho_…` token (read-only scopes) reaches an arbitrary self-hosted backend; the spec requires verify-then-discard (never persist), redaction in all logs, TLS end-to-end (already enforced), and documentation of residual scope. @@ -766,13 +777,13 @@ Running server: `save-off` → `save-all flush` → snapshot copy → `save-on` | D-2 | Setup token mandatory? (§8.1) | **ADOPTED** | `X-Setup-Token` required; configured `SETUP_TOKEN` or one-time generated/logged bootstrap token | Stable v1 | | D-3 | Deletion semantics (§11.6) | **ADOPTED** | v1 retains host data; guarded manual cleanup; tombstone/backup/sweeper deferred | Stable v1 documentation | | D-4 | Shipped socket default (§10.2) | **OPEN** | rootful vs rootless default | Compose config | -| D-5 | OAuth token binding (§17.1) | **OPEN** | challenge binding vs per-backend client vs assertion broker | Phase 1.5 | +| D-5 | OAuth token binding (§17.1) | **ADOPTED** | single-use hashed 5-min backend challenge carried through Google `nonce`/GitHub `state`, atomically consumed; PKCE on the frontend leg; fixes the shared-Google-client confused-deputy replay (per-backend client and assertion broker rejected) | Phase 1.5 (this round) | | D-6 | WS auth primary path (§8.6) | **OPEN** | cookie vs ticket primary after D-1 / when cookies unavailable | Phase 3 real-time | -| D-7 | Identity linking policy (§17.1) | **OPEN** | explicit confirmation vs silent email-match | Phase 1.5 | +| D-7 | Identity linking policy (§17.1) | **ADOPTED** | silent email-match linking forbidden; provider login matching an existing account returns `LinkConfirmationRequired`; linking requires password re-authentication or an existing authenticated session; auto-link only when the account was created by the same provider id | Phase 1.5 (this round) | | D-8 | Write architecture (§10.4) | **OPEN** | sidecar vs rw mount with path module vs per-op exec | Phase 3 write features | | D-9 | Password hashing (§18.1) | **OPEN** | pepper pre-hash vs byte-limit vs Argon2id | Stable v1 hardening | -| D-10 | Identity normalization (§8.8) | **OPEN** | normalize username at registration vs case-insensitive lookup | Stable v1 | -| D-11 | License (B-P3-10) | **OPEN** | e.g. MIT / Apache-2.0 / AGPL-3.0 | Any public release | +| D-10 | Identity normalization (§8.8) | **ADOPTED** | canonical lowercase at write: trim + lowercase username at registration and provider-generated usernames; login keeps lowercasing; migration adds case-collision preflight (fail loudly on `Bob`+`bob` pairs, manual resolution, no silent data loss) | Stable v1 | +| D-11 | License (B-P3-10) | **ADOPTED** | MIT — LICENSE file committed (b5df536); reconcile package.json/SPEC/README/PWA metadata; no license-type change | Any public release | --- @@ -805,10 +816,10 @@ Password storage: The previous SPEC.md (pre-rewrite) was an ambitious design document that conflated three things. This revision separates them (§1 legend). Notable corrections: -1. **Never existed** (claimed as current): `nestjs-pino` logging (actual: `ConsoleLogger`), `@nestjs/schedule` cron (absent), `pendingDeleteAt` + deletion cleanup, `discordWebhook`, `googleId`/`githubId`/`minecraftVerified`, `Ban`/`MagicLinkToken`/`ApiKey`/`Webhook`/`SystemEvent`/`AuditLog`/`Backup`/`ScheduledTask`/`Notification`/`ServerPlugin`/`ServerMod`/`ServerProxy` tables, `/users` endpoints, `/versions`, version-update and icon endpoints, `/panel/logo`, `/system/stats` REST, magic links, OAuth and Minecraft-linking endpoints, strict/standard/relaxed rate-limit tiers, per-username brute-force counter, `refresh_tokens.userAgent`/`lastUsedAt`, 24h sliding refresh renewal, rootless-Docker default, `${XDG_RUNTIME_DIR}` compose default, SvelteKit frontend. +1. **Never existed** (claimed as current): `nestjs-pino` logging (actual: `ConsoleLogger`), `@nestjs/schedule` cron (absent), `pendingDeleteAt` + deletion cleanup, `discordWebhook`, `Ban`/`MagicLinkToken`/`ApiKey`/`Webhook`/`SystemEvent`/`AuditLog`/`MetricSnapshot`/`Backup`/`ScheduledTask`/`Notification`/`ServerPlugin`/`ServerMod`/`ServerProxy` tables, `/users` endpoints, `/versions`, version-update and icon endpoints, `/panel/logo`, `/system/stats` REST, magic links, OAuth and Minecraft-linking endpoints, strict/standard/relaxed rate-limit tiers, per-username brute-force counter, `refresh_tokens.userAgent`/`lastUsedAt`, 24h sliding refresh renewal, rootless-Docker default, `${XDG_RUNTIME_DIR}` compose default, SvelteKit frontend. 2. **Implemented differently than described**: refresh rotates on every use (not within 24h of expiry); production `sameSite` is `none` (docs claimed `lax`); PKs are `randomUUID` (not cuid); ban deletes sessions (spec claimed it does not); 422 error code is `InsufficientResources` (not per-resource codes); `DbExceptionFilter` emits `{message}` only (spec claimed NestJS default shape); delete returns 202 for a synchronous op; RCON is `docker exec rcon-cli`, not TCP RCON; the `mc-{id}` container name (not `minepanel-mc-{id}`). 3. **Unsafe examples removed**: `startsWith(serverDir)` path check (§18.2), "128 chars is safe with bcrypt" (§18.1), `SameSite=None; Secure` described as sufficient (§8.5), `tokeninfo` + "optional audience check" OAuth flow (§17.1), silent email-match linking (§17.1), "production-ready"/"100% complete" claims (§2). -4. **Product truth**: "open-source" claim removed (UNLICENSED, private, no LICENSE); frontend stack corrected to React 19 + Vite 7 + Tailwind 4; version strings reconciled as inconsistent (B-P2-6). +4. **Product truth**: frontend stack corrected to React 19 + Vite 7 + Tailwind 4; version strings reconciled as inconsistent (B-P2-6). The repository is MIT-licensed (LICENSE file; D-11 adopted), and `unpublished` context in earlier drafts is obsolete — `package.json` declares `"license": "MIT"`. ## Appendix B — Validation note diff --git a/minepanel-site.json b/minepanel-site.json index 63b99fc..9e653b8 100644 --- a/minepanel-site.json +++ b/minepanel-site.json @@ -81,10 +81,10 @@ "name": "Access Control & OAuth", "status": "wip", "description": "Fine-grained access control, social login, Minecraft account linking, and per-server visibility modes.", - "progress": 40, - "progressLabel": "40% — authorization spine complete", + "progress": 50, + "progressLabel": "50% — authorization spine + Google OAuth complete", "items": [ - { "text": "Google OAuth (frontend-initiated token flow)", "done": false }, + { "text": "Google OAuth (frontend-initiated token flow)", "done": true }, { "text": "GitHub OAuth (frontend-initiated token flow)", "done": false }, { "text": "Minecraft account linking (Microsoft OAuth premium flow)", "done": false }, { "text": "Offline player UUID linking", "done": false }, diff --git a/package.json b/package.json index 861eba2..a391a86 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "author": "", "private": true, - "license": "UNLICENSED", + "license": "MIT", "engines": { "bun": ">=1.3.14" }, @@ -50,6 +50,7 @@ "cookie-parser": "^1.4.7", "dockerode": "^4.0.9", "drizzle-orm": "0.45.2", + "google-auth-library": "^11.0.2", "helmet": "^8.1.0", "otplib": "^13.3.0", "postgres": "^3.4.8",