From 8a529a8e7ce472a3e4265d28001e4fafd9e5321f Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 1 Mar 2026 15:46:01 +0000 Subject: [PATCH] Add OIDC provider for Outline wiki SSO Add oidc-provider with MongoDB adapter so ghostguild.org can act as the identity provider for the self-hosted Outline wiki. Members authenticate via the existing magic-link flow, with automatic SSO when an active session exists. Includes interaction routes, well-known discovery endpoint, and login page. --- .env.example | 7 +- app/pages/oidc/login.vue | 96 +++ nuxt.config.ts | 3 + package-lock.json | 613 ++++++++++++++++++ package.json | 5 +- server/middleware/01.csrf.js | 1 + .../.well-known/openid-configuration.get.ts | 24 + server/routes/oidc/[...].ts | 30 + server/routes/oidc/interaction/[uid].get.ts | 94 +++ server/routes/oidc/interaction/login.post.ts | 81 +++ server/routes/oidc/interaction/verify.get.ts | 75 +++ server/utils/oidc-mongodb-adapter.ts | 114 ++++ server/utils/oidc-provider.ts | 117 ++++ 13 files changed, 1258 insertions(+), 2 deletions(-) create mode 100644 app/pages/oidc/login.vue create mode 100644 server/routes/.well-known/openid-configuration.get.ts create mode 100644 server/routes/oidc/[...].ts create mode 100644 server/routes/oidc/interaction/[uid].get.ts create mode 100644 server/routes/oidc/interaction/login.post.ts create mode 100644 server/routes/oidc/interaction/verify.get.ts create mode 100644 server/utils/oidc-mongodb-adapter.ts create mode 100644 server/utils/oidc-provider.ts diff --git a/.env.example b/.env.example index 5a02892..28109ca 100644 --- a/.env.example +++ b/.env.example @@ -20,4 +20,9 @@ SLACK_OAUTH_TOKEN=your-slack-oauth-token JWT_SECRET=your-jwt-secret-key-change-this-in-production # Application URLs -APP_URL=http://localhost:3000 \ No newline at end of file +APP_URL=http://localhost:3000 + +# OIDC Provider (for Outline Wiki SSO) +OIDC_CLIENT_ID=outline-wiki +OIDC_CLIENT_SECRET= +OIDC_COOKIE_SECRET= \ No newline at end of file diff --git a/app/pages/oidc/login.vue b/app/pages/oidc/login.vue new file mode 100644 index 0000000..c224c11 --- /dev/null +++ b/app/pages/oidc/login.vue @@ -0,0 +1,96 @@ + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index ab64f1c..eadc666 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -20,6 +20,9 @@ export default defineNuxtConfig({ slackBotToken: process.env.SLACK_BOT_TOKEN || "", slackSigningSecret: process.env.SLACK_SIGNING_SECRET || "", slackVettingChannelId: process.env.SLACK_VETTING_CHANNEL_ID || "", + oidcClientId: process.env.OIDC_CLIENT_ID || "outline-wiki", + oidcClientSecret: process.env.OIDC_CLIENT_SECRET || "", + oidcCookieSecret: process.env.OIDC_COOKIE_SECRET || "", // Public keys (available on client-side) public: { diff --git a/package-lock.json b/package-lock.json index 1ba137f..63183ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "mongoose": "^8.18.0", "nitro-cors": "^0.7.1", "nuxt": "^4.0.3", + "oidc-provider": "^9.6.1", "rate-limiter-flexible": "^9.1.1", "resend": "^6.0.1", "typescript": "^5.9.2", @@ -33,6 +34,8 @@ "devDependencies": { "@nuxt/test-utils": "^4.0.0", "@tailwindcss/typography": "^0.5.19", + "@types/jsonwebtoken": "^9.0.10", + "@types/oidc-provider": "^9.5.0", "jsdom": "^28.1.0", "vitest": "^4.0.18" } @@ -1949,6 +1952,41 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@koa/cors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@koa/cors/-/cors-5.0.0.tgz", + "integrity": "sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==", + "license": "MIT", + "dependencies": { + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@koa/router": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-15.3.1.tgz", + "integrity": "sha512-n7UgxsPmgKtEsrguz8a0d6BNx3lO2x52Z4UqkGsGwJculk4TlzZf3btd3QZMq1r1M+bSxUkBbyul4mDhysIVaQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "koa-compose": "^4.1.0", + "path-to-regexp": "^8.3.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "koa": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "koa": { + "optional": false + } + } + }, "node_modules/@kwsites/file-exists": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", @@ -5962,6 +6000,27 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -5973,6 +6032,36 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/content-disposition": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz", + "integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cookies": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.2.tgz", + "integrity": "sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -5986,12 +6075,96 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-assert": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", + "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/koa": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-3.0.1.tgz", + "integrity": "sha512-VkB6WJUQSe0zBpR+Q7/YIUESGp5wPHcaXr0xueU5W0EOUWtlSbblsl+Kl31lyRQ63nIILh0e/7gXjQ09JXJIHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "^2", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "node_modules/@types/koa-compose": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.9.tgz", + "integrity": "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -6038,6 +6211,13 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", @@ -6047,6 +6227,32 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/oidc-provider": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-9.5.0.tgz", + "integrity": "sha512-eEzCRVTSqIHD9Bo/qRJ4XQWQ5Z/zBcG+Z2cGJluRsSuWx1RJihqRyPxhIEpMXTwPzHYRTQkVp7hwisQOwzzSAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/keygrip": "*", + "@types/koa": "*", + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -6059,6 +6265,27 @@ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "license": "MIT" }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -7189,6 +7416,19 @@ "node": ">=6.5" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -7791,6 +8031,15 @@ "esbuild": ">=0.18" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/c12": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.3.tgz", @@ -8165,6 +8414,28 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -8177,6 +8448,19 @@ "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", "license": "MIT" }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -8591,6 +8875,12 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -8658,6 +8948,12 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -8682,6 +8978,16 @@ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "license": "MIT" }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -9712,6 +10018,18 @@ "node": ">=0.10.0" } }, + "node_modules/eta": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.5.1.tgz", + "integrity": "sha512-EaNCGm+8XEIU7YNcc+THptWAO5NfKBHHARxt+wxZljj9bTr/+arRoOm9/MpGt4n6xn9fLnPFRSoLD0WFYGFUxQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -10497,6 +10815,53 @@ ], "license": "MIT" }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-assert/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -10999,6 +11364,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11214,6 +11588,18 @@ "node": ">=12.0.0" } }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -11247,6 +11633,75 @@ "integrity": "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==", "license": "MIT" }, + "node_modules/koa": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/koa/-/koa-3.1.2.tgz", + "integrity": "sha512-2LOQnFKu3m0VxpE+5sb5+BRTSKrXmNxGgxVRiKwD9s5KQB1zID/FRXhtzeV7RT1L2GVpdEEAfVuclFOMGl1ikA==", + "license": "MIT", + "dependencies": { + "accepts": "^1.3.8", + "content-disposition": "~1.0.1", + "content-type": "^1.0.5", + "cookies": "~0.9.1", + "delegates": "^1.0.0", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.5.0", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "license": "MIT" + }, + "node_modules/koa/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/launch-editor": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.0.tgz", @@ -11922,6 +12377,15 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "license": "MIT" }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", @@ -12288,6 +12752,15 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nitro-cors": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/nitro-cors/-/nitro-cors-0.7.1.tgz", @@ -12732,6 +13205,27 @@ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "license": "MIT" }, + "node_modules/oidc-provider": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-9.6.1.tgz", + "integrity": "sha512-8AtFXE4gEV6MLd8Re78VhqGNjBm/SUw0fUxrP2XwQc+5DZKw6GyuTuy2M4jkidpH3jRrhtkkqQpXlxD1Awi6tg==", + "license": "MIT", + "dependencies": { + "@koa/cors": "^5.0.0", + "@koa/router": "^15.3.0", + "debug": "^4.4.3", + "eta": "^4.5.1", + "jose": "^6.1.3", + "jsesc": "^3.1.0", + "koa": "^3.1.1", + "nanoid": "^5.1.6", + "quick-lru": "^7.3.0", + "raw-body": "^3.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/on-change": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/on-change/-/on-change-6.0.2.tgz", @@ -13168,6 +13662,16 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -14070,6 +14574,18 @@ ], "license": "MIT" }, + "node_modules/quick-lru": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.3.0.tgz", + "integrity": "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/radix3": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", @@ -14100,6 +14616,37 @@ "integrity": "sha512-imxFjzPCmvDLMe7d2tsgiSQvs5EI2fI9SNymmslAfOqznZhsZ+PqbIjIYKpuSbd3pKovR1aMG47qfCLIO/adVg==", "license": "ISC" }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rc9": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.0.tgz", @@ -15484,6 +16031,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -15511,6 +16067,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/type-level-regexp": { "version": "0.1.17", "resolved": "https://registry.npmjs.org/type-level-regexp/-/type-level-regexp-0.1.17.tgz", @@ -15732,6 +16327,15 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unplugin": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", @@ -16207,6 +16811,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vaul-vue": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/vaul-vue/-/vaul-vue-0.4.1.tgz", diff --git a/package.json b/package.json index 7cbfc71..3661871 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@nuxt/ui": "^4.0.0", "@nuxtjs/plausible": "^3.0.1", "@slack/web-api": "^7.10.0", -"chrono-node": "^2.8.4", + "chrono-node": "^2.8.4", "cloudinary": "^2.7.0", "eslint": "^9.34.0", "isomorphic-dompurify": "^3.0.0", @@ -28,6 +28,7 @@ "mongoose": "^8.18.0", "nitro-cors": "^0.7.1", "nuxt": "^4.0.3", + "oidc-provider": "^9.6.1", "rate-limiter-flexible": "^9.1.1", "resend": "^6.0.1", "typescript": "^5.9.2", @@ -38,6 +39,8 @@ "devDependencies": { "@nuxt/test-utils": "^4.0.0", "@tailwindcss/typography": "^0.5.19", + "@types/jsonwebtoken": "^9.0.10", + "@types/oidc-provider": "^9.5.0", "jsdom": "^28.1.0", "vitest": "^4.0.18" } diff --git a/server/middleware/01.csrf.js b/server/middleware/01.csrf.js index 0ee2eec..3f3e841 100644 --- a/server/middleware/01.csrf.js +++ b/server/middleware/01.csrf.js @@ -7,6 +7,7 @@ const EXEMPT_PREFIXES = [ '/api/helcim/webhook', '/api/slack/webhook', '/api/auth/verify', + '/oidc/', ] function isExempt(path) { diff --git a/server/routes/.well-known/openid-configuration.get.ts b/server/routes/.well-known/openid-configuration.get.ts new file mode 100644 index 0000000..4e0dc85 --- /dev/null +++ b/server/routes/.well-known/openid-configuration.get.ts @@ -0,0 +1,24 @@ +/** + * Forward /.well-known/openid-configuration to the oidc-provider. + * + * The provider generates this discovery document automatically, but since the + * catch-all route is mounted under /oidc/, requests to /.well-known/ need + * explicit forwarding. + */ +import { getOidcProvider } from "../../utils/oidc-provider.js"; + +export default defineEventHandler(async (event) => { + const provider = await getOidcProvider(); + const { req, res } = event.node; + + // The provider expects the path relative to its root + req.url = "/.well-known/openid-configuration"; + + const callback = provider.callback() as Function; + await new Promise((resolve, reject) => { + callback(req, res, (err: unknown) => { + if (err) reject(err); + else resolve(); + }); + }); +}); diff --git a/server/routes/oidc/[...].ts b/server/routes/oidc/[...].ts new file mode 100644 index 0000000..8628a61 --- /dev/null +++ b/server/routes/oidc/[...].ts @@ -0,0 +1,30 @@ +/** + * Catch-all route that delegates all /oidc/* requests to the oidc-provider. + * + * This exposes the standard OIDC endpoints: + * /oidc/auth — authorization + * /oidc/token — token exchange + * /oidc/me — userinfo + * /oidc/session/end — logout + * /oidc/jwks — JSON Web Key Set + */ +import { getOidcProvider } from "../../utils/oidc-provider.js"; + +export default defineEventHandler(async (event) => { + const provider = await getOidcProvider(); + const { req, res } = event.node; + + // oidc-provider expects paths relative to its own mount point. + // Nitro gives us the full path, so strip the /oidc prefix. + const originalUrl = req.url || ""; + req.url = originalUrl.replace(/^\/oidc/, "") || "/"; + + // Hand off to oidc-provider's Connect-style callback + const callback = provider.callback() as Function; + await new Promise((resolve, reject) => { + callback(req, res, (err: unknown) => { + if (err) reject(err); + else resolve(); + }); + }); +}); diff --git a/server/routes/oidc/interaction/[uid].get.ts b/server/routes/oidc/interaction/[uid].get.ts new file mode 100644 index 0000000..ad6ad5a --- /dev/null +++ b/server/routes/oidc/interaction/[uid].get.ts @@ -0,0 +1,94 @@ +/** + * OIDC interaction handler — checks for an existing Ghost Guild session. + * + * Flow: + * 1. Outline redirects user to /oidc/auth + * 2. oidc-provider creates an interaction and redirects here + * 3. If the user has a valid auth-token cookie → complete the interaction (SSO) + * 4. Otherwise → redirect to the OIDC login page + */ +import jwt from "jsonwebtoken"; +import Member from "../../../models/member.js"; +import { connectDB } from "../../../utils/mongoose.js"; +import { getOidcProvider } from "../../../utils/oidc-provider.js"; + +export default defineEventHandler(async (event) => { + const provider = await getOidcProvider(); + const uid = getRouterParam(event, "uid")!; + + // Load the interaction details from oidc-provider + const interactionDetails = await provider.interactionDetails( + event.node.req, + event.node.res + ); + const { prompt } = interactionDetails; + + // ----- Login prompt ----- + if (prompt.name === "login") { + // Check for existing Ghost Guild session + const token = getCookie(event, "auth-token"); + + if (token) { + try { + const config = useRuntimeConfig(); + const decoded = jwt.verify(token, config.jwtSecret) as { + memberId: string; + }; + + await connectDB(); + const member = await (Member as any).findById(decoded.memberId); + + if ( + member && + member.status !== "suspended" && + member.status !== "cancelled" + ) { + // Auto-complete the login interaction (SSO) + const result = { + login: { accountId: member._id.toString() }, + }; + await provider.interactionFinished( + event.node.req, + event.node.res, + result, + { mergeWithLastSubmission: false } + ); + return; + } + } catch { + // Token invalid — fall through to login page + } + } + + // No valid session — redirect to login page + return sendRedirect(event, `/oidc/login?uid=${uid}`, 302); + } + + // ----- Consent prompt ----- + if (prompt.name === "consent") { + // Auto-approve consent for our first-party client + const grant = interactionDetails.grantId + ? await provider.Grant.find(interactionDetails.grantId) + : new provider.Grant({ + accountId: interactionDetails.session!.accountId, + clientId: interactionDetails.params.client_id as string, + }); + + if (grant) { + grant.addOIDCScope("openid profile email"); + await grant.save(); + + const result = { consent: { grantId: grant.jti } }; + await provider.interactionFinished( + event.node.req, + event.node.res, + result, + { mergeWithLastSubmission: true } + ); + return; + } + } + + // Fallback — shouldn't reach here normally + throw createError({ statusCode: 400, statusMessage: "Unknown interaction" }); +}); diff --git a/server/routes/oidc/interaction/login.post.ts b/server/routes/oidc/interaction/login.post.ts new file mode 100644 index 0000000..d342dbc --- /dev/null +++ b/server/routes/oidc/interaction/login.post.ts @@ -0,0 +1,81 @@ +/** + * Handle magic link login request during OIDC interaction flow. + * + * POST /oidc/interaction/login + * Body: { email: string, uid: string } + * + * Sends a magic link email. The link includes the OIDC interaction uid so the + * verify step can complete the interaction after authenticating. + */ +import jwt from "jsonwebtoken"; +import { Resend } from "resend"; +import Member from "../../../models/member.js"; +import { connectDB } from "../../../utils/mongoose.js"; + +const resend = new Resend(process.env.RESEND_API_KEY); + +export default defineEventHandler(async (event) => { + await connectDB(); + + const body = await readBody(event); + const email = body?.email?.trim()?.toLowerCase(); + const uid = body?.uid; + + if (!email || !uid) { + throw createError({ + statusCode: 400, + statusMessage: "Email and interaction uid are required", + }); + } + + const GENERIC_MESSAGE = + "If this email is registered, we've sent a login link."; + + const member = await (Member as any).findOne({ email }); + if (!member) { + return { success: true, message: GENERIC_MESSAGE }; + } + + const config = useRuntimeConfig(event); + const token = jwt.sign( + { memberId: member._id, oidcUid: uid }, + config.jwtSecret, + { expiresIn: "15m" } + ); + + const headers = getHeaders(event); + const baseUrl = + process.env.BASE_URL || + `${headers.host?.includes("localhost") ? "http" : "https"}://${headers.host}`; + + try { + await resend.emails.send({ + from: "Ghost Guild ", + to: email, + subject: "Sign in to Ghost Guild Wiki", + html: ` +
+

