diff --git a/.env.example b/.env.example index 239f6cd20..4cb53b133 100644 --- a/.env.example +++ b/.env.example @@ -24,8 +24,22 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key ### Auth provider: supabase (default) or ory # AUTH_PROVIDER=supabase -### Ory Network SDK URL (required when AUTH_PROVIDER=ory) +### Ory Network configuration (required when AUTH_PROVIDER=ory) +### SDK URL of the Ory Network project (or custom domain like https://auth.e2b.dev) # ORY_SDK_URL=https://your-project.projects.oryapis.com +### OAuth2 client credentials issued by Ory for this dashboard deployment +# ORY_OAUTH2_CLIENT_ID= +# ORY_OAUTH2_CLIENT_SECRET= +### Access-token audience requested from Ory. Must match infra AUTH_PROVIDER_CONFIG.jwt[].issuer.audiences. +# ORY_OAUTH2_AUDIENCE=https://api.e2b.dev +### Ory project admin API token used by oryAuthAdmin (IdentityApi lookups) +# ORY_PROJECT_API_TOKEN= + +### Auth.js configuration (required when AUTH_PROVIDER=ory) +### Generate with `npx auth secret` or `openssl rand -hex 32`. Used to encrypt the JWT session cookie. +# AUTH_SECRET= +### Set to 1 outside Vercel-hosted production to allow Auth.js to trust the Host header +# AUTH_TRUST_HOST=1 ### Billing API URL (Required if NEXT_PUBLIC_INCLUDE_BILLING=1) # BILLING_API_URL=https://billing.e2b.dev diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..71c1aa0de --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +spec/openapi.dashboard-api.yaml linguist-generated=true +src/core/shared/contracts/dashboard-api.types.ts linguist-generated=true diff --git a/bun.lock b/bun.lock index a97d38482..e29e0ffe6 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-node": "^0.203.0", "@opentelemetry/semantic-conventions": "^1.36.0", + "@ory/client-fetch": "^1.22.37", "@radix-ui/react-avatar": "^1.1.4", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -69,6 +70,7 @@ "motion": "^12.23.25", "nanoid": "^5.0.9", "next": "^16.2.6", + "next-auth": "^5.0.0-beta.31", "next-safe-action": "^8.0.11", "next-themes": "^0.4.6", "nuqs": "^2.7.0", @@ -143,6 +145,8 @@ "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@auth/core": ["@auth/core@0.41.2", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w=="], + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], @@ -561,6 +565,8 @@ "@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.41.2", "", { "dependencies": { "@opentelemetry/core": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ=="], + "@ory/client-fetch": ["@ory/client-fetch@1.22.37", "", {}, "sha512-OFPso6JcQ1NVA7UF4Ip112b9/3yoFlGF2kM78fy6gG3uwciC5eUXZWHBGLZdCEi7eKe1JVMJwraR5j6QVmS8vw=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.128.0", "", { "os": "android", "cpu": "arm" }, "sha512-aca6ZvzmCBUGOANQRiRQRZuRKYI3ENhcit6GisnknOOmcezfQc7xJ4dxlPU7MV7mOvrC7RNR1u3LAD7xyaiCxA=="], "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.128.0", "", { "os": "android", "cpu": "arm64" }, "sha512-BbeDmuohoJ7Rz/it5wnkj69i/OsCPS3Z51nLEzwO/Y6YshtC4JU+15oNwhY8v4LRKRYclRc7ggOikwrsJ/eOEQ=="], @@ -643,6 +649,8 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.19.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw=="], + "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pivanov/utils": ["@pivanov/utils@0.0.2", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-q9CN0bFWxWgMY5hVVYyBgez1jGiLBa6I+LkG37ycylPhFvEGOOeaADGtUSu46CaZasPnlY8fCdVJZmrgKb1EPA=="], @@ -1017,8 +1025,6 @@ "@vercel/speed-insights": ["@vercel/speed-insights@1.2.0", "", { "peerDependencies": { "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw=="], - "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "ast-v8-to-istanbul": "^0.3.3", "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.2.4", "vitest": "3.2.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], @@ -1037,6 +1043,8 @@ "@vitest/utils": ["@vitest/utils@3.0.7", "", { "dependencies": { "@vitest/pretty-format": "3.0.7", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" } }, "sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg=="], + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], @@ -1393,6 +1401,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], @@ -1503,6 +1513,8 @@ "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="], + "next-auth": ["next-auth@5.0.0-beta.31", "", { "dependencies": { "@auth/core": "0.41.2" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q=="], + "next-safe-action": ["next-safe-action@8.0.11", "", { "peerDependencies": { "next": ">= 14.0.0", "react": ">= 18.2.0", "react-dom": ">= 18.2.0" } }, "sha512-gqJLmnQLAoFCq1kRBopN46New+vx1n9J9Y/qDQLXpv/VqU40AWxDakvshwwnWAt8R0kLvlakNYNLX5PqlXWSMg=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], @@ -1515,6 +1527,8 @@ "nuqs": ["nuqs@2.7.2", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^6 || ^7", "react-router-dom": "^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-wOPJoz5om7jMJQick9zU1S/Q+joL+B2DZTZxfCleHEcUzjUnPoujGod4+nAmUWb+G9TwZnyv+mfNqlyfEi8Zag=="], + "oauth4webapi": ["oauth4webapi@3.8.6", "", {}, "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], @@ -1593,6 +1607,8 @@ "preact": ["preact@10.27.2", "", {}, "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg=="], + "preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="], + "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], @@ -1925,6 +1941,8 @@ "@asamuzakjp/dom-selector/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "@auth/core/preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], diff --git a/package.json b/package.json index 2477377ca..75d6469fc 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-node": "^0.203.0", "@opentelemetry/semantic-conventions": "^1.36.0", + "@ory/client-fetch": "^1.22.37", "@radix-ui/react-avatar": "^1.1.4", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -111,6 +112,7 @@ "motion": "^12.23.25", "nanoid": "^5.0.9", "next": "^16.2.6", + "next-auth": "^5.0.0-beta.31", "next-safe-action": "^8.0.11", "next-themes": "^0.4.6", "nuqs": "^2.7.0", diff --git a/spec/openapi.dashboard-api.yaml b/spec/openapi.dashboard-api.yaml index 453c94c8b..00318ad13 100644 --- a/spec/openapi.dashboard-api.yaml +++ b/spec/openapi.dashboard-api.yaml @@ -10,6 +10,9 @@ components: type: apiKey in: header name: X-Admin-Token + # Generated code uses security schemas in the alphabetical order. + # In order to check first the token, and then the team (so we can already use the user), + # there is a 1 and 2 present in the names of the security schemas. Supabase1TokenAuth: type: apiKey in: header @@ -18,6 +21,16 @@ components: type: apiKey in: header name: X-Supabase-Team + # AuthProviderBearerAuth / AuthProviderTeamAuth: B before T in the name + # so Bearer is validated before Team (same reason as Supabase1/2 above). + AuthProviderBearerAuth: + type: http + scheme: bearer + bearerFormat: access_token + AuthProviderTeamAuth: + type: apiKey + in: header + name: X-Team-ID parameters: build_id: @@ -156,6 +169,74 @@ components: type: string description: Error message. + AdminAuthProviderProfile: + type: object + required: + - userId + - email + properties: + userId: + type: string + format: uuid + description: Internal E2B user identifier. + email: + type: string + nullable: true + description: Email address from the configured auth provider. + + AdminAuthProviderProfilesResponse: + type: object + required: + - profiles + properties: + profiles: + type: array + items: + $ref: "#/components/schemas/AdminAuthProviderProfile" + + AdminAuthProviderProfilesResolveRequest: + type: object + required: + - userIds + properties: + userIds: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + type: string + format: uuid + + AdminAuthProviderProfilesLookupEmailRequest: + type: object + required: + - email + properties: + email: + type: string + format: email + + AdminAuthProviderUserBootstrapRequest: + type: object + required: + - oidc_issuer + - oidc_user_id + - oidc_user_email + properties: + oidc_issuer: + type: string + minLength: 1 + oidc_user_id: + type: string + minLength: 1 + oidc_user_email: + type: string + format: email + oidc_user_name: + type: string + nullable: true + BuildStatus: type: string description: Build status mapped for dashboard clients. @@ -438,12 +519,24 @@ components: - email - isDefault - createdAt + - providers properties: id: type: string format: uuid email: type: string + name: + type: string + nullable: true + profilePictureUrl: + type: string + format: uri + nullable: true + providers: + type: array + items: + type: string isDefault: type: boolean addedBy: @@ -617,6 +710,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/build_id_or_template" - $ref: "#/components/parameters/build_statuses" @@ -645,6 +740,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/build_ids" @@ -671,6 +768,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/build_id" responses: @@ -696,6 +795,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/sandboxID" responses: @@ -721,6 +822,7 @@ paths: tags: [teams] security: - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] responses: "200": description: Successfully returned user teams. @@ -737,6 +839,7 @@ paths: tags: [teams] security: - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] requestBody: required: true content: @@ -777,6 +880,106 @@ paths: "500": $ref: "#/components/responses/500" + /admin/users/bootstrap: + post: + summary: Bootstrap auth provider user + tags: [teams] + security: + - AdminTokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderUserBootstrapRequest" + responses: + "200": + description: Successfully bootstrapped user. + content: + application/json: + schema: + $ref: "#/components/schemas/TeamResolveResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /admin/user-profiles/resolve: + post: + summary: Resolve user profiles + tags: [admin] + security: + - AdminTokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesResolveRequest" + responses: + "200": + description: Successfully resolved profiles. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /admin/user-profiles/by-email: + post: + summary: Lookup user profiles by email + tags: [admin] + security: + - AdminTokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesLookupEmailRequest" + responses: + "200": + description: Successfully found matching profiles. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /admin/user-profiles/{userId}: + get: + summary: Get user profile + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - $ref: "#/components/parameters/userId" + responses: + "200": + description: Successfully found profile. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + /teams/resolve: get: summary: Resolve team identity @@ -784,6 +987,7 @@ paths: tags: [teams] security: - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] parameters: - $ref: "#/components/parameters/teamSlug" responses: @@ -809,6 +1013,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" requestBody: @@ -840,6 +1046,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" responses: @@ -861,6 +1069,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" requestBody: @@ -890,6 +1100,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" - $ref: "#/components/parameters/userId" @@ -912,6 +1124,7 @@ paths: tags: [templates] security: - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] responses: "200": description: Successfully returned default templates. diff --git a/src/app/(auth)/forgot-password/forgot-password-form.tsx b/src/app/(auth)/forgot-password/forgot-password-form.tsx new file mode 100644 index 000000000..8c8e1d4ac --- /dev/null +++ b/src/app/(auth)/forgot-password/forgot-password-form.tsx @@ -0,0 +1,114 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' +import { useSearchParams } from 'next/navigation' +import { useEffect, useState } from 'react' +import { AUTH_URLS } from '@/configs/urls' +import { + getTimeoutMsFromUserMessage, + USER_MESSAGES, +} from '@/configs/user-messages' +import { forgotPasswordAction } from '@/core/server/actions/auth-actions' +import { forgotPasswordSchema } from '@/core/server/functions/auth/auth.types' +import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' +import { Button } from '@/ui/primitives/button' +import { Input } from '@/ui/primitives/input' +import { Label } from '@/ui/primitives/label' + +export default function ForgotPassword() { + const searchParams = useSearchParams() + const [message, setMessage] = useState() + + const { + form, + handleSubmitWithAction, + action: { isExecuting }, + } = useHookFormAction( + forgotPasswordAction, + zodResolver(forgotPasswordSchema), + { + actionProps: { + onSuccess: () => { + form.reset() + setMessage({ success: USER_MESSAGES.passwordReset.message }) + }, + onError: ({ error }) => { + if (error.serverError) { + setMessage({ error: error.serverError }) + } + }, + }, + } + ) + + useEffect(() => { + const email = searchParams.get('email') + if (email) { + form.setValue('email', email) + } + }, [searchParams, form]) + + useEffect(() => { + if ( + message && + (('success' in message && message.success) || + ('error' in message && message.error)) + ) { + const timer = setTimeout( + () => setMessage(undefined), + getTimeoutMsFromUserMessage( + 'success' in message + ? message.success! + : 'error' in message + ? message.error! + : '' + ) || 5000 + ) + return () => clearTimeout(timer) + } + }, [message]) + + const handleBackToSignIn = () => { + const email = form.getValues('email') + const searchParams = email ? `?email=${encodeURIComponent(email)}` : '' + window.location.href = `${AUTH_URLS.SIGN_IN}${searchParams}` + } + + return ( +
+

Reset Password

+

+ Remember your password?{' '} + + . +

+ +
+ + + +
+ + {message && } +
+ ) +} diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index 8c8e1d4ac..4b75ef913 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -1,114 +1,5 @@ -'use client' +import ForgotPassword from './forgot-password-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' -import { useSearchParams } from 'next/navigation' -import { useEffect, useState } from 'react' -import { AUTH_URLS } from '@/configs/urls' -import { - getTimeoutMsFromUserMessage, - USER_MESSAGES, -} from '@/configs/user-messages' -import { forgotPasswordAction } from '@/core/server/actions/auth-actions' -import { forgotPasswordSchema } from '@/core/server/functions/auth/auth.types' -import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' -import { Button } from '@/ui/primitives/button' -import { Input } from '@/ui/primitives/input' -import { Label } from '@/ui/primitives/label' - -export default function ForgotPassword() { - const searchParams = useSearchParams() - const [message, setMessage] = useState() - - const { - form, - handleSubmitWithAction, - action: { isExecuting }, - } = useHookFormAction( - forgotPasswordAction, - zodResolver(forgotPasswordSchema), - { - actionProps: { - onSuccess: () => { - form.reset() - setMessage({ success: USER_MESSAGES.passwordReset.message }) - }, - onError: ({ error }) => { - if (error.serverError) { - setMessage({ error: error.serverError }) - } - }, - }, - } - ) - - useEffect(() => { - const email = searchParams.get('email') - if (email) { - form.setValue('email', email) - } - }, [searchParams, form]) - - useEffect(() => { - if ( - message && - (('success' in message && message.success) || - ('error' in message && message.error)) - ) { - const timer = setTimeout( - () => setMessage(undefined), - getTimeoutMsFromUserMessage( - 'success' in message - ? message.success! - : 'error' in message - ? message.error! - : '' - ) || 5000 - ) - return () => clearTimeout(timer) - } - }, [message]) - - const handleBackToSignIn = () => { - const email = form.getValues('email') - const searchParams = email ? `?email=${encodeURIComponent(email)}` : '' - window.location.href = `${AUTH_URLS.SIGN_IN}${searchParams}` - } - - return ( -
-

Reset Password

-

- Remember your password?{' '} - - . -

- -
- - - -
- - {message && } -
- ) +export default function Page() { + return } diff --git a/src/app/(auth)/sign-in/login-form.tsx b/src/app/(auth)/sign-in/login-form.tsx new file mode 100644 index 000000000..6da5d319f --- /dev/null +++ b/src/app/(auth)/sign-in/login-form.tsx @@ -0,0 +1,172 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' +import Link from 'next/link' +import { useSearchParams } from 'next/navigation' +import { Suspense, useEffect, useState } from 'react' +import { AUTH_URLS } from '@/configs/urls' +import { USER_MESSAGES } from '@/configs/user-messages' +import { signInAction } from '@/core/server/actions/auth-actions' +import { signInSchema } from '@/core/server/functions/auth/auth.types' +import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' +import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' +import { Button } from '@/ui/primitives/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/ui/primitives/form' +import { Input } from '@/ui/primitives/input' +import TextSeparator from '@/ui/text-separator' + +export default function Login() { + 'use no memo' + + const searchParams = useSearchParams() + + const [message, setMessage] = useState(() => { + const error = searchParams.get('error') + const success = searchParams.get('success') + if (error) return { error } + if (success) return { success } + return undefined + }) + + const { + form, + handleSubmitWithAction, + action: { isExecuting }, + } = useHookFormAction(signInAction, zodResolver(signInSchema), { + actionProps: { + onError: ({ error }) => { + if ( + error.serverError === USER_MESSAGES.signInEmailNotConfirmed.message + ) { + setMessage({ success: error.serverError }) + return + } + + if (error.serverError) { + setMessage({ error: error.serverError }) + } + }, + }, + }) + + const returnTo = searchParams.get('returnTo') || undefined + + useEffect(() => { + form.setValue('returnTo', returnTo) + }, [returnTo, form]) + + // Handle email prefill from forgot password flow + useEffect(() => { + const email = searchParams.get('email') + if (email) { + form.setValue('email', email) + // Focus password field if email is prefilled + form.setFocus('password') + } else { + // Focus email field if no prefill + form.setFocus('email') + } + }, [searchParams, form]) + + const handleForgotPassword = () => { + const email = form.getValues('email') + const params = new URLSearchParams() + if (email) params.set('email', email) + if (returnTo) params.set('returnTo', returnTo) + window.location.href = `${AUTH_URLS.FORGOT_PASSWORD}?${params.toString()}` + } + + return ( +
+

Sign in

+ + + + + + + +
+ + ( + + E-Mail + + + + + + )} + /> + +
+ Password + +
+ + ( + + + + + + + )} + /> + + + + + + + +

+ Don't have an account?{' '} + + Sign up + + . +

+ + {message && } +
+ ) +} diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index 6da5d319f..30f2a3f11 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -1,172 +1,5 @@ -'use client' +import Login from './login-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' -import Link from 'next/link' -import { useSearchParams } from 'next/navigation' -import { Suspense, useEffect, useState } from 'react' -import { AUTH_URLS } from '@/configs/urls' -import { USER_MESSAGES } from '@/configs/user-messages' -import { signInAction } from '@/core/server/actions/auth-actions' -import { signInSchema } from '@/core/server/functions/auth/auth.types' -import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' -import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' -import { Button } from '@/ui/primitives/button' -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/ui/primitives/form' -import { Input } from '@/ui/primitives/input' -import TextSeparator from '@/ui/text-separator' - -export default function Login() { - 'use no memo' - - const searchParams = useSearchParams() - - const [message, setMessage] = useState(() => { - const error = searchParams.get('error') - const success = searchParams.get('success') - if (error) return { error } - if (success) return { success } - return undefined - }) - - const { - form, - handleSubmitWithAction, - action: { isExecuting }, - } = useHookFormAction(signInAction, zodResolver(signInSchema), { - actionProps: { - onError: ({ error }) => { - if ( - error.serverError === USER_MESSAGES.signInEmailNotConfirmed.message - ) { - setMessage({ success: error.serverError }) - return - } - - if (error.serverError) { - setMessage({ error: error.serverError }) - } - }, - }, - }) - - const returnTo = searchParams.get('returnTo') || undefined - - useEffect(() => { - form.setValue('returnTo', returnTo) - }, [returnTo, form]) - - // Handle email prefill from forgot password flow - useEffect(() => { - const email = searchParams.get('email') - if (email) { - form.setValue('email', email) - // Focus password field if email is prefilled - form.setFocus('password') - } else { - // Focus email field if no prefill - form.setFocus('email') - } - }, [searchParams, form]) - - const handleForgotPassword = () => { - const email = form.getValues('email') - const params = new URLSearchParams() - if (email) params.set('email', email) - if (returnTo) params.set('returnTo', returnTo) - window.location.href = `${AUTH_URLS.FORGOT_PASSWORD}?${params.toString()}` - } - - return ( -
-

Sign in

- - - - - - - -
- - ( - - E-Mail - - - - - - )} - /> - -
- Password - -
- - ( - - - - - - - )} - /> - - - - - - - -

- Don't have an account?{' '} - - Sign up - - . -

- - {message && } -
- ) +export default function Page() { + return } diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx index 6397103d3..12d998e4e 100644 --- a/src/app/(auth)/sign-up/page.tsx +++ b/src/app/(auth)/sign-up/page.tsx @@ -1,214 +1,5 @@ -'use client' +import SignUp from './signup-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' -import Link from 'next/link' -import { useSearchParams } from 'next/navigation' -import { Suspense, useEffect, useRef, useState } from 'react' -import { CAPTCHA_REQUIRED_CLIENT } from '@/configs/flags' -import { AUTH_URLS } from '@/configs/urls' -import { - getTimeoutMsFromUserMessage, - USER_MESSAGES, -} from '@/configs/user-messages' -import { signUpAction } from '@/core/server/actions/auth-actions' -import { signUpSchema } from '@/core/server/functions/auth/auth.types' -import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' -import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' -import { TurnstileWidget } from '@/features/auth/turnstile-widget' -import { useTurnstile } from '@/features/auth/use-turnstile' -import { Button } from '@/ui/primitives/button' -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/ui/primitives/form' -import { Input } from '@/ui/primitives/input' -import TextSeparator from '@/ui/text-separator' - -export default function SignUp() { - 'use no memo' - - const searchParams = useSearchParams() - const [message, setMessage] = useState(() => { - const error = searchParams.get('error') - const success = searchParams.get('success') - if (error) return { error } - if (success) return { success } - - return undefined - }) - - const turnstileResetRef = useRef<() => void>(() => {}) - - const returnTo = searchParams.get('returnTo') || undefined - - const { - form, - handleSubmitWithAction, - action: { isExecuting }, - } = useHookFormAction(signUpAction, zodResolver(signUpSchema), { - actionProps: { - onSuccess: () => { - turnstileResetRef.current() - setMessage({ success: USER_MESSAGES.signUpVerification.message }) - }, - onError: ({ error }) => { - turnstileResetRef.current() - - if (error.serverError) { - setMessage({ error: error.serverError }) - } - }, - }, - }) - - const turnstile = useTurnstile(form) - turnstileResetRef.current = turnstile.reset - - useEffect(() => { - form.setValue('returnTo', returnTo) - }, [returnTo, form]) - - // Handle email prefill - useEffect(() => { - const email = searchParams.get('email') - if (email) { - form.setValue('email', email) - form.setFocus('password') - } else { - form.setFocus('email') - } - }, [searchParams, form]) - - useEffect(() => { - if (message && 'success' in message && message.success) { - const timer = setTimeout( - () => setMessage(undefined), - getTimeoutMsFromUserMessage(message.success) || 5000 - ) - return () => clearTimeout(timer) - } - }, [message]) - - return ( -
-

Sign up

- - - - - - - -
- - ( - - E-Mail - - - - - - )} - /> - - Password - ( - - - - - - - )} - /> - - ( - - - - - - - )} - /> - - - - - - - - - - -

- Already have an account?{' '} - - Sign in - - . -

-

- By signing up, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - - . -

- - {message && } -
- ) +export default function Page() { + return } diff --git a/src/app/(auth)/sign-up/signup-form.tsx b/src/app/(auth)/sign-up/signup-form.tsx new file mode 100644 index 000000000..2b4acc7b9 --- /dev/null +++ b/src/app/(auth)/sign-up/signup-form.tsx @@ -0,0 +1,233 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' +import Link from 'next/link' +import { useSearchParams } from 'next/navigation' +import { Suspense, useEffect, useRef, useState } from 'react' +import { + AUTH_MIGRATION_IN_PROGRESS, + CAPTCHA_REQUIRED_CLIENT, +} from '@/configs/flags' +import { AUTH_URLS } from '@/configs/urls' +import { + getTimeoutMsFromUserMessage, + USER_MESSAGES, +} from '@/configs/user-messages' +import { signUpAction } from '@/core/server/actions/auth-actions' +import { signUpSchema } from '@/core/server/functions/auth/auth.types' +import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' +import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' +import { TurnstileWidget } from '@/features/auth/turnstile-widget' +import { useTurnstile } from '@/features/auth/use-turnstile' +import { Button } from '@/ui/primitives/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/ui/primitives/form' +import { Input } from '@/ui/primitives/input' +import TextSeparator from '@/ui/text-separator' + +export default function SignUp() { + 'use no memo' + + const searchParams = useSearchParams() + const [message, setMessage] = useState(() => { + const error = searchParams.get('error') + const success = searchParams.get('success') + if (error) return { error } + if (success) return { success } + + return undefined + }) + + const turnstileResetRef = useRef<() => void>(() => {}) + + const returnTo = searchParams.get('returnTo') || undefined + + const { + form, + handleSubmitWithAction, + action: { isExecuting }, + } = useHookFormAction(signUpAction, zodResolver(signUpSchema), { + actionProps: { + onSuccess: () => { + turnstileResetRef.current() + setMessage({ success: USER_MESSAGES.signUpVerification.message }) + }, + onError: ({ error }) => { + turnstileResetRef.current() + + if (error.serverError) { + setMessage({ error: error.serverError }) + } + }, + }, + }) + + const turnstile = useTurnstile(form) + turnstileResetRef.current = turnstile.reset + + useEffect(() => { + form.setValue('returnTo', returnTo) + }, [returnTo, form]) + + // Handle email prefill + useEffect(() => { + const email = searchParams.get('email') + if (email) { + form.setValue('email', email) + form.setFocus('password') + } else { + form.setFocus('email') + } + }, [searchParams, form]) + + useEffect(() => { + if (message && 'success' in message && message.success) { + const timer = setTimeout( + () => setMessage(undefined), + getTimeoutMsFromUserMessage(message.success) || 5000 + ) + return () => clearTimeout(timer) + } + }, [message]) + + if (AUTH_MIGRATION_IN_PROGRESS) { + return ( +
+

Sign up

+

+ New sign-ups are temporarily paused while we migrate our + authentication system. Existing users can still{' '} + + sign in + + . +

+
+ ) + } + + return ( +
+

Sign up

+ + + + + + + +
+ + ( + + E-Mail + + + + + + )} + /> + + Password + ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> + + + + + + + + + + +

+ Already have an account?{' '} + + Sign in + + . +

+

+ By signing up, you agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . +

