diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d04d4c17..f7d57a7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@bugsnag/plugin-koa': specifier: ^8.6.0 version: 8.6.0(@bugsnag/core@8.6.0) + '@clerk/backend': + specifier: ^2.17.2 + version: 2.17.2 '@koa/cors': specifier: ^5.0.0 version: 5.0.0 @@ -65,6 +68,9 @@ importers: busboy: specifier: ^1.6.0 version: 1.6.0 + cookies: + specifier: ^0.9.1 + version: 0.9.1 csv-parse: specifier: ^5.6.0 version: 5.6.0 @@ -101,6 +107,9 @@ importers: jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 + jwks-rsa: + specifier: ^3.2.0 + version: 3.2.0 knex: specifier: ^2.5.1 version: 2.5.1(pg@8.16.3) @@ -155,6 +164,9 @@ importers: rrule: specifier: 2.7.2 version: 2.7.2 + svix: + specifier: ^1.77.0 + version: 1.77.0 uuid: specifier: ^9.0.1 version: 9.0.1 @@ -249,6 +261,9 @@ importers: services/ui: dependencies: + '@clerk/clerk-react': + specifier: ^5.51.0 + version: 5.51.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@fontsource/inter': specifier: ^4.5.15 version: 4.5.15 @@ -1337,6 +1352,33 @@ packages: '@bugsnag/safe-json-stringify@6.1.0': resolution: {integrity: sha512-ImA35rnM7bGr+J30R979FQ95BhRB4UO1KfJA0J2sVqc8nwnrS9hhE5mkTmQWMs8Vh1Da+hkLKs5jJB4JjNZp4A==} + '@clerk/backend@2.17.2': + resolution: {integrity: sha512-zgKySfoOXySYOMEDc+S2vXLchCldwPVb85tyvP1NnmxvgyAm10yCA+xd4No4dNWm+lwkwHuNWX3rM9ro8khSmw==} + engines: {node: '>=18.17.0'} + + '@clerk/clerk-react@5.51.0': + resolution: {integrity: sha512-jBreKiUS4DKm+JUIt59B1699aRr3wQmQ1O+DlWCEY3iEz+F6ETir1SJcRNG5mHVoA9EGn+m93USJSUWZBRG8yQ==} + engines: {node: '>=18.17.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-0 + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-0 + + '@clerk/shared@3.27.3': + resolution: {integrity: sha512-OJqWwlQGi6XMVWJVtY1YmOESAkEAflDrynFSjwQQ/sC8c4hmUukIq07XTOlcv6j4u1i4akhtNwy40B1qiRrLdg==} + engines: {node: '>=18.17.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-0 + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + '@clerk/types@4.92.0': + resolution: {integrity: sha512-+bUiHjqVXEHJIOOhshIy3uYDF/c4/yNc2BPfgPTXxxsbz/2wG0XUx0PL+mxUPiruPZOD+D63AtmORuFW3yBa2w==} + engines: {node: '>=18.17.0'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -2365,6 +2407,9 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@surma/rollup-plugin-off-main-thread@2.2.3': resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} @@ -4292,6 +4337,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -4888,6 +4937,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -5984,6 +6036,10 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -8118,6 +8174,9 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} @@ -8132,6 +8191,9 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -8313,6 +8375,14 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + svix@1.77.0: + resolution: {integrity: sha512-rqyvcFHMq1eGIjYwZEEsW5MkeLH4FRr23TuSsLLhH+/wilK4sjdJSYmALTke3kyMqab7lqWTc9jyKFw6o0/oKg==} + + swr@2.3.4: + resolution: {integrity: sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -8683,6 +8753,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -10602,6 +10676,41 @@ snapshots: '@bugsnag/safe-json-stringify@6.1.0': {} + '@clerk/backend@2.17.2': + dependencies: + '@clerk/shared': 3.27.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@clerk/types': 4.92.0 + cookie: 1.0.2 + standardwebhooks: 1.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - react + - react-dom + + '@clerk/clerk-react@5.51.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@clerk/shared': 3.27.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@clerk/types': 4.92.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + + '@clerk/shared@3.27.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@clerk/types': 4.92.0 + dequal: 2.0.3 + glob-to-regexp: 0.4.1 + js-cookie: 3.0.5 + std-env: 3.9.0 + swr: 2.3.4(react@18.3.1) + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@clerk/types@4.92.0': + dependencies: + csstype: 3.1.3 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -10963,7 +11072,7 @@ snapshots: '@jest/console@27.5.1': dependencies: '@jest/types': 27.5.1 - '@types/node': 18.19.130 + '@types/node': 24.7.1 chalk: 4.1.2 jest-message-util: 27.5.1 jest-util: 27.5.1 @@ -11054,7 +11163,7 @@ snapshots: dependencies: '@jest/fake-timers': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.19.130 + '@types/node': 24.7.1 jest-mock: 27.5.1 '@jest/environment@28.1.3': @@ -11079,7 +11188,7 @@ snapshots: dependencies: '@jest/types': 27.5.1 '@sinonjs/fake-timers': 8.1.0 - '@types/node': 18.19.130 + '@types/node': 24.7.1 jest-message-util: 27.5.1 jest-mock: 27.5.1 jest-util: 27.5.1 @@ -11114,7 +11223,7 @@ snapshots: '@jest/test-result': 27.5.1 '@jest/transform': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.19.130 + '@types/node': 24.7.1 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -12082,6 +12191,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} + '@surma/rollup-plugin-off-main-thread@2.2.3': dependencies: ejs: 3.1.10 @@ -12267,7 +12378,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 18.19.130 + '@types/node': 24.7.1 '@types/bonjour@3.5.13': dependencies: @@ -12441,7 +12552,7 @@ snapshots: '@types/express-serve-static-core@4.19.7': dependencies: - '@types/node': 18.19.130 + '@types/node': 24.7.1 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.0 @@ -12581,7 +12692,7 @@ snapshots: '@types/node-forge@1.3.14': dependencies: - '@types/node': 18.19.130 + '@types/node': 24.7.1 '@types/node-pushnotifications@1.0.8': dependencies: @@ -12663,7 +12774,7 @@ snapshots: '@types/resolve@1.17.1': dependencies: - '@types/node': 18.19.130 + '@types/node': 24.7.1 '@types/retry@0.12.0': {} @@ -12672,20 +12783,20 @@ snapshots: '@types/send@0.17.5': dependencies: '@types/mime': 1.3.5 - '@types/node': 18.19.130 + '@types/node': 24.7.1 '@types/send@1.2.0': dependencies: - '@types/node': 18.19.130 + '@types/node': 24.7.1 '@types/serve-index@1.9.4': dependencies: - '@types/express': 4.17.23 + '@types/express': 5.0.3 '@types/serve-static@1.15.9': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 18.19.130 + '@types/node': 24.7.1 '@types/send': 0.17.5 '@types/sizzle@2.3.10': {} @@ -14138,6 +14249,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destroy@1.2.0: {} detect-libc@2.1.2: {} @@ -14904,6 +15017,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fast-xml-parser@4.5.3: @@ -15886,7 +16001,7 @@ snapshots: '@jest/environment': 27.5.1 '@jest/test-result': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.19.130 + '@types/node': 24.7.1 chalk: 4.1.2 co: 4.6.0 dedent: 0.7.0 @@ -16106,7 +16221,7 @@ snapshots: '@jest/environment': 27.5.1 '@jest/fake-timers': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.19.130 + '@types/node': 24.7.1 jest-mock: 27.5.1 jest-util: 27.5.1 jsdom: 16.7.0 @@ -16121,7 +16236,7 @@ snapshots: '@jest/environment': 27.5.1 '@jest/fake-timers': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.19.130 + '@types/node': 24.7.1 jest-mock: 27.5.1 jest-util: 27.5.1 @@ -16177,7 +16292,7 @@ snapshots: '@jest/source-map': 27.5.1 '@jest/test-result': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.19.130 + '@types/node': 24.7.1 chalk: 4.1.2 co: 4.6.0 expect: 27.5.1 @@ -16244,7 +16359,7 @@ snapshots: jest-mock@27.5.1: dependencies: '@jest/types': 27.5.1 - '@types/node': 18.19.130 + '@types/node': 24.7.1 jest-mock@28.1.3: dependencies: @@ -16310,7 +16425,7 @@ snapshots: '@jest/test-result': 27.5.1 '@jest/transform': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.19.130 + '@types/node': 24.7.1 chalk: 4.1.2 emittery: 0.8.1 graceful-fs: 4.2.11 @@ -16414,7 +16529,7 @@ snapshots: jest-serializer@27.5.1: dependencies: - '@types/node': 18.19.130 + '@types/node': 24.7.1 graceful-fs: 4.2.11 jest-snapshot@27.5.1: @@ -16523,7 +16638,7 @@ snapshots: dependencies: '@jest/test-result': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.19.130 + '@types/node': 24.7.1 ansi-escapes: 4.3.2 chalk: 4.1.2 jest-util: 27.5.1 @@ -16542,7 +16657,7 @@ snapshots: jest-worker@26.6.2: dependencies: - '@types/node': 18.19.130 + '@types/node': 24.7.1 merge-stream: 2.0.0 supports-color: 7.2.0 @@ -16587,6 +16702,8 @@ snapshots: joycon@3.1.1: {} + js-cookie@3.0.5: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -18139,7 +18256,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 18.19.130 + '@types/node': 24.7.1 long: 5.3.2 optional: true @@ -19027,6 +19144,11 @@ snapshots: standard-as-callback@2.1.0: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + state-local@1.0.7: {} static-eval@2.0.2: @@ -19037,6 +19159,8 @@ snapshots: statuses@2.0.1: {} + std-env@3.9.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -19270,6 +19394,18 @@ snapshots: picocolors: 1.1.1 stable: 0.1.8 + svix@1.77.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + uuid: 10.0.0 + + swr@2.3.4(react@18.3.1): + dependencies: + dequal: 2.0.3 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + symbol-tree@3.2.4: {} synckit@0.11.11: @@ -19672,6 +19808,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@10.0.0: {} + uuid@11.1.0: {} uuid@3.4.0: {} diff --git a/services/platform/db/migrations/1760123845_postgresql_migration.js b/services/platform/db/migrations/1760123845_postgresql_migration.js index 590e0a9d..75af1177 100644 --- a/services/platform/db/migrations/1760123845_postgresql_migration.js +++ b/services/platform/db/migrations/1760123845_postgresql_migration.js @@ -1,5 +1,5 @@ exports.up = async function (knex) { - await knex.raw(` + await knex.raw(` CREATE OR REPLACE FUNCTION set_updated_at () RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at := current_timestamp; diff --git a/services/platform/db/migrations/1760294845_organizations.js b/services/platform/db/migrations/1760294845_organizations.js new file mode 100644 index 00000000..d0619c7a --- /dev/null +++ b/services/platform/db/migrations/1760294845_organizations.js @@ -0,0 +1,15 @@ +exports.up = async function (knex) { + await knex.schema.alterTable('organizations', table => { + table.dropColumn('domain'); + table.dropColumn('username'); + table.string('name').notNullable().defaultTo(''); + }) +} + +exports.down = async function (knex) { + await knex.schema.alterTable('organizations', table => { + table.dropColumn('name'); + table.string('domain').notNullable().defaultTo(''); + table.string('username').notNullable().defaultTo(''); + }) +} diff --git a/services/platform/db/migrations/1760303889_admins_external_id.js b/services/platform/db/migrations/1760303889_admins_external_id.js new file mode 100644 index 00000000..ddc3d24d --- /dev/null +++ b/services/platform/db/migrations/1760303889_admins_external_id.js @@ -0,0 +1,11 @@ +exports.up = async function (knex) { + await knex.schema.alterTable('admins', table => { + table.string('external_id'); + }) +} + +exports.down = async function (knex) { + await knex.schema.alterTable('admins', table => { + table.dropColumn('external_id'); + }) +} diff --git a/services/platform/package.json b/services/platform/package.json index e968d35d..4e787bfb 100644 --- a/services/platform/package.json +++ b/services/platform/package.json @@ -11,6 +11,7 @@ "@aws-sdk/lib-storage": "^3.908.0", "@bugsnag/js": "^8.6.0", "@bugsnag/plugin-koa": "^8.6.0", + "@clerk/backend": "^2.17.2", "@koa/cors": "^5.0.0", "@koa/router": "^11.0.2", "@ladjs/country-language": "^1.0.3", @@ -23,6 +24,7 @@ "ajv-formats": "^2.1.1", "bullmq": "^5.61.0", "busboy": "^1.6.0", + "cookies": "^0.9.1", "csv-parse": "^5.6.0", "date-fns": "^2.30.0", "date-fns-tz": "^1.3.8", @@ -35,6 +37,7 @@ "ioredis": "^5.8.1", "jsonpath": "^1.1.1", "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.2.0", "knex": "^2.5.1", "koa": "^2.16.2", "koa-body": "5.0.0", @@ -53,6 +56,7 @@ "pino-pretty": "^8.1.0", "posthog-node": "^3.6.3", "rrule": "2.7.2", + "svix": "^1.77.0", "uuid": "^9.0.1" }, "scripts": { diff --git a/services/platform/src/auth/Admin.ts b/services/platform/src/auth/Admin.ts index f58141fe..e1db00aa 100644 --- a/services/platform/src/auth/Admin.ts +++ b/services/platform/src/auth/Admin.ts @@ -3,6 +3,7 @@ import Model, { ModelParams } from '../core/Model' import { UUID } from 'crypto' export default class Admin extends Model { + external_id?: string organization_id!: UUID email!: string first_name?: string @@ -11,6 +12,6 @@ export default class Admin extends Model { role!: OrganizationRole } -export type AdminParams = Omit & { domain?: string } +export type AdminParams = Omit -export type AuthAdminParams = Omit & { domain?: string } +export type AuthAdminParams = Omit diff --git a/services/platform/src/auth/AdminController.ts b/services/platform/src/auth/AdminController.ts index 2213590b..09ffa706 100644 --- a/services/platform/src/auth/AdminController.ts +++ b/services/platform/src/auth/AdminController.ts @@ -96,7 +96,6 @@ router.patch('/:adminId', async ctx => { }) router.delete('/:adminId', async ctx => { - requireOrganizationRole(ctx.state.admin!, ctx.state.modelAdmin!.role) await Admin.deleteById(ctx.state.modelAdmin!.id) diff --git a/services/platform/src/auth/AdminRepository.ts b/services/platform/src/auth/AdminRepository.ts index bdcb42ad..2b55d476 100644 --- a/services/platform/src/auth/AdminRepository.ts +++ b/services/platform/src/auth/AdminRepository.ts @@ -13,6 +13,14 @@ export const getAdmin = async (id: UUID, organizationId: UUID): Promise qb.where('organization_id', organizationId)) } +export const getAdminById = async (id: UUID): Promise => { + return await Admin.find(id) +} + +export const getAdminByExternalId = async (id: string): Promise => { + return await Admin.first(qb => qb.where('external_id', id)) +} + export const getAdminByEmail = async (email: string): Promise => { return await Admin.first(qb => qb.where('email', email)) } @@ -29,3 +37,7 @@ export const createOrUpdateAdmin = async ({ organization_id, ...params }: AdminP }) } } + +export const deleteAdmin = async (id: UUID): Promise => { + await Admin.delete(qb => qb.where('id', id)) +} diff --git a/services/platform/src/auth/Auth.ts b/services/platform/src/auth/Auth.ts index aa77ec27..7a9d4949 100644 --- a/services/platform/src/auth/Auth.ts +++ b/services/platform/src/auth/Auth.ts @@ -2,27 +2,32 @@ import { Context } from 'koa' import AuthProvider from './AuthProvider' import OpenIDProvider, { OpenIDConfig } from './OpenIDAuthProvider' import GoogleProvider, { GoogleConfig } from './GoogleAuthProvider' +import CloudProvider, { CloudConfig } from './CloudAuthProvider' import SAMLProvider, { SAMLConfig } from './SAMLAuthProvider' import { DriverConfig } from '../config/env' import BasicAuthProvider, { BasicAuthConfig } from './BasicAuthProvider' import Organization from '../organizations/Organization' import App from '../app' -import MultiAuthProvider, { MultiAuthConfig } from './MultiAuthProvider' import EmailAuthProvider, { EmailAuthConfig } from './EmailAuthProvider' -export type AuthProviderName = 'basic' | 'email' | 'saml' | 'openid' | 'google' | 'multi' +export type AuthProviderName = 'basic' | 'email' | 'saml' | 'openid' | 'google' | 'cloud' -export type AuthProviderConfig = BasicAuthConfig | EmailAuthConfig | SAMLConfig | OpenIDConfig | GoogleConfig | MultiAuthConfig +export type AuthProviderConfig = BasicAuthConfig | EmailAuthConfig | SAMLConfig | OpenIDConfig | GoogleConfig | CloudConfig export interface AuthConfig { driver: AuthProviderName[] tokenLife: number + jwt: JWTConfig basic: BasicAuthConfig email: EmailAuthConfig saml: SAMLConfig openid: OpenIDConfig google: GoogleConfig - multi: MultiAuthConfig + cloud: CloudConfig +} + +export interface JWTConfig { + jwksUrl?: string } export { BasicAuthConfig, SAMLConfig, OpenIDConfig } @@ -35,36 +40,30 @@ export interface AuthTypeConfig extends DriverConfig { interface AuthMethod { driver: AuthProviderName name: string + publicConfig?: { [key: string]: string } } export const initProvider = (config?: AuthProviderConfig): AuthProvider => { - if (config?.driver === 'basic') { + switch (config?.driver) { + case 'basic': return new BasicAuthProvider(config) - } else if (config?.driver === 'email') { + case 'email': return new EmailAuthProvider(config) - } else if (config?.driver === 'saml') { + case 'saml': return new SAMLProvider(config) - } else if (config?.driver === 'openid') { + case 'openid': return new OpenIDProvider(config) - } else if (config?.driver === 'google') { + case 'google': return new GoogleProvider(config) - } else if (config?.driver === 'multi') { - return new MultiAuthProvider() - } else { + case 'cloud': + return new CloudProvider(config) + default: throw new Error('A valid auth driver must be set!') } } -export const authMethods = async (organization?: Organization): Promise => { - - if (!App.main.env.config.multiOrg) return mapMethods(App.main.env.auth) - - // If we know the org, don't require any extra steps like - // providing email since we know where to route you. Otherwise - // we need context to properly fetch SSO and such. - return organization - ? [mapMethod(organization.auth)] - : mapMethods(App.main.env.auth) +export const authMethods = async (): Promise => { + return mapMethods(App.main.env.auth) } export const checkAuth = (organization?: Organization): boolean => { @@ -81,13 +80,17 @@ export const validateAuth = async (ctx: Context): Promise => { return await provider.validate(ctx) } -const loadProvider = async (ctx: Context): Promise => { - const driver = ctx.params.driver as AuthProviderName - const organization = ctx.state.organization - if (organization && App.main.env.config.multiOrg) { - return initProvider(organization.auth) +export const authWebhook = async (ctx: Context): Promise => { + const provider = await loadProvider(ctx) + if (!provider.webhook) { + return ctx.throw(404) } + return await provider.webhook(ctx) +} + +const loadProvider = async (ctx: Context): Promise => { + const driver = ctx.params.driver as AuthProviderName return initProvider(App.main.env.auth[driver]) } diff --git a/services/platform/src/auth/AuthController.ts b/services/platform/src/auth/AuthController.ts index a6aa939b..67f68e03 100644 --- a/services/platform/src/auth/AuthController.ts +++ b/services/platform/src/auth/AuthController.ts @@ -1,8 +1,7 @@ import Router from '@koa/router' -import { getTokenCookies, revokeAccessToken } from './TokenRepository' import { getOrganizationByEmail } from '../organizations/OrganizationService' import Organization from '../organizations/Organization' -import { authMethods, checkAuth, startAuth, validateAuth } from './Auth' +import { authMethods, authWebhook, checkAuth, startAuth, validateAuth } from './Auth' const router = new Router<{ organization?: Organization @@ -11,7 +10,7 @@ const router = new Router<{ }) router.get('/methods', async ctx => { - ctx.body = await authMethods(ctx.state.organization) + ctx.body = await authMethods() }) router.post('/check', async ctx => { @@ -40,20 +39,8 @@ router.post('/login/:driver/callback', async ctx => { await validateAuth(ctx) }) -router.post('/logout', async ctx => { - const oauth = getTokenCookies(ctx) - if (oauth) { - await revokeAccessToken(oauth.access_token, ctx) - } - ctx.redirect('/') -}) - -router.get('/logout', async ctx => { - const oauth = getTokenCookies(ctx) - if (oauth) { - await revokeAccessToken(oauth.access_token, ctx) - } - ctx.redirect('/') +router.post('/login/:driver/webhook', async ctx => { + await authWebhook(ctx) }) export default router diff --git a/services/platform/src/auth/AuthMiddleware.ts b/services/platform/src/auth/AuthMiddleware.ts index 1f0e20c1..6bb3dcfb 100644 --- a/services/platform/src/auth/AuthMiddleware.ts +++ b/services/platform/src/auth/AuthMiddleware.ts @@ -1,4 +1,5 @@ -import jwt from 'jsonwebtoken' +import jwt, { Jwt, JwtHeader, JwtPayload, SigningKeyCallback } from 'jsonwebtoken' +import jwks from 'jwks-rsa' import { Context } from 'koa' import App from '../app' import { RequestError } from '../core/errors' @@ -6,9 +7,11 @@ import Project, { ProjectRole } from '../projects/Project' import { ProjectApiKey } from '../projects/ProjectApiKey' import { getProjectApiKey } from '../projects/ProjectService' import AuthError from './AuthError' -import { getTokenCookies, isAccessTokenRevoked } from './TokenRepository' +import { getCookiesOAuthToken } from './TokenRepository' import { OrganizationRole } from '../organizations/Organization' import { UUID } from 'node:crypto' +import { getAdminByExternalId, getAdminById } from './AdminRepository' +import Admin from './Admin' export interface JwtAdmin { id: UUID @@ -32,34 +35,83 @@ export interface ProjectState extends AuthState { projectRole: ProjectRole } +export function retrieveAuthToken(ctx: Context): string | undefined { + const tokenSameOrigin = ctx.cookies.get('__session') + const tokenCrossOrigin = ctx.headers.authorization + const tokenOAuth = getCookiesOAuthToken(ctx)?.access_token + return tokenOAuth || tokenSameOrigin || tokenCrossOrigin +} + const parseAuth = async (ctx: Context) => { - const token = getBearerToken(ctx) + const token = retrieveAuthToken(ctx) + if (!token) { throw new RequestError(AuthError.AuthorizationError) } - if (token.startsWith('pk_')) { - // Public key - return { - scope: 'public', - key: await getProjectApiKey(token), - } - } else if (token.startsWith('sk_')) { - // Secret key - return { - scope: 'secret', - key: await getProjectApiKey(token), - } - } else { - const admin = await verify(token) as JwtAdmin - if (await isAccessTokenRevoked(token)) { - throw new RequestError(AuthError.AccessDenied) - } - // User JWT - return { - scope: 'admin', - admin, - } + if (isPublicKey(token)) { + return createPublicScope(parseBearer(token)) + } + + if (isSecretKey(token)) { + return createSecretScope(parseBearer(token)) + } + + return await createAdminScope(token) +} + +function parseBearer(token: string) { + return token.replace('Bearer ', '') +} + +function isPublicKey(token: string): boolean { + return token.startsWith('Bearer pk_') +} + +function isSecretKey(token: string): boolean { + return token.startsWith('Bearer sk_') +} + +async function createPublicScope(token: string) { + return { + scope: 'public' as const, + key: await getProjectApiKey(token), + } +} + +async function createSecretScope(token: string) { + return { + scope: 'secret' as const, + key: await getProjectApiKey(token), + } +} + +async function createAdminScope(token: string) { + const payload = await jwtVerify(token) + if (!payload || !payload.sub) { + throw new RequestError(AuthError.InvalidToken) + } + + let admin: Admin | undefined + if (payload.iss && payload.iss !== App.main.env.baseUrl) { + admin = await getAdminByExternalId(payload.sub) + } + + if (!admin) { + admin = await getAdminById(payload.sub as UUID) + } + + if (!admin) { + throw new RequestError(AuthError.InvalidToken) + } + + return { + scope: 'admin' as const, + admin: { + id: admin.id, + organization_id: admin.organization_id, + role: admin.role, + }, } } @@ -83,18 +135,81 @@ export const scopeMiddleware = (scope: string | string[]) => { } } -export const verify = async (token: string) => { +let jwksClient: jwks.JwksClient | undefined + +export const jwtVerify = async (token: string): Promise => { + const secret = App.main.env.secret + + if (App.main.env.auth.jwt.jwksUrl) { + jwksClient = jwks({ + jwksUri: App.main.env.auth.jwt.jwksUrl, + cache: true, + rateLimit: true, + }) + } + + if (jwksClient) { + const options: jwt.VerifyOptions = { algorithms: ['RS256'] } + + const getKey = (header: JwtHeader, callback: SigningKeyCallback) => { + if (!header.kid) { + return callback(new Error('Missing KID in token header')) + } + jwksClient!.getSigningKey(header.kid, (err, key) => { + if (err) return callback(err) + if (!key) return callback(new Error('No signing key found')) + const signingKey = key.getPublicKey() + callback(null, signingKey) + }) + } + + // Verify the token using the dynamic JWKS public key + return new Promise((resolve, reject) => { + jwt.verify(token, getKey, options, (err, decoded) => { + if (err) { + return reject(new RequestError(AuthError.InvalidToken)) + } + if (!validJWTToken(decoded)) { + return reject(new RequestError(AuthError.InvalidToken)) + } + resolve(decoded as JwtPayload) + }) + }) + } + return new Promise((resolve, reject) => { - jwt.verify(token, App.main.env.secret, (error, decoded) => { - error ? reject(error) : resolve(decoded) + jwt.verify(token, secret, (err, decoded) => { + if (err) { + return reject(new RequestError(AuthError.InvalidToken)) + } + if (!validJWTToken(decoded)) { + return reject(new RequestError(AuthError.InvalidToken)) + } + resolve(decoded as JwtPayload) }) }) } -const getBearerToken = (ctx: Context): string | undefined => { - const authHeader = String(ctx.request.headers.authorization || '') - if (authHeader.startsWith('Bearer ')) { - return authHeader.substring(7, authHeader.length) +function validJWTToken(decoded: string | JwtPayload | Jwt | undefined): boolean { + if (decoded === undefined || typeof decoded === 'string') { + return false + } + + let payload = decoded as JwtPayload + if (decoded.payload) { + payload = decoded.payload } - return getTokenCookies(ctx)?.access_token + + const currentTime = Math.floor(Date.now() / 1000) + if (payload.exp && payload.exp < currentTime) { + return false + } + + if (payload.nbf && payload.nbf > currentTime) { + return false + } + + // TODO: validate the token's authorized party (azp) claim + + return true } diff --git a/services/platform/src/auth/AuthProvider.ts b/services/platform/src/auth/AuthProvider.ts index 48dcd7a3..370dcb89 100644 --- a/services/platform/src/auth/AuthProvider.ts +++ b/services/platform/src/auth/AuthProvider.ts @@ -1,48 +1,39 @@ import { Context } from 'koa' import App from '../app' -import Admin, { AdminParams, AuthAdminParams } from './Admin' +import Admin, { AuthAdminParams } from './Admin' import { getAdminByEmail } from './AdminRepository' -import { generateAccessToken, OAuthResponse, setTokenCookies } from './TokenRepository' +import { generateAccessToken, OAuthResponse, setCookiesOauthToken } from './TokenRepository' import Organization from '../organizations/Organization' import { State } from './AuthMiddleware' -import { createOrganization, getDefaultOrganization, getOrganizationByDomain } from '../organizations/OrganizationService' +import { createOrganization } from '../organizations/OrganizationService' type OrgState = State & { organization?: Organization } export type AuthContext = Context & { state: OrgState } export default abstract class AuthProvider { - abstract start(ctx: AuthContext): Promise abstract validate(ctx: AuthContext): Promise + webhook?(ctx: AuthContext): Promise - async loadAuthOrganization(ctx: AuthContext, domain?: string): Promise<{ organization: Organization, isNew: boolean }> { - + async loadAuthOrganization(ctx: AuthContext): Promise<{ organization: Organization, isNew: boolean }> { // If we have an organization or can find one by domain // we use that to start - let organization = ctx.state.organization ?? await getOrganizationByDomain(domain) - if (organization) return { organization, isNew: false } - - // If we are not in multi-org mode we always fall back to - // a single organization - if (!App.main.env.config.multiOrg) { - organization = await getDefaultOrganization() - } + const organization = ctx.state.organization if (organization) return { organization, isNew: false } // If there is no organization at all or are in multi-org mode // and have no org for the user, create one return { - organization: await createOrganization(domain), + organization: await createOrganization(), isNew: true, } } - async login({ domain, ...params }: AuthAdminParams, ctx: AuthContext, redirect?: string): Promise { - + async login({ ...params }: AuthAdminParams, ctx: AuthContext, redirect?: string): Promise { // Check for existing, otherwise create one let admin = await getAdminByEmail(params.email) if (!admin) { - const { organization, isNew } = await this.loadAuthOrganization(ctx, domain) + const { organization, isNew } = await this.loadAuthOrganization(ctx) admin = await Admin.insertAndFetch({ ...params, organization_id: organization.id, @@ -57,15 +48,9 @@ export default abstract class AuthProvider { const oauth = await generateAccessToken(admin, ctx) if (ctx) { - setTokenCookies(ctx, oauth) + setCookiesOauthToken(ctx, oauth) ctx.redirect(redirect || App.main.env.baseUrl) } return oauth } - - async logout(params: Pick, ctx: AuthContext) { - console.log(params, ctx) - // not sure how we find the refresh token for a given session atm - // revokeRefreshToken() - } } diff --git a/services/platform/src/auth/BasicAuthProvider.ts b/services/platform/src/auth/BasicAuthProvider.ts index 5ae2b986..ced167cb 100644 --- a/services/platform/src/auth/BasicAuthProvider.ts +++ b/services/platform/src/auth/BasicAuthProvider.ts @@ -2,7 +2,7 @@ import { Context } from 'koa' import { AuthTypeConfig } from './Auth' import AuthProvider from './AuthProvider' import App from '../app' -import { combineURLs, firstQueryParam } from '../utilities' +import { firstQueryParam } from '../utilities' import { RequestError } from '../core/errors' import AuthError from './AuthError' @@ -13,7 +13,6 @@ export interface BasicAuthConfig extends AuthTypeConfig { } export default class BasicAuthProvider extends AuthProvider { - private config: BasicAuthConfig constructor(config: BasicAuthConfig) { super() @@ -21,15 +20,13 @@ export default class BasicAuthProvider extends AuthProvider { } async start(ctx: Context) { - const redirect = firstQueryParam(ctx.request.query.r) // Redirect to the login form - ctx.redirect(combineURLs([App.main.env.baseUrl, '/login/basic']) + '?r=' + redirect) + ctx.redirect(new URL(`/login/basic?r=${redirect}`, App.main.env.baseUrl).toString()) } async validate(ctx: Context) { - const { email, password } = ctx.request.body if (!email || !password) throw new RequestError(AuthError.MissingCredentials) @@ -39,6 +36,6 @@ export default class BasicAuthProvider extends AuthProvider { } // Process the login - await this.login({ email, first_name: 'Admin', domain: 'local' }, ctx) + await this.login({ email, first_name: 'Admin' }, ctx) } } diff --git a/services/platform/src/auth/CloudAuthProvider.ts b/services/platform/src/auth/CloudAuthProvider.ts new file mode 100644 index 00000000..28c0e00e --- /dev/null +++ b/services/platform/src/auth/CloudAuthProvider.ts @@ -0,0 +1,144 @@ +import { AuthTypeConfig } from './Auth' +import { RequestError } from '../core/errors' +import AuthError from './AuthError' +import { jwtVerify, retrieveAuthToken } from './AuthMiddleware' +import AuthProvider, { AuthContext } from './AuthProvider' +import { createOrUpdateAdmin, deleteAdmin, getAdminByExternalId } from './AdminRepository' +import { createOrganization } from '../organizations/OrganizationService' +import { createClerkClient, ClerkClient, WebhookEvent } from '@clerk/backend' +import { logger } from '../config/logger' +import svix from 'svix' + +export interface CloudConfig extends AuthTypeConfig { + driver: 'cloud' + secretKey: string + webhookSecret: string +} + +export default class CloudAuthProvider extends AuthProvider { + private client: ClerkClient + private webhookClient: svix.Webhook + + constructor(config: CloudConfig) { + super() + + this.webhookClient = new svix.Webhook(config.webhookSecret) + this.client = createClerkClient({ + secretKey: config.secretKey, + }) + } + + async start(): Promise { + throw new Error('Method not implemented.') + } + + async validate(ctx: AuthContext): Promise { + logger.trace('Validating cloud Auth...') + + const token = retrieveAuthToken(ctx) + if (!token) { + logger.error('No token provided') + throw new RequestError(AuthError.InvalidToken) + } + + const payload = await jwtVerify(token) + if (!payload || !payload.sub) { + logger.error('Invalid JWT payload') + throw new RequestError(AuthError.InvalidToken) + } + + const admin = await getAdminByExternalId(payload.sub) + if (!admin) { + const user = await this.client.users.getUser(payload.sub) + if (!user) { + logger.error(`Clerk user not found: ${payload.sub}`) + throw new RequestError(AuthError.InvalidToken) + } + + const primaryEmailAddress = user.primaryEmailAddress + if (!primaryEmailAddress) { + logger.error(`Clerk user has no email: ${payload.sub}`) + throw new RequestError(AuthError.InvalidEmail) + } + + const organization = await createOrganization() + await createOrUpdateAdmin({ + email: primaryEmailAddress.emailAddress, + external_id: payload.sub, + organization_id: organization.id, + role: 'admin', + }) + } + } + + async webhook(ctx: AuthContext) { + const svixId = ctx.req.headers['svix-id'] + const svixTimestamp = ctx.req.headers['svix-timestamp'] + const svixSignature = ctx.req.headers['svix-signature'] + + if (!svixId || !svixTimestamp || !svixSignature) { + logger.error('Missing Svix headers') + throw new RequestError(AuthError.AccessDenied) + } + + const payloadString = ctx.body || ctx.request.body + const { type, data } = this.webhookClient.verify(payloadString, { + 'svix-id': svixId as string, + 'svix-timestamp': svixTimestamp as string, + 'svix-signature': svixSignature as string, + }) as WebhookEvent + + switch (type) { + case 'user.created': { + const createdExternalAdmin = await getAdminByExternalId(data.id) + if (createdExternalAdmin) { + return + } + + const primaryEmailAddress = data.email_addresses?.find((email: any) => email.id === data.primary_email_address_id) + if (!primaryEmailAddress) { + logger.error(`Clerk user has no email: ${data.id}`) + throw new RequestError(AuthError.InvalidEmail) + } + + const organization = await createOrganization() + await createOrUpdateAdmin({ + email: primaryEmailAddress?.email_address, + external_id: data.id, + organization_id: organization.id, + role: 'admin', + }) + break + } + case 'user.updated': { + const updatedExternalAdmin = await getAdminByExternalId(data.id) + if (!updatedExternalAdmin) { + return + } + + const primaryEmailAddress = data.email_addresses?.find((email) => email.id === data.primary_email_address_id) + if (!primaryEmailAddress) { + logger.error(`Clerk user has no email: ${data.id}`) + throw new RequestError(AuthError.InvalidEmail) + } + + updatedExternalAdmin.email = primaryEmailAddress.email_address + await createOrUpdateAdmin(updatedExternalAdmin) + break + } + case 'user.deleted': { + if (!data.id) { + return + } + + const deletedExternalAdmin = await getAdminByExternalId(data.id) + if (!deletedExternalAdmin) { + return + } + + await deleteAdmin(deletedExternalAdmin.id) + break + } + } + } +} diff --git a/services/platform/src/auth/EmailAuthProvider.ts b/services/platform/src/auth/EmailAuthProvider.ts index 93ef1575..42c51fae 100644 --- a/services/platform/src/auth/EmailAuthProvider.ts +++ b/services/platform/src/auth/EmailAuthProvider.ts @@ -2,13 +2,13 @@ import { Context } from 'koa' import { AuthTypeConfig } from './Auth' import AuthProvider from './AuthProvider' import App from '../app' -import { combineURLs, firstQueryParam } from '../utilities' +import { firstQueryParam } from '../utilities' import { RequestError } from '../core/errors' import AuthError from './AuthError' import { sign } from 'jsonwebtoken' import { addSeconds } from 'date-fns' import SMTPEmailProvider, { SMTPDataParams } from '../providers/email/SMPTEmailProvider' -import { verify } from './AuthMiddleware' +import { jwtVerify } from './AuthMiddleware' export interface EmailAuthConfig extends AuthTypeConfig, SMTPDataParams { driver: 'email' @@ -34,7 +34,7 @@ export default class EmailAuthProvider extends AuthProvider { if (email) { await this.send(email, redirect) } else { - ctx.redirect(combineURLs([App.main.env.apiBaseUrl, '/login/email']) + '?r=' + redirect) + ctx.redirect(new URL(`/login/email?r=${redirect}`, App.main.env.baseUrl).toString()) } } @@ -42,7 +42,7 @@ export default class EmailAuthProvider extends AuthProvider { const token = firstQueryParam(ctx.request.query.token) if (!token) throw new RequestError(AuthError.MissingCredentials) - const { email } = await verify(token) as { email: string } + const { email } = await jwtVerify(token) as { email: string } await this.login({ email, first_name: 'Admin', @@ -59,14 +59,16 @@ export default class EmailAuthProvider extends AuthProvider { }, App.main.env.secret) // Generate the link - const link = `${combineURLs([App.main.env.apiBaseUrl, '/auth/login/email/callback'])}?token=${token}&r=${redirect}` + const link = new URL('/auth/login/email/callback', App.main.env.apiBaseUrl) + link.searchParams.set('token', token) + if (redirect) link.searchParams.set('r', redirect) // Send the message await this.provider.send({ to: email, from: this.config.from, subject: 'Login to Lunogram', - html: this.generateMessage(link), + html: this.generateMessage(link.toString()), text: `Click the link below to login to Lunogram: ${link}`, }) } diff --git a/services/platform/src/auth/GoogleAuthProvider.ts b/services/platform/src/auth/GoogleAuthProvider.ts index 78c57bfe..77c0ae18 100644 --- a/services/platform/src/auth/GoogleAuthProvider.ts +++ b/services/platform/src/auth/GoogleAuthProvider.ts @@ -10,7 +10,6 @@ export interface GoogleConfig extends AuthTypeConfig { } export default class GoogleAuthProvider extends AuthProvider { - private provider: OpenIDAuthProvider constructor(config: GoogleConfig) { super() diff --git a/services/platform/src/auth/MultiAuthProvider.ts b/services/platform/src/auth/MultiAuthProvider.ts deleted file mode 100644 index 0ba549b8..00000000 --- a/services/platform/src/auth/MultiAuthProvider.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { getOrganizationByEmail } from '../organizations/OrganizationService' -import { AuthTypeConfig, initProvider } from './Auth' -import AuthProvider, { AuthContext } from './AuthProvider' - -export interface MultiAuthConfig extends AuthTypeConfig { - driver: 'multi' -} - -export default class MultiAuthProvider extends AuthProvider { - async start(ctx: AuthContext): Promise { - - // Redirect to the default for the given org - if (ctx.query.email || ctx.request.body.email) { - const email = ctx.query.email ?? ctx.request.body.email - const organization = await getOrganizationByEmail(email) - if (organization) { - return await initProvider(organization.auth).start(ctx) - } - } - - throw new Error('No organization found.') - } - - async validate(): Promise { - // Will never be called since it routes to other providers - } -} diff --git a/services/platform/src/auth/OpenIDAuthProvider.ts b/services/platform/src/auth/OpenIDAuthProvider.ts index 895bd273..8b5f0880 100644 --- a/services/platform/src/auth/OpenIDAuthProvider.ts +++ b/services/platform/src/auth/OpenIDAuthProvider.ts @@ -18,7 +18,6 @@ export interface OpenIDConfig extends AuthTypeConfig { } export default class OpenIDAuthProvider extends AuthProvider { - private config: OpenIDConfig private client!: BaseClient constructor(config: OpenIDConfig) { @@ -82,7 +81,6 @@ export default class OpenIDAuthProvider extends AuthProvider { const tokenSet = await client.callback(this.config.redirectUri, params, { nonce, state }) const claims = tokenSet.claims() - const domain = this.getDomain(claims) if (!claims.email) { throw new RequestError(AuthError.InvalidEmail) } @@ -92,7 +90,6 @@ export default class OpenIDAuthProvider extends AuthProvider { first_name: claims.given_name ?? claims.name, last_name: claims.family_name, image_url: claims.picture, - domain, } await this.login(admin, ctx, state) diff --git a/services/platform/src/auth/SAMLAuthProvider.ts b/services/platform/src/auth/SAMLAuthProvider.ts index 5514a9fb..53aa8ee2 100644 --- a/services/platform/src/auth/SAMLAuthProvider.ts +++ b/services/platform/src/auth/SAMLAuthProvider.ts @@ -88,16 +88,14 @@ export default class SAMLAuthProvider extends AuthProvider { // If there is no profile we take no action if (!response.profile) throw new RequestError(AuthError.SAMLValidationError) if (response.loggedOut) { - await this.logout({ email: response.profile.nameID }, ctx) return } // If we are logging in, grab profile and create tokens const { first_name, last_name, nameID: email } = response.profile - const domain = this.getDomain(email) - if (!email || !domain) throw new RequestError(AuthError.SAMLValidationError) + if (!email) throw new RequestError(AuthError.SAMLValidationError) - await this.login({ first_name, last_name, email, domain }, ctx, state) + await this.login({ first_name, last_name, email }, ctx, state) ctx.cookies.set('organization', null) } diff --git a/services/platform/src/auth/TokenRepository.ts b/services/platform/src/auth/TokenRepository.ts index c55b48c2..f81bd4f4 100644 --- a/services/platform/src/auth/TokenRepository.ts +++ b/services/platform/src/auth/TokenRepository.ts @@ -1,6 +1,6 @@ import { addSeconds } from 'date-fns' import { Context } from 'koa' -import { sign } from 'jsonwebtoken' +import jwt from 'jsonwebtoken' import { AccessToken } from './AccessToken' import App from '../app' import Admin from './Admin' @@ -10,27 +10,15 @@ export interface OAuthResponse { expires_at: Date } -export async function isAccessTokenRevoked(token: string) { - return (await AccessToken.count(qb => qb.where({ token, revoked: true }))) > 0 -} - -export async function revokeAccessToken(token: string, ctx?: Context) { - await AccessToken.update(qb => qb.where({ token }), { revoked: true }) - if (ctx) { - ctx.cookies.set('oauth') - } -} - export async function cleanupExpiredRevokedTokens(until: Date) { await AccessToken.delete(qb => qb.where('expires_at', '<=', until)) } -export const generateAccessToken = async ({ id, organization_id, role }: Admin, ctx?: Context) => { +export const generateAccessToken = async ({ id }: Admin, ctx?: Context) => { const expires_at = addSeconds(Date.now(), App.main.env.auth.tokenLife) - const token = sign({ - id, - organization_id, - role, + const token = jwt.sign({ + sub: id, + iss: App.main.env.baseUrl, exp: Math.floor(expires_at.getTime() / 1000), }, App.main.env.secret) @@ -49,15 +37,14 @@ export const generateAccessToken = async ({ id, organization_id, role }: Admin, } } -export const getTokenCookies = (ctx: Context) => { +export const getCookiesOAuthToken = (ctx: Context) => { const cookie = ctx.cookies.get('oauth') if (cookie) { return JSON.parse(cookie) as OAuthResponse } } -export const setTokenCookies = (ctx: Context, oauth: OAuthResponse): OAuthResponse => { - +export const setCookiesOauthToken = (ctx: Context, oauth: OAuthResponse): OAuthResponse => { ctx.cookies.set('oauth', JSON.stringify(oauth), { secure: ctx.request.secure, httpOnly: true, diff --git a/services/platform/src/config/env.ts b/services/platform/src/config/env.ts index 3d76a68f..a60724df 100644 --- a/services/platform/src/config/env.ts +++ b/services/platform/src/config/env.ts @@ -118,6 +118,9 @@ export default (type?: EnvType): Env => { auth: { driver: (process.env.AUTH_DRIVER?.split(',') ?? []) as AuthProviderName[], tokenLife: defaultTokenLife, + jwt: { + jwksUrl: process.env.AUTH_JWKS_URL, + }, basic: { driver: 'basic', name: process.env.AUTH_BASIC_NAME!, @@ -161,9 +164,10 @@ export default (type?: EnvType): Env => { clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!, redirectUri: `${apiBaseUrl}/auth/login/google/callback`, }, - multi: { - driver: 'multi', - name: process.env.AUTH_MULTI_NAME!, + cloud: { + driver: 'cloud', + secretKey: process.env.AUTH_CLERK_SECRET_KEY!, + webhookSecret: process.env.AUTH_CLERK_WEBHOOK_SECRET!, }, }, logger: { diff --git a/services/platform/src/organizations/OrganizationMiddleware.ts b/services/platform/src/organizations/OrganizationMiddleware.ts index d66edef4..70b127ab 100644 --- a/services/platform/src/organizations/OrganizationMiddleware.ts +++ b/services/platform/src/organizations/OrganizationMiddleware.ts @@ -1,23 +1,19 @@ import { Context } from 'koa' -import { getDefaultOrganization, getOrganization, getOrganizationByUsername } from './OrganizationService' -import App from '../app' +import { getOrganization } from './OrganizationService' import { UUID } from 'crypto' import { validate as uuidValidate } from 'uuid' export const organizationMiddleware = async (ctx: Context, next: () => void) => { const organizationId = ctx.cookies.get('organization', { signed: true }) as UUID | undefined - if (!App.main.env.config.multiOrg) { - ctx.state.organization = await getDefaultOrganization() - } else if (organizationId) { - if (!uuidValidate(organizationId)) { - ctx.throw(400, 'Invalid organization ID') - return - } + if (!organizationId) { + return next() + } - ctx.state.organization = await getOrganization(organizationId) - } else if (ctx.subdomains && ctx.subdomains[0]) { - const subdomain = ctx.subdomains[0] - ctx.state.organization = await getOrganizationByUsername(subdomain) + if (!uuidValidate(organizationId)) { + ctx.throw(400, 'Invalid organization ID') + return } + + ctx.state.organization = await getOrganization(organizationId) return next() } diff --git a/services/platform/src/organizations/OrganizationService.ts b/services/platform/src/organizations/OrganizationService.ts index 6db3dc0d..d5437233 100644 --- a/services/platform/src/organizations/OrganizationService.ts +++ b/services/platform/src/organizations/OrganizationService.ts @@ -1,7 +1,6 @@ import { RequestError } from '../core/errors' import Admin from '../auth/Admin' import Provider from '../providers/Provider' -import { uuid } from '../utilities' import Organization, { OrganizationRole, organizationRoles } from './Organization' import { JwtAdmin } from '../auth/AuthMiddleware' import { Next, ParameterizedContext } from 'koa' @@ -11,47 +10,14 @@ export const getOrganization = async (id: UUID) => { return await Organization.find(id) } -export const getOrganizationByUsername = async (username: string) => { - return await Organization.first(qb => qb.where('username', username)) -} - -export const getOrganizationByDomain = async (domain?: string) => { - if (!domain) return undefined - return await Organization.first(qb => qb.where('domain', domain)) -} - export const getOrganizationByEmail = async (email: string) => { const admin = await Admin.first(qb => qb.where('email', email)) if (!admin) return undefined return await getOrganization(admin.organization_id) } -export const getDefaultOrganization = async () => { - return await Organization.first() -} - -export const createOrganization = async (domain?: string): Promise => { - let username = domain?.split('.').shift() - let org: Organization | undefined - try { - org = await Organization.insertAndFetch({ - username, - }) - } catch { - username = undefined - org = await Organization.insertAndFetch({ - username: uuid(), - }) - } - - // If for some reason the domain format is odd, generate - // a random username from the org id - if (!username) { - await Organization.updateAndFetch(org.id, { - username: org.id, - }) - } - return org +export const createOrganization = async (): Promise => { + return await Organization.insertAndFetch() } export const updateOrganization = async (organization: Organization, params: Partial) => { diff --git a/services/platform/src/projects/ProjectController.ts b/services/platform/src/projects/ProjectController.ts index a43e94ec..aa852098 100644 --- a/services/platform/src/projects/ProjectController.ts +++ b/services/platform/src/projects/ProjectController.ts @@ -1,7 +1,6 @@ import Router from '@koa/router' import { ParameterizedContext } from 'koa' import App from '../app' -import { getAdmin } from '../auth/AdminRepository' import { AuthState, ProjectState } from '../auth/AuthMiddleware' import { RequestError } from '../core/errors' import { searchParamsSchema } from '../core/searchParams' @@ -106,9 +105,8 @@ const projectCreateParams: JSONSchemaType = { router.post('/', async ctx => { requireOrganizationRole(ctx.state.admin!, 'admin') const payload = validate(projectCreateParams, ctx.request.body) - const { id, organization_id } = ctx.state.admin! - const admin = await getAdmin(id, organization_id) - const project = await createProject(admin!, payload) + if (!ctx.state.admin) throw new RequestError(ProjectError.ProjectAccessDenied) + const project = await createProject(ctx.state.admin, payload) ctx.body = { ...project, has_provider: await hasProvider(project.id), diff --git a/services/platform/src/projects/ProjectService.ts b/services/platform/src/projects/ProjectService.ts index d530911c..06a6b6c5 100644 --- a/services/platform/src/projects/ProjectService.ts +++ b/services/platform/src/projects/ProjectService.ts @@ -1,4 +1,4 @@ -import { ProjectState } from '../auth/AuthMiddleware' +import { JwtAdmin, ProjectState } from '../auth/AuthMiddleware' import { Next, ParameterizedContext } from 'koa' import { RequestError } from '../core/errors' import { PageParams } from '../core/searchParams' @@ -7,7 +7,6 @@ import { uuid } from '../utilities' import Project, { ProjectParams, ProjectRole, projectRoles } from './Project' import { ProjectAdmin } from './ProjectAdmins' import { ProjectApiKey, ProjectApiKeyParams } from './ProjectApiKey' -import Admin from '../auth/Admin' import { getAdmin } from '../auth/AdminRepository' import Locale, { LocaleParams } from './Locale' import { UUID } from 'crypto' @@ -55,7 +54,7 @@ export const getProject = async (id: UUID, adminId?: UUID) => { }) } -export const createProject = async (admin: Admin, params: ProjectParams): Promise => { +export const createProject = async (admin: JwtAdmin, params: ProjectParams): Promise => { const projectId = await Project.insert({ ...params, organization_id: admin.organization_id, diff --git a/services/platform/src/render/LinkService.ts b/services/platform/src/render/LinkService.ts index 33cab9a0..25f00aa5 100644 --- a/services/platform/src/render/LinkService.ts +++ b/services/platform/src/render/LinkService.ts @@ -6,7 +6,6 @@ import { getCampaign } from '../campaigns/CampaignService' import EventPostJob from '../client/EventPostJob' import { User } from '../users/User' import { getUser } from '../users/UserRepository' -import { combineURLs } from '../utilities' import { UUID } from 'node:crypto' export interface TrackedLinkParams { @@ -21,8 +20,7 @@ interface TrackedLinkParts extends TrackedLinkParams { } export const paramsToEncodedLink = (params: TrackedLinkParts): string => { - const baseUrl = combineURLs([App.main.env.baseUrl, params.path]) - const url = new URL(baseUrl) + const url = new URL(params.path, App.main.env.baseUrl) url.searchParams.set('u', params.userId) url.searchParams.set('c', params.campaignId) if (params.referenceId) { diff --git a/services/platform/src/storage/Storage.ts b/services/platform/src/storage/Storage.ts index 86c5af05..cb7a39dd 100644 --- a/services/platform/src/storage/Storage.ts +++ b/services/platform/src/storage/Storage.ts @@ -4,7 +4,7 @@ import Image from './Image' import { S3Config, S3StorageProvider } from './S3StorageProvider' import { ImageUploadTask, StorageProvider, StorageProviderName } from './StorageProvider' import path from 'path' -import { combineURLs, uuid } from '../utilities' +import { uuid } from '../utilities' import { InternalError } from '../core/errors' import StorageError from './StorageError' import App from '../app' @@ -61,7 +61,7 @@ export default class Storage { // If an override is provide, utilize that if (App.main.env.storage.baseUrl) { - return combineURLs([App.main.env.storage.baseUrl, path]) + return new URL(path, App.main.env.storage.baseUrl).toString() } // If we are using S3, provide a path based on endpoint if needed diff --git a/services/ui/package.json b/services/ui/package.json index 34f7c408..5fbd63bc 100644 --- a/services/ui/package.json +++ b/services/ui/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "repository": "https://github.com/lunogram/platform", "dependencies": { + "@clerk/clerk-react": "^5.51.0", "@fontsource/inter": "^4.5.15", "@headlessui/react": "1.7.18", "@monaco-editor/react": "^4.7.0", @@ -63,11 +64,11 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", "@types/node": "^16.18.126", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", "@types/uuid": "^9.0.8", - "@types/jest": "^27.5.2", "@typescript-eslint/eslint-plugin": "^5.62.0", "copyfiles": "2.4.1", "eslint": "^8.57.1", diff --git a/services/ui/src/api.ts b/services/ui/src/api.ts index 04f41ad6..cb4aca4b 100644 --- a/services/ui/src/api.ts +++ b/services/ui/src/api.ts @@ -130,12 +130,12 @@ const api = { emailAuth: async (email: string, redirect: string = '/') => { await client.post('/auth/login/email', { email, redirect }) }, + cloudAuth: async (redirect: string = '/') => { + await client.post('/auth/login/cloud/callback', { redirect }) + }, login() { window.location.href = `/login?r=${encodeURIComponent(window.location.href)}` }, - async logout() { - window.location.href = env.api.baseURL + '/auth/logout' - }, }, profile: { diff --git a/services/ui/src/index.tsx b/services/ui/src/index.tsx index a8bbce05..d815d262 100644 --- a/services/ui/src/index.tsx +++ b/services/ui/src/index.tsx @@ -1,8 +1,9 @@ +import './i18n' +import App from './App' import { StrictMode } from 'react' import ReactDOM from 'react-dom/client' -import App from './App' -import './i18n' import reportWebVitals from './reportWebVitals' +import { ClerkProvider } from '@clerk/clerk-react' import '@fontsource/inter/400.css' import '@fontsource/inter/500.css' @@ -10,12 +11,22 @@ import '@fontsource/inter/700.css' import './variables.css' import './index.css' +const CLERK_PUBLISHABLE_KEY = process.env.REACT_APP_CLERK_PUBLISHABLE_KEY + const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement, ) root.render( - + {CLERK_PUBLISHABLE_KEY + ? ( + + + + ) + : ( + + )} , ) diff --git a/services/ui/src/ui/Sidebar.tsx b/services/ui/src/ui/Sidebar.tsx index bd3130cd..cc7d8b63 100644 --- a/services/ui/src/ui/Sidebar.tsx +++ b/services/ui/src/ui/Sidebar.tsx @@ -9,7 +9,6 @@ import clsx from 'clsx' import Menu, { MenuItem } from './Menu' import { AdminContext, OrganizationContext, ProjectContext } from '../contexts' import { PreferencesContext } from './PreferencesContext' -import api from '../api' import { snakeToTitle } from '../utils' import Modal from './Modal' import RadioInput from './form/RadioInput' @@ -83,7 +82,7 @@ export default function Sidebar({ children, links, prepend, append }: PropsWithC }}>{t('settings')} setIsLanguageOpen(true)}>{t('language')} setPreferences({ ...preferences, mode: preferences.mode === 'dark' ? 'light' : 'dark' })}>{preferences.mode === 'dark' ? t('light_mode') : t('dark_mode')} - await api.auth.logout()}>{t('sign_out')} + {/* await api.auth.logout()}>{t('sign_out')} */} } diff --git a/services/ui/src/views/ErrorPage.tsx b/services/ui/src/views/ErrorPage.tsx index ae1f8aa5..140f797e 100644 --- a/services/ui/src/views/ErrorPage.tsx +++ b/services/ui/src/views/ErrorPage.tsx @@ -2,7 +2,6 @@ import { isRouteErrorResponse, Navigate, useNavigate, useRouteError } from 'reac import Alert, { AlertProps } from '../ui/Alert' import Button from '../ui/Button' import './ErrorPage.css' -import api from '../api' const ErrorAlert = (props: AlertProps) => { return
@@ -62,7 +61,7 @@ export default function ErrorPage({ status = 500 }: { status?: number }) { export function AccessDenied() { const handleLogout = async () => { - await api.auth.logout() + // await api.auth.logout() } return ( diff --git a/services/ui/src/views/auth/Login.tsx b/services/ui/src/views/auth/Login.tsx index 69f3c580..a6aeb6ef 100644 --- a/services/ui/src/views/auth/Login.tsx +++ b/services/ui/src/views/auth/Login.tsx @@ -1,8 +1,8 @@ import { useSearchParams } from 'react-router' +import { SignIn } from '@clerk/clerk-react' // import { ReactComponent as Logo } from '../../assets/logo.svg' import { env } from '../../config/env' import Button from '../../ui/Button' -import './Auth.css' import { useEffect, useState } from 'react' import api from '../../api' import { AuthMethod } from '../../types' @@ -11,6 +11,8 @@ import TextInput from '../../ui/form/TextInput' import { Alert } from '../../ui' import { useTranslation } from 'react-i18next' +import './Auth.css' + interface LoginParams { email: string password?: string @@ -22,30 +24,39 @@ export default function Login() { const [methods, setMethods] = useState() const [method, setMethod] = useState() const [message, setMessage] = useState() + const redirect = searchParams.get('r') ?? '/' const handleRedirect = (driver: string, email?: string) => { - window.location.href = `${env.api.baseURL}/auth/login/${driver}?r=${searchParams.get('r') ?? '/'}&email=${email}` + window.location.href = new URL(`/auth/login/${driver}?r=${redirect}&email=${email}`, env.api.baseURL).toString() } + const reservedDrivers = ['cloud', 'basic', 'email'] const handleMethod = (method: AuthMethod) => { - if (['multi', 'basic', 'email'].includes(method.driver)) { + if (reservedDrivers.includes(method.driver)) { setMethod(method) } else { handleRedirect(method.driver) } } + const handleBasicAuth = async ({ email, password }: LoginParams) => { + if (!password) { + setMessage(t('login_basic_instructions')) + return + } + + await api.auth.basicAuth(email, password, searchParams.get('r') ?? '/') + } + + const handleEmailAuth = async ({ email }: LoginParams) => { + await api.auth.emailAuth(email, searchParams.get('r') ?? '/') + setMessage(t('login_email_confirmation')) + } + const handleLogin = (method: string) => { - return async ({ email, password }: LoginParams) => { - if (password && method === 'basic') { - await api.auth.basicAuth(email, password, searchParams.get('r') ?? '/') - } else if (method === 'email') { - await api.auth.emailAuth(email, searchParams.get('r') ?? '/') - setMessage(t('login_email_confirmation')) - } else { - await checkEmail(method, email) - handleRedirect(method, email) - } + return async ({ email }: LoginParams) => { + await checkEmail(method, email) + handleRedirect(method, email) } } @@ -58,9 +69,17 @@ export default function Login() { useEffect(() => { api.auth.methods().then((methods) => { setMethods(methods) + if (methods.length === 1) { + handleMethod(methods[0]) + } }).catch(() => { }) }, []) + // TODO: we have to think what to do if no methods are available + if (!methods || methods.length === 0) { + return null + } + return (
@@ -82,7 +101,7 @@ export default function Login() {

{t('welcome')}

{t('login_basic_instructions')}

- onSubmit={handleLogin(method.driver)}> + onSubmit={handleBasicAuth}> {form => <> @@ -102,7 +121,7 @@ export default function Login() { : <>

{t('login_email_instructions')}

- onSubmit={handleLogin(method.driver)}> + onSubmit={handleEmailAuth}> {form => <> } @@ -112,7 +131,10 @@ export default function Login() { }
)} - {method && !['basic', 'email'].includes(method.driver) && ( + {method && method.driver === 'cloud' && ( + + )} + {method && !reservedDrivers.includes(method.driver) && (

{t('welcome')}

{t('login_email_available_methods')}

diff --git a/services/ui/src/views/auth/LoginCallback.tsx b/services/ui/src/views/auth/LoginCallback.tsx new file mode 100644 index 00000000..e5f732e0 --- /dev/null +++ b/services/ui/src/views/auth/LoginCallback.tsx @@ -0,0 +1,30 @@ +import { useParams, useSearchParams } from 'react-router' +import { useEffect } from 'react' +import api from '../../api' + +import './Auth.css' + +export default function LoginCallback() { + const { driver } = useParams() as { driver: string } + const [searchParams] = useSearchParams() + const redirect = searchParams.get('r') ?? '/' + + const handleAuth = async () => { + switch (driver) { + case 'cloud': + await api.auth.cloudAuth(redirect) + break + } + + window.location.href = redirect + } + + useEffect(() => { + handleAuth().catch((err) => { + console.error('Authentication error', err) + }) + }, [driver, redirect]) + + // TODO: handle callback error + return <> +} diff --git a/services/ui/src/views/organization/Settings.tsx b/services/ui/src/views/organization/Settings.tsx index f9ddd4ac..21ec748b 100644 --- a/services/ui/src/views/organization/Settings.tsx +++ b/services/ui/src/views/organization/Settings.tsx @@ -17,7 +17,7 @@ export default function Settings() { const deleteOrganization = async () => { if (confirm('Are you sure you want to delete this organization?')) { await api.organizations.delete() - await api.auth.logout() + // await api.auth.logout() window.location.href = '/' } } diff --git a/services/ui/src/views/router.tsx b/services/ui/src/views/router.tsx index 54bbbb50..26763cf2 100644 --- a/services/ui/src/views/router.tsx +++ b/services/ui/src/views/router.tsx @@ -29,6 +29,7 @@ import ProjectSettings from './settings/ProjectSettings' import Integrations from './settings/Integrations' import Tags from './settings/Tags' import Login from './auth/Login' +import LoginCallback from './auth/LoginCallback' import OnboardingStart from './auth/OnboardingStart' import Onboarding from './auth/Onboarding' import OnboardingProject from './auth/OnboardingProject' @@ -79,6 +80,10 @@ export const createRouter = ({ path: '/login', element: , }, + { + path: '/login/:driver/callback', + element: , + }, { path: '*', errorElement: ,