Sign in to the Ghost Guild Wiki

+

Click the button below to sign in:

+ +

+ This link expires in 15 minutes. If you didn't request this, you can safely ignore this email. +

+
+ `, + }); + + return { success: true, message: GENERIC_MESSAGE }; + } catch (error) { + console.error("Failed to send OIDC login email:", error); + throw createError({ + statusCode: 500, + statusMessage: "Failed to send login email. Please try again.", + }); + } +}); diff --git a/server/routes/oidc/interaction/verify.get.ts b/server/routes/oidc/interaction/verify.get.ts new file mode 100644 index 0000000..30b447b --- /dev/null +++ b/server/routes/oidc/interaction/verify.get.ts @@ -0,0 +1,75 @@ +/** + * Verify magic link token and complete the OIDC login interaction. + * + * GET /oidc/interaction/verify?token=... + * + * This is the endpoint the magic link email points to. It: + * 1. Verifies the JWT token + * 2. Sets the Ghost Guild session cookie (so future logins are SSO) + * 3. Completes the OIDC interaction so the user is redirected back to Outline + */ +import jwt from "jsonwebtoken"; +import Member from "../../../models/member.js"; +import { connectDB } from "../../../utils/mongoose.js"; +import { getOidcProvider } from "../../../utils/oidc-provider.js"; + +export default defineEventHandler(async (event) => { + const { token } = getQuery(event); + + if (!token) { + throw createError({ statusCode: 400, statusMessage: "Token is required" }); + } + + const config = useRuntimeConfig(event); + + let decoded: { memberId: string; oidcUid: string }; + try { + decoded = jwt.verify(token as string, config.jwtSecret) as typeof decoded; + } catch { + throw createError({ + statusCode: 401, + statusMessage: "Invalid or expired token", + }); + } + + await connectDB(); + const member = await (Member as any).findById(decoded.memberId); + + if (!member) { + throw createError({ statusCode: 404, statusMessage: "Member not found" }); + } + + if (member.status === "suspended" || member.status === "cancelled") { + throw createError({ + statusCode: 403, + statusMessage: `Account is ${member.status}`, + }); + } + + // Set Ghost Guild session cookie for future SSO + const sessionToken = jwt.sign( + { memberId: member._id, email: member.email }, + config.jwtSecret, + { expiresIn: "7d" } + ); + + setCookie(event, "auth-token", sessionToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, + }); + + // Complete the OIDC interaction + const provider = await getOidcProvider(); + const result = { + login: { accountId: member._id.toString() }, + }; + + await provider.interactionFinished( + event.node.req, + event.node.res, + result, + { mergeWithLastSubmission: false } + ); +}); diff --git a/server/utils/oidc-mongodb-adapter.ts b/server/utils/oidc-mongodb-adapter.ts new file mode 100644 index 0000000..f47f3b8 --- /dev/null +++ b/server/utils/oidc-mongodb-adapter.ts @@ -0,0 +1,114 @@ +/** + * MongoDB adapter for oidc-provider. + * + * Stores OIDC tokens, sessions, and grants in an `oidc_payloads` collection + * with TTL indexes for automatic cleanup. Uses the existing Mongoose connection. + */ +import mongoose from "mongoose"; +import { connectDB } from "./mongoose.js"; + +const collectionName = "oidc_payloads"; + +type MongoPayload = { + _id: string; + payload: Record; + expiresAt?: Date; + userCode?: string; + uid?: string; + grantId?: string; +}; + +let collectionReady = false; + +async function getCollection() { + await connectDB(); + const db = mongoose.connection.db!; + const col = db.collection(collectionName); + + if (!collectionReady) { + // TTL index — MongoDB automatically removes documents after expiresAt + await col + .createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }) + .catch(() => {}); + // Lookup indexes + await col.createIndex({ "payload.grantId": 1 }).catch(() => {}); + await col.createIndex({ "payload.userCode": 1 }).catch(() => {}); + await col.createIndex({ "payload.uid": 1 }).catch(() => {}); + collectionReady = true; + } + + return col; +} + +function prefixedId(model: string, id: string) { + return `${model}:${id}`; +} + +export class MongoAdapter { + model: string; + + constructor(model: string) { + this.model = model; + } + + async upsert( + id: string, + payload: Record, + expiresIn: number + ) { + const col = await getCollection(); + const expiresAt = expiresIn + ? new Date(Date.now() + expiresIn * 1000) + : undefined; + + await col.updateOne( + { _id: prefixedId(this.model, id) as any }, + { + $set: { + payload, + ...(expiresAt ? { expiresAt } : {}), + }, + }, + { upsert: true } + ); + } + + async find(id: string) { + const col = await getCollection(); + const doc = await col.findOne({ _id: prefixedId(this.model, id) as any }); + if (!doc) return undefined; + return doc.payload; + } + + async findByUserCode(userCode: string) { + const col = await getCollection(); + const doc = await col.findOne({ "payload.userCode": userCode }); + if (!doc) return undefined; + return doc.payload; + } + + async findByUid(uid: string) { + const col = await getCollection(); + const doc = await col.findOne({ "payload.uid": uid }); + if (!doc) return undefined; + return doc.payload; + } + + async consume(id: string) { + const col = await getCollection(); + await col.updateOne( + { _id: prefixedId(this.model, id) as any }, + { $set: { "payload.consumed": Math.floor(Date.now() / 1000) } } + ); + } + + async destroy(id: string) { + const col = await getCollection(); + await col.deleteOne({ _id: prefixedId(this.model, id) as any }); + } + + async revokeByGrantId(grantId: string) { + const col = await getCollection(); + await col.deleteMany({ "payload.grantId": grantId }); + } +} diff --git a/server/utils/oidc-provider.ts b/server/utils/oidc-provider.ts new file mode 100644 index 0000000..a4b22f0 --- /dev/null +++ b/server/utils/oidc-provider.ts @@ -0,0 +1,117 @@ +/** + * OIDC Provider configuration for Ghost Guild. + * + * ghostguild.org acts as the identity provider. Outline wiki is the sole + * relying party (client). Members authenticate via the existing magic-link + * flow, and the provider issues standard OIDC tokens so Outline can identify + * them. + */ +import Provider from "oidc-provider"; +import { MongoAdapter } from "./oidc-mongodb-adapter.js"; +import Member from "../models/member.js"; +import { connectDB } from "./mongoose.js"; + +let _provider: InstanceType | null = null; + +export async function getOidcProvider() { + if (_provider) return _provider; + + const config = useRuntimeConfig(); + const issuer = + process.env.OIDC_ISSUER || config.public.appUrl || "https://ghostguild.org"; + + _provider = new Provider(issuer, { + adapter: MongoAdapter, + + clients: [ + { + client_id: process.env.OIDC_CLIENT_ID || "outline-wiki", + client_secret: process.env.OIDC_CLIENT_SECRET || "", + redirect_uris: [ + "https://wiki.ghostguild.org/auth/oidc.callback", + // Local development callback + "http://localhost:3100/auth/oidc.callback", + ], + post_logout_redirect_uris: [ + "https://wiki.ghostguild.org", + "http://localhost:3100", + ], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_post", + }, + ], + + claims: { + openid: ["sub"], + profile: ["name", "preferred_username"], + email: ["email", "email_verified"], + }, + + scopes: ["openid", "profile", "email", "offline_access"], + + findAccount: async (_ctx: unknown, id: string) => { + await connectDB(); + const member = await (Member as any).findById(id); + if (!member) return undefined; + + return { + accountId: id, + async claims(_use: string, _scope: string) { + return { + sub: id, + name: member.name, + preferred_username: member.name, + email: member.email, + email_verified: true, + }; + }, + }; + }, + + cookies: { + keys: (process.env.OIDC_COOKIE_SECRET || "dev-cookie-secret").split(","), + }, + + ttl: { + AccessToken: 3600, // 1 hour + AuthorizationCode: 600, // 10 minutes + RefreshToken: 14 * 24 * 60 * 60, // 14 days + Session: 14 * 24 * 60 * 60, // 14 days + Interaction: 600, // 10 minutes + Grant: 14 * 24 * 60 * 60, // 14 days + }, + + features: { + devInteractions: { + enabled: process.env.NODE_ENV !== "production", + }, + revocation: { enabled: true }, + rpInitiatedLogout: { enabled: true }, + }, + + interactions: { + url(_ctx: unknown, interaction: { uid: string }) { + return `/oidc/interaction/${interaction.uid}`; + }, + }, + + // Allow Outline to use PKCE but don't require it + pkce: { + required: () => false, + }, + + // Skip consent for our first-party Outline client + loadExistingGrant: async (ctx: any) => { + const grant = new (ctx.oidc.provider.Grant as any)({ + accountId: ctx.oidc.session!.accountId, + clientId: ctx.oidc.client!.clientId, + }); + grant.addOIDCScope("openid profile email"); + await grant.save(); + return grant; + }, + }); + + return _provider; +}