+ + {message && } +
+ ) +} diff --git a/src/app/api/auth/oauth-recover/route.ts b/src/app/api/auth/oauth-recover/route.ts new file mode 100644 index 000000000..bb12c75b5 --- /dev/null +++ b/src/app/api/auth/oauth-recover/route.ts @@ -0,0 +1,48 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { AUTH_URLS } from '@/configs/urls' +import { l } from '@/core/shared/clients/logger/logger' + +// Auth.js renders its built-in `${basePath}/error` page when something fails +// during the OAuth dance (most commonly a stale state/PKCE/nonce cookie that +// expired while the user lingered on the Ory hosted UI). We point +// `pages.error` here so the user never sees that page - we log the failure +// for observability and bounce them back to /sign-in, which restarts the +// flow with fresh cookies via the middleware -> oauth-start chain. +// +// A short-lived cookie prevents tight loops when the underlying failure is +// genuinely persistent (e.g. ORY_SDK_URL misconfigured). After one recovery +// attempt in the window, subsequent failures fall back to the marketing +// root so the user isn't bounced indefinitely. +const RECOVERY_COOKIE = 'auth_recover_attempted' +const RECOVERY_COOKIE_MAX_AGE_SECONDS = 30 + +export async function GET(request: NextRequest) { + const errorCode = request.nextUrl.searchParams.get('error') ?? 'unknown' + const alreadyAttempted = request.cookies.get(RECOVERY_COOKIE)?.value === '1' + + l.error( + { + key: 'oauth_recover:auth_js_error', + context: { error_code: errorCode, already_attempted: alreadyAttempted }, + }, + 'Auth.js OAuth flow failed; recovering user' + ) + + const destination = alreadyAttempted ? '/' : AUTH_URLS.SIGN_IN + const response = NextResponse.redirect(new URL(destination, request.url)) + + if (alreadyAttempted) { + response.cookies.delete(RECOVERY_COOKIE) + } else { + response.cookies.set(RECOVERY_COOKIE, '1', { + maxAge: RECOVERY_COOKIE_MAX_AGE_SECONDS, + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: process.env.NODE_ENV === 'production', + }) + } + + return response +} diff --git a/src/app/api/auth/oauth-start/route.ts b/src/app/api/auth/oauth-start/route.ts new file mode 100644 index 000000000..a25e3beeb --- /dev/null +++ b/src/app/api/auth/oauth-start/route.ts @@ -0,0 +1,30 @@ +import { signIn } from '@/auth' + +// Server-side entry point for the Ory OAuth2 flow. Pages redirect here +// instead of rendering a client-side form so that Auth.js can set its +// state/PKCE cookies (only allowed in route handlers / server actions +// / middleware) without any client JS in the loop. +// +// `intent=signup` forwards `prompt=registration` to Hydra, which routes +// to its registration UI (`urls.registration`, default `/ui/registration`) +// instead of the login UI. +// +// `intent=reauth` forwards `prompt=login`, forcing Hydra to redo the login +// flow even with an active session so we get a fresh `auth_time`. Used to +// re-authenticate before sensitive account changes (password). +// https://www.ory.com/docs/oauth2-oidc/authorization-code-flow +export async function GET(request: Request) { + const url = new URL(request.url) + const intent = url.searchParams.get('intent') + const returnTo = url.searchParams.get('returnTo') + const redirectTo = returnTo && returnTo.length > 0 ? returnTo : '/dashboard' + + const authorizationParams = + intent === 'signup' + ? { prompt: 'registration' } + : intent === 'reauth' + ? { prompt: 'login' } + : undefined + + await signIn('ory', { redirectTo }, authorizationParams) +} diff --git a/src/app/api/auth/oauth/[...nextauth]/route.ts b/src/app/api/auth/oauth/[...nextauth]/route.ts new file mode 100644 index 000000000..c4ea2950b --- /dev/null +++ b/src/app/api/auth/oauth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/auth' + +export const { GET, POST } = handlers diff --git a/src/app/api/auth/oauth/signout-flow/route.ts b/src/app/api/auth/oauth/signout-flow/route.ts new file mode 100644 index 000000000..b9a994279 --- /dev/null +++ b/src/app/api/auth/oauth/signout-flow/route.ts @@ -0,0 +1,66 @@ +import 'server-only' + +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { auth, signOut } from '@/auth' +import { revokeKratosSessionsForIdentity } from '@/core/server/auth/ory/kratos-session' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +// lands users on the marketing root instead of /sign-in so they don't get +// bounced straight back to the Ory-hosted login UI after signing out. +const ORY_POST_LOGOUT_PATH = '/' + +export async function GET(request: NextRequest) { + const origin = request.nextUrl.origin + const postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin) + + let idToken: string | undefined + let identityId: string | undefined + try { + const session = await auth() + idToken = session?.idToken + // The Kratos identity id resolved at sign-in — NOT the OIDC subject (which + // is the E2B user id) — so we revoke the right identity's Kratos sessions. + identityId = session?.identityId + } catch (error) { + l.warn( + { + key: 'oauth_signout:read_session:error', + error: serializeErrorForLog(error), + }, + 'failed to read Auth.js session before sign-out' + ) + } + + try { + await signOut({ redirect: false }) + } catch (error) { + l.warn( + { + key: 'oauth_signout:authjs_sign_out:error', + error: serializeErrorForLog(error), + }, + 'Auth.js signOut() failed' + ) + } + + if (identityId) { + await revokeKratosSessionsForIdentity(identityId) + } + + const sdkUrl = process.env.ORY_SDK_URL + if (!idToken || !sdkUrl) { + return NextResponse.redirect(postLogoutUrl) + } + + const hydraLogout = new URL( + `${sdkUrl.replace(/\/$/, '')}/oauth2/sessions/logout` + ) + hydraLogout.searchParams.set('id_token_hint', idToken) + hydraLogout.searchParams.set( + 'post_logout_redirect_uri', + postLogoutUrl.toString() + ) + + return NextResponse.redirect(hydraLogout.toString()) +} diff --git a/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts b/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts index af2718918..26fa32148 100644 --- a/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts +++ b/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts @@ -1,6 +1,6 @@ import { cookies } from 'next/headers' import { type NextRequest, NextResponse } from 'next/server' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { COOKIE_KEYS } from '@/configs/cookies' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' @@ -59,7 +59,7 @@ async function hasSandboxInTeam( }, }, headers: { - ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + ...authHeaders(accessToken, teamId), }, cache: 'no-store', }) diff --git a/src/app/dashboard/[teamSlug]/layout.tsx b/src/app/dashboard/[teamSlug]/layout.tsx index 389737aaa..99251f84d 100644 --- a/src/app/dashboard/[teamSlug]/layout.tsx +++ b/src/app/dashboard/[teamSlug]/layout.tsx @@ -6,6 +6,7 @@ import { COOKIE_KEYS } from '@/configs/cookies' import { METADATA } from '@/configs/metadata' import { AUTH_URLS } from '@/configs/urls' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' +import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries' import { auth } from '@/core/server/auth' import DashboardLayoutView from '@/features/dashboard/layouts/layout' import Sidebar from '@/features/dashboard/sidebar/sidebar' @@ -43,13 +44,24 @@ export default async function DashboardLayout({ throw redirect(AUTH_URLS.SIGN_IN) } - await prefetchAsync( - trpc.teams.list.queryOptions(undefined, DASHBOARD_TEAMS_LIST_QUERY_OPTIONS) - ) + await Promise.all([ + prefetchAsync( + trpc.teams.list.queryOptions( + undefined, + DASHBOARD_TEAMS_LIST_QUERY_OPTIONS + ) + ), + prefetchAsync( + trpc.user.profile.queryOptions( + undefined, + DASHBOARD_USER_PROFILE_QUERY_OPTIONS + ) + ), + ]) return ( - + diff --git a/src/app/dashboard/[teamSlug]/team-gate.tsx b/src/app/dashboard/[teamSlug]/team-gate.tsx index 4278c92d0..db1ab0dbf 100644 --- a/src/app/dashboard/[teamSlug]/team-gate.tsx +++ b/src/app/dashboard/[teamSlug]/team-gate.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' -import type { AuthUser } from '@/core/server/auth' +import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries' import { DashboardContextProvider } from '@/features/dashboard/context' import LoadingLayout from '@/features/dashboard/loading-layout' import { useTRPC } from '@/trpc/client' @@ -10,28 +10,33 @@ import Unauthorized from '../unauthorized' interface DashboardTeamGateProps { teamSlug: string - user: AuthUser children: React.ReactNode } export function DashboardTeamGate({ teamSlug, - user, children, }: DashboardTeamGateProps) { const trpc = useTRPC() - const { data: teams, isPending } = useQuery( + const { data: teams, isPending: teamsPending } = useQuery( trpc.teams.list.queryOptions(undefined, DASHBOARD_TEAMS_LIST_QUERY_OPTIONS) ) - if (isPending) { + const { data: user, isPending: userPending } = useQuery( + trpc.user.profile.queryOptions( + undefined, + DASHBOARD_USER_PROFILE_QUERY_OPTIONS + ) + ) + + if (teamsPending || userPending) { return } const team = teams?.find((candidate) => candidate.slug === teamSlug) - if (!team || !teams) { + if (!team || !teams || !user) { return } diff --git a/src/app/dashboard/account/route.ts b/src/app/dashboard/account/route.ts index 3a89050b2..3ae1d5383 100644 --- a/src/app/dashboard/account/route.ts +++ b/src/app/dashboard/account/route.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { auth } from '@/core/server/auth' import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' -import { encodedRedirect } from '@/lib/utils/auth' +import { l } from '@/core/shared/clients/logger/logger' import { setTeamCookies } from '@/lib/utils/cookies' export async function GET(request: NextRequest) { @@ -18,15 +18,17 @@ export async function GET(request: NextRequest) { ) if (!team) { - await auth.signOut() + l.warn( + { + key: 'dashboard_account:no_personal_team', + user_id: authContext.user.id, + }, + 'no personal team for user, signing out' + ) - const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url) + const { redirectTo } = await auth.signOut() - return encodedRedirect( - 'error', - signInUrl.toString(), - 'No personal team found. Please contact support.' - ) + return NextResponse.redirect(new URL(redirectTo, request.url)) } await setTeamCookies(team.id, team.slug) diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts index a44259813..e27cdc161 100644 --- a/src/app/dashboard/route.ts +++ b/src/app/dashboard/route.ts @@ -1,9 +1,9 @@ import { type NextRequest, NextResponse } from 'next/server' import { TAB_URL_MAP } from '@/configs/dashboard-tab-url-map' -import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' +import { PROTECTED_URLS } from '@/configs/urls' import { auth } from '@/core/server/auth' import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' -import { encodedRedirect } from '@/lib/utils/auth' +import { l } from '@/core/shared/clients/logger/logger' import { setTeamCookies } from '@/lib/utils/cookies' function getTabRedirectPath(tab: string | null, teamSlug: string) { @@ -34,15 +34,17 @@ export async function GET(request: NextRequest) { ) if (!team) { - await auth.signOut() + l.warn( + { + key: 'dashboard:no_personal_team', + user_id: authContext.user.id, + }, + 'no personal team for user, signing out' + ) - const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url) + const { redirectTo } = await auth.signOut() - return encodedRedirect( - 'error', - signInUrl.toString(), - 'No personal team found. Please contact support.' - ) + return NextResponse.redirect(new URL(redirectTo, request.url)) } await setTeamCookies(team.id, team.slug) diff --git a/src/app/dashboard/terminal/page.tsx b/src/app/dashboard/terminal/page.tsx index a5454a625..39cfd1ca5 100644 --- a/src/app/dashboard/terminal/page.tsx +++ b/src/app/dashboard/terminal/page.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' import type { Metadata } from 'next/types' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { AUTH_URLS } from '@/configs/urls' import type { TeamModel } from '@/core/modules/teams/models' import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' @@ -181,7 +181,7 @@ async function hasSandboxInTeam({ }, }, headers: { - ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + ...authHeaders(accessToken, teamId), }, cache: 'no-store', }) diff --git a/src/app/sbx/new/route.ts b/src/app/sbx/new/route.ts index d55bf2aa0..f6d39ac66 100644 --- a/src/app/sbx/new/route.ts +++ b/src/app/sbx/new/route.ts @@ -1,6 +1,6 @@ import Sandbox from 'e2b' import { type NextRequest, NextResponse } from 'next/server' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { auth } from '@/core/server/auth' import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' @@ -32,7 +32,7 @@ export const GET = async (req: NextRequest) => { const sbx = await Sandbox.create('base', { domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, headers: { - ...SUPABASE_AUTH_HEADERS(authContext.accessToken, team.id), + ...authHeaders(authContext.accessToken, team.id), }, }) diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 000000000..25b02adc0 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,52 @@ +import NextAuth from 'next-auth' +import OryHydra from 'next-auth/providers/ory-hydra' +import { + applyTokenToSession, + resolveOryJwt, +} from '@/core/server/auth/ory/auth-callbacks' +import { bootstrapOryUser } from '@/core/server/auth/ory/bootstrap' + +const oryOAuth2Audience = process.env.ORY_OAUTH2_AUDIENCE + +export const { handlers, auth, signIn, signOut } = NextAuth({ + // isolates from existing /api/auth/{callback,email-callback,verify-otp} + basePath: '/api/auth/oauth', + secret: process.env.AUTH_SECRET, + session: { strategy: 'jwt' }, + // route handler that logs the failure and redirects to /sign-in so users + // never see Auth.js's built-in error page; see oauth-recover/route.ts. + pages: { + error: '/api/auth/oauth-recover', + }, + providers: [ + OryHydra({ + id: 'ory', + name: 'Ory', + issuer: process.env.ORY_SDK_URL, + clientId: process.env.ORY_OAUTH2_CLIENT_ID, + clientSecret: process.env.ORY_OAUTH2_CLIENT_SECRET, + authorization: { + params: { + scope: 'openid offline_access email profile', + ...(oryOAuth2Audience ? { audience: oryOAuth2Audience } : {}), + }, + }, + checks: ['state'], + }), + ], + callbacks: { + jwt: ({ token, account, profile }) => + resolveOryJwt({ token, account, profile }), + session: ({ session, token }) => applyTokenToSession(session, token), + }, + events: { + async signIn({ account }) { + if (!account?.access_token) return + await bootstrapOryUser({ + accessToken: account.access_token, + idToken: account.id_token, + provider: account.provider, + }) + }, + }, +}) diff --git a/src/configs/api.ts b/src/configs/api.ts index 226386aef..05a643404 100644 --- a/src/configs/api.ts +++ b/src/configs/api.ts @@ -1,14 +1,42 @@ +import { isOryAuthEnabled } from './flags' + export const API_KEY_PREFIX = 'e2b_' export const ACCESS_TOKEN_PREFIX = 'sk_e2b_' export const SUPABASE_TOKEN_HEADER = 'X-Supabase-Token' export const SUPABASE_TEAM_HEADER = 'X-Supabase-Team' +export const AUTH_PROVIDER_TEAM_HEADER = 'X-Team-ID' export const ENVD_ACCESS_TOKEN_HEADER = 'X-Access-Token' export const ADMIN_TOKEN_HEADER = 'X-Admin-Token' -export const SUPABASE_AUTH_HEADERS = (token: string, teamId?: string) => ({ - [SUPABASE_TOKEN_HEADER]: token, - ...(teamId && { [SUPABASE_TEAM_HEADER]: teamId }), -}) +type AuthHeaderStrategy = { + tokenHeader: string + tokenPrefix: string + teamHeader: string +} + +const oryHeaderStrategy: AuthHeaderStrategy = { + tokenHeader: 'Authorization', + tokenPrefix: 'Bearer ', + teamHeader: AUTH_PROVIDER_TEAM_HEADER, +} + +const supabaseHeaderStrategy: AuthHeaderStrategy = { + tokenHeader: SUPABASE_TOKEN_HEADER, + tokenPrefix: '', + teamHeader: SUPABASE_TEAM_HEADER, +} + +export function authHeaders( + token: string, + teamId?: string +): Record { + const s = isOryAuthEnabled() ? oryHeaderStrategy : supabaseHeaderStrategy + const headers: Record = { + [s.tokenHeader]: `${s.tokenPrefix}${token}`, + } + if (teamId) headers[s.teamHeader] = teamId + return headers +} export const ADMIN_AUTH_HEADERS = (token: string) => ({ [ADMIN_TOKEN_HEADER]: token, diff --git a/src/configs/flags.ts b/src/configs/flags.ts index 66a0b604f..45ee382b2 100644 --- a/src/configs/flags.ts +++ b/src/configs/flags.ts @@ -29,3 +29,9 @@ export const CAPTCHA_REQUIRED_SERVER = export function isOryAuthEnabled() { return process.env.AUTH_PROVIDER === 'ory' } + +// Freezes new identity creation while we migrate identity stores. +// When on: blocks new sign-ups (email/password + freshly-registered OIDC +// identities). Existing users keep signing in normally. +export const AUTH_MIGRATION_IN_PROGRESS = + process.env.NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS === '1' diff --git a/src/core/application/user/queries.ts b/src/core/application/user/queries.ts new file mode 100644 index 000000000..eaa1419d2 --- /dev/null +++ b/src/core/application/user/queries.ts @@ -0,0 +1,10 @@ +// Mirrors DASHBOARD_TEAMS_LIST_QUERY_OPTIONS: the profile is prefetched once in +// the dashboard layout and treated as fresh on the client, so it isn't refetched +// on every mount/focus. Cache updates after account mutations come from explicit +// setQueryData calls in the account-settings forms. +export const DASHBOARD_USER_PROFILE_QUERY_OPTIONS = { + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, +} as const diff --git a/src/core/modules/billing/repository.server.ts b/src/core/modules/billing/repository.server.ts index 4cc79c1e3..8d2561bb1 100644 --- a/src/core/modules/billing/repository.server.ts +++ b/src/core/modules/billing/repository.server.ts @@ -1,7 +1,7 @@ import 'server-only' import { z } from 'zod' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import type { AddOnOrderConfirmResponse, AddOnOrderCreateResponse, @@ -68,7 +68,7 @@ export function createBillingRepository( method: 'POST', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, body: JSON.stringify({ teamID: scope.teamId, @@ -93,7 +93,7 @@ export function createBillingRepository( headers: { 'Content-Type': 'application/json', ...(origin ? { Origin: origin } : {}), - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, }) @@ -110,7 +110,7 @@ export function createBillingRepository( method: 'GET', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -128,7 +128,7 @@ export function createBillingRepository( method: 'GET', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -151,7 +151,7 @@ export function createBillingRepository( `${deps.billingApiUrl}/teams/${scope.teamId}/invoices`, { headers: { - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -169,7 +169,7 @@ export function createBillingRepository( method: 'GET', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -191,7 +191,7 @@ export function createBillingRepository( method: 'PATCH', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, body: JSON.stringify({ [key]: value, @@ -212,7 +212,7 @@ export function createBillingRepository( method: 'DELETE', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -230,7 +230,7 @@ export function createBillingRepository( method: 'POST', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, body: JSON.stringify({ items: [{ name: itemId, quantity: 1 }], @@ -251,7 +251,7 @@ export function createBillingRepository( method: 'POST', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -269,7 +269,7 @@ export function createBillingRepository( method: 'POST', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -287,7 +287,7 @@ export function createBillingRepository( method: 'POST', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -315,7 +315,7 @@ export function createBillingRepository( method: 'POST', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) diff --git a/src/core/modules/builds/repository.server.ts b/src/core/modules/builds/repository.server.ts index 6148dc772..1cfaab6d9 100644 --- a/src/core/modules/builds/repository.server.ts +++ b/src/core/modules/builds/repository.server.ts @@ -1,6 +1,6 @@ import 'server-only' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import type { components as InfraComponents } from '@/contracts/infra-api' import { INITIAL_BUILD_STATUSES } from '@/core/modules/builds/constants' import type { @@ -17,7 +17,7 @@ import { err, ok, type RepoResult } from '@/core/shared/result' type BuildsRepositoryDeps = { apiClient: typeof api infraClient: typeof infra - authHeaders: typeof SUPABASE_AUTH_HEADERS + authHeaders: typeof authHeaders } export type BuildsScope = TeamRequestScope @@ -86,7 +86,7 @@ export function createBuildsRepository( deps: BuildsRepositoryDeps = { apiClient: api, infraClient: infra, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, } ): BuildsRepository { return { diff --git a/src/core/modules/keys/repository.server.ts b/src/core/modules/keys/repository.server.ts index a0f24f510..0d408e4f4 100644 --- a/src/core/modules/keys/repository.server.ts +++ b/src/core/modules/keys/repository.server.ts @@ -1,6 +1,6 @@ import 'server-only' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import type { CreatedTeamAPIKey, TeamAPIKey } from '@/core/modules/keys/models' import { type AuthUserEmailResolver, @@ -14,7 +14,7 @@ import { err, ok, type RepoResult } from '@/core/shared/result' type KeysRepositoryDeps = { infraClient: typeof infra - authHeaders: typeof SUPABASE_AUTH_HEADERS + authHeaders: typeof authHeaders resolveAuthUserEmailsById: AuthUserEmailResolver } @@ -30,7 +30,7 @@ export function createKeysRepository( scope: KeysScope, deps: KeysRepositoryDeps = { infraClient: infra, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, resolveAuthUserEmailsById: getAuthUserEmailsById, } ): KeysRepository { diff --git a/src/core/modules/sandboxes/repository.server.ts b/src/core/modules/sandboxes/repository.server.ts index 2f4ad1aba..0d69d4e34 100644 --- a/src/core/modules/sandboxes/repository.server.ts +++ b/src/core/modules/sandboxes/repository.server.ts @@ -1,6 +1,6 @@ import 'server-only' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import type { components as DashboardComponents } from '@/contracts/dashboard-api' import type { components as InfraComponents } from '@/contracts/infra-api' import type { @@ -18,7 +18,7 @@ import { err, ok, type RepoResult } from '@/core/shared/result' type SandboxesRepositoryDeps = { apiClient: typeof api infraClient: typeof infra - authHeaders: typeof SUPABASE_AUTH_HEADERS + authHeaders: typeof authHeaders } export type SandboxesRequestScope = TeamRequestScope @@ -86,7 +86,7 @@ export function createSandboxesRepository( deps: SandboxesRepositoryDeps = { apiClient: api, infraClient: infra, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, } ): SandboxesRepository { return { diff --git a/src/core/modules/teams/teams-repository.server.ts b/src/core/modules/teams/teams-repository.server.ts index 1e5524791..b8286248e 100644 --- a/src/core/modules/teams/teams-repository.server.ts +++ b/src/core/modules/teams/teams-repository.server.ts @@ -1,9 +1,7 @@ import 'server-only' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import type { components as DashboardComponents } from '@/contracts/dashboard-api' -import type { AuthAdmin } from '@/core/server/auth' -import { authAdmin } from '@/core/server/auth' import { api } from '@/core/shared/clients/api' import { createRepoError, repoErrorFromHttp } from '@/core/shared/errors' import type { RequestScope } from '@/core/shared/repository-scope' @@ -12,8 +10,7 @@ import type { TeamMember } from './models' type TeamsRepositoryDeps = { apiClient: typeof api - authHeaders: typeof SUPABASE_AUTH_HEADERS - authAdmin: Pick + authHeaders: typeof authHeaders } export type TeamsRequestScope = RequestScope & { @@ -50,8 +47,7 @@ export function createTeamsRepository( scope: TeamsRequestScope, deps: TeamsRepositoryDeps = { apiClient: api, - authHeaders: SUPABASE_AUTH_HEADERS, - authAdmin, + authHeaders: authHeaders, } ): TeamsRepository { return { @@ -79,29 +75,22 @@ export function createTeamsRepository( ) } - const members = data?.members ?? [] - const enrichedMembers = await Promise.all( - members.map(async (member) => { - const user = await deps.authAdmin.getUserById(member.id) - - return { - info: { - id: member.id, - email: member.email, - name: user?.name ?? undefined, - avatar_url: user?.avatarUrl ?? undefined, - providers: user?.providers ?? [], - createdAt: member.createdAt, - }, - relation: { - added_by: member.addedBy ?? null, - is_default: member.isDefault, - }, - } satisfies TeamMember - }) - ) - - return ok(enrichedMembers) + const mapped: TeamMember[] = (data?.members ?? []).map((member) => ({ + info: { + id: member.id, + email: member.email, + name: member.name ?? undefined, + avatar_url: member.profilePictureUrl ?? undefined, + providers: member.providers ?? [], + createdAt: member.createdAt, + }, + relation: { + added_by: member.addedBy ?? null, + is_default: member.isDefault, + }, + })) + + return ok(mapped) }, async updateTeamName( name diff --git a/src/core/modules/teams/user-teams-repository.server.ts b/src/core/modules/teams/user-teams-repository.server.ts index de701eabf..e5776104c 100644 --- a/src/core/modules/teams/user-teams-repository.server.ts +++ b/src/core/modules/teams/user-teams-repository.server.ts @@ -1,7 +1,7 @@ import 'server-only' import { secondsInMinute } from 'date-fns/constants' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import type { components as DashboardComponents } from '@/contracts/dashboard-api' import { api } from '@/core/shared/clients/api' import { createRepoError, repoErrorFromHttp } from '@/core/shared/errors' @@ -11,7 +11,7 @@ import type { ResolvedTeam, TeamModel } from './models' type UserTeamsRepositoryDeps = { apiClient: typeof api - authHeaders: typeof SUPABASE_AUTH_HEADERS + authHeaders: typeof authHeaders } export type UserTeamsRequestScope = RequestScope @@ -31,7 +31,7 @@ export function createUserTeamsRepository( scope: UserTeamsRequestScope, deps: UserTeamsRepositoryDeps = { apiClient: api, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, } ): UserTeamsRepository { const listApiUserTeams = async (): Promise> => { diff --git a/src/core/modules/templates/repository.server.ts b/src/core/modules/templates/repository.server.ts index 9d282af3e..680d2fbcb 100644 --- a/src/core/modules/templates/repository.server.ts +++ b/src/core/modules/templates/repository.server.ts @@ -1,6 +1,6 @@ import 'server-only' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { CACHE_TAGS } from '@/configs/cache' import { USE_MOCK_DATA } from '@/configs/flags' import { @@ -24,7 +24,7 @@ import { err, ok, type RepoResult } from '@/core/shared/result' type TemplatesRepositoryDeps = { apiClient: typeof api infraClient: typeof infra - authHeaders: typeof SUPABASE_AUTH_HEADERS + authHeaders: typeof authHeaders resolveAuthUserEmailsById: AuthUserEmailResolver } @@ -48,7 +48,7 @@ export function createTemplatesRepository( deps: TemplatesRepositoryDeps = { apiClient: api, infraClient: infra, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, resolveAuthUserEmailsById: getAuthUserEmailsById, } ): TeamTemplatesRepository { @@ -145,7 +145,7 @@ export function createDefaultTemplatesRepository( scope: RequestScope, deps: Pick = { apiClient: api, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, } ): DefaultTemplatesRepository { return { diff --git a/src/core/modules/webhooks/repository.server.ts b/src/core/modules/webhooks/repository.server.ts index f7188d7df..04c9501e9 100644 --- a/src/core/modules/webhooks/repository.server.ts +++ b/src/core/modules/webhooks/repository.server.ts @@ -1,6 +1,6 @@ import 'server-only' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { infra } from '@/core/shared/clients/api' import type { components as ArgusComponents } from '@/core/shared/contracts/argus-api.types' import { repoErrorFromHttp } from '@/core/shared/errors' @@ -9,7 +9,7 @@ import { err, ok, type RepoResult } from '@/core/shared/result' type WebhooksRepositoryDeps = { infraClient: typeof infra - authHeaders: typeof SUPABASE_AUTH_HEADERS + authHeaders: typeof authHeaders } export type WebhooksScope = TeamRequestScope @@ -40,7 +40,7 @@ export function createWebhooksRepository( scope: WebhooksScope, deps: WebhooksRepositoryDeps = { infraClient: infra, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, } ): WebhooksRepository { return { diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts index 17e3c66c0..8ec1ea95a 100644 --- a/src/core/server/actions/auth-actions.ts +++ b/src/core/server/actions/auth-actions.ts @@ -4,7 +4,10 @@ import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { returnValidationErrors } from 'next-safe-action' import { z } from 'zod' -import { CAPTCHA_REQUIRED_SERVER } from '@/configs/flags' +import { + AUTH_MIGRATION_IN_PROGRESS, + CAPTCHA_REQUIRED_SERVER, +} from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { USER_MESSAGES } from '@/configs/user-messages' import { actionClient } from '@/core/server/actions/client' @@ -177,6 +180,12 @@ export const signUpAction = actionClient async ({ parsedInput: { email, password, returnTo = '', captchaToken }, }) => { + if (AUTH_MIGRATION_IN_PROGRESS) { + return returnServerError( + 'Sign-ups are temporarily paused while we migrate our authentication system. Please try again later.' + ) + } + const captchaError = await validateCaptcha(captchaToken) if (captchaError) return captchaError @@ -356,10 +365,32 @@ export const forgotPasswordAction = actionClient }) export async function signOutAction(returnTo?: string) { - await auth.signOut() + const { redirectTo } = await auth.signOut({ returnTo }) - throw redirect( - AUTH_URLS.SIGN_IN + - (returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '') - ) + throw redirect(redirectTo) +} + +// Drives the account-settings re-authentication step and returns the URL the +// client should HARD-navigate to. Supabase signs the user out and bounces +// through /sign-in (which lands back on the account page with ?reauth=1); Ory +// forces a fresh OAuth2 login via the oauth-start route. +// +// We deliberately return the URL instead of redirect()-ing: a server-action +// redirect is a soft RSC navigation, which prefetches and re-invokes the +// oauth-start GET (a side-effecting endpoint that mints OAuth state/pkce/ +// callback-url cookies). Those duplicate invocations corrupt the cookies so the +// post-reauth callback loses its callbackUrl and falls back to "/". A single +// window.location navigation on the client avoids that entirely. +export async function reauthForAccountSettingsAction(): Promise<{ + url: string +}> { + const dispatch = await auth.startReauthForAccountSettings() + + if (dispatch.kind === 'sign-out') { + // Supabase: clear the session server-side, then hand back the sign-in URL. + const { redirectTo } = await auth.signOut({ returnTo: dispatch.returnTo }) + return { url: redirectTo } + } + + return { url: dispatch.to } } diff --git a/src/core/server/actions/sandbox-actions.ts b/src/core/server/actions/sandbox-actions.ts index b0c35cb27..e41e569c9 100644 --- a/src/core/server/actions/sandbox-actions.ts +++ b/src/core/server/actions/sandbox-actions.ts @@ -2,7 +2,7 @@ import { updateTag } from 'next/cache' import { z } from 'zod' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { CACHE_TAGS } from '@/configs/cache' import { authActionClient, @@ -28,7 +28,7 @@ export const killSandboxAction = authActionClient const res = await infra.DELETE('/sandboxes/{sandboxID}', { headers: { - ...SUPABASE_AUTH_HEADERS(session.access_token, teamId), + ...authHeaders(session.access_token, teamId), }, params: { path: { diff --git a/src/core/server/actions/user-actions.ts b/src/core/server/actions/user-actions.ts deleted file mode 100644 index 78a97eb7b..000000000 --- a/src/core/server/actions/user-actions.ts +++ /dev/null @@ -1,140 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { headers } from 'next/headers' -import { returnValidationErrors } from 'next-safe-action' -import { z } from 'zod' -import { authActionClient } from '@/core/server/actions/client' -import { auth } from '@/core/server/auth' -import { supabaseAuthFlows } from '@/core/server/auth/supabase/flows' -import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -import { generateE2BUserAccessToken } from '@/lib/utils/server' - -const UpdateUserSchema = z - .object({ - email: z.email().optional(), - password: z.string().min(8).optional(), - name: z.string().min(1).max(100).optional(), - }) - .refine( - (data) => { - return Boolean(data.email || data.password || data.name) - }, - { - message: 'At least one field must be provided (email, password, name)', - path: [], - } - ) - -export type UpdateUserSchemaType = z.infer - -export const updateUserAction = authActionClient - .schema(UpdateUserSchema) - .metadata({ actionName: 'updateUser' }) - .action(async ({ parsedInput, ctx }) => { - const { user } = ctx - - // basic security check, that password does not equal e-mail - if (parsedInput.password) { - const passwordAsUserEmail = - parsedInput.password.toLowerCase() === user?.email?.toLowerCase() - const passwordAsEmail = - parsedInput.password.toLowerCase() === parsedInput.email?.toLowerCase() - - if (passwordAsUserEmail || passwordAsEmail) { - return returnValidationErrors(UpdateUserSchema, { - password: { - _errors: ['Password is too weak.'], - }, - }) - } - } - - const origin = (await headers()).get('origin') - - let emailRedirectTo: string | undefined - - if (parsedInput.email) { - if (!origin) { - throw new Error('Missing origin header for email update redirect') - } - - const redirectUrl = new URL('/api/auth/email-callback', origin) - redirectUrl.searchParams.set('new_email', parsedInput.email) - emailRedirectTo = redirectUrl.toString() - } - - const { data: updateData, error } = await supabaseAuthFlows.updateUser({ - email: parsedInput.email, - password: parsedInput.password, - name: parsedInput.name, - emailRedirectTo, - }) - - if (!error) { - // ensure other sessions are logged out if password was changed - if (parsedInput.password) { - const { error: signOutError } = await auth.signOut({ scope: 'others' }) - - if (signOutError) { - l.error( - { - key: 'update_user_action:sign_out_others_failed', - user_id: user.id, - error: serializeErrorForLog(signOutError), - }, - 'failed to invalidate other sessions after password change' - ) - } - } - - revalidatePath('/dashboard', 'layout') - - return { - user: updateData.user, - } - } - - switch (error?.code) { - case 'email_address_invalid': - return returnValidationErrors(UpdateUserSchema, { - email: { - _errors: ['Invalid e-mail address.'], - }, - }) - case 'email_exists': - return returnValidationErrors(UpdateUserSchema, { - email: { - _errors: ['E-mail already in use.'], - }, - }) - case 'same_password': - return returnValidationErrors(UpdateUserSchema, { - password: { - _errors: ['New password cannot be the same as the old password.'], - }, - }) - case 'weak_password': - return returnValidationErrors(UpdateUserSchema, { - password: { - _errors: ['Password is too weak.'], - }, - }) - case 'reauthentication_needed': - return { - requiresReauth: true, - } - default: - throw error - } - }) - -export const getUserAccessTokenAction = authActionClient - .metadata({ actionName: 'getUserAccessToken' }) - .action(async ({ ctx }) => { - const { session } = ctx - - const token = await generateE2BUserAccessToken(session.access_token) - - return token - }) diff --git a/src/core/server/api/routers/index.ts b/src/core/server/api/routers/index.ts index 530503282..eb8ad34a8 100644 --- a/src/core/server/api/routers/index.ts +++ b/src/core/server/api/routers/index.ts @@ -6,6 +6,7 @@ import { sandboxesRouter } from './sandboxes' import { supportRouter } from './support' import { teamsRouter } from './teams' import { templatesRouter } from './templates' +import { userRouter } from './user' export const trpcAppRouter = createTRPCRouter({ sandbox: sandboxRouter, @@ -15,6 +16,7 @@ export const trpcAppRouter = createTRPCRouter({ billing: billingRouter, support: supportRouter, teams: teamsRouter, + user: userRouter, }) export type TRPCAppRouter = typeof trpcAppRouter diff --git a/src/core/server/api/routers/user.ts b/src/core/server/api/routers/user.ts new file mode 100644 index 000000000..c6716dd20 --- /dev/null +++ b/src/core/server/api/routers/user.ts @@ -0,0 +1,144 @@ +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import { isOryAuthEnabled } from '@/configs/flags' +import type { AuthUser } from '@/core/server/auth' +import { createAuthForHeaders } from '@/core/server/auth' +import { createTRPCRouter } from '@/core/server/trpc/init' +import { protectedProcedure } from '@/core/server/trpc/procedures' +import { l } from '@/core/shared/clients/logger/logger' +import { generateE2BUserAccessToken } from '@/lib/utils/server' + +// How long the live identity-provider profile lookup is allowed to take before +// we fall back to the cheap session user. Keeps a slow Ory admin API out of the +// critical render path for every dashboard page. +const PROFILE_LOOKUP_TIMEOUT_MS = 3000 + +const UpdateUserSchema = z + .object({ + email: z.email().optional(), + password: z.string().min(8).optional(), + name: z.string().min(1).max(100).optional(), + }) + .refine((data) => Boolean(data.email || data.password || data.name), { + message: 'At least one field must be provided (email, password, name)', + path: [], + }) + +const TIMEOUT = Symbol('profile-lookup-timeout') + +function withTimeout( + promise: Promise, + ms: number +): Promise { + return Promise.race([ + promise, + new Promise((resolve) => { + setTimeout(() => resolve(TIMEOUT), ms) + }), + ]) +} + +export const userRouter = createTRPCRouter({ + // Live profile (full traits + credential-derived providers). Prefetched once + // per dashboard load and injected into DashboardContext. The lookup is raced + // against a timeout and falls back to the cheap session user so the dashboard + // never hangs on the identity provider. + profile: protectedProcedure.query(async ({ ctx }): Promise => { + const provider = createAuthForHeaders(ctx.headers) + + const result = await withTimeout( + provider.getUserProfile().catch(() => null), + PROFILE_LOOKUP_TIMEOUT_MS + ) + + if (result && result !== TIMEOUT) { + return result + } + + l.error( + { + key: 'trpc_user_profile:fallback', + user_id: ctx.user.id, + context: { timed_out: result === TIMEOUT }, + }, + 'user profile lookup failed or timed out; falling back to session user' + ) + + return ctx.user + }), + + update: protectedProcedure + .input(UpdateUserSchema) + .mutation(async ({ ctx, input }) => { + if (input.email !== undefined && isOryAuthEnabled()) { + return { + status: 'error' as const, + code: 'account_credentials_not_changeable' as const, + } + } + + // Basic security check: a password must not equal the account email + // (current or the new one being set in the same request). + if (input.password) { + const password = input.password.toLowerCase() + const matchesCurrentEmail = password === ctx.user.email?.toLowerCase() + const matchesNewEmail = + input.email !== undefined && password === input.email.toLowerCase() + + if (matchesCurrentEmail || matchesNewEmail) { + return { status: 'error' as const, code: 'weak_password' as const } + } + } + + const provider = createAuthForHeaders(ctx.headers) + + if (input.email !== undefined || input.password !== undefined) { + const profile = await provider.getUserProfile() + + if ( + !profile || + (input.email !== undefined && !profile.canChangeEmail) || + (input.password !== undefined && !profile.canChangePassword) + ) { + return { + status: 'error' as const, + code: 'account_credentials_not_changeable' as const, + } + } + } + + const result = await provider.updateUser({ + email: input.email, + password: input.password, + name: input.name, + }) + + if (result.ok) { + // Invalidate other sessions when the password changed. + if (input.password) { + await provider.signOutOtherSessions() + } + + return { status: 'ok' as const, user: result.user } + } + + if (result.code === 'reauthentication_needed') { + return { status: 'reauth' as const } + } + + return { status: 'error' as const, code: result.code } + }), + + // Creates (POSTs) a fresh E2B access token — non-idempotent, fired on demand. + createAccessToken: protectedProcedure.mutation(async ({ ctx }) => { + try { + return await generateE2BUserAccessToken(ctx.session.access_token) + } catch (error) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to generate access token', + cause: error, + }) + } + }), +}) diff --git a/src/core/server/auth/index.ts b/src/core/server/auth/index.ts index 569e821be..d24c06bbc 100644 --- a/src/core/server/auth/index.ts +++ b/src/core/server/auth/index.ts @@ -4,11 +4,7 @@ import type { NextRequest, NextResponse } from 'next/server' import { isOryAuthEnabled } from '@/configs/flags' import type { AuthAdmin } from './admin' import { oryAuthAdmin } from './ory/admin' -import { - createOryAuthForHeaders, - createOryAuthForProxy, - OryHostedAuthProvider, -} from './ory/provider' +import { oryAuthProvider } from './ory/provider' import type { AuthProvider } from './provider' import { supabaseAuthAdmin } from './supabase/admin' import { @@ -18,7 +14,7 @@ import { } from './supabase/provider' export const auth: AuthProvider = isOryAuthEnabled() - ? new OryHostedAuthProvider() + ? oryAuthProvider : new SupabaseAuthProvider() export const authAdmin: AuthAdmin = isOryAuthEnabled() @@ -30,13 +26,13 @@ export function createAuthForProxy( response: NextResponse ): AuthProvider { return isOryAuthEnabled() - ? createOryAuthForProxy(request, response) + ? oryAuthProvider : createSupabaseAuthForProxy(request, response) } export function createAuthForHeaders(headers: Headers): AuthProvider { return isOryAuthEnabled() - ? createOryAuthForHeaders(headers) + ? oryAuthProvider : createSupabaseAuthForHeaders(headers) } diff --git a/src/core/server/auth/ory/admin.ts b/src/core/server/auth/ory/admin.ts index 5c420edf4..8c9c86231 100644 --- a/src/core/server/auth/ory/admin.ts +++ b/src/core/server/auth/ory/admin.ts @@ -1,14 +1,74 @@ import 'server-only' +import { ResponseError } from '@ory/client-fetch' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import type { AuthAdmin } from '../admin' +import { getOryIdentityApi } from './client' +import { fromOryIdentity } from './identity' + +const ORY_LIST_IDENTITIES_MAX_PAGE_SIZE = 1000 export const oryAuthAdmin: AuthAdmin = { - // fail-closed: callers treat null as unauthenticated / missing - getUserById(_userId) { - return Promise.resolve(null) + async getUserById(userId) { + try { + const identity = await getOryIdentityApi().getIdentity({ id: userId }) + return fromOryIdentity(identity) + } catch (error) { + if (error instanceof ResponseError && error.response.status === 404) { + return null + } + l.error( + { + key: 'auth_admin:ory_get_user_by_id:error', + user_id: userId, + error: serializeErrorForLog(error), + }, + 'oryAuthAdmin.getUserById failed' + ) + return null + } }, - getEmailsByIds(_userIds) { - return Promise.resolve(new Map()) + async getEmailsByIds(userIds) { + const uniqueIds = [...new Set(userIds.filter(Boolean))] + if (uniqueIds.length === 0) { + return new Map() + } + + try { + const result = new Map() + + for ( + let start = 0; + start < uniqueIds.length; + start += ORY_LIST_IDENTITIES_MAX_PAGE_SIZE + ) { + const ids = uniqueIds.slice( + start, + start + ORY_LIST_IDENTITIES_MAX_PAGE_SIZE + ) + const identities = await getOryIdentityApi().listIdentities({ + ids, + pageSize: ids.length, + }) + + for (const identity of identities) { + const { email } = fromOryIdentity(identity) + result.set(identity.id, email) + } + } + + return result + } catch (error) { + l.error( + { + key: 'auth_admin:ory_get_emails_by_ids:error', + context: { count: uniqueIds.length }, + error: serializeErrorForLog(error), + }, + 'oryAuthAdmin.getEmailsByIds failed' + ) + return new Map() + } }, } diff --git a/src/core/server/auth/ory/auth-callbacks.ts b/src/core/server/auth/ory/auth-callbacks.ts new file mode 100644 index 000000000..9398253a7 --- /dev/null +++ b/src/core/server/auth/ory/auth-callbacks.ts @@ -0,0 +1,135 @@ +import 'server-only' + +import type { Account, Profile, Session } from 'next-auth' +import type { JWT } from 'next-auth/jwt' +import { resolveOryIdentity } from './find-identity' +import { decodeJwtClaims, readStringClaim } from './jwt-claims' +import { refreshOryToken } from './refresh-token' + +// Refresh the access token slightly before it actually expires so we never hand +// a token that dies mid-request to downstream APIs. +const ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 60 + +// Implements the Auth.js `jwt` callback: mint the token on fresh sign-in, +// otherwise refresh it as it nears expiry. +export async function resolveOryJwt(params: { + token: JWT + account?: Account | null + profile?: Profile +}): Promise { + const { token, account, profile } = params + + if (account) { + return buildSignInToken(token, account, profile) + } + + // Once a refresh has failed we stop retrying. The dead token (cleared + // access/refresh) propagates to the session, oryAuthProvider returns null, + // and the proxy redirects to /sign-in. + if (token.error) { + return token + } + + if (isAccessTokenExpiring(token)) { + return refreshOryToken(token) + } + + return token +} + +// Implements the Auth.js `session` callback: project the persisted token fields +// onto the session the rest of the app reads. +export function applyTokenToSession(session: Session, token: JWT): Session { + session.user.id = token.sub ?? session.user.id + session.accessToken = token.accessToken + session.idToken = token.idToken + session.identityId = token.identityId + session.error = token.error + return session +} + +// Persist the Ory tokens on a fresh sign-in and cache the resolved Kratos +// identity id. Clears any RefreshTokenError carried over from a previously +// poisoned cookie so the new session starts clean. +async function buildSignInToken( + token: JWT, + account: Account, + profile?: Profile +): Promise { + return { + ...token, + accessToken: account.access_token, + refreshToken: account.refresh_token, + idToken: account.id_token, + expiresAt: account.expires_at ?? null, + identityId: await resolveKratosIdentityId(token, account, profile), + error: undefined, + } +} + +// The Kratos identity id is NOT the OIDC subject the dashboard uses as the E2B +// user id (`token.sub`, consumed by dashboard-api and infra). It is surfaced via +// the OIDC profile `sub`. Resolve it once at sign-in — by profile.sub, then +// token.sub, then the verified email — so account operations can use a stable +// Kratos id without a per-request lookup. Returns undefined on failure; the +// provider then falls back to a per-request lookup, so sign-in is never blocked. +async function resolveKratosIdentityId( + token: JWT, + account: Account, + profile?: Profile +): Promise { + const profileSub = typeof profile?.sub === 'string' ? profile.sub : undefined + + const identity = await resolveOryIdentity({ + subjects: [profileSub, token.sub], + email: readEmailClaim(account), + }) + + return identity?.id +} + +function readEmailClaim(account: Account): string | undefined { + for (const jwt of [account.id_token, account.access_token]) { + if (typeof jwt !== 'string') continue + const email = readStringClaim(decodeJwtClaims(jwt), 'email') + if (email) return email + } + return undefined +} + +function isAccessTokenExpiring( + token: JWT, + nowSeconds: number = Math.floor(Date.now() / 1000) +): boolean { + if (token.expiresAt == null) return false + return nowSeconds > token.expiresAt - ACCESS_TOKEN_REFRESH_SKEW_SECONDS +} + +declare module 'next-auth' { + interface Session { + accessToken?: string + idToken?: string + // Kratos identity id, resolved from the OIDC subject at sign-in. Differs + // from user.id (the OIDC subject / E2B user id) when the project customizes + // the OAuth2 subject. + identityId?: string + error?: string + user: { + id: string + email?: string | null + name?: string | null + image?: string | null + } + } +} + +declare module 'next-auth/jwt' { + interface JWT { + accessToken?: string + refreshToken?: string + idToken?: string + identityId?: string + expiresAt?: number | null + error?: string + } +} diff --git a/src/core/server/auth/ory/auth-route-redirect.ts b/src/core/server/auth/ory/auth-route-redirect.ts new file mode 100644 index 000000000..6e98b22b1 --- /dev/null +++ b/src/core/server/auth/ory/auth-route-redirect.ts @@ -0,0 +1,24 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { buildOryStartURL, type OryAuthIntent } from './build-start-url' + +// Map each legacy auth page to the intent we want the Ory hosted UI to +// open with. Done at the middleware layer so the (auth) layout never +// renders in Ory mode - otherwise the user briefly sees the auth shell +// before the page-level redirect kicks in. +const INTENT_BY_PATH: Record = { + '/sign-in': 'signin', + '/sign-up': 'signup', + '/forgot-password': 'signin', +} + +export function getOryAuthRouteRedirect( + request: NextRequest +): NextResponse | null { + const intent = INTENT_BY_PATH[request.nextUrl.pathname] + if (!intent) return null + + const returnTo = request.nextUrl.searchParams.get('returnTo') ?? undefined + const target = new URL(buildOryStartURL(intent, returnTo), request.url) + + return NextResponse.redirect(target) +} diff --git a/src/core/server/auth/ory/bootstrap.ts b/src/core/server/auth/ory/bootstrap.ts new file mode 100644 index 000000000..21975b8e2 --- /dev/null +++ b/src/core/server/auth/ory/bootstrap.ts @@ -0,0 +1,129 @@ +import 'server-only' + +import { ADMIN_AUTH_HEADERS } from '@/configs/api' +import { api } from '@/core/shared/clients/api' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { repoErrorFromHttp } from '@/core/shared/errors' +import { decodeJwtClaims, readStringClaim, tokenFormat } from './jwt-claims' + +type BootstrapOryUserInput = { + accessToken: string + idToken?: string + provider?: string +} + +type OryTokenClaims = { + iss?: unknown + sub?: unknown + email?: unknown + name?: unknown + given_name?: unknown + preferred_username?: unknown +} + +export async function bootstrapOryUser( + input: BootstrapOryUserInput +): Promise { + try { + const accessClaims = decodeJwtClaims(input.accessToken) + const idClaims = input.idToken + ? decodeJwtClaims(input.idToken) + : null + const oidcIssuer = + readStringClaim(accessClaims, 'iss') ?? readStringClaim(idClaims, 'iss') + const oidcUserId = readStringClaim(accessClaims, 'sub') + const oidcUserEmail = + readStringClaim(accessClaims, 'email') ?? + readStringClaim(idClaims, 'email') + const oidcUserName = + readDisplayName(accessClaims) ?? readDisplayName(idClaims) + + if (!oidcIssuer || !oidcUserId || !oidcUserEmail) { + l.error( + { + key: 'auth_events:bootstrap_user:missing_claims', + context: { + provider: input.provider, + access_token_format: tokenFormat(input.accessToken), + id_token_format: input.idToken + ? tokenFormat(input.idToken) + : 'missing', + has_access_claims: !!accessClaims, + has_id_claims: !!idClaims, + has_iss: !!oidcIssuer, + has_sub: !!oidcUserId, + has_email: !!oidcUserEmail, + has_name: !!oidcUserName, + }, + }, + 'Ory access token is missing required bootstrap claims' + ) + return + } + + const adminToken = process.env.DASHBOARD_API_ADMIN_TOKEN + if (!adminToken) { + l.error( + { + key: 'auth_events:bootstrap_user:missing_admin_token', + context: { provider: input.provider }, + }, + 'DASHBOARD_API_ADMIN_TOKEN is not configured' + ) + return + } + + const body = { + oidc_issuer: oidcIssuer, + oidc_user_id: oidcUserId, + oidc_user_email: oidcUserEmail, + oidc_user_name: oidcUserName, + } + + const { error, response } = await api.POST('/admin/users/bootstrap', { + body, + headers: ADMIN_AUTH_HEADERS(adminToken), + }) + + if (!response.ok || error) { + const repoError = repoErrorFromHttp( + response.status, + error?.message ?? 'Failed to bootstrap user', + error + ) + l.error( + { + key: 'auth_events:bootstrap_user:error', + context: { + provider: input.provider, + error_status: response.status, + has_oidc_issuer: body.oidc_issuer !== '', + has_oidc_user_id: body.oidc_user_id !== '', + has_oidc_user_email: body.oidc_user_email !== '', + has_oidc_user_name: body.oidc_user_name !== null, + }, + }, + `bootstrap_user failed: ${repoError.message}` + ) + } + } catch (error) { + l.error( + { + key: 'auth_events:bootstrap_user:exception', + context: { + provider: input.provider, + }, + error: serializeErrorForLog(error), + }, + 'bootstrap_user threw unexpected exception' + ) + } +} + +function readDisplayName(claims: OryTokenClaims | null): string | null { + return ( + readStringClaim(claims, 'name') ?? + readStringClaim(claims, 'given_name') ?? + readStringClaim(claims, 'preferred_username') + ) +} diff --git a/src/core/server/auth/ory/build-start-url.ts b/src/core/server/auth/ory/build-start-url.ts new file mode 100644 index 000000000..ffc559ab5 --- /dev/null +++ b/src/core/server/auth/ory/build-start-url.ts @@ -0,0 +1,14 @@ +export type OryAuthIntent = 'signin' | 'signup' | 'reauth' + +const ORY_START_PATH = '/api/auth/oauth-start' + +export function buildOryStartURL( + intent: OryAuthIntent, + returnTo?: string +): string { + const params = new URLSearchParams({ intent }) + if (returnTo && returnTo.length > 0) { + params.set('returnTo', returnTo) + } + return `${ORY_START_PATH}?${params}` +} diff --git a/src/core/server/auth/ory/client.ts b/src/core/server/auth/ory/client.ts new file mode 100644 index 000000000..2ed25a79f --- /dev/null +++ b/src/core/server/auth/ory/client.ts @@ -0,0 +1,29 @@ +import 'server-only' + +import { Configuration, IdentityApi } from '@ory/client-fetch' + +let cached: IdentityApi | null = null + +// the IdentityApi requires the Ory project admin token (PAT). callers should +// ensure ORY_PROJECT_API_TOKEN is set at deploy time when AUTH_PROVIDER=ory. +export function getOryIdentityApi(): IdentityApi { + if (cached) return cached + + const basePath = process.env.ORY_SDK_URL + const accessToken = process.env.ORY_PROJECT_API_TOKEN + + if (!basePath) { + throw new Error('ORY_SDK_URL is not configured') + } + if (!accessToken) { + throw new Error('ORY_PROJECT_API_TOKEN is not configured') + } + + cached = new IdentityApi( + new Configuration({ + basePath: basePath.replace(/\/$/, ''), + accessToken, + }) + ) + return cached +} diff --git a/src/core/server/auth/ory/find-identity.ts b/src/core/server/auth/ory/find-identity.ts new file mode 100644 index 000000000..e89cfcabb --- /dev/null +++ b/src/core/server/auth/ory/find-identity.ts @@ -0,0 +1,168 @@ +import 'server-only' + +import { + type GetIdentityByExternalIDIncludeCredentialEnum, + type GetIdentityIncludeCredentialEnum, + type Identity, + ResponseError, +} from '@ory/client-fetch' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { getOryIdentityApi } from './client' +import { readOryError } from './ory-error' + +// Resolving the Kratos identity for the logged-in user is not a simple +// "getIdentity(sub)" because the OIDC subject the dashboard sees is not +// guaranteed to be the Kratos identity id: +// - In a vanilla Ory setup the OAuth2 subject IS the Kratos identity id. +// - Projects that customize the subject (e.g. to keep a stable app user id +// across a migration) expose the Kratos id under a *different* OIDC subject, +// or only via the id_token/userinfo profile `sub`. +// - Migrated identities may carry a legacy id as `external_id`. +// So we try every identifier we have (a list of candidate subjects, then the +// verified email) and return the first identity that resolves. + +export type ResolveOryIdentityInput = { + // Candidate subject ids, in priority order (e.g. profile.sub, then token.sub). + // Falsy entries are ignored and duplicates de-duped. + subjects?: Array + // Verified login email — the unambiguous fallback for password identities. + email?: string | null + // Optional credential config needed by callers that decide account + // capabilities. Leave unset on hot paths that only need the identity id. + includeCredential?: OryIdentityCredentialInclude[] +} + +export type OryIdentityCredentialInclude = GetIdentityIncludeCredentialEnum & + GetIdentityByExternalIDIncludeCredentialEnum + +export async function resolveOryIdentity( + input: ResolveOryIdentityInput +): Promise { + const subjects = [ + ...new Set( + (input.subjects ?? []).filter( + (subject): subject is string => typeof subject === 'string' && !!subject + ) + ), + ] + + for (const subject of subjects) { + const identity = await findOryIdentityBySubject( + subject, + input.includeCredential + ) + if (identity) return identity + } + + if (input.email) { + const identity = await findOryIdentityByEmail( + input.email, + input.includeCredential + ) + if (identity) return identity + } + + l.error( + { + key: 'auth_provider:resolve_identity:not_found', + context: { + attempted_subjects: subjects, + attempted_email: input.email ?? null, + // The project we queried — a mismatch with the token issuer points to a + // misconfigured admin client (wrong Ory project). + ory_sdk_url: process.env.ORY_SDK_URL ?? null, + }, + }, + 'no Kratos identity found by subject(s) or email' + ) + return null +} + +// Tries a single subject as a Kratos identity id, then as an external_id. A 404 +// means "not this strategy" and falls through; any other error is unexpected, +// logged, and stops the search. The terminal "not found" belongs to +// resolveOryIdentity once every strategy is exhausted. +export async function findOryIdentityBySubject( + subject: string, + includeCredential?: OryIdentityCredentialInclude[] +): Promise { + const api = getOryIdentityApi() + + try { + return await api.getIdentity( + withIncludedCredentials({ id: subject }, includeCredential) + ) + } catch (error) { + if (!isNotFound(error)) { + await logLookupError('by_id', error) + return null + } + } + + try { + return await api.getIdentityByExternalID( + withIncludedCredentials({ externalID: subject }, includeCredential) + ) + } catch (error) { + if (!isNotFound(error)) { + await logLookupError('by_external_id', error) + } + return null + } +} + +export async function findOryIdentityByEmail( + email: string, + includeCredential?: OryIdentityCredentialInclude[] +): Promise { + try { + const identities = await getOryIdentityApi().listIdentities({ + credentialsIdentifier: email, + pageSize: 2, + ...(includeCredential ? { includeCredential } : {}), + }) + + if (identities.length === 0) return null + + // Prefer an exact email-trait match; fall back to the first result. + const exact = identities.find( + (identity) => emailTrait(identity)?.toLowerCase() === email.toLowerCase() + ) + return exact ?? identities[0] ?? null + } catch (error) { + await logLookupError('by_email', error) + return null + } +} + +function withIncludedCredentials>( + params: T, + includeCredential: OryIdentityCredentialInclude[] | undefined +): T & { includeCredential?: OryIdentityCredentialInclude[] } { + return includeCredential ? { ...params, includeCredential } : params +} + +function emailTrait(identity: Identity): string | null { + const traits = (identity.traits ?? {}) as Record + return typeof traits.email === 'string' ? traits.email : null +} + +function isNotFound(error: unknown): boolean { + return error instanceof ResponseError && error.response.status === 404 +} + +async function logLookupError( + stage: 'by_id' | 'by_external_id' | 'by_email', + error: unknown +): Promise { + const ory = error instanceof ResponseError ? await readOryError(error) : null + + l.error( + { + key: 'auth_provider:resolve_identity:error', + context: { stage, ory }, + error: serializeErrorForLog(error), + }, + `Ory identity lookup failed (${stage})` + ) +} diff --git a/src/core/server/auth/ory/flows.ts b/src/core/server/auth/ory/flows.ts new file mode 100644 index 000000000..5157f8cd6 --- /dev/null +++ b/src/core/server/auth/ory/flows.ts @@ -0,0 +1,173 @@ +import 'server-only' + +import { + type Identity, + type JsonPatch, + JsonPatchOpEnum, +} from '@ory/client-fetch' +import { l } from '@/core/shared/clients/logger/logger' +import type { UpdateUserErrorCode, UpdateUserResult } from '../types' +import { getOryIdentityApi } from './client' +import { fromOryIdentity } from './identity' +import { isOryResponseError, readOryError } from './ory-error' + +type OryUpdateUserInput = { + identityId: string + name?: string + email?: string + password?: string +} + +export const oryAuthFlows = { + async updateUser({ + identityId, + name, + email, + password, + }: OryUpdateUserInput): Promise { + try { + // A password change must go through updateIdentity (the credential import + // path) — see setPassword. Trait-only changes use the lighter patch. + const identity = + password !== undefined + ? await setPassword(identityId, { name, email, password }) + : await patchTraits(identityId, { name, email }) + + return { ok: true, user: fromOryIdentity(identity) } + } catch (error) { + return mapUpdateUserError(error, identityId) + } + }, +} + +// Kratos only hashes a cleartext password when it runs through the credential +// IMPORT pipeline (updateIdentity / createIdentity). A JSON-Patch write to +// `/credentials/password/config/password` is accepted with 200 but stored raw — +// `hashed_password` is left untouched, so the change appears to succeed while +// the OLD password keeps working and the new one never does. So we set the +// password via updateIdentity (PUT). Only the password credential is supplied, +// which Kratos hashes; existing credentials (e.g. oidc) are preserved. We +// re-send schema_id/state/traits/external_id/metadata to avoid clobbering them +// on the full update. +async function setPassword( + identityId: string, + { name, email, password }: Omit +): Promise { + const api = getOryIdentityApi() + const current = await api.getIdentity({ id: identityId }) + + return api.updateIdentity({ + id: identityId, + updateIdentityBody: { + schema_id: current.schema_id, + state: current.state ?? 'active', + traits: mergeTraits(current.traits, { name, email }), + external_id: current.external_id, + metadata_public: current.metadata_public, + metadata_admin: current.metadata_admin, + credentials: { password: { config: { password } } }, + }, + }) +} + +async function patchTraits( + identityId: string, + { name, email }: Pick +): Promise { + const api = getOryIdentityApi() + const jsonPatch = buildTraitPatches({ name, email }) + + if (jsonPatch.length === 0) { + return api.getIdentity({ id: identityId }) + } + + return api.patchIdentity({ id: identityId, jsonPatch }) +} + +function mergeTraits( + current: unknown, + { name, email }: Pick +): Record { + const traits = { ...((current as Record) ?? {}) } + if (name !== undefined) traits.name = name + if (email !== undefined) traits.email = email + return traits +} + +// Assumes a flat `name` trait. If the project's identity schema nests name as +// `{ first, last }`, these patch paths need to target those sub-paths instead. +function buildTraitPatches({ + name, + email, +}: Pick): JsonPatch[] { + const patches: JsonPatch[] = [] + + if (name !== undefined) { + patches.push({ + op: JsonPatchOpEnum.Replace, + path: '/traits/name', + value: name, + }) + } + if (email !== undefined) { + patches.push({ + op: JsonPatchOpEnum.Replace, + path: '/traits/email', + value: email, + }) + } + + return patches +} + +async function mapUpdateUserError( + error: unknown, + identityId: string +): Promise { + if (!isOryResponseError(error)) { + throw error + } + + const details = await readOryError(error) + const code = classifyUpdateError( + details.status, + details.reason, + details.message + ) + + l.error( + { + key: 'auth_provider:ory_update_user:error', + user_id: identityId, + context: { ory: details, mapped_code: code }, + }, + 'Ory identity update failed' + ) + + // Unclassified failures (5xx, unexpected 4xx) are surfaced as unexpected + // server errors rather than a misleading user-facing message. + if (!code) { + throw error + } + + return { ok: false, code, message: details.message } +} + +function classifyUpdateError( + status: number, + reason?: string, + message?: string +): UpdateUserErrorCode | null { + const haystack = `${reason ?? ''} ${message ?? ''}`.toLowerCase() + + if (status === 409) return 'email_exists' + + if (status === 400) { + if (haystack.includes('password')) return 'weak_password' + if (haystack.includes('email') || haystack.includes('valid')) { + return 'email_invalid' + } + } + + return null +} diff --git a/src/core/server/auth/ory/freshness.ts b/src/core/server/auth/ory/freshness.ts new file mode 100644 index 000000000..479053b54 --- /dev/null +++ b/src/core/server/auth/ory/freshness.ts @@ -0,0 +1,33 @@ +import { decodeJwtClaims } from './jwt-claims' + +// How recently the user must have authenticated (via the OAuth2 login flow) +// for a sensitive operation like a password change to be allowed without a +// forced re-auth round-trip. +export const REAUTH_FRESHNESS_WINDOW_SECONDS = 300 + +type AuthTimeClaims = { + auth_time?: unknown +} + +// Reads the OIDC `auth_time` claim (epoch seconds) from the id_token. Hydra +// stamps this with the moment the user last actively authenticated, which is +// what `prompt=login` refreshes. +export function readAuthTime(idToken: string | undefined): number | null { + if (!idToken) return null + + const claims = decodeJwtClaims(idToken) + const authTime = claims?.auth_time + return typeof authTime === 'number' && Number.isFinite(authTime) + ? authTime + : null +} + +export function isReauthFresh( + idToken: string | undefined, + nowSeconds: number = Math.floor(Date.now() / 1000) +): boolean { + const authTime = readAuthTime(idToken) + if (authTime === null) return false + + return nowSeconds - authTime <= REAUTH_FRESHNESS_WINDOW_SECONDS +} diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts new file mode 100644 index 000000000..5958bb74c --- /dev/null +++ b/src/core/server/auth/ory/identity.ts @@ -0,0 +1,113 @@ +import 'server-only' + +import type { Identity } from '@ory/client-fetch' +import type { Session } from 'next-auth' +import type { AuthUser } from '../types' + +// Cheap path: build the user from the Auth.js session alone (no Ory call). Used +// at request time by getAuthContext. `providers` is empty because the session +// doesn't carry credential info — use fromOryIdentity when that's needed. +export function fromAuthSession(session: Session): AuthUser { + return { + id: session.user.id, + email: session.user.email ?? null, + name: session.user.name ?? null, + avatarUrl: session.user.image ?? null, + providers: [], + canChangeEmail: false, + canChangePassword: false, + } +} + +// Rich path: build the user from a full Kratos Identity (traits + credentials). +// Used wherever we've fetched the identity via the admin API — admin lookups and +// the live profile query. +export function fromOryIdentity(identity: Identity): AuthUser { + const traits = (identity.traits ?? {}) as Record + const email = readString(traits, 'email') + const name = readDisplayName(traits) + const avatarUrl = + readString(traits, 'picture') ?? readString(traits, 'avatar_url') + const providers = normalizeProviders(identity.credentials) + const hasPasswordCredential = hasUsablePasswordCredential( + identity.credentials?.password + ) + const hasOidcCredential = hasLinkedOidcCredential(identity.credentials?.oidc) + const canChangePassword = hasPasswordCredential && !hasOidcCredential + + return { + id: identity.id, + email, + name, + avatarUrl, + providers, + // Email changes are disabled until the custom UI drives Ory's + // settings/verification flows instead of patching traits directly. + canChangeEmail: false, + canChangePassword, + } +} + +// Kratos credential keys (`password`, `oidc`, …) don't match the provider +// vocabulary the dashboard UI expects (Supabase emits `email` for the +// email/password credential). Map `password` → `email` for display parity, +// while preserving other keys like `oidc`. +function normalizeProviders(credentials: Identity['credentials']): string[] { + if (!credentials) return [] + + const mapped = Object.keys(credentials).map((key) => + key === 'password' ? 'email' : key + ) + + return [...new Set(mapped)] +} + +function hasUsablePasswordCredential( + credential: NonNullable[string] | undefined +): boolean { + const config = credential?.config as Record | undefined + return ( + (typeof config?.hashed_password === 'string' && + config.hashed_password !== '') || + config?.use_password_migration_hook === true + ) +} + +function hasLinkedOidcCredential( + credential: NonNullable[string] | undefined +): boolean { + if (!credential) return false + + if (credential.identifiers && credential.identifiers.length > 0) { + return true + } + + const config = credential.config as Record | undefined + const providers = config?.providers + return Array.isArray(providers) && providers.length > 0 +} + +function readString( + traits: Record, + key: string +): string | null { + const value = traits[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +function readDisplayName(traits: Record): string | null { + // ory's default schema nests name as { first, last } or stores it flat + const flat = readString(traits, 'name') + if (flat) return flat + + const nested = traits.name + if (nested && typeof nested === 'object') { + const obj = nested as Record + const first = readString(obj, 'first') + const last = readString(obj, 'last') + const composite = [first, last].filter(Boolean).join(' ').trim() + if (composite) return composite + } + + return null +} diff --git a/src/core/server/auth/ory/jwt-claims.ts b/src/core/server/auth/ory/jwt-claims.ts new file mode 100644 index 000000000..b2e225c25 --- /dev/null +++ b/src/core/server/auth/ory/jwt-claims.ts @@ -0,0 +1,26 @@ +export function decodeJwtClaims>( + token: string +): T | null { + const [, payload] = token.split('.') + if (!payload) return null + + try { + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as T + } catch { + return null + } +} + +export function tokenFormat(token: string): 'jwt' | 'opaque' | 'empty' { + if (!token) return 'empty' + return token.split('.').length === 3 ? 'jwt' : 'opaque' +} + +// Reads a non-empty string claim, trimming surrounding whitespace. +export function readStringClaim( + claims: Record | null | undefined, + name: string +): string | null { + const value = claims?.[name] + return typeof value === 'string' && value.trim() !== '' ? value.trim() : null +} diff --git a/src/core/server/auth/ory/kratos-session.ts b/src/core/server/auth/ory/kratos-session.ts new file mode 100644 index 000000000..c981a1a42 --- /dev/null +++ b/src/core/server/auth/ory/kratos-session.ts @@ -0,0 +1,69 @@ +import 'server-only' + +import { ResponseError } from '@ory/client-fetch' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { getOryIdentityApi } from './client' +import { readOryError } from './ory-error' + +/** + * Revokes every Kratos identity session for the given identity. + * + * Hydra's /oauth2/sessions/logout only ends the OAuth2 session; the Kratos + * identity cookie on the Ory domain is independent and is what causes the + * Account Experience to show "Reauthenticate as " on the next + * sign-in instead of a fresh provider chooser. + * + * We can't surgically target a single session because the OIDC `sid` claim + * from Hydra is Hydra's own OAuth2 session id, not a Kratos session id, and + * we don't have access to the user's Kratos cookie from this side. Revoking + * all identity sessions matches the expected "sign out of identity provider" + * semantics anyway. + */ +// Ory uses optimistic locking on identity rows; concurrent writes (e.g. our +// admin DELETE racing with Hydra's RP-initiated logout cleanup during the +// same signout flow) return 429 with reason "Conflicting concurrent +// requests". Retrying after a short backoff lets the in-flight write +// settle so ours can proceed. +const REVOKE_MAX_ATTEMPTS = 3 +const REVOKE_BACKOFF_MS = 150 + +export async function revokeKratosSessionsForIdentity( + identityId: string +): Promise { + for (let attempt = 1; attempt <= REVOKE_MAX_ATTEMPTS; attempt++) { + try { + await getOryIdentityApi().deleteIdentitySessions({ id: identityId }) + return + } catch (error) { + if (error instanceof ResponseError && error.response.status === 404) { + return + } + + const isContention = + error instanceof ResponseError && error.response.status === 429 + const lastAttempt = attempt === REVOKE_MAX_ATTEMPTS + + if (isContention && !lastAttempt) { + await sleep(REVOKE_BACKOFF_MS * attempt) + continue + } + + const oryDetails = + error instanceof ResponseError ? await readOryError(error) : null + + l.error( + { + key: 'auth_provider:revoke_kratos_sessions:error', + context: { ory: oryDetails, attempt }, + error: serializeErrorForLog(error), + }, + 'failed to revoke Kratos sessions; user may see reauth UX on next sign-in' + ) + return + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/core/server/auth/ory/ory-error.ts b/src/core/server/auth/ory/ory-error.ts new file mode 100644 index 000000000..9abc2045b --- /dev/null +++ b/src/core/server/auth/ory/ory-error.ts @@ -0,0 +1,60 @@ +import { ResponseError } from '@ory/client-fetch' + +export type OryErrorDetails = { + status: number + url: string + code?: number + reason?: string + message?: string + request_id?: string + body?: string +} + +// Ory returns a structured error envelope like +// { "error": { "code": 401, "status": "Unauthorized", "reason": "...", "message": "...", "id": "..." } } +// The SDK's ResponseError doesn't unpack it, so we read the body here to +// surface the actual cause instead of "Response returned an error code". +export async function readOryError( + error: ResponseError +): Promise { + const { response } = error + const base: OryErrorDetails = { status: response.status, url: response.url } + + let raw: string + try { + raw = await response.clone().text() + } catch { + return base + } + + try { + const parsed = JSON.parse(raw) as { + error?: { + code?: unknown + reason?: unknown + message?: unknown + id?: unknown + request?: unknown + } + } + const oryError = parsed.error ?? {} + return { + ...base, + code: typeof oryError.code === 'number' ? oryError.code : undefined, + reason: stringOrUndefined(oryError.reason), + message: stringOrUndefined(oryError.message), + request_id: + stringOrUndefined(oryError.id) ?? stringOrUndefined(oryError.request), + } + } catch { + return { ...base, body: raw.slice(0, 500) } + } +} + +export function isOryResponseError(error: unknown): error is ResponseError { + return error instanceof ResponseError +} + +function stringOrUndefined(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} diff --git a/src/core/server/auth/ory/provider.ts b/src/core/server/auth/ory/provider.ts index c50bc63ae..e5e9588de 100644 --- a/src/core/server/auth/ory/provider.ts +++ b/src/core/server/auth/ory/provider.ts @@ -1,45 +1,263 @@ import 'server-only' -import type { NextRequest, NextResponse } from 'next/server' -import { l } from '@/core/shared/clients/logger/logger' +import type { Identity, IdentityCredentials } from '@ory/client-fetch' +import type { Session } from 'next-auth' +import { auth as authjs } from '@/auth' +import { PROTECTED_URLS } from '@/configs/urls' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import type { AuthProvider } from '../provider' -import type { AuthContext, SignOutOptions, SignOutResult } from '../types' +import type { + AuthContext, + AuthUser, + ReauthDispatch, + UpdateUserInput, + UpdateUserResult, +} from '../types' +import { buildOryStartURL } from './build-start-url' +import { + type OryIdentityCredentialInclude, + resolveOryIdentity, +} from './find-identity' +import { oryAuthFlows } from './flows' +import { isReauthFresh } from './freshness' +import { fromAuthSession, fromOryIdentity } from './identity' +import { revokeKratosSessionsForIdentity } from './kratos-session' +import { ORY_SIGN_OUT_FLOW_PATH } from './signout' -export class OryHostedAuthProvider implements AuthProvider { - constructor(private readonly cookie: string = '') {} +// Where the account-settings page expects to land after a forced re-auth so it +// reveals the password form (matches the Supabase ?reauth=1 contract). +const ACCOUNT_SETTINGS_REAUTH_RETURN_TO = `${PROTECTED_URLS.ACCOUNT_SETTINGS}?reauth=1` +const PROFILE_IDENTITY_CREDENTIALS = [ + 'password', + 'oidc', +] satisfies OryIdentityCredentialInclude[] - // fail-closed until ory is wired: callers (proxy, middleware) treat null as - // unauthenticated and redirect to sign-in instead of letting requests through - getAuthContext(): Promise { - void this.cookie - l.warn( +export const oryAuthProvider: AuthProvider = { + async getAuthContext() { + const session = await readSession() + if (!session) return null + + if (!session.user?.id || !session.accessToken) { + return null + } + + if (session.error) { + l.warn( + { + key: 'auth_provider:ory_session_error', + user_id: session.user.id, + context: { error: session.error }, + }, + `Auth.js session reports error '${session.error}'; treating as unauthenticated` + ) + return null + } + + return { + user: fromAuthSession(session), + accessToken: session.accessToken, + } satisfies AuthContext + }, + + async getUserProfile(): Promise { + const session = await readSession() + if (!session?.user?.id) return null + + // The live profile needs the full Kratos identity (traits + credentials). + // The cached session.identityId hits directly; user.id and email are + // fallbacks. Callers (the tRPC profile query) time this out and fall back to + // the cheap session user, so a null/slow response never blocks the dashboard. + const identity = await resolveOryIdentity({ + subjects: [session.identityId, session.user.id], + email: session.user.email, + includeCredential: PROFILE_IDENTITY_CREDENTIALS, + }) + + l.debug( { - key: 'auth_provider:ory_stub_unauthenticated', + key: 'auth_provider:ory_get_user_profile:identity', + user_id: session.user.id, + context: { + session_identity_id: session.identityId ?? null, + session_user_email: session.user.email ?? null, + identity: identity ? summarizeIdentityForLog(identity) : null, + }, }, - 'OryHostedAuthProvider.getAuthContext is a stub and always returns null' + 'resolved Ory identity for dashboard user profile' ) - return Promise.resolve(null) - } - signOut(_options?: SignOutOptions): Promise { - return Promise.resolve({ - error: { - message: 'OryHostedAuthProvider.signOut is not implemented yet', - code: 'ory_stub_not_implemented', - }, + return identity ? fromOryIdentity(identity) : null + }, + + signOut() { + return Promise.resolve({ redirectTo: ORY_SIGN_OUT_FLOW_PATH }) + }, + + async updateUser(input: UpdateUserInput): Promise { + const session = await readSession() + if (!session?.user?.id) { + throw new Error('updateUser called without an authenticated Ory session') + } + + // Changing the password OR the email is privileged: require a recent active + // login so a stolen dashboard session can't silently take over the account + // (swap the email, then reset the password via the new inbox). The caller + // turns this into the forced OAuth2 re-auth round-trip. + const changesCredentials = + input.password !== undefined || input.email !== undefined + if (changesCredentials && !isReauthFresh(session.idToken)) { + return { ok: false, code: 'reauthentication_needed' } + } + + const identityId = await resolveIdentityId(session) + if (!identityId) { + throw new Error( + 'updateUser could not resolve an Ory identity for the session subject' + ) + } + + return oryAuthFlows.updateUser({ + identityId, + name: input.name, + email: input.email, + password: input.password, }) + }, + + async startReauthForAccountSettings(): Promise { + return { + kind: 'redirect', + to: buildOryStartURL('reauth', ACCOUNT_SETTINGS_REAUTH_RETURN_TO), + } + }, + + async signOutOtherSessions(): Promise { + const session = await readSession() + if (!session?.user?.id) return + + const identityId = await resolveIdentityId(session) + if (!identityId) return + + // The dashboard session is the Auth.js JWT, independent of Kratos identity + // sessions, so revoking all Kratos sessions invalidates other browsers + // without logging the current dashboard session out. + await revokeKratosSessionsForIdentity(identityId) + }, +} + +// The Kratos identity id is resolved once at sign-in and cached on the session +// (see src/auth.ts). Fall back to a per-request lookup (by the E2B user id, then +// the verified email) for sessions minted before that wiring existed or when +// the sign-in resolution failed. +async function resolveIdentityId(session: Session): Promise { + if (session.identityId) return session.identityId + + const identity = await resolveOryIdentity({ + subjects: [session.user.id], + email: session.user.email, + }) + return identity?.id ?? null +} + +async function readSession(): Promise { + try { + return await authjs() + } catch (error) { + l.error( + { + key: 'auth_provider:ory_get_session:error', + error: serializeErrorForLog(error), + }, + 'Auth.js auth() helper threw while reading session' + ) + return null + } +} + +function summarizeIdentityForLog(identity: Identity) { + const traits = (identity.traits ?? {}) as Record + + return { + id: identity.id, + external_id: identity.external_id ?? null, + schema_id: identity.schema_id, + state: identity.state ?? null, + traits: { + email: readTraitString(traits, 'email'), + name: readTraitString(traits, 'name'), + keys: Object.keys(traits), + }, + credential_keys: Object.keys(identity.credentials ?? {}), + credentials: summarizeCredentialsForLog(identity.credentials), + } +} + +function summarizeCredentialsForLog(credentials: Identity['credentials']) { + if (!credentials) return null + + return { + password_credential: summarizeCredentialForLog(credentials.password), + oidc_credential: summarizeCredentialForLog(credentials.oidc), + other_credential_keys: Object.keys(credentials).filter( + (key) => key !== 'password' && key !== 'oidc' + ), + } +} + +function summarizeCredentialForLog( + credential: IdentityCredentials | undefined +) { + if (!credential) return null + + const config = credential.config as Record | undefined + + return { + type: credential.type ?? null, + identifiers: credential.identifiers ?? [], + has_config: !!config, + config_keys: config ? Object.keys(config) : [], + has_hashed_password: + typeof config?.hashed_password === 'string' && + config.hashed_password !== '', + uses_password_migration_hook: config?.use_password_migration_hook === true, + oidc_providers: readOidcProvidersForLog(config), } } -export function createOryAuthForProxy( - request: NextRequest, - _response: NextResponse -): OryHostedAuthProvider { - return new OryHostedAuthProvider(request.headers.get('cookie') ?? '') +function readOidcProvidersForLog(config: Record | undefined) { + const providers = config?.providers + if (!Array.isArray(providers)) return [] + + return providers + .filter( + (provider): provider is Record => + provider !== null && typeof provider === 'object' + ) + .map((provider) => ({ + provider: readTraitString(provider, 'provider'), + organization: readTraitString(provider, 'organization'), + has_subject: + typeof provider.subject === 'string' && provider.subject !== '', + use_auto_link: + typeof provider.use_auto_link === 'boolean' + ? provider.use_auto_link + : null, + has_initial_access_token: + typeof provider.initial_access_token === 'string' && + provider.initial_access_token !== '', + has_initial_id_token: + typeof provider.initial_id_token === 'string' && + provider.initial_id_token !== '', + has_initial_refresh_token: + typeof provider.initial_refresh_token === 'string' && + provider.initial_refresh_token !== '', + })) } -export function createOryAuthForHeaders( - headers: Headers -): OryHostedAuthProvider { - return new OryHostedAuthProvider(headers.get('cookie') ?? '') +function readTraitString( + source: Record, + field: string +): string | null { + const value = source[field] + return typeof value === 'string' && value.length > 0 ? value : null } diff --git a/src/core/server/auth/ory/refresh-token.ts b/src/core/server/auth/ory/refresh-token.ts new file mode 100644 index 000000000..f25dc5aa1 --- /dev/null +++ b/src/core/server/auth/ory/refresh-token.ts @@ -0,0 +1,79 @@ +import 'server-only' + +import type { JWT } from 'next-auth/jwt' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +type OryTokenResponse = { + access_token: string + expires_in: number + refresh_token?: string + id_token?: string +} + +// returned on every failure path so the next jwt-callback invocation +// short-circuits instead of re-presenting an already-invalidated refresh_token +// in a loop. expiresAt is zeroed so isExpired() checks don't matter — the +// error gate kicks in first. +function deadToken(token: JWT, error: string): JWT { + return { + ...token, + accessToken: undefined, + refreshToken: undefined, + idToken: undefined, + expiresAt: 0, + error, + } +} + +export async function refreshOryToken(token: JWT): Promise { + if (!token.refreshToken) return deadToken(token, 'NoRefreshToken') + + const sdkUrl = process.env.ORY_SDK_URL!.replace(/\/$/, '') + const credentials = btoa( + `${process.env.ORY_OAUTH2_CLIENT_ID}:${process.env.ORY_OAUTH2_CLIENT_SECRET}` + ) + + try { + const res = await fetch(`${sdkUrl}/oauth2/token`, { + method: 'POST', + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: token.refreshToken, + }), + }) + + if (!res.ok) { + l.warn( + { + key: 'auth_provider:refresh_token:rejected', + context: { status: res.status }, + }, + `Ory refresh_token rejected (${res.status})` + ) + return deadToken(token, 'RefreshTokenError') + } + + const fresh = (await res.json()) as OryTokenResponse + return { + ...token, + accessToken: fresh.access_token, + refreshToken: fresh.refresh_token ?? token.refreshToken, + idToken: fresh.id_token ?? token.idToken, + expiresAt: Math.floor(Date.now() / 1000) + fresh.expires_in, + error: undefined, + } + } catch (error) { + l.error( + { + key: 'auth_provider:refresh_token:exception', + error: serializeErrorForLog(error), + }, + 'Ory refresh_token threw' + ) + return deadToken(token, 'RefreshTokenError') + } +} diff --git a/src/core/server/auth/ory/signout.ts b/src/core/server/auth/ory/signout.ts new file mode 100644 index 000000000..b5d40d656 --- /dev/null +++ b/src/core/server/auth/ory/signout.ts @@ -0,0 +1,3 @@ +// Route handler that performs the full Ory sign-out (Auth.js + Kratos sessions +// + Hydra RP-initiated logout). The provider redirects here on signOut(). +export const ORY_SIGN_OUT_FLOW_PATH = '/api/auth/oauth/signout-flow' diff --git a/src/core/server/auth/provider.ts b/src/core/server/auth/provider.ts index 6953cce2d..9fb435caa 100644 --- a/src/core/server/auth/provider.ts +++ b/src/core/server/auth/provider.ts @@ -1,6 +1,22 @@ -import type { AuthContext, SignOutOptions, SignOutResult } from './types' +import type { + AuthContext, + AuthUser, + ReauthDispatch, + SignOutOptions, + SignOutResult, + UpdateUserInput, + UpdateUserResult, +} from './types' export interface AuthProvider { getAuthContext(): Promise + // Live profile lookup from the identity provider (Ory IdentityApi / Supabase + // getUser). Unlike getAuthContext's cheap session path, this carries the full + // traits and credential-derived providers. Heavier — call it once per + // dashboard load behind a cache, not on every request. + getUserProfile(): Promise signOut(options?: SignOutOptions): Promise + updateUser(input: UpdateUserInput): Promise + startReauthForAccountSettings(): Promise + signOutOtherSessions(): Promise } diff --git a/src/core/server/auth/supabase/flows.ts b/src/core/server/auth/supabase/flows.ts index b7351875e..468483224 100644 --- a/src/core/server/auth/supabase/flows.ts +++ b/src/core/server/auth/supabase/flows.ts @@ -20,13 +20,6 @@ type SignUpOptions = { data?: Record } -type UpdateUserOptions = { - email?: string - password?: string - name?: string - emailRedirectTo?: string -} - export const supabaseAuthFlows = { async signInWithOAuth({ provider, @@ -63,20 +56,6 @@ export const supabaseAuthFlows = { return client.auth.resetPasswordForEmail(email) }, - async updateUser({ - email, - password, - name, - emailRedirectTo, - }: UpdateUserOptions) { - const client = await createClient() - - return client.auth.updateUser( - { email, password, data: { name } }, - emailRedirectTo ? { emailRedirectTo } : undefined - ) - }, - async verifyOtp(...args: Parameters) { const client = await createClient() return client.auth.verifyOtp(...args) diff --git a/src/core/server/auth/supabase/provider.ts b/src/core/server/auth/supabase/provider.ts index 47f65d24d..a506eebd9 100644 --- a/src/core/server/auth/supabase/provider.ts +++ b/src/core/server/auth/supabase/provider.ts @@ -1,10 +1,21 @@ import 'server-only' +import { headers } from 'next/headers' import type { NextRequest, NextResponse } from 'next/server' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { createClient } from '@/core/shared/clients/supabase/server' import type { AuthProvider } from '../provider' -import type { AuthContext, SignOutOptions, SignOutResult } from '../types' +import type { + AuthContext, + AuthUser, + ReauthDispatch, + SignOutOptions, + SignOutResult, + UpdateUserErrorCode, + UpdateUserInput, + UpdateUserResult, +} from '../types' import { createServerClientForHeaders, createServerClientForProxy, @@ -59,9 +70,31 @@ export class SupabaseAuthProvider implements AuthProvider { } } + async getUserProfile(): Promise { + const client = await this.resolveClient() + const { data, error } = await client.auth.getUser() + + if (error || !data.user) { + if (error) { + l.error( + { + key: 'auth_provider:get_user_profile:error', + error: serializeErrorForLog(error), + }, + `supabase getUser failed: ${error.message}` + ) + } + return null + } + + return toAuthUser(data.user) + } + async signOut(options?: SignOutOptions): Promise { const client = await this.resolveClient() - const { error } = await client.auth.signOut(options) + const { error } = await client.auth.signOut( + options?.scope ? { scope: options.scope } : undefined + ) if (error) { l.error( @@ -78,7 +111,59 @@ export class SupabaseAuthProvider implements AuthProvider { ) } - return { error: error ?? null } + return { + redirectTo: buildSignInRedirect(options?.returnTo), + error: error ?? null, + } + } + + async updateUser(input: UpdateUserInput): Promise { + const emailRedirectTo = input.email + ? await buildEmailVerificationRedirect(input.email) + : undefined + + const client = await this.resolveClient() + const { data, error } = await client.auth.updateUser( + { + email: input.email, + password: input.password, + data: { name: input.name }, + }, + emailRedirectTo ? { emailRedirectTo } : undefined + ) + + if (!error) { + return { ok: true, user: toAuthUser(data.user) } + } + + const code = mapSupabaseUpdateError(error.code) + // Preserve the original action behavior of throwing on unmapped errors so + // they surface as unexpected server errors. + if (!code) { + throw error + } + + return { ok: false, code, message: error.message } + } + + async startReauthForAccountSettings(): Promise { + return { kind: 'sign-out', returnTo: PROTECTED_URLS.ACCOUNT_SETTINGS } + } + + async signOutOtherSessions(): Promise { + const client = await this.resolveClient() + const { error } = await client.auth.signOut({ scope: 'others' }) + + if (error) { + l.error( + { + key: 'auth_provider:sign_out_others:error', + error: serializeErrorForLog(error), + context: { error_code: error.code, error_status: error.status }, + }, + `supabase signOut(others) failed: ${error.message}` + ) + } } private resolveClient(): Promise { @@ -86,6 +171,36 @@ export class SupabaseAuthProvider implements AuthProvider { } } +async function buildEmailVerificationRedirect(email: string): Promise { + const origin = (await headers()).get('origin') + if (!origin) { + throw new Error('Missing origin header for email update redirect') + } + + const url = new URL('/api/auth/email-callback', origin) + url.searchParams.set('new_email', email) + return url.toString() +} + +function mapSupabaseUpdateError( + code: string | undefined +): UpdateUserErrorCode | null { + switch (code) { + case 'email_address_invalid': + return 'email_invalid' + case 'email_exists': + return 'email_exists' + case 'same_password': + return 'same_password' + case 'weak_password': + return 'weak_password' + case 'reauthentication_needed': + return 'reauthentication_needed' + default: + return null + } +} + export function createSupabaseAuthForProxy( request: NextRequest, response: NextResponse @@ -98,3 +213,9 @@ export function createSupabaseAuthForHeaders( ): SupabaseAuthProvider { return new SupabaseAuthProvider(createServerClientForHeaders(headers)) } + +function buildSignInRedirect(returnTo?: string): string { + if (!returnTo) return AUTH_URLS.SIGN_IN + const params = new URLSearchParams({ returnTo }) + return `${AUTH_URLS.SIGN_IN}?${params.toString()}` +} diff --git a/src/core/server/auth/supabase/user.ts b/src/core/server/auth/supabase/user.ts index a89ff9a64..2801e6b04 100644 --- a/src/core/server/auth/supabase/user.ts +++ b/src/core/server/auth/supabase/user.ts @@ -2,12 +2,17 @@ import type { User } from '@supabase/supabase-js' import type { AuthUser } from '../types' export function toAuthUser(user: User): AuthUser { + const providers = extractProviders(user) + const canChangeEmail = canChangeEmailPasswordSettings(providers) + return { id: user.id, email: user.email ?? null, name: getStringFromMetadata(user.user_metadata, 'name'), avatarUrl: getStringFromMetadata(user.user_metadata, 'avatar_url'), - providers: extractProviders(user), + providers, + canChangeEmail, + canChangePassword: canChangeEmail, } } @@ -34,3 +39,12 @@ function extractProviders(user: User): string[] { return [...new Set([...fromAppMetadata, ...fromIdentities])] } + +function canChangeEmailPasswordSettings(providers: string[]): boolean { + return ( + providers.includes('email') && + providers.every((provider) => { + return provider === 'email' + }) + ) +} diff --git a/src/core/server/auth/types.ts b/src/core/server/auth/types.ts index c6520b495..582b5163f 100644 --- a/src/core/server/auth/types.ts +++ b/src/core/server/auth/types.ts @@ -4,6 +4,8 @@ export type AuthUser = { name: string | null avatarUrl: string | null providers: string[] + canChangeEmail: boolean + canChangePassword: boolean } export type AuthContext = { @@ -13,6 +15,7 @@ export type AuthContext = { export type SignOutOptions = { scope?: 'local' | 'others' | 'global' + returnTo?: string } export type AuthError = { @@ -21,4 +24,34 @@ export type AuthError = { status?: number } -export type SignOutResult = { error: AuthError | null } +export type SignOutResult = { + redirectTo: string + error?: AuthError | null +} + +export type UpdateUserInput = { + name?: string + email?: string + password?: string +} + +// Expected, user-facing update failures. Anything else throws and is handled +// as an unexpected server error by the action client. +export type UpdateUserErrorCode = + | 'email_exists' + | 'email_invalid' + | 'weak_password' + | 'same_password' + | 'reauthentication_needed' + | 'account_credentials_not_changeable' + +export type UpdateUserResult = + | { ok: true; user: AuthUser } + | { ok: false; code: UpdateUserErrorCode; message?: string } + +// How the caller should drive the account-settings re-authentication step. +// Supabase signs the user out and bounces through /sign-in; Ory forces a +// fresh OAuth2 login via the oauth-start route. +export type ReauthDispatch = + | { kind: 'sign-out'; returnTo: string } + | { kind: 'redirect'; to: string } diff --git a/src/core/server/functions/sandboxes/get-team-metrics-core.ts b/src/core/server/functions/sandboxes/get-team-metrics-core.ts index 2457b305a..a6851d89f 100644 --- a/src/core/server/functions/sandboxes/get-team-metrics-core.ts +++ b/src/core/server/functions/sandboxes/get-team-metrics-core.ts @@ -1,7 +1,7 @@ import 'server-only' import { cache } from 'react' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { USE_MOCK_DATA } from '@/configs/flags' import { calculateTeamMetricsStep, @@ -89,7 +89,7 @@ export const getTeamMetricsCore = cache( }, }, headers: { - ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + ...authHeaders(accessToken, teamId), }, cache: 'no-store', }) diff --git a/src/core/server/functions/sandboxes/get-team-metrics-max.ts b/src/core/server/functions/sandboxes/get-team-metrics-max.ts index 0a2078f8a..9a5a4a066 100644 --- a/src/core/server/functions/sandboxes/get-team-metrics-max.ts +++ b/src/core/server/functions/sandboxes/get-team-metrics-max.ts @@ -1,7 +1,7 @@ import 'server-only' import { z } from 'zod' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { USE_MOCK_DATA } from '@/configs/flags' import { MOCK_TEAM_METRICS_MAX_DATA } from '@/configs/mock-data' import { @@ -79,7 +79,7 @@ export const getTeamMetricsMax = authActionClient }, }, headers: { - ...SUPABASE_AUTH_HEADERS(session.access_token, teamId), + ...authHeaders(session.access_token, teamId), }, cache: 'no-store', }) diff --git a/src/core/server/http/proxy.ts b/src/core/server/http/proxy.ts index e49242d32..13cb8f9c3 100644 --- a/src/core/server/http/proxy.ts +++ b/src/core/server/http/proxy.ts @@ -1,7 +1,11 @@ import 'server-cli-only' import { type NextRequest, NextResponse } from 'next/server' +import { ALLOW_SEO_INDEXING } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' +import { createAuthForProxy } from '@/core/server/auth' +import { getMiddlewareRedirectFromPath } from '@/lib/utils/redirects' +import { getRewriteForPath } from '@/lib/utils/rewrites' export function isAuthRoute(pathname: string): boolean { return ( @@ -45,3 +49,80 @@ export function getAuthRedirect( return null } + +// The handlers below are the ordered concerns the proxy runs for every request. +// Each returns a Response when it handles the request, or null to fall through +// to the next concern. They live here (not in static next.config matchers) +// because they need custom headers / runtime path logic. + +// Redirects that require custom response headers. +export function handleMiddlewareRedirect( + request: NextRequest +): NextResponse | null { + const redirect = getMiddlewareRedirectFromPath(request.nextUrl.pathname) + if (!redirect) return null + + return NextResponse.redirect(new URL(redirect.destination, request.url), { + status: redirect.statusCode, + headers: new Headers(redirect.headers), + }) +} + +// Catch-all route rewrites are resolved by the route itself, so the proxy just +// passes them through untouched. +export function handleRouteRewritePassthrough( + request: NextRequest +): NextResponse | null { + const { config } = getRewriteForPath(request.nextUrl.pathname, 'route') + return config ? NextResponse.next({ request }) : null +} + +// Rewrites the proxy performs itself (serving another origin under our domain), +// tagging the request/response with the SEO-indexing intent. +export function handleMiddlewareRewrite( + request: NextRequest +): NextResponse | null { + const { config, rule } = getRewriteForPath( + request.nextUrl.pathname, + 'middleware' + ) + if (!config) return null + + const rewriteUrl = new URL(request.url) + rewriteUrl.hostname = config.domain + rewriteUrl.protocol = 'https' + rewriteUrl.port = '' + if (rule?.pathPreprocessor) { + rewriteUrl.pathname = rule.pathPreprocessor(rewriteUrl.pathname) + } + + const requestHeaders = new Headers(request.headers) + if (ALLOW_SEO_INDEXING) { + requestHeaders.set('x-e2b-should-index', '1') + } + + const response = NextResponse.rewrite(rewriteUrl, { + request: { headers: requestHeaders }, + }) + response.headers.set( + 'X-Robots-Tag', + ALLOW_SEO_INDEXING ? 'index, follow' : 'noindex, nofollow' + ) + return response +} + +// Terminal concern: gate dashboard/auth routes on authentication. `knownAuth` +// is supplied in Ory mode (resolved by the Auth.js middleware wrapper); in +// Supabase mode it's resolved here from the request/response cookies. +export async function handleAuthGate( + request: NextRequest, + knownAuth?: boolean +): Promise { + const response = NextResponse.next({ request }) + + const isAuthenticated = + knownAuth ?? + !!(await createAuthForProxy(request, response).getAuthContext()) + + return getAuthRedirect(request, isAuthenticated) ?? response +} diff --git a/src/core/shared/contracts/dashboard-api.types.ts b/src/core/shared/contracts/dashboard-api.types.ts index 6cf043667..c45658aa3 100644 --- a/src/core/shared/contracts/dashboard-api.types.ts +++ b/src/core/shared/contracts/dashboard-api.types.ts @@ -4,871 +4,1072 @@ */ export interface paths { - '/health': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Health check */ - get: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Health check successful */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['HealthResponse'] - } - } - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/builds': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** List team builds */ - get: { - parameters: { - query?: { - /** @description Optional filter by build identifier, template identifier, or template alias. */ - build_id_or_template?: components['parameters']['build_id_or_template'] - /** @description Comma-separated list of build statuses to include. */ - statuses?: components['parameters']['build_statuses'] - /** @description Maximum number of items to return per page. */ - limit?: components['parameters']['builds_limit'] - /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ - cursor?: components['parameters']['builds_cursor'] - } - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned paginated builds. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['BuildsListResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/builds/statuses': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Get build statuses */ - get: { - parameters: { - query: { - /** @description Comma-separated list of build IDs to get statuses for. */ - build_ids: components['parameters']['build_ids'] - } - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned build statuses */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['BuildsStatusesResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/builds/{build_id}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Get build details */ - get: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the build. */ - build_id: components['parameters']['build_id'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned build details. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['BuildInfo'] - } - } - 401: components['responses']['401'] - 403: components['responses']['403'] - 404: components['responses']['404'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/sandboxes/{sandboxID}/record': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Get sandbox record */ - get: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the sandbox. */ - sandboxID: components['parameters']['sandboxID'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned sandbox details. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['SandboxRecord'] - } - } - 401: components['responses']['401'] - 403: components['responses']['403'] - 404: components['responses']['404'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/teams': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** - * List user teams - * @description Returns all teams the authenticated user belongs to, with limits and default flag. - */ - get: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned user teams. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['UserTeamsResponse'] - } - } - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - put?: never - /** Create team */ - post: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['CreateTeamRequest'] - } - } - responses: { - /** @description Successfully created team. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamResolveResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/admin/users/{userId}/bootstrap': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - /** Bootstrap user */ - post: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the user. */ - userId: components['parameters']['userId'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully bootstrapped user. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamResolveResponse'] - } - } - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/teams/resolve': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** - * Resolve team identity - * @description Resolves a team slug to the team's identity, validating the user is a member. - */ - get: { - parameters: { - query: { - /** @description Team slug to resolve. */ - slug: components['parameters']['teamSlug'] - } - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully resolved team. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamResolveResponse'] - } - } - 401: components['responses']['401'] - 403: components['responses']['403'] - 404: components['responses']['404'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/teams/{teamID}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - post?: never - delete?: never - options?: never - head?: never - /** Update team */ - patch: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the team. */ - teamID: components['parameters']['teamID'] - } - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['UpdateTeamRequest'] - } - } - responses: { - /** @description Successfully updated team. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['UpdateTeamResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - trace?: never - } - '/teams/{teamID}/members': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** List team members */ - get: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the team. */ - teamID: components['parameters']['teamID'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned team members. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamMembersResponse'] - } - } - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - put?: never - /** Add team member */ - post: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the team. */ - teamID: components['parameters']['teamID'] - } - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['AddTeamMemberRequest'] - } - } - responses: { - /** @description Successfully added team member. */ - 201: { - headers: { - [name: string]: unknown - } - content?: never - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 404: components['responses']['404'] - 500: components['responses']['500'] - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/teams/{teamID}/members/{userId}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - post?: never - /** Remove team member */ - delete: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the team. */ - teamID: components['parameters']['teamID'] - /** @description Identifier of the user. */ - userId: components['parameters']['userId'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully removed team member. */ - 204: { - headers: { - [name: string]: unknown - } - content?: never - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - options?: never - head?: never - patch?: never - trace?: never - } - '/templates/defaults': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** - * List default templates - * @description Returns the list of default templates with their latest build info and aliases. - */ - get: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned default templates. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['DefaultTemplatesResponse'] - } - } - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health check */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Health check successful */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/builds": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List team builds */ + get: { + parameters: { + query?: { + /** @description Optional filter by build identifier, template identifier, or template alias. */ + build_id_or_template?: components["parameters"]["build_id_or_template"]; + /** @description Comma-separated list of build statuses to include. */ + statuses?: components["parameters"]["build_statuses"]; + /** @description Maximum number of items to return per page. */ + limit?: components["parameters"]["builds_limit"]; + /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ + cursor?: components["parameters"]["builds_cursor"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned paginated builds. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BuildsListResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/builds/statuses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get build statuses */ + get: { + parameters: { + query: { + /** @description Comma-separated list of build IDs to get statuses for. */ + build_ids: components["parameters"]["build_ids"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned build statuses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BuildsStatusesResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/builds/{build_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get build details */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the build. */ + build_id: components["parameters"]["build_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned build details. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BuildInfo"]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxID}/record": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get sandbox record */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the sandbox. */ + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned sandbox details. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SandboxRecord"]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List user teams + * @description Returns all teams the authenticated user belongs to, with limits and default flag. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned user teams. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserTeamsResponse"]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + /** Create team */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTeamRequest"]; + }; + }; + responses: { + /** @description Successfully created team. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamResolveResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/{userId}/bootstrap": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Bootstrap user */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the user. */ + userId: components["parameters"]["userId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully bootstrapped user. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamResolveResponse"]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/bootstrap": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Bootstrap auth provider user */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAuthProviderUserBootstrapRequest"]; + }; + }; + responses: { + /** @description Successfully bootstrapped user. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamResolveResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/user-profiles/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Resolve user profiles */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesResolveRequest"]; + }; + }; + responses: { + /** @description Successfully resolved profiles. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/user-profiles/by-email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Lookup user profiles by email */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesLookupEmailRequest"]; + }; + }; + responses: { + /** @description Successfully found matching profiles. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/user-profiles/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get user profile */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the user. */ + userId: components["parameters"]["userId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully found profile. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Resolve team identity + * @description Resolves a team slug to the team's identity, validating the user is a member. + */ + get: { + parameters: { + query: { + /** @description Team slug to resolve. */ + slug: components["parameters"]["teamSlug"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully resolved team. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamResolveResponse"]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams/{teamID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update team */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the team. */ + teamID: components["parameters"]["teamID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTeamRequest"]; + }; + }; + responses: { + /** @description Successfully updated team. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UpdateTeamResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + trace?: never; + }; + "/teams/{teamID}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List team members */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the team. */ + teamID: components["parameters"]["teamID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned team members. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamMembersResponse"]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + /** Add team member */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the team. */ + teamID: components["parameters"]["teamID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddTeamMemberRequest"]; + }; + }; + responses: { + /** @description Successfully added team member. */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams/{teamID}/members/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove team member */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the team. */ + teamID: components["parameters"]["teamID"]; + /** @description Identifier of the user. */ + userId: components["parameters"]["userId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully removed team member. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/templates/defaults": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List default templates + * @description Returns the list of default templates with their latest build info and aliases. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned default templates. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DefaultTemplatesResponse"]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } -export type webhooks = Record +export type webhooks = Record; export interface components { - schemas: { - Error: { - /** - * Format: int32 - * @description Error code. - */ - code: number - /** @description Error message. */ - message: string - } - /** - * @description Build status mapped for dashboard clients. - * @enum {string} - */ - BuildStatus: 'building' | 'failed' | 'success' - ListedBuild: { - /** - * Format: uuid - * @description Identifier of the build. - */ - id: string - /** @description Template alias when present, otherwise template ID. */ - template: string - /** @description Identifier of the template. */ - templateId: string - status: components['schemas']['BuildStatus'] - /** @description Failure message when status is `failed`, otherwise `null`. */ - statusMessage: string | null - /** - * Format: date-time - * @description Build creation timestamp in RFC3339 format. - */ - createdAt: string - /** - * Format: date-time - * @description Build completion timestamp in RFC3339 format, if finished. - */ - finishedAt: string | null - } - BuildsListResponse: { - data: components['schemas']['ListedBuild'][] - /** @description Cursor to pass to the next list request, or `null` if there is no next page. */ - nextCursor: string | null - } - BuildStatusItem: { - /** - * Format: uuid - * @description Identifier of the build. - */ - id: string - status: components['schemas']['BuildStatus'] - /** - * Format: date-time - * @description Build completion timestamp in RFC3339 format, if finished. - */ - finishedAt: string | null - /** @description Failure message when status is `failed`, otherwise `null`. */ - statusMessage: string | null - } - BuildsStatusesResponse: { - /** @description List of build statuses */ - buildStatuses: components['schemas']['BuildStatusItem'][] - } - BuildInfo: { - /** @description Template names related to this build, if available. */ - names?: string[] | null - /** - * Format: date-time - * @description Build creation timestamp in RFC3339 format. - */ - createdAt: string - /** - * Format: date-time - * @description Build completion timestamp in RFC3339 format, if finished. - */ - finishedAt: string | null - status: components['schemas']['BuildStatus'] - /** @description Failure message when status is `failed`, otherwise `null`. */ - statusMessage: string | null - } - /** - * Format: int64 - * @description CPU cores for the sandbox - */ - CPUCount: number - /** - * Format: int64 - * @description Memory for the sandbox in MiB - */ - MemoryMB: number - /** - * Format: int64 - * @description Disk size for the sandbox in MiB - */ - DiskSizeMB: number - SandboxRecord: { - /** @description Identifier of the template from which is the sandbox created */ - templateID: string - /** @description Alias of the template */ - alias?: string - /** @description Identifier of the sandbox */ - sandboxID: string - /** - * Format: date-time - * @description Time when the sandbox was started - */ - startedAt: string - /** - * Format: date-time - * @description Time when the sandbox was stopped - */ - stoppedAt?: string | null - /** @description Base domain where the sandbox traffic is accessible */ - domain?: string | null - cpuCount: components['schemas']['CPUCount'] - memoryMB: components['schemas']['MemoryMB'] - diskSizeMB: components['schemas']['DiskSizeMB'] - } - HealthResponse: { - /** @description Human-readable health check result. */ - message: string - } - UserTeamLimits: { - /** Format: int64 */ - maxLengthHours: number - /** Format: int32 */ - concurrentSandboxes: number - /** Format: int32 */ - concurrentTemplateBuilds: number - /** Format: int32 */ - maxVcpu: number - /** Format: int32 */ - maxRamMb: number - /** Format: int32 */ - diskMb: number - } - UserTeam: { - /** Format: uuid */ - id: string - name: string - slug: string - tier: string - email: string - profilePictureUrl: string | null - isBlocked: boolean - isBanned: boolean - blockedReason: string | null - isDefault: boolean - limits: components['schemas']['UserTeamLimits'] - /** Format: date-time */ - createdAt: string - } - UserTeamsResponse: { - teams: components['schemas']['UserTeam'][] - } - TeamMember: { - /** Format: uuid */ - id: string - email: string - isDefault: boolean - /** Format: uuid */ - addedBy?: string | null - /** Format: date-time */ - createdAt: string | null - } - TeamMembersResponse: { - members: components['schemas']['TeamMember'][] - } - UpdateTeamRequest: { - name?: string - profilePictureUrl?: string | null - } - UpdateTeamResponse: { - /** Format: uuid */ - id: string - name: string - profilePictureUrl?: string | null - } - AddTeamMemberRequest: { - /** Format: email */ - email: string - } - CreateTeamRequest: { - name: string - } - DefaultTemplateAlias: { - alias: string - namespace?: string | null - } - DefaultTemplate: { - id: string - aliases: components['schemas']['DefaultTemplateAlias'][] - /** Format: uuid */ - buildId: string - /** Format: int64 */ - ramMb: number - /** Format: int64 */ - vcpu: number - /** Format: int64 */ - totalDiskSizeMb: number | null - envdVersion?: string | null - /** Format: date-time */ - createdAt: string - public: boolean - /** Format: int32 */ - buildCount: number - /** Format: int64 */ - spawnCount: number - } - DefaultTemplatesResponse: { - templates: components['schemas']['DefaultTemplate'][] - } - TeamResolveResponse: { - /** Format: uuid */ - id: string - slug: string - } - } - responses: { - /** @description Bad request */ - 400: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - /** @description Authentication error */ - 401: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - /** @description Server error */ - 500: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - } - parameters: { - /** @description Identifier of the build. */ - build_id: string - /** @description Identifier of the sandbox. */ - sandboxID: string - /** @description Maximum number of items to return per page. */ - builds_limit: number - /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ - builds_cursor: string - /** @description Optional filter by build identifier, template identifier, or template alias. */ - build_id_or_template: string - /** @description Comma-separated list of build statuses to include. */ - build_statuses: components['schemas']['BuildStatus'][] - /** @description Comma-separated list of build IDs to get statuses for. */ - build_ids: string[] - /** @description Identifier of the team. */ - teamID: string - /** @description Identifier of the user. */ - userId: string - /** @description Team slug to resolve. */ - teamSlug: string - } - requestBodies: never - headers: never - pathItems: never + schemas: { + Error: { + /** + * Format: int32 + * @description Error code. + */ + code: number; + /** @description Error message. */ + message: string; + }; + AdminAuthProviderProfile: { + /** + * Format: uuid + * @description Internal E2B user identifier. + */ + userId: string; + /** @description Email address from the configured auth provider. */ + email: string | null; + }; + AdminAuthProviderProfilesResponse: { + profiles: components["schemas"]["AdminAuthProviderProfile"][]; + }; + AdminAuthProviderProfilesResolveRequest: { + userIds: string[]; + }; + AdminAuthProviderProfilesLookupEmailRequest: { + /** Format: email */ + email: string; + }; + AdminAuthProviderUserBootstrapRequest: { + oidc_issuer: string; + oidc_user_id: string; + /** Format: email */ + oidc_user_email: string; + oidc_user_name?: string | null; + }; + /** + * @description Build status mapped for dashboard clients. + * @enum {string} + */ + BuildStatus: "building" | "failed" | "success"; + ListedBuild: { + /** + * Format: uuid + * @description Identifier of the build. + */ + id: string; + /** @description Template alias when present, otherwise template ID. */ + template: string; + /** @description Identifier of the template. */ + templateId: string; + status: components["schemas"]["BuildStatus"]; + /** @description Failure message when status is `failed`, otherwise `null`. */ + statusMessage: string | null; + /** + * Format: date-time + * @description Build creation timestamp in RFC3339 format. + */ + createdAt: string; + /** + * Format: date-time + * @description Build completion timestamp in RFC3339 format, if finished. + */ + finishedAt: string | null; + }; + BuildsListResponse: { + data: components["schemas"]["ListedBuild"][]; + /** @description Cursor to pass to the next list request, or `null` if there is no next page. */ + nextCursor: string | null; + }; + BuildStatusItem: { + /** + * Format: uuid + * @description Identifier of the build. + */ + id: string; + status: components["schemas"]["BuildStatus"]; + /** + * Format: date-time + * @description Build completion timestamp in RFC3339 format, if finished. + */ + finishedAt: string | null; + /** @description Failure message when status is `failed`, otherwise `null`. */ + statusMessage: string | null; + }; + BuildsStatusesResponse: { + /** @description List of build statuses */ + buildStatuses: components["schemas"]["BuildStatusItem"][]; + }; + BuildInfo: { + /** @description Template names related to this build, if available. */ + names?: string[] | null; + /** + * Format: date-time + * @description Build creation timestamp in RFC3339 format. + */ + createdAt: string; + /** + * Format: date-time + * @description Build completion timestamp in RFC3339 format, if finished. + */ + finishedAt: string | null; + status: components["schemas"]["BuildStatus"]; + /** @description Failure message when status is `failed`, otherwise `null`. */ + statusMessage: string | null; + }; + /** + * Format: int64 + * @description CPU cores for the sandbox + */ + CPUCount: number; + /** + * Format: int64 + * @description Memory for the sandbox in MiB + */ + MemoryMB: number; + /** + * Format: int64 + * @description Disk size for the sandbox in MiB + */ + DiskSizeMB: number; + SandboxRecord: { + /** @description Identifier of the template from which is the sandbox created */ + templateID: string; + /** @description Alias of the template */ + alias?: string; + /** @description Identifier of the sandbox */ + sandboxID: string; + /** + * Format: date-time + * @description Time when the sandbox was started + */ + startedAt: string; + /** + * Format: date-time + * @description Time when the sandbox was stopped + */ + stoppedAt?: string | null; + /** @description Base domain where the sandbox traffic is accessible */ + domain?: string | null; + cpuCount: components["schemas"]["CPUCount"]; + memoryMB: components["schemas"]["MemoryMB"]; + diskSizeMB: components["schemas"]["DiskSizeMB"]; + }; + HealthResponse: { + /** @description Human-readable health check result. */ + message: string; + }; + UserTeamLimits: { + /** Format: int64 */ + maxLengthHours: number; + /** Format: int32 */ + concurrentSandboxes: number; + /** Format: int32 */ + concurrentTemplateBuilds: number; + /** Format: int32 */ + maxVcpu: number; + /** Format: int32 */ + maxRamMb: number; + /** Format: int32 */ + diskMb: number; + }; + UserTeam: { + /** Format: uuid */ + id: string; + name: string; + slug: string; + tier: string; + email: string; + profilePictureUrl: string | null; + isBlocked: boolean; + isBanned: boolean; + blockedReason: string | null; + isDefault: boolean; + limits: components["schemas"]["UserTeamLimits"]; + /** Format: date-time */ + createdAt: string; + }; + UserTeamsResponse: { + teams: components["schemas"]["UserTeam"][]; + }; + TeamMember: { + /** Format: uuid */ + id: string; + email: string; + name?: string | null; + /** Format: uri */ + profilePictureUrl?: string | null; + providers: string[]; + isDefault: boolean; + /** Format: uuid */ + addedBy?: string | null; + /** Format: date-time */ + createdAt: string | null; + }; + TeamMembersResponse: { + members: components["schemas"]["TeamMember"][]; + }; + UpdateTeamRequest: { + name?: string; + profilePictureUrl?: string | null; + }; + UpdateTeamResponse: { + /** Format: uuid */ + id: string; + name: string; + profilePictureUrl?: string | null; + }; + AddTeamMemberRequest: { + /** Format: email */ + email: string; + }; + CreateTeamRequest: { + name: string; + }; + DefaultTemplateAlias: { + alias: string; + namespace?: string | null; + }; + DefaultTemplate: { + id: string; + aliases: components["schemas"]["DefaultTemplateAlias"][]; + /** Format: uuid */ + buildId: string; + /** Format: int64 */ + ramMb: number; + /** Format: int64 */ + vcpu: number; + /** Format: int64 */ + totalDiskSizeMb: number | null; + envdVersion?: string | null; + /** Format: date-time */ + createdAt: string; + public: boolean; + /** Format: int32 */ + buildCount: number; + /** Format: int64 */ + spawnCount: number; + }; + DefaultTemplatesResponse: { + templates: components["schemas"]["DefaultTemplate"][]; + }; + TeamResolveResponse: { + /** Format: uuid */ + id: string; + slug: string; + }; + }; + responses: { + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Authentication error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + parameters: { + /** @description Identifier of the build. */ + build_id: string; + /** @description Identifier of the sandbox. */ + sandboxID: string; + /** @description Maximum number of items to return per page. */ + builds_limit: number; + /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ + builds_cursor: string; + /** @description Optional filter by build identifier, template identifier, or template alias. */ + build_id_or_template: string; + /** @description Comma-separated list of build statuses to include. */ + build_statuses: components["schemas"]["BuildStatus"][]; + /** @description Comma-separated list of build IDs to get statuses for. */ + build_ids: string[]; + /** @description Identifier of the team. */ + teamID: string; + /** @description Identifier of the user. */ + userId: string; + /** @description Team slug to resolve. */ + teamSlug: string; + }; + requestBodies: never; + headers: never; + pathItems: never; } -export type $defs = Record -export type operations = Record +export type $defs = Record; +export type operations = Record; diff --git a/src/features/dashboard/account/email-settings.tsx b/src/features/dashboard/account/email-settings.tsx index 6d363eb1f..34b6252ec 100644 --- a/src/features/dashboard/account/email-settings.tsx +++ b/src/features/dashboard/account/email-settings.tsx @@ -1,19 +1,19 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useSearchParams } from 'next/navigation' -import { useAction } from 'next-safe-action/hooks' -import { useEffect, useMemo } from 'react' +import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' -import { updateUserAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' +import { useTRPC } from '@/trpc/client' import { Button } from '@/ui/primitives/button' import { Card, @@ -32,6 +32,7 @@ import { } from '@/ui/primitives/form' import { Input } from '@/ui/primitives/input' import { useDashboard } from '../context' +import { ReauthDialog } from './reauth-dialog' const formSchema = z.object({ email: z.email('Invalid e-mail address'), @@ -49,6 +50,12 @@ export function EmailSettings({ className }: EmailSettingsProps) { const { user } = useDashboard() const searchParams = useSearchParams() const { toast } = useToast() + const trpc = useTRPC() + const queryClient = useQueryClient() + const [reauthDialogOpen, setReauthDialogOpen] = useState(false) + const showEmailSettings = + Boolean(user?.canChangeEmail) || Boolean(user?.providers.includes('email')) + const canChangeEmail = Boolean(user?.canChangeEmail) const form = useForm({ resolver: zodResolver(formSchema), @@ -60,31 +67,50 @@ export function EmailSettings({ className }: EmailSettingsProps) { }, }) - const hasEmailProvider = useMemo( - () => user.providers.includes('email'), - [user] + const { mutate: updateEmail, isPending } = useMutation( + trpc.user.update.mutationOptions({ + onSuccess: (data) => { + if (data.status === 'reauth') { + setReauthDialogOpen(true) + return + } + + if (data.status === 'ok') { + queryClient.setQueryData(trpc.user.profile.queryKey(), data.user) + toast( + defaultSuccessToast(USER_MESSAGES.emailUpdateVerification.message, { + duration: USER_MESSAGES.emailUpdateVerification.timeoutMs, + }) + ) + return + } + + if (data.status === 'error' && data.code === 'email_exists') { + form.setError('email', { message: 'E-mail already in use.' }) + return + } + + if (data.status === 'error' && data.code === 'email_invalid') { + form.setError('email', { message: 'Invalid e-mail address.' }) + return + } + + if ( + data.status === 'error' && + data.code === 'account_credentials_not_changeable' + ) { + toast(defaultErrorToast('E-mail changes are currently unavailable.')) + return + } + + toast(defaultErrorToast('Failed to update e-mail.')) + }, + onError: () => { + toast(defaultErrorToast('Failed to update e-mail.')) + }, + }) ) - const { execute: updateEmail, isPending } = useAction(updateUserAction, { - onSuccess: () => { - toast( - defaultSuccessToast(USER_MESSAGES.emailUpdateVerification.message, { - duration: USER_MESSAGES.emailUpdateVerification.timeoutMs, - }) - ) - }, - onError: ({ error }) => { - if (error.validationErrors?.fieldErrors?.email?.[0]) { - form.setError('email', { - message: error.validationErrors.fieldErrors.email?.[0], - }) - return - } - - toast(defaultErrorToast(error.serverError || 'Failed to update e-mail.')) - }, - }) - useEffect(() => { if ( !searchParams.has('success') && @@ -112,60 +138,77 @@ export function EmailSettings({ className }: EmailSettingsProps) { ) ) } - }, [searchParams]) + }, [searchParams, toast]) + + if (!user || !showEmailSettings) return null - if (!user || !hasEmailProvider) return null + function submitEmailChange(values: FormValues) { + if (!canChangeEmail) return + updateEmail({ email: values.email }) + } return ( -
- - updateEmail({ email: values.email }) - )} - className="w-full" - > - - - E-Mail - Update your e-mail address. - - - - ( - - - - - - - )} - /> - - - -

- Has to be a valid e-mail address. -

- -
-
-
- + <> +
+ + + + E-Mail + + {canChangeEmail + ? 'Update your e-mail address.' + : 'E-mail changes are currently unavailable.'} + + + + + ( + + + + + + + )} + /> + + + +

+ Has to be a valid e-mail address. +

+ +
+
+
+ + + + ) } diff --git a/src/features/dashboard/account/name-settings.tsx b/src/features/dashboard/account/name-settings.tsx index 44cb659f5..4bd493eec 100644 --- a/src/features/dashboard/account/name-settings.tsx +++ b/src/features/dashboard/account/name-settings.tsx @@ -1,17 +1,17 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import { useAction } from 'next-safe-action/hooks' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' -import { updateUserAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' +import { useTRPC } from '@/trpc/client' import { Button } from '@/ui/primitives/button' import { Card, @@ -49,6 +49,8 @@ export function NameSettings({ className }: NameSettingsProps) { const { user } = useDashboard() const { toast } = useToast() + const trpc = useTRPC() + const queryClient = useQueryClient() const form = useForm({ resolver: zodResolver(formSchema), @@ -60,18 +62,22 @@ export function NameSettings({ className }: NameSettingsProps) { }, }) - const { execute: updateName, isPending } = useAction(updateUserAction, { - onSuccess: async () => { - toast(defaultSuccessToast(USER_MESSAGES.nameUpdated.message)) - }, - onError: ({ error }) => { - toast( - defaultErrorToast( - error.serverError || USER_MESSAGES.failedUpdateName.message - ) - ) - }, - }) + const { mutate: updateName, isPending } = useMutation( + trpc.user.update.mutationOptions({ + onSuccess: (data) => { + if (data.status === 'ok') { + queryClient.setQueryData(trpc.user.profile.queryKey(), data.user) + toast(defaultSuccessToast(USER_MESSAGES.nameUpdated.message)) + return + } + + toast(defaultErrorToast(USER_MESSAGES.failedUpdateName.message)) + }, + onError: () => { + toast(defaultErrorToast(USER_MESSAGES.failedUpdateName.message)) + }, + }) + ) if (!user) return null diff --git a/src/features/dashboard/account/password-settings.tsx b/src/features/dashboard/account/password-settings.tsx index 6f240c90f..0423f094c 100644 --- a/src/features/dashboard/account/password-settings.tsx +++ b/src/features/dashboard/account/password-settings.tsx @@ -1,18 +1,18 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import { useAction } from 'next-safe-action/hooks' -import { useEffect, useMemo, useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' -import { updateUserAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' +import { useTRPC } from '@/trpc/client' import { Button } from '@/ui/primitives/button' import { Card, @@ -61,6 +61,7 @@ export function PasswordSettings({ const { user } = useDashboard() const { toast } = useToast() + const trpc = useTRPC() const [reauthDialogOpen, setReauthDialogOpen] = useState(false) const [clientShowPasswordForm, setClientShowPasswordForm] = useState( showPasswordChangeForm @@ -70,11 +71,6 @@ export function PasswordSettings({ setClientShowPasswordForm(showPasswordChangeForm) }, [showPasswordChangeForm]) - const hasEmailProvider = useMemo( - () => user.providers.includes('email'), - [user] - ) - const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -83,33 +79,39 @@ export function PasswordSettings({ }, }) - const { execute: updatePassword, isPending } = useAction(updateUserAction, { - onSuccess: ({ data }) => { - if (data?.requiresReauth) { - setReauthDialogOpen(true) - return - } - - toast(defaultSuccessToast(USER_MESSAGES.passwordUpdated.message)) - - form.reset() - setClientShowPasswordForm(false) - window.history.replaceState({}, '', window.location.pathname) - }, - onError: ({ error }) => { - if (error.validationErrors?.fieldErrors?.password) { - form.setError('confirmPassword', { - message: error.validationErrors.fieldErrors.password?.[0], - }) - } else { - toast( - defaultErrorToast( - error.serverError || USER_MESSAGES.failedUpdatePassword.message - ) - ) - } - }, - }) + const { mutate: updatePassword, isPending } = useMutation( + trpc.user.update.mutationOptions({ + onSuccess: (data) => { + if (data.status === 'reauth') { + setReauthDialogOpen(true) + return + } + + if (data.status === 'error') { + if (data.code === 'account_credentials_not_changeable') { + toast(defaultErrorToast(USER_MESSAGES.failedUpdatePassword.message)) + return + } + + const message = + data.code === 'same_password' + ? 'New password cannot be the same as the old password.' + : 'Password is too weak.' + form.setError('confirmPassword', { message }) + return + } + + toast(defaultSuccessToast(USER_MESSAGES.passwordUpdated.message)) + + form.reset() + setClientShowPasswordForm(false) + window.history.replaceState({}, '', window.location.pathname) + }, + onError: () => { + toast(defaultErrorToast(USER_MESSAGES.failedUpdatePassword.message)) + }, + }) + ) function onSubmit(values: FormValues) { updatePassword({ password: values.password }) @@ -119,7 +121,7 @@ export function PasswordSettings({ setReauthDialogOpen(true) } - if (!user || !hasEmailProvider) return null + if (!user || !user.canChangePassword) return null return ( <> diff --git a/src/features/dashboard/account/reauth-dialog.tsx b/src/features/dashboard/account/reauth-dialog.tsx index c5d322709..6e15f4061 100644 --- a/src/features/dashboard/account/reauth-dialog.tsx +++ b/src/features/dashboard/account/reauth-dialog.tsx @@ -1,7 +1,6 @@ 'use client' -import { PROTECTED_URLS } from '@/configs/urls' -import { signOutAction } from '@/core/server/actions/auth-actions' +import { reauthForAccountSettingsAction } from '@/core/server/actions/auth-actions' import { AlertDialog } from '@/ui/alert-dialog' interface ReauthDialogProps { @@ -10,8 +9,12 @@ interface ReauthDialogProps { } export function ReauthDialog({ open, onOpenChange }: ReauthDialogProps) { - const handleReauth = () => { - signOutAction(PROTECTED_URLS.ACCOUNT_SETTINGS) + const handleReauth = async () => { + // Hard navigation (not the Next router): oauth-start is a side-effecting GET + // that must run exactly once, so a soft RSC navigation would corrupt the + // OAuth flow. See reauthForAccountSettingsAction. + const { url } = await reauthForAccountSettingsAction() + window.location.href = url } return ( @@ -21,8 +24,8 @@ export function ReauthDialog({ open, onOpenChange }: ReauthDialogProps) { title="Re-authentication Required" description={

- To change your password, you'll need to{' '} - re-authenticate for security. + To make this change, you'll need to re-authenticate{' '} + for security.

} confirm="Sign in again" diff --git a/src/features/dashboard/account/user-access-token.tsx b/src/features/dashboard/account/user-access-token.tsx index 433b26c22..23ea04127 100644 --- a/src/features/dashboard/account/user-access-token.tsx +++ b/src/features/dashboard/account/user-access-token.tsx @@ -1,9 +1,9 @@ 'use client' -import { useAction } from 'next-safe-action/hooks' +import { useMutation } from '@tanstack/react-query' import { useState } from 'react' -import { getUserAccessTokenAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' import CopyButton from '@/ui/copy-button' import { IconButton } from '@/ui/primitives/icon-button' import { EyeIcon, EyeOffIcon } from '@/ui/primitives/icons' @@ -16,22 +16,22 @@ interface UserAccessTokenProps { export default function UserAccessToken({ className }: UserAccessTokenProps) { const { toast } = useToast() + const trpc = useTRPC() const [token, setToken] = useState() const [isVisible, setIsVisible] = useState(false) - const { execute: fetchToken, isPending } = useAction( - getUserAccessTokenAction, - { - onSuccess: (result) => { - if (result.data) { - setToken(result.data.token) + const { mutate: fetchToken, isPending } = useMutation( + trpc.user.createAccessToken.mutationOptions({ + onSuccess: (data) => { + if (data?.token) { + setToken(data.token) setIsVisible(true) } }, onError: () => { toast(defaultErrorToast('Failed to fetch access token')) }, - } + }) ) return ( diff --git a/src/features/dashboard/sandbox/inspect/context.tsx b/src/features/dashboard/sandbox/inspect/context.tsx index e15acc0d7..3148eb6ca 100644 --- a/src/features/dashboard/sandbox/inspect/context.tsx +++ b/src/features/dashboard/sandbox/inspect/context.tsx @@ -11,7 +11,7 @@ import { useMemo, useRef, } from 'react' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { AUTH_URLS } from '@/configs/urls' import { supabase } from '@/core/shared/clients/supabase/client' import { useSandboxInspectAnalytics } from '@/lib/hooks/use-analytics' @@ -193,7 +193,7 @@ export default function SandboxInspectProvider({ // Keep inspect connections from extending sandbox TTL via SDK default connect timeout. timeoutMs: 1_000, headers: { - ...SUPABASE_AUTH_HEADERS(data.session.access_token, teamId), + ...authHeaders(data.session.access_token, teamId), }, }) diff --git a/src/features/dashboard/terminal/sandbox-session.ts b/src/features/dashboard/terminal/sandbox-session.ts index 2e6f86700..a0179d4c5 100644 --- a/src/features/dashboard/terminal/sandbox-session.ts +++ b/src/features/dashboard/terminal/sandbox-session.ts @@ -1,5 +1,5 @@ import Sandbox from 'e2b' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { supabase } from '@/core/shared/clients/supabase/client' import { TERMINAL_SANDBOX_TIMEOUT_MS } from './constants' import { @@ -30,7 +30,7 @@ export async function openTerminalSandbox({ } const userId = data.session.user.id - const headers = SUPABASE_AUTH_HEADERS(data.session.access_token, teamId) + const headers = authHeaders(data.session.access_token, teamId) if (sandboxId) { onStatus(`Connecting to terminal sandbox ${sandboxId}...\r\n`) diff --git a/src/lib/env.ts b/src/lib/env.ts index ab016be32..0034e4b82 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -14,6 +14,15 @@ export const serverSchema = z.object({ TURNSTILE_SECRET_KEY: z.string().optional(), + AUTH_PROVIDER: z.enum(['supabase', 'ory']).optional(), + AUTH_SECRET: z.string().min(1).optional(), + AUTH_TRUST_HOST: z.string().optional(), + ORY_SDK_URL: z.url().optional(), + ORY_OAUTH2_CLIENT_ID: z.string().min(1).optional(), + ORY_OAUTH2_CLIENT_SECRET: z.string().min(1).optional(), + ORY_OAUTH2_AUDIENCE: z.string().min(1).optional(), + ORY_PROJECT_API_TOKEN: z.string().min(1).optional(), + OTEL_SERVICE_NAME: z.string().optional(), OTEL_EXPORTER_OTLP_ENDPOINT: z.url().optional(), OTEL_EXPORTER_OTLP_PROTOCOL: z diff --git a/src/lib/utils/server.ts b/src/lib/utils/server.ts index 40732c351..2d8d1bd8c 100644 --- a/src/lib/utils/server.ts +++ b/src/lib/utils/server.ts @@ -3,7 +3,7 @@ import 'server-only' import { cookies } from 'next/headers' import { cache } from 'react' import { z } from 'zod' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { COOKIE_KEYS } from '@/configs/cookies' import { returnServerError } from '@/core/server/actions/utils' import { infra } from '@/core/shared/clients/api' @@ -12,7 +12,7 @@ import { l } from '@/core/shared/clients/logger/logger' /* * This function generates an e2b user access token for a given user. */ -export async function generateE2BUserAccessToken(supabaseAccessToken: string) { +export async function generateE2BUserAccessToken(accessToken: string) { const TOKEN_NAME = 'e2b_dashboard_generated_access_token' const res = await infra.POST('/access-tokens', { @@ -20,7 +20,7 @@ export async function generateE2BUserAccessToken(supabaseAccessToken: string) { name: TOKEN_NAME, }, headers: { - ...SUPABASE_AUTH_HEADERS(supabaseAccessToken), + ...authHeaders(accessToken), }, }) diff --git a/src/proxy.ts b/src/proxy.ts index 98aa919f7..8662fcf0b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,93 +1,34 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { ALLOW_SEO_INDEXING } from './configs/flags' -import { createAuthForProxy } from './core/server/auth' -import { getAuthRedirect } from './core/server/http/proxy' +import { + type NextFetchEvent, + type NextRequest, + NextResponse, +} from 'next/server' +import type { Session } from 'next-auth' +import { auth as authjsMiddleware } from '@/auth' +import { isOryAuthEnabled } from './configs/flags' +import { getOryAuthRouteRedirect } from './core/server/auth/ory/auth-route-redirect' +import { + handleAuthGate, + handleMiddlewareRedirect, + handleMiddlewareRewrite, + handleRouteRewritePassthrough, +} from './core/server/http/proxy' import { l, serializeErrorForLog } from './core/shared/clients/logger/logger' -import { getMiddlewareRedirectFromPath } from './lib/utils/redirects' -import { getRewriteForPath } from './lib/utils/rewrites' -export async function proxy(request: NextRequest) { +// Runs the proxy's ordered concerns: the first handler that returns a Response +// wins; otherwise we fall through to the auth gate. `knownAuth` is passed in Ory +// mode (resolved by the Auth.js middleware wrapper) and omitted in Supabase mode. +async function proxyCore( + request: NextRequest, + knownAuth?: boolean +): Promise { try { - const pathname = request.nextUrl.pathname - - // Redirects, that require custom headers - // NOTE: We don't handle this via config matchers, because nextjs configs need to be static - const middlewareRedirect = getMiddlewareRedirectFromPath( - request.nextUrl.pathname + return ( + handleMiddlewareRedirect(request) ?? + handleRouteRewritePassthrough(request) ?? + handleMiddlewareRewrite(request) ?? + (await handleAuthGate(request, knownAuth)) ) - - if (middlewareRedirect) { - const headers = new Headers(middlewareRedirect.headers) - const url = new URL(middlewareRedirect.destination, request.url) - - return NextResponse.redirect(url, { - status: middlewareRedirect.statusCode, - headers, - }) - } - - // Catch-all route rewrite paths should not be handled by middleware - // NOTE: We don't handle this via config matchers, because nextjs configs need to be static - const { config: routeRewriteConfig } = getRewriteForPath(pathname, 'route') - - if (routeRewriteConfig) { - return NextResponse.next({ - request, - }) - } - - // Check if the path should be rewritten by middleware - const { config: middlewareRewriteConfig, rule: middlewareRewriteRule } = - getRewriteForPath(pathname, 'middleware') - - if (middlewareRewriteConfig) { - const rewriteUrl = new URL(request.url) - rewriteUrl.hostname = middlewareRewriteConfig.domain - rewriteUrl.protocol = 'https' - rewriteUrl.port = '' - if (middlewareRewriteRule?.pathPreprocessor) { - rewriteUrl.pathname = middlewareRewriteRule.pathPreprocessor( - rewriteUrl.pathname - ) - } - - const headers = new Headers(request.headers) - - if (ALLOW_SEO_INDEXING) { - headers.set('x-e2b-should-index', '1') - } - - const response = NextResponse.rewrite(rewriteUrl, { - request: { - headers, - }, - }) - - if (ALLOW_SEO_INDEXING) { - response.headers.set('X-Robots-Tag', 'index, follow') - } else { - response.headers.set('X-Robots-Tag', 'noindex, nofollow') - } - - return response - } - - const response = NextResponse.next({ - request, - }) - const authContext = await createAuthForProxy( - request, - response - ).getAuthContext() - const isAuthenticated = !!authContext - - const authRedirect = getAuthRedirect(request, isAuthenticated) - - if (authRedirect) { - return authRedirect - } - - return response } catch (error) { l.error( { @@ -102,10 +43,36 @@ export async function proxy(request: NextRequest) { ) // return a basic response to avoid infinite loops - return NextResponse.next({ - request, - }) + return NextResponse.next({ request }) + } +} + +// req.auth is truthy even when the session carries a RefreshTokenError, so we +// must check session.error too — otherwise the auth-route guard treats a +// poisoned session as "logged in" and ping-pongs the user between /dashboard +// (redirects to /sign-in via getAuthContext()) and /sign-in (redirects back to +// /dashboard via the proxy's authenticated-on-auth-route rule). +function isSessionAuthenticated(session: Session | null): boolean { + return !!session && !session.error +} + +// In Ory mode the Auth.js middleware wrapper populates req.auth and manages its +// session cookies, so auth is resolved here and threaded into proxyCore. +const proxyWithOryAuth = authjsMiddleware((req, _event: NextFetchEvent) => + proxyCore(req, isSessionAuthenticated(req.auth)) +) + +export async function proxy(request: NextRequest, event: NextFetchEvent) { + if (!isOryAuthEnabled()) { + return proxyCore(request) } + + // Bounce the legacy auth pages straight to the Ory hosted UI before the + // (auth) layout can render. + const authRouteRedirect = getOryAuthRouteRedirect(request) + if (authRouteRedirect) return authRouteRedirect + + return proxyWithOryAuth(request, event) } export const config = { diff --git a/tests/integration/auth-ory-bootstrap.test.ts b/tests/integration/auth-ory-bootstrap.test.ts new file mode 100644 index 000000000..f7e82a74b --- /dev/null +++ b/tests/integration/auth-ory-bootstrap.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})) + +const apiPostMock = vi.hoisted(() => vi.fn()) +const originalDashboardApiAdminToken = process.env.DASHBOARD_API_ADMIN_TOKEN + +function jwt(claims: Record) { + return [ + Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString( + 'base64url' + ), + Buffer.from(JSON.stringify(claims)).toString('base64url'), + 'signature', + ].join('.') +} + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: loggerMocks, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +vi.mock('@/configs/api', () => ({ + ADMIN_AUTH_HEADERS: vi.fn((token: string) => ({ 'X-Admin-Token': token })), +})) + +vi.mock('@/core/shared/clients/api', () => ({ + api: { + POST: apiPostMock, + }, +})) + +const { bootstrapOryUser } = await import('@/core/server/auth/ory/bootstrap') + +describe('bootstrapOryUser (Auth.js events.signIn handler)', () => { + beforeEach(() => { + process.env.DASHBOARD_API_ADMIN_TOKEN = 'admin-token' + loggerMocks.error.mockClear() + loggerMocks.warn.mockClear() + apiPostMock.mockReset() + }) + + afterEach(() => { + process.env.DASHBOARD_API_ADMIN_TOKEN = originalDashboardApiAdminToken + }) + + it('calls dashboard-api bootstrap with Ory user fields', async () => { + apiPostMock.mockResolvedValue({ + data: { id: 'team-1', slug: 'team-1' }, + error: null, + response: { ok: true, status: 200, statusText: 'OK' }, + }) + + await bootstrapOryUser({ + accessToken: jwt({ + iss: 'https://ory.example.test', + sub: 'access-token-sub', + email: 'access-token-user@example.com', + name: 'Access Token User', + }), + provider: 'ory', + }) + + expect(apiPostMock).toHaveBeenCalledTimes(1) + expect(apiPostMock).toHaveBeenCalledWith('/admin/users/bootstrap', { + body: { + oidc_issuer: 'https://ory.example.test', + oidc_user_id: 'access-token-sub', + oidc_user_email: 'access-token-user@example.com', + oidc_user_name: 'Access Token User', + }, + headers: { 'X-Admin-Token': 'admin-token' }, + }) + expect(loggerMocks.error).not.toHaveBeenCalled() + }) + + it('falls back to id_token email and name while keeping access-token subject', async () => { + apiPostMock.mockResolvedValue({ + data: { id: 'team-1', slug: 'team-1' }, + error: null, + response: { ok: true, status: 200, statusText: 'OK' }, + }) + + await bootstrapOryUser({ + accessToken: jwt({ + iss: 'https://ory.example.test', + sub: 'access-token-sub', + }), + idToken: jwt({ + iss: 'https://ory.example.test', + sub: 'id-token-sub', + email: 'id-token-user@example.com', + given_name: 'Id Token User', + }), + provider: 'ory', + }) + + expect(apiPostMock).toHaveBeenCalledWith('/admin/users/bootstrap', { + body: { + oidc_issuer: 'https://ory.example.test', + oidc_user_id: 'access-token-sub', + oidc_user_email: 'id-token-user@example.com', + oidc_user_name: 'Id Token User', + }, + headers: { 'X-Admin-Token': 'admin-token' }, + }) + }) + + it('skips the bootstrap call and logs when iss is missing', async () => { + await bootstrapOryUser({ + accessToken: jwt({ + sub: 'access-token-sub', + email: 'user@example.com', + }), + provider: 'ory', + }) + + expect(apiPostMock).not.toHaveBeenCalled() + expect(loggerMocks.error).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'auth_events:bootstrap_user:missing_claims', + context: expect.objectContaining({ + has_iss: false, + has_sub: true, + has_email: true, + }), + }), + expect.stringContaining('missing required bootstrap claims') + ) + }) + + it('logs but does not throw when the bootstrap call returns an api error', async () => { + apiPostMock.mockResolvedValue({ + data: null, + error: { status: 503, message: 'dashboard-api unavailable' }, + response: { ok: false, status: 503, statusText: 'Service Unavailable' }, + }) + + await expect( + bootstrapOryUser({ + accessToken: jwt({ + iss: 'https://ory.example.test', + sub: 'access-token-sub', + email: 'user@example.com', + name: 'User', + }), + provider: 'ory', + }) + ).resolves.toBeUndefined() + + expect(loggerMocks.error).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'auth_events:bootstrap_user:error', + context: expect.objectContaining({ + provider: 'ory', + error_status: 503, + }), + }), + expect.stringContaining('dashboard-api unavailable') + ) + }) + + it('logs but does not throw when the repository throws', async () => { + const failure = new Error('network down') + apiPostMock.mockRejectedValue(failure) + + await expect( + bootstrapOryUser({ + accessToken: jwt({ + iss: 'https://ory.example.test', + sub: 'access-token-sub', + email: 'user@example.com', + }), + }) + ).resolves.toBeUndefined() + + expect(loggerMocks.error).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'auth_events:bootstrap_user:exception', + error: failure, + }), + expect.stringContaining('threw unexpected exception') + ) + }) +}) diff --git a/tests/unit/auth-headers.test.ts b/tests/unit/auth-headers.test.ts new file mode 100644 index 000000000..4e8ac9e28 --- /dev/null +++ b/tests/unit/auth-headers.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + AUTH_PROVIDER_TEAM_HEADER, + authHeaders, + SUPABASE_TEAM_HEADER, + SUPABASE_TOKEN_HEADER, +} from '@/configs/api' + +const originalAuthProvider = process.env.AUTH_PROVIDER + +afterEach(() => { + process.env.AUTH_PROVIDER = originalAuthProvider +}) + +describe('authHeaders', () => { + it('uses Supabase headers by default', () => { + process.env.AUTH_PROVIDER = 'supabase' + + expect(authHeaders('token', 'team-id')).toEqual({ + [SUPABASE_TOKEN_HEADER]: 'token', + [SUPABASE_TEAM_HEADER]: 'team-id', + }) + }) + + it('uses Authorization and X-Team-ID in Ory mode', () => { + process.env.AUTH_PROVIDER = 'ory' + + expect(authHeaders('token', 'team-id')).toEqual({ + Authorization: 'Bearer token', + [AUTH_PROVIDER_TEAM_HEADER]: 'team-id', + }) + }) +}) diff --git a/tests/unit/auth-ory-callbacks.test.ts b/tests/unit/auth-ory-callbacks.test.ts new file mode 100644 index 000000000..89bd54d34 --- /dev/null +++ b/tests/unit/auth-ory-callbacks.test.ts @@ -0,0 +1,132 @@ +import type { Session } from 'next-auth' +import type { JWT } from 'next-auth/jwt' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const resolveIdentityMock = vi.hoisted(() => vi.fn()) +const refreshOryTokenMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/server/auth/ory/find-identity', () => ({ + resolveOryIdentity: resolveIdentityMock, +})) + +vi.mock('@/core/server/auth/ory/refresh-token', () => ({ + refreshOryToken: refreshOryTokenMock, +})) + +const { resolveOryJwt, applyTokenToSession } = await import( + '@/core/server/auth/ory/auth-callbacks' +) + +function makeJwt(claims: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString( + 'base64url' + ) + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url') + return `${header}.${payload}.sig` +} + +const nowSeconds = Math.floor(Date.now() / 1000) + +describe('resolveOryJwt', () => { + beforeEach(() => { + resolveIdentityMock.mockReset() + refreshOryTokenMock.mockReset() + }) + + it('persists Ory tokens and the resolved Kratos id on fresh sign-in', async () => { + resolveIdentityMock.mockResolvedValue({ id: 'kratos-uuid' }) + + const result = await resolveOryJwt({ + token: { sub: 'e2b-user-id', error: 'StalePoison' } as JWT, + account: { + provider: 'ory', + type: 'oidc', + providerAccountId: 'x', + access_token: 'at', + refresh_token: 'rt', + id_token: makeJwt({ email: 'ada@example.test' }), + expires_at: 1234, + }, + profile: { sub: 'profile-sub' }, + }) + + expect(resolveIdentityMock).toHaveBeenCalledWith({ + subjects: ['profile-sub', 'e2b-user-id'], + email: 'ada@example.test', + }) + expect(result).toMatchObject({ + sub: 'e2b-user-id', + accessToken: 'at', + refreshToken: 'rt', + expiresAt: 1234, + identityId: 'kratos-uuid', + error: undefined, + }) + }) + + it('leaves identityId undefined when resolution fails (sign-in not blocked)', async () => { + resolveIdentityMock.mockResolvedValue(null) + + const result = await resolveOryJwt({ + token: {} as JWT, + account: { + provider: 'ory', + type: 'oidc', + providerAccountId: 'x', + access_token: 'at', + }, + }) + + expect(result.identityId).toBeUndefined() + expect(result.accessToken).toBe('at') + }) + + it('stops retrying once the token carries a refresh error', async () => { + const token = { error: 'RefreshTokenError', sub: 'x' } as JWT + + const result = await resolveOryJwt({ token, account: null }) + + expect(result).toBe(token) + expect(refreshOryTokenMock).not.toHaveBeenCalled() + }) + + it('refreshes when the access token is near expiry', async () => { + refreshOryTokenMock.mockResolvedValue({ accessToken: 'fresh' }) + + const result = await resolveOryJwt({ + token: { expiresAt: nowSeconds + 30 } as JWT, + account: null, + }) + + expect(refreshOryTokenMock).toHaveBeenCalled() + expect(result).toEqual({ accessToken: 'fresh' }) + }) + + it('leaves a still-valid token untouched', async () => { + const token = { expiresAt: nowSeconds + 3600 } as JWT + + const result = await resolveOryJwt({ token, account: null }) + + expect(result).toBe(token) + expect(refreshOryTokenMock).not.toHaveBeenCalled() + }) +}) + +describe('applyTokenToSession', () => { + it('projects the token fields onto the session', () => { + const session = { user: { id: 'placeholder' } } as Session + + const result = applyTokenToSession(session, { + sub: 'e2b-user-id', + accessToken: 'at', + idToken: 'it', + identityId: 'kratos-uuid', + error: undefined, + } as JWT) + + expect(result.user.id).toBe('e2b-user-id') + expect(result.accessToken).toBe('at') + expect(result.idToken).toBe('it') + expect(result.identityId).toBe('kratos-uuid') + }) +}) diff --git a/tests/unit/auth-ory-find-identity.test.ts b/tests/unit/auth-ory-find-identity.test.ts new file mode 100644 index 000000000..9971cfac4 --- /dev/null +++ b/tests/unit/auth-ory-find-identity.test.ts @@ -0,0 +1,192 @@ +import { ResponseError } from '@ory/client-fetch' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})) + +const getIdentityMock = vi.hoisted(() => vi.fn()) +const getIdentityByExternalIDMock = vi.hoisted(() => vi.fn()) +const listIdentitiesMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: loggerMocks, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +vi.mock('@/core/server/auth/ory/client', () => ({ + getOryIdentityApi: () => ({ + getIdentity: getIdentityMock, + getIdentityByExternalID: getIdentityByExternalIDMock, + listIdentities: listIdentitiesMock, + }), +})) + +const { resolveOryIdentity, findOryIdentityBySubject, findOryIdentityByEmail } = + await import('@/core/server/auth/ory/find-identity') + +function notFound(): ResponseError { + return new ResponseError(new Response(null, { status: 404 }), 'not found') +} + +describe('findOryIdentityBySubject', () => { + beforeEach(() => { + getIdentityMock.mockReset() + getIdentityByExternalIDMock.mockReset() + loggerMocks.error.mockClear() + }) + + it('resolves by Kratos id without an external_id lookup', async () => { + getIdentityMock.mockResolvedValue({ id: 'sub-is-kratos-id' }) + + const identity = await findOryIdentityBySubject('sub-is-kratos-id') + + expect(identity).toEqual({ id: 'sub-is-kratos-id' }) + expect(getIdentityByExternalIDMock).not.toHaveBeenCalled() + }) + + it('falls back to external_id when the id lookup 404s', async () => { + getIdentityMock.mockRejectedValue(notFound()) + getIdentityByExternalIDMock.mockResolvedValue({ id: 'kratos-uuid' }) + + const identity = await findOryIdentityBySubject('legacy-id') + + expect(getIdentityByExternalIDMock).toHaveBeenCalledWith({ + externalID: 'legacy-id', + }) + expect(identity).toEqual({ id: 'kratos-uuid' }) + }) + + it('passes included credential requests through both subject lookup strategies', async () => { + getIdentityMock.mockRejectedValue(notFound()) + getIdentityByExternalIDMock.mockResolvedValue({ id: 'kratos-uuid' }) + + await findOryIdentityBySubject('legacy-id', ['password', 'oidc']) + + expect(getIdentityMock).toHaveBeenCalledWith({ + id: 'legacy-id', + includeCredential: ['password', 'oidc'], + }) + expect(getIdentityByExternalIDMock).toHaveBeenCalledWith({ + externalID: 'legacy-id', + includeCredential: ['password', 'oidc'], + }) + }) + + it('returns null without a terminal error log when both miss', async () => { + getIdentityMock.mockRejectedValue(notFound()) + getIdentityByExternalIDMock.mockRejectedValue(notFound()) + + const identity = await findOryIdentityBySubject('ghost') + + expect(identity).toBeNull() + // the terminal not_found error belongs to resolveOryIdentity, not here + expect(loggerMocks.error).not.toHaveBeenCalled() + }) +}) + +describe('findOryIdentityByEmail', () => { + beforeEach(() => { + listIdentitiesMock.mockReset() + loggerMocks.error.mockClear() + }) + + it('queries by credentials identifier and prefers an exact email match', async () => { + listIdentitiesMock.mockResolvedValue([ + { id: 'other', traits: { email: 'someone@else.test' } }, + { id: 'match', traits: { email: 'Ada@Example.test' } }, + ]) + + const identity = await findOryIdentityByEmail('ada@example.test') + + expect(listIdentitiesMock).toHaveBeenCalledWith({ + credentialsIdentifier: 'ada@example.test', + pageSize: 2, + }) + expect(identity?.id).toBe('match') + }) + + it('returns null when no identity has that credential', async () => { + listIdentitiesMock.mockResolvedValue([]) + + const identity = await findOryIdentityByEmail('nobody@example.test') + + expect(identity).toBeNull() + }) + + it('passes included credential requests through email lookups', async () => { + listIdentitiesMock.mockResolvedValue([ + { id: 'match', traits: { email: 'ada@example.test' } }, + ]) + + await findOryIdentityByEmail('ada@example.test', ['password', 'oidc']) + + expect(listIdentitiesMock).toHaveBeenCalledWith({ + credentialsIdentifier: 'ada@example.test', + pageSize: 2, + includeCredential: ['password', 'oidc'], + }) + }) +}) + +describe('resolveOryIdentity', () => { + beforeEach(() => { + getIdentityMock.mockReset() + getIdentityByExternalIDMock.mockReset() + listIdentitiesMock.mockReset() + loggerMocks.error.mockClear() + }) + + it('tries subjects in order and returns the first hit', async () => { + getIdentityMock + .mockRejectedValueOnce(notFound()) // profile.sub by id + .mockResolvedValueOnce({ id: 'kratos-uuid' }) // token.sub by id + getIdentityByExternalIDMock.mockRejectedValue(notFound()) + + const identity = await resolveOryIdentity({ + subjects: ['profile-sub', 'token-sub'], + }) + + expect(identity).toEqual({ id: 'kratos-uuid' }) + expect(listIdentitiesMock).not.toHaveBeenCalled() + }) + + it('falls back to email when every subject misses', async () => { + getIdentityMock.mockRejectedValue(notFound()) + getIdentityByExternalIDMock.mockRejectedValue(notFound()) + listIdentitiesMock.mockResolvedValue([ + { id: 'kratos-uuid', traits: { email: 'ada@example.test' } }, + ]) + + const identity = await resolveOryIdentity({ + subjects: ['e2b-user-id'], + email: 'ada@example.test', + }) + + expect(identity?.id).toBe('kratos-uuid') + }) + + it('de-dupes falsy/duplicate subjects and logs not_found when all strategies fail', async () => { + getIdentityMock.mockRejectedValue(notFound()) + getIdentityByExternalIDMock.mockRejectedValue(notFound()) + listIdentitiesMock.mockResolvedValue([]) + + const identity = await resolveOryIdentity({ + subjects: ['x', 'x', null, undefined], + email: 'ghost@example.test', + }) + + expect(identity).toBeNull() + // 'x' resolved once despite duplicates → 1 id + 1 external_id call + expect(getIdentityMock).toHaveBeenCalledTimes(1) + expect(loggerMocks.error).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'auth_provider:resolve_identity:not_found', + }), + expect.any(String) + ) + }) +}) diff --git a/tests/unit/auth-ory-flows.test.ts b/tests/unit/auth-ory-flows.test.ts new file mode 100644 index 000000000..1c0881aea --- /dev/null +++ b/tests/unit/auth-ory-flows.test.ts @@ -0,0 +1,179 @@ +import { ResponseError } from '@ory/client-fetch' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})) + +const patchIdentityMock = vi.hoisted(() => vi.fn()) +const getIdentityMock = vi.hoisted(() => vi.fn()) +const updateIdentityMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: loggerMocks, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +vi.mock('@/core/server/auth/ory/client', () => ({ + getOryIdentityApi: () => ({ + patchIdentity: patchIdentityMock, + getIdentity: getIdentityMock, + updateIdentity: updateIdentityMock, + }), +})) + +const { oryAuthFlows } = await import('@/core/server/auth/ory/flows') + +function oryError( + status: number, + body: Record +): ResponseError { + return new ResponseError( + new Response(JSON.stringify(body), { status }), + 'Response returned an error code' + ) +} + +describe('oryAuthFlows.updateUser', () => { + beforeEach(() => { + patchIdentityMock.mockReset() + getIdentityMock.mockReset() + updateIdentityMock.mockReset() + loggerMocks.error.mockClear() + }) + + it('patches only the provided traits and returns the mapped user', async () => { + patchIdentityMock.mockResolvedValue({ + id: 'identity-1', + traits: { email: 'new@example.test', name: 'Ada' }, + credentials: { password: {} }, + }) + + const result = await oryAuthFlows.updateUser({ + identityId: 'identity-1', + name: 'Ada', + email: 'new@example.test', + }) + + expect(patchIdentityMock).toHaveBeenCalledWith({ + id: 'identity-1', + jsonPatch: [ + { op: 'replace', path: '/traits/name', value: 'Ada' }, + { op: 'replace', path: '/traits/email', value: 'new@example.test' }, + ], + }) + expect(result).toEqual({ + ok: true, + user: expect.objectContaining({ + id: 'identity-1', + email: 'new@example.test', + name: 'Ada', + // `password` credential is normalized to the `email` provider vocabulary + providers: ['email'], + }), + }) + }) + + it('sets the password via updateIdentity (import path) so Kratos hashes it', async () => { + getIdentityMock.mockResolvedValue({ + id: 'identity-1', + schema_id: 'default', + state: 'active', + traits: { email: 'a@b.test', name: 'Ada' }, + external_id: 'legacy-id', + }) + updateIdentityMock.mockResolvedValue({ + id: 'identity-1', + traits: { email: 'a@b.test' }, + credentials: { password: {} }, + }) + + await oryAuthFlows.updateUser({ + identityId: 'identity-1', + password: 'super-secret', + }) + + // not the raw patch — that writes cleartext without hashing + expect(patchIdentityMock).not.toHaveBeenCalled() + expect(updateIdentityMock).toHaveBeenCalledWith({ + id: 'identity-1', + updateIdentityBody: expect.objectContaining({ + schema_id: 'default', + state: 'active', + external_id: 'legacy-id', + traits: { email: 'a@b.test', name: 'Ada' }, + credentials: { password: { config: { password: 'super-secret' } } }, + }), + }) + }) + + it('maps a 409 conflict to email_exists', async () => { + patchIdentityMock.mockRejectedValue( + oryError(409, { + error: { code: 409, reason: 'identity address already exists' }, + }) + ) + + const result = await oryAuthFlows.updateUser({ + identityId: 'identity-1', + email: 'taken@example.test', + }) + + expect(result).toEqual({ + ok: false, + code: 'email_exists', + message: undefined, + }) + }) + + it('maps a 400 password policy violation to weak_password', async () => { + getIdentityMock.mockResolvedValue({ + id: 'identity-1', + schema_id: 'default', + state: 'active', + traits: { email: 'a@b.test' }, + }) + updateIdentityMock.mockRejectedValue( + oryError(400, { + error: { + code: 400, + reason: 'the password does not fulfill the password policy', + message: 'password too short', + }, + }) + ) + + const result = await oryAuthFlows.updateUser({ + identityId: 'identity-1', + password: 'short', + }) + + expect(result).toEqual({ + ok: false, + code: 'weak_password', + message: 'password too short', + }) + }) + + it('rethrows unclassified Ory errors as unexpected', async () => { + patchIdentityMock.mockRejectedValue( + oryError(500, { error: { code: 500, reason: 'internal error' } }) + ) + + await expect( + oryAuthFlows.updateUser({ identityId: 'identity-1', name: 'X' }) + ).rejects.toBeInstanceOf(ResponseError) + expect(loggerMocks.error).toHaveBeenCalled() + }) + + it('rethrows non-Ory errors', async () => { + patchIdentityMock.mockRejectedValue(new Error('network down')) + + await expect( + oryAuthFlows.updateUser({ identityId: 'identity-1', name: 'X' }) + ).rejects.toThrow('network down') + }) +}) diff --git a/tests/unit/auth-ory-freshness.test.ts b/tests/unit/auth-ory-freshness.test.ts new file mode 100644 index 000000000..d562687c5 --- /dev/null +++ b/tests/unit/auth-ory-freshness.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { + isReauthFresh, + REAUTH_FRESHNESS_WINDOW_SECONDS, + readAuthTime, +} from '@/core/server/auth/ory/freshness' + +function makeIdToken(claims: Record): string { + const header = Buffer.from( + JSON.stringify({ alg: 'RS256', typ: 'JWT' }) + ).toString('base64url') + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url') + return `${header}.${payload}.signature` +} + +describe('readAuthTime', () => { + it('returns null for undefined token', () => { + expect(readAuthTime(undefined)).toBeNull() + }) + + it('returns null when auth_time claim is missing', () => { + expect(readAuthTime(makeIdToken({ sub: 'user-1' }))).toBeNull() + }) + + it('returns null when auth_time is not a number', () => { + expect(readAuthTime(makeIdToken({ auth_time: 'nope' }))).toBeNull() + }) + + it('returns the auth_time epoch seconds when present', () => { + expect(readAuthTime(makeIdToken({ auth_time: 1_700_000_000 }))).toBe( + 1_700_000_000 + ) + }) + + it('returns null for a malformed token', () => { + expect(readAuthTime('not-a-jwt')).toBeNull() + }) +}) + +describe('isReauthFresh', () => { + const now = 1_700_000_000 + + it('is true when auth_time is within the window', () => { + const token = makeIdToken({ auth_time: now - 60 }) + expect(isReauthFresh(token, now)).toBe(true) + }) + + it('is true exactly at the window boundary', () => { + const token = makeIdToken({ + auth_time: now - REAUTH_FRESHNESS_WINDOW_SECONDS, + }) + expect(isReauthFresh(token, now)).toBe(true) + }) + + it('is false when auth_time is older than the window', () => { + const token = makeIdToken({ + auth_time: now - REAUTH_FRESHNESS_WINDOW_SECONDS - 1, + }) + expect(isReauthFresh(token, now)).toBe(false) + }) + + it('is false when there is no id_token', () => { + expect(isReauthFresh(undefined, now)).toBe(false) + }) +}) diff --git a/tests/unit/auth-ory-identity.test.ts b/tests/unit/auth-ory-identity.test.ts new file mode 100644 index 000000000..4b400b533 --- /dev/null +++ b/tests/unit/auth-ory-identity.test.ts @@ -0,0 +1,93 @@ +import type { Identity } from '@ory/client-fetch' +import { describe, expect, it } from 'vitest' +import { fromOryIdentity } from '@/core/server/auth/ory/identity' + +function identity(partial: Partial): Identity { + return { + id: 'identity-1', + schema_id: 'default', + schema_url: '', + traits: {}, + ...partial, + } as Identity +} + +describe('fromOryIdentity providers normalization', () => { + it('maps the Kratos `password` credential to `email`', () => { + const user = fromOryIdentity(identity({ credentials: { password: {} } })) + expect(user.providers).toEqual(['email']) + }) + + it('maps `password` to `email` and preserves other keys like `oidc`', () => { + const user = fromOryIdentity( + identity({ credentials: { password: {}, oidc: {} } }) + ) + expect(user.providers).toEqual(['email', 'oidc']) + }) + + it('leaves oauth-only identities without the email provider', () => { + const user = fromOryIdentity(identity({ credentials: { oidc: {} } })) + expect(user.providers).toEqual(['oidc']) + }) + + it('returns no providers when credentials are absent', () => { + const user = fromOryIdentity(identity({ credentials: undefined })) + expect(user.providers).toEqual([]) + }) +}) + +describe('fromOryIdentity account capabilities', () => { + it('blocks email changes but allows password changes for password-only identities with password material', () => { + const user = fromOryIdentity( + identity({ + credentials: { + password: { config: { hashed_password: 'hash' } }, + }, + }) + ) + + expect(user.canChangeEmail).toBe(false) + expect(user.canChangePassword).toBe(true) + }) + + it('does not treat a bare password credential key as a usable email/password account', () => { + const user = fromOryIdentity(identity({ credentials: { password: {} } })) + + expect(user.canChangeEmail).toBe(false) + expect(user.canChangePassword).toBe(false) + }) + + it('blocks account credential changes when an oidc credential is linked', () => { + const user = fromOryIdentity( + identity({ + credentials: { + password: { config: { hashed_password: 'hash' } }, + oidc: { identifiers: ['github:123'] }, + }, + }) + ) + + expect(user.canChangeEmail).toBe(false) + expect(user.canChangePassword).toBe(false) + }) +}) + +describe('fromOryIdentity traits', () => { + it('reads the flat `name` trait (the project schema shape)', () => { + const user = fromOryIdentity( + identity({ + traits: { email: 'ada@example.test', name: 'Ada Lovelace' }, + credentials: { password: {} }, + }) + ) + expect(user.email).toBe('ada@example.test') + expect(user.name).toBe('Ada Lovelace') + }) + + it('falls back to a nested { first, last } name', () => { + const user = fromOryIdentity( + identity({ traits: { name: { first: 'Ada', last: 'Lovelace' } } }) + ) + expect(user.name).toBe('Ada Lovelace') + }) +}) diff --git a/tests/unit/auth-ory-provider-account.test.ts b/tests/unit/auth-ory-provider-account.test.ts new file mode 100644 index 000000000..0e47364a6 --- /dev/null +++ b/tests/unit/auth-ory-provider-account.test.ts @@ -0,0 +1,239 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})) + +const authjsMock = vi.hoisted(() => vi.fn()) +const updateUserMock = vi.hoisted(() => vi.fn()) +const revokeSessionsMock = vi.hoisted(() => vi.fn()) +const resolveIdentityMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: loggerMocks, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +vi.mock('@/auth', () => ({ auth: authjsMock })) + +vi.mock('@/core/server/auth/ory/flows', () => ({ + oryAuthFlows: { updateUser: updateUserMock }, +})) + +vi.mock('@/core/server/auth/ory/find-identity', () => ({ + resolveOryIdentity: resolveIdentityMock, +})) + +vi.mock('@/core/server/auth/ory/kratos-session', () => ({ + revokeKratosSessionsForIdentity: revokeSessionsMock, +})) + +const { oryAuthProvider } = await import('@/core/server/auth/ory/provider') + +function makeIdToken(claims: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString( + 'base64url' + ) + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url') + return `${header}.${payload}.sig` +} + +const nowSeconds = Math.floor(Date.now() / 1000) + +describe('oryAuthProvider account operations', () => { + beforeEach(() => { + authjsMock.mockReset() + updateUserMock.mockReset() + revokeSessionsMock.mockReset() + resolveIdentityMock.mockReset() + // Vanilla case: the OIDC subject is the Kratos identity id. + resolveIdentityMock.mockResolvedValue({ id: 'identity-1' }) + loggerMocks.error.mockClear() + }) + + describe('startReauthForAccountSettings', () => { + it('redirects through oauth-start with the reauth intent', async () => { + const dispatch = await oryAuthProvider.startReauthForAccountSettings() + + expect(dispatch).toEqual({ + kind: 'redirect', + to: '/api/auth/oauth-start?intent=reauth&returnTo=%2Fdashboard%2Faccount%3Freauth%3D1', + }) + }) + }) + + describe('updateUser', () => { + it('throws when there is no authenticated session', async () => { + authjsMock.mockResolvedValue(null) + + await expect(oryAuthProvider.updateUser({ name: 'X' })).rejects.toThrow( + 'updateUser called without an authenticated Ory session' + ) + }) + + it('forwards a name-only change without a freshness gate', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'identity-1' }, + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }), + }) + updateUserMock.mockResolvedValue({ ok: true, user: { id: 'identity-1' } }) + + const result = await oryAuthProvider.updateUser({ name: 'Ada' }) + + expect(updateUserMock).toHaveBeenCalledWith({ + identityId: 'identity-1', + name: 'Ada', + email: undefined, + password: undefined, + }) + expect(result).toEqual({ ok: true, user: { id: 'identity-1' } }) + }) + + it('uses the identity id cached on the session without a lookup', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'legacy-id' }, + identityId: 'kratos-uuid', + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }), + }) + updateUserMock.mockResolvedValue({ + ok: true, + user: { id: 'kratos-uuid' }, + }) + + await oryAuthProvider.updateUser({ name: 'Ada' }) + + expect(resolveIdentityMock).not.toHaveBeenCalled() + expect(updateUserMock).toHaveBeenCalledWith( + expect.objectContaining({ identityId: 'kratos-uuid' }) + ) + }) + + it('patches the resolved Kratos id when the subject is an external_id', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'legacy-id' }, + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }), + }) + resolveIdentityMock.mockResolvedValue({ id: 'kratos-uuid' }) + updateUserMock.mockResolvedValue({ + ok: true, + user: { id: 'kratos-uuid' }, + }) + + await oryAuthProvider.updateUser({ name: 'Ada' }) + + expect(resolveIdentityMock).toHaveBeenCalledWith( + expect.objectContaining({ subjects: ['legacy-id'] }) + ) + expect(updateUserMock).toHaveBeenCalledWith( + expect.objectContaining({ identityId: 'kratos-uuid' }) + ) + }) + + it('throws when the Ory identity cannot be resolved', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'ghost' }, + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }), + }) + resolveIdentityMock.mockResolvedValue(null) + + await expect(oryAuthProvider.updateUser({ name: 'Ada' })).rejects.toThrow( + 'could not resolve an Ory identity' + ) + expect(updateUserMock).not.toHaveBeenCalled() + }) + + it('requires reauth for an email change when auth_time is stale', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'identity-1' }, + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }), + }) + + const result = await oryAuthProvider.updateUser({ + email: 'new@example.test', + }) + + expect(result).toEqual({ ok: false, code: 'reauthentication_needed' }) + expect(updateUserMock).not.toHaveBeenCalled() + }) + + it('requires reauth for a password change when auth_time is stale', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'identity-1' }, + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }), + }) + + const result = await oryAuthProvider.updateUser({ + password: 'new-secret', + }) + + expect(result).toEqual({ ok: false, code: 'reauthentication_needed' }) + expect(updateUserMock).not.toHaveBeenCalled() + }) + + it('requires reauth for a password change when there is no id_token', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'identity-1' }, + accessToken: 'a', + }) + + const result = await oryAuthProvider.updateUser({ + password: 'new-secret', + }) + + expect(result).toEqual({ ok: false, code: 'reauthentication_needed' }) + expect(updateUserMock).not.toHaveBeenCalled() + }) + + it('forwards a password change when auth_time is fresh', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'identity-1' }, + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 30 }), + }) + updateUserMock.mockResolvedValue({ ok: true, user: { id: 'identity-1' } }) + + const result = await oryAuthProvider.updateUser({ + password: 'new-secret', + }) + + expect(updateUserMock).toHaveBeenCalledWith({ + identityId: 'identity-1', + name: undefined, + email: undefined, + password: 'new-secret', + }) + expect(result).toEqual({ ok: true, user: { id: 'identity-1' } }) + }) + }) + + describe('signOutOtherSessions', () => { + it('revokes all Kratos sessions for the current identity', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'identity-1' }, + accessToken: 'a', + }) + revokeSessionsMock.mockResolvedValue(undefined) + + await oryAuthProvider.signOutOtherSessions() + + expect(revokeSessionsMock).toHaveBeenCalledWith('identity-1') + }) + + it('no-ops when there is no session', async () => { + authjsMock.mockResolvedValue(null) + + await oryAuthProvider.signOutOtherSessions() + + expect(revokeSessionsMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests/unit/auth-ory-provider-profile.test.ts b/tests/unit/auth-ory-provider-profile.test.ts new file mode 100644 index 000000000..e0e20f0ab --- /dev/null +++ b/tests/unit/auth-ory-provider-profile.test.ts @@ -0,0 +1,168 @@ +import { ResponseError } from '@ory/client-fetch' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})) + +const authjsMock = vi.hoisted(() => vi.fn()) +const getIdentityMock = vi.hoisted(() => vi.fn()) +const getIdentityByExternalIDMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: loggerMocks, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +vi.mock('@/auth', () => ({ + auth: authjsMock, +})) + +vi.mock('@/core/server/auth/ory/client', () => ({ + getOryIdentityApi: () => ({ + getIdentity: getIdentityMock, + getIdentityByExternalID: getIdentityByExternalIDMock, + }), +})) + +const { oryAuthProvider } = await import('@/core/server/auth/ory/provider') + +describe('oryAuthProvider.getUserProfile', () => { + beforeEach(() => { + authjsMock.mockReset() + getIdentityMock.mockReset() + getIdentityByExternalIDMock.mockReset() + loggerMocks.error.mockClear() + loggerMocks.debug.mockClear() + }) + + it('returns the normalized profile from the live identity lookup', async () => { + authjsMock.mockResolvedValue({ user: { id: 'identity-1' } }) + getIdentityMock.mockResolvedValue({ + id: 'identity-1', + traits: { email: 'ada@example.test', name: 'Ada' }, + credentials: { + password: { + config: { hashed_password: 'hash' }, + }, + }, + }) + + const profile = await oryAuthProvider.getUserProfile() + + expect(getIdentityMock).toHaveBeenCalledWith({ + id: 'identity-1', + includeCredential: ['password', 'oidc'], + }) + expect(profile).toEqual({ + id: 'identity-1', + email: 'ada@example.test', + name: 'Ada', + avatarUrl: null, + providers: ['email'], + canChangeEmail: false, + canChangePassword: true, + }) + }) + + it('uses the identity id cached on the session, skipping external_id', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'legacy-id' }, + identityId: 'kratos-uuid', + }) + getIdentityMock.mockResolvedValue({ + id: 'kratos-uuid', + traits: { email: 'ada@example.test' }, + credentials: { + password: { + config: { hashed_password: 'hash' }, + }, + }, + }) + + const profile = await oryAuthProvider.getUserProfile() + + expect(getIdentityMock).toHaveBeenCalledWith({ + id: 'kratos-uuid', + includeCredential: ['password', 'oidc'], + }) + expect(getIdentityByExternalIDMock).not.toHaveBeenCalled() + expect(profile?.id).toBe('kratos-uuid') + }) + + it('returns null when there is no session', async () => { + authjsMock.mockResolvedValue(null) + + const profile = await oryAuthProvider.getUserProfile() + + expect(profile).toBeNull() + expect(getIdentityMock).not.toHaveBeenCalled() + }) + + it('falls back to external_id when the subject is not a Kratos id', async () => { + authjsMock.mockResolvedValue({ user: { id: 'legacy-id' } }) + getIdentityMock.mockRejectedValue( + new ResponseError(new Response(null, { status: 404 }), 'not found') + ) + getIdentityByExternalIDMock.mockResolvedValue({ + id: 'kratos-uuid', + traits: { email: 'ada@example.test', name: 'Ada' }, + credentials: { + password: { + config: { hashed_password: 'hash' }, + }, + }, + }) + + const profile = await oryAuthProvider.getUserProfile() + + expect(getIdentityByExternalIDMock).toHaveBeenCalledWith({ + externalID: 'legacy-id', + includeCredential: ['password', 'oidc'], + }) + expect(profile?.id).toBe('kratos-uuid') + expect(profile?.providers).toEqual(['email']) + }) + + it('does not allow account credential changes for oidc-linked identities', async () => { + authjsMock.mockResolvedValue({ user: { id: 'identity-1' } }) + getIdentityMock.mockResolvedValue({ + id: 'identity-1', + traits: { email: 'ada@example.test', name: 'Ada' }, + credentials: { + password: { + config: { hashed_password: 'hash' }, + }, + oidc: { + identifiers: ['github:123'], + }, + }, + }) + + const profile = await oryAuthProvider.getUserProfile() + + expect(profile).toEqual( + expect.objectContaining({ + canChangeEmail: false, + canChangePassword: false, + }) + ) + }) + + it('returns null when neither id nor external_id matches', async () => { + authjsMock.mockResolvedValue({ user: { id: 'missing' } }) + getIdentityMock.mockRejectedValue( + new ResponseError(new Response(null, { status: 404 }), 'not found') + ) + getIdentityByExternalIDMock.mockRejectedValue( + new ResponseError(new Response(null, { status: 404 }), 'not found') + ) + + const profile = await oryAuthProvider.getUserProfile() + + expect(profile).toBeNull() + }) +}) diff --git a/tests/unit/auth-ory-provider.test.ts b/tests/unit/auth-ory-provider.test.ts new file mode 100644 index 000000000..04ea78b25 --- /dev/null +++ b/tests/unit/auth-ory-provider.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})) + +const authjsMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: loggerMocks, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +vi.mock('@/auth', () => ({ + auth: authjsMock, +})) + +const { oryAuthProvider } = await import('@/core/server/auth/ory/provider') + +describe('OryAuthProvider', () => { + beforeEach(() => { + loggerMocks.error.mockClear() + loggerMocks.warn.mockClear() + authjsMock.mockReset() + }) + + describe('getAuthContext', () => { + it('returns null when there is no session', async () => { + authjsMock.mockResolvedValue(null) + + const result = await oryAuthProvider.getAuthContext() + + expect(result).toBeNull() + expect(loggerMocks.error).not.toHaveBeenCalled() + }) + + it('returns null when accessToken is missing', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'user-1', email: 'a@b.dev' }, + accessToken: undefined, + }) + + const result = await oryAuthProvider.getAuthContext() + + expect(result).toBeNull() + }) + + it('returns null and warns when the session reports a refresh error', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'user-1', email: 'a@b.dev' }, + accessToken: 'access-token', + error: 'RefreshTokenError', + }) + + const result = await oryAuthProvider.getAuthContext() + + expect(result).toBeNull() + expect(loggerMocks.warn).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'auth_provider:ory_session_error', + user_id: 'user-1', + context: expect.objectContaining({ error: 'RefreshTokenError' }), + }), + expect.stringContaining("error 'RefreshTokenError'") + ) + }) + + it('returns null and logs when Auth.js auth() throws', async () => { + const failure = new Error('boom') + authjsMock.mockRejectedValue(failure) + + const result = await oryAuthProvider.getAuthContext() + + expect(result).toBeNull() + expect(loggerMocks.error).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'auth_provider:ory_get_session:error', + error: failure, + }), + expect.stringContaining('Auth.js auth() helper threw') + ) + }) + + it('returns AuthContext on a happy session', async () => { + authjsMock.mockResolvedValue({ + user: { + id: 'user-1', + email: 'a@b.dev', + name: 'Alice', + image: 'https://example.test/a.png', + }, + accessToken: 'access-token', + }) + + const result = await oryAuthProvider.getAuthContext() + + expect(result).toEqual({ + user: { + id: 'user-1', + email: 'a@b.dev', + name: 'Alice', + avatarUrl: 'https://example.test/a.png', + providers: [], + canChangeEmail: false, + canChangePassword: false, + }, + accessToken: 'access-token', + }) + expect(loggerMocks.error).not.toHaveBeenCalled() + }) + }) + + describe('signOut', () => { + it('returns the signout-flow route URL so callers redirect to it', async () => { + const result = await oryAuthProvider.signOut() + expect(result).toEqual({ + redirectTo: '/api/auth/oauth/signout-flow', + }) + }) + }) +}) diff --git a/tests/unit/auth-supabase-provider.test.ts b/tests/unit/auth-supabase-provider.test.ts index 690b7a866..468772127 100644 --- a/tests/unit/auth-supabase-provider.test.ts +++ b/tests/unit/auth-supabase-provider.test.ts @@ -150,7 +150,7 @@ describe('SupabaseAuthProvider', () => { const result = await provider.signOut({ scope: 'others' }) - expect(result).toEqual({ error: signOutError }) + expect(result).toEqual({ redirectTo: '/sign-in', error: signOutError }) expect(loggerMocks.error).toHaveBeenCalledWith( expect.objectContaining({ key: 'auth_provider:sign_out:error', @@ -165,7 +165,7 @@ describe('SupabaseAuthProvider', () => { ) }) - it('returns { error: null } on success without logging', async () => { + it('returns the sign-in redirect with null error on success without logging', async () => { const client = buildClient({ signOut: vi.fn().mockResolvedValue({ error: null }), }) @@ -173,8 +173,24 @@ describe('SupabaseAuthProvider', () => { const result = await provider.signOut() - expect(result).toEqual({ error: null }) + expect(result).toEqual({ redirectTo: '/sign-in', error: null }) expect(loggerMocks.error).not.toHaveBeenCalled() }) + + it('preserves returnTo as a sign-in query param for the reauth flow', async () => { + const client = buildClient({ + signOut: vi.fn().mockResolvedValue({ error: null }), + }) + const provider = new SupabaseAuthProvider(client) + + const result = await provider.signOut({ + returnTo: '/dashboard/account', + }) + + expect(result).toEqual({ + redirectTo: '/sign-in?returnTo=%2Fdashboard%2Faccount', + error: null, + }) + }) }) }) diff --git a/tests/unit/proxy-handlers.test.ts b/tests/unit/proxy-handlers.test.ts new file mode 100644 index 000000000..6ed05e357 --- /dev/null +++ b/tests/unit/proxy-handlers.test.ts @@ -0,0 +1,118 @@ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getMiddlewareRedirectMock = vi.hoisted(() => vi.fn()) +const getRewriteForPathMock = vi.hoisted(() => vi.fn()) +const createAuthForProxyMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/configs/flags', () => ({ ALLOW_SEO_INDEXING: false })) + +vi.mock('@/lib/utils/redirects', () => ({ + getMiddlewareRedirectFromPath: getMiddlewareRedirectMock, +})) + +vi.mock('@/lib/utils/rewrites', () => ({ + getRewriteForPath: getRewriteForPathMock, +})) + +vi.mock('@/core/server/auth', () => ({ + createAuthForProxy: createAuthForProxyMock, +})) + +const { + handleMiddlewareRedirect, + handleRouteRewritePassthrough, + handleMiddlewareRewrite, + handleAuthGate, +} = await import('@/core/server/http/proxy') + +function request(path: string): NextRequest { + return new NextRequest(`https://app.e2b.dev${path}`) +} + +beforeEach(() => { + getMiddlewareRedirectMock.mockReset().mockReturnValue(undefined) + getRewriteForPathMock.mockReset().mockReturnValue({ config: undefined }) + createAuthForProxyMock.mockReset() +}) + +describe('handleMiddlewareRedirect', () => { + it('returns a redirect with the configured status and headers', () => { + getMiddlewareRedirectMock.mockReturnValue({ + destination: '/new-home', + statusCode: 308, + headers: { 'x-custom': 'yes' }, + }) + + const response = handleMiddlewareRedirect(request('/old-home')) + + expect(response?.status).toBe(308) + expect(response?.headers.get('location')).toContain('/new-home') + }) + + it('returns null when there is no matching redirect', () => { + expect(handleMiddlewareRedirect(request('/anything'))).toBeNull() + }) +}) + +describe('handleRouteRewritePassthrough', () => { + it('passes through when a catch-all route rewrite matches', () => { + getRewriteForPathMock.mockReturnValue({ config: { domain: 'x' } }) + + const response = handleRouteRewritePassthrough(request('/docs')) + + expect(response).not.toBeNull() + expect(response?.headers.get('location')).toBeNull() + }) + + it('returns null when no route rewrite matches', () => { + expect(handleRouteRewritePassthrough(request('/docs'))).toBeNull() + }) +}) + +describe('handleMiddlewareRewrite', () => { + it('rewrites to the configured domain, applies the path preprocessor, and tags no-index', () => { + getRewriteForPathMock.mockReturnValue({ + config: { domain: 'docs.e2b.dev' }, + rule: { pathPreprocessor: (p: string) => p.replace('/docs', '') }, + }) + + const response = handleMiddlewareRewrite(request('/docs/guide')) + + expect(response).not.toBeNull() + const rewrittenTo = response?.headers.get('x-middleware-rewrite') ?? '' + expect(rewrittenTo).toContain('docs.e2b.dev') + expect(rewrittenTo).toContain('/guide') + expect(response?.headers.get('X-Robots-Tag')).toBe('noindex, nofollow') + }) + + it('returns null when no middleware rewrite matches', () => { + expect(handleMiddlewareRewrite(request('/dashboard'))).toBeNull() + }) +}) + +describe('handleAuthGate', () => { + it('redirects an unauthenticated dashboard request without resolving auth when knownAuth is provided', async () => { + const response = await handleAuthGate(request('/dashboard/team-x'), false) + + expect(response.headers.get('location')).toContain('/sign-in') + expect(createAuthForProxyMock).not.toHaveBeenCalled() + }) + + it('resolves auth from the request when knownAuth is omitted', async () => { + createAuthForProxyMock.mockReturnValue({ + getAuthContext: vi.fn().mockResolvedValue(null), + }) + + const response = await handleAuthGate(request('/dashboard/team-x')) + + expect(createAuthForProxyMock).toHaveBeenCalled() + expect(response.headers.get('location')).toContain('/sign-in') + }) + + it('passes through when authenticated on a neutral route', async () => { + const response = await handleAuthGate(request('/some/page'), true) + + expect(response.headers.get('location')).toBeNull() + }) +}) diff --git a/tests/unit/signout-flow.test.ts b/tests/unit/signout-flow.test.ts new file mode 100644 index 000000000..fa50b306d --- /dev/null +++ b/tests/unit/signout-flow.test.ts @@ -0,0 +1,77 @@ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const authMock = vi.hoisted(() => vi.fn()) +const signOutMock = vi.hoisted(() => vi.fn()) +const revokeKratosSessionsMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/auth', () => ({ auth: authMock, signOut: signOutMock })) + +vi.mock('@/core/server/auth/ory/kratos-session', () => ({ + revokeKratosSessionsForIdentity: revokeKratosSessionsMock, +})) + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +const { GET } = await import('@/app/api/auth/oauth/signout-flow/route') + +function request(): NextRequest { + return new NextRequest('https://app.e2b.dev/api/auth/oauth/signout-flow') +} + +beforeEach(() => { + authMock.mockReset() + signOutMock.mockReset().mockResolvedValue(undefined) + revokeKratosSessionsMock.mockReset().mockResolvedValue(undefined) + vi.stubEnv('ORY_SDK_URL', 'https://project.oryapis.com') +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('signout-flow GET', () => { + it('revokes Kratos sessions using the resolved identityId (not the OIDC sub)', async () => { + authMock.mockResolvedValue({ + idToken: 'id.token.sig', + identityId: 'kratos-uuid', + }) + + await GET(request()) + + expect(revokeKratosSessionsMock).toHaveBeenCalledWith('kratos-uuid') + }) + + it('skips revocation when the session has no resolved identityId', async () => { + authMock.mockResolvedValue({ idToken: 'id.token.sig' }) + + await GET(request()) + + expect(revokeKratosSessionsMock).not.toHaveBeenCalled() + }) + + it('redirects to the Hydra logout endpoint with the id_token hint', async () => { + authMock.mockResolvedValue({ + idToken: 'id.token.sig', + identityId: 'kratos-uuid', + }) + + const response = await GET(request()) + const location = response.headers.get('location') ?? '' + + expect(location).toContain('/oauth2/sessions/logout') + expect(location).toContain('id_token_hint=id.token.sig') + }) + + it('redirects to the marketing root when there is no id_token', async () => { + authMock.mockResolvedValue({ identityId: 'kratos-uuid' }) + + const response = await GET(request()) + const location = response.headers.get('location') ?? '' + + expect(location).toBe('https://app.e2b.dev/') + }) +}) diff --git a/tests/unit/teams-repository.test.ts b/tests/unit/teams-repository.test.ts index 493d2951d..f9f9a77a3 100644 --- a/tests/unit/teams-repository.test.ts +++ b/tests/unit/teams-repository.test.ts @@ -1,16 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' -vi.mock('@/core/shared/clients/supabase/admin', () => ({ - supabaseAdmin: { - auth: { - admin: { - getUserById: vi.fn(), - }, - }, - }, -})) - describe('createTeamsRepository', () => { it('returns a repo error instead of throwing when a team-scoped method has no teamId', async () => { const repository = createTeamsRepository( @@ -23,9 +13,6 @@ describe('createTeamsRepository', () => { DELETE: vi.fn(), } as unknown as typeof import('@/core/shared/clients/api').api, authHeaders: vi.fn(() => ({ 'X-Supabase-Token': 'token' })), - authAdmin: { - getUserById: vi.fn(), - }, } ) diff --git a/tests/unit/user-router.test.ts b/tests/unit/user-router.test.ts new file mode 100644 index 000000000..8e97a1a20 --- /dev/null +++ b/tests/unit/user-router.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTRPCContext } from '@/core/server/trpc/init' + +const providerMock = vi.hoisted(() => ({ + getAuthContext: vi.fn(), + getUserProfile: vi.fn(), + updateUser: vi.fn(), + signOut: vi.fn(), + startReauthForAccountSettings: vi.fn(), + signOutOtherSessions: vi.fn(), +})) + +vi.mock('@/core/server/auth', () => ({ + createAuthForHeaders: vi.fn(() => providerMock), +})) + +vi.mock('@/lib/utils/server', () => ({ + generateE2BUserAccessToken: vi.fn(), +})) + +const { createCallerFactory } = await import('@/core/server/trpc/init') +const { userRouter } = await import('@/core/server/api/routers/user') + +const createCaller = createCallerFactory(userRouter) + +const authUser = { + id: 'user-1', + email: 'old@example.test', + name: 'Ada', + avatarUrl: null, + providers: ['email'], + canChangeEmail: false, + canChangePassword: true, +} + +describe('userRouter.update', () => { + beforeEach(() => { + vi.stubEnv('AUTH_PROVIDER', 'ory') + providerMock.getAuthContext.mockResolvedValue({ + user: authUser, + accessToken: 'access-token', + }) + providerMock.getUserProfile.mockReset() + providerMock.updateUser.mockReset() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('denies email changes in Ory mode before updating the provider user', async () => { + const ctx = await createTRPCContext({ headers: new Headers() }) + const caller = createCaller(ctx) + + const result = await caller.update({ email: 'new@example.test' }) + + expect(result).toEqual({ + status: 'error', + code: 'account_credentials_not_changeable', + }) + expect(providerMock.getUserProfile).not.toHaveBeenCalled() + expect(providerMock.updateUser).not.toHaveBeenCalled() + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 121a72515..22f0805f2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,14 @@ export default defineConfig({ reporter: ['text', 'json', 'html'], }, setupFiles: ['./tests/setup.ts'], + server: { + deps: { + // next-auth ships ESM that imports 'next/server' without the .js extension + // which vitest's default resolver cannot follow. inlining lets vite's + // bundler resolve next.js exports correctly. + inline: [/next-auth/, /@auth\/core/], + }, + }, }, resolve: {