diff --git a/docsfera.json b/docsfera.json index dbd3636..6c26d80 100644 --- a/docsfera.json +++ b/docsfera.json @@ -110,16 +110,92 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateIdentityProvider" + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthRefreshAccessTokenRes" + } } } } }, + "tags": [ + "Auth" + ], + "security": [ + { + "access-token": [] + } + ] + } + }, + "/auth/idp/{name}": { + "get": { + "operationId": "AuthController_loginIdp", + "parameters": [ + { + "name": "dadosfera-lang", + "in": "header", + "required": false, + "schema": { + "enum": [ + "pt-br", + "en-us" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthRefreshAccessTokenRes" + } + } + } + } + }, + "tags": [ + "Auth" + ], + "security": [ + { + "access-token": [] + } + ] + } + }, + "/auth/idp/{name}/callback": { + "get": { + "operationId": "AuthController_callbackIdp", + "parameters": [ + { + "name": "dadosfera-lang", + "in": "header", + "required": false, + "schema": { + "enum": [ + "pt-br", + "en-us" + ], + "type": "string" + } + }, + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "", @@ -5889,25 +5965,6 @@ "accessToken" ] }, - "CreateIdentityProvider": { - "type": "object", - "properties": { - "clientId": { - "type": "string" - }, - "clientSecret": { - "type": "string" - }, - "issueUrl": { - "type": "string" - } - }, - "required": [ - "clientId", - "clientSecret", - "issueUrl" - ] - }, "CreateConnectionDto": { "type": "object", "properties": { diff --git a/package-lock.json b/package-lock.json index 9983a8b..2e46038 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,11 +32,13 @@ "cron-parser": "^4.9.0", "dotenv": "^14.3.2", "elastic-apm-node": "^3.50.0", + "express-session": "^1.18.1", "helmet": "^5.1.1", "jsonwebtoken": "^9.0.2", "jwk-to-pem": "^2.0.5", "mixpanel": "^0.17.0", "ms": "^3.0.0-canary.1", + "openid-client": "^5.7.1", "passport": "^0.6.0", "passport-facebook": "^3.0.0", "passport-forcedotcom": "^0.2.0", @@ -50,6 +52,7 @@ }, "devDependencies": { "@types/express": "^4.17.17", + "@types/express-session": "^1.18.1", "@types/jest": "27.0.2", "@types/jsonwebtoken": "^8.5.9", "@types/jwk-to-pem": "^2.0.1", @@ -1300,7 +1303,7 @@ "node_modules/@dadosfera/protospack-v2": { "version": "0.0.0", "resolved": "file:../protospack-v2/dadosfera-protospack-v2-0.0.0.tgz", - "integrity": "sha512-bV/C8RR3Qska7qrNjDxPIjEsjWd5ZFjTVJg/C3zTOk4HeYvPbR+ln3elRRla4CkZzPz4T4VzD+j4284+sq5x8g==", + "integrity": "sha512-Ttj4IjYPgm8c29IW7RYOF76jzCsofcKSSeo6eUrElzSYCkEJAW3k4QDlTSKmCNrv6J6SZdMckPQotGaMN2oFDA==", "license": "ISC", "dependencies": { "@grpc/grpc-js": "^1.9.3", @@ -3434,6 +3437,16 @@ "@types/send": "*" } }, + "node_modules/@types/express-session": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.1.tgz", + "integrity": "sha512-S6TkD/lljxDlQ2u/4A70luD8/ZxZcrU5pQwI1rVXCiaVIywoFgbA+PIUNDjPhQpPdK0dGleLtYc/y7XWBfclBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/gensync": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.4.tgz", @@ -6157,6 +6170,75 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz", + "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/express-session/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/express/node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -8016,6 +8098,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8874,6 +8965,15 @@ "resolved": "https://registry.npmjs.org/object-filter-sequence/-/object-filter-sequence-1.0.0.tgz", "integrity": "sha512-CsubGNxhIEChNY4cXYuA6KXafztzHqzLLZ/y3Kasf3A+sa3lL9thq3z+7o0pZqzEinjXT6lXDPAfVWI59dUyzQ==" }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-identity-map": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/object-identity-map/-/object-identity-map-1.0.2.tgz", @@ -8914,6 +9014,15 @@ "node": ">= 0.4" } }, + "node_modules/oidc-token-hash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz", + "integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8925,6 +9034,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8955,6 +9073,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optional-js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/optional-js/-/optional-js-2.3.0.tgz", @@ -9662,6 +9795,15 @@ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -11083,6 +11225,18 @@ "node": ">=8" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uid2": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", diff --git a/package.json b/package.json index c824a10..d61a0da 100644 --- a/package.json +++ b/package.json @@ -50,11 +50,13 @@ "cron-parser": "^4.9.0", "dotenv": "^14.3.2", "elastic-apm-node": "^3.50.0", + "express-session": "^1.18.1", "helmet": "^5.1.1", "jsonwebtoken": "^9.0.2", "jwk-to-pem": "^2.0.5", "mixpanel": "^0.17.0", "ms": "^3.0.0-canary.1", + "openid-client": "^5.7.1", "passport": "^0.6.0", "passport-facebook": "^3.0.0", "passport-forcedotcom": "^0.2.0", @@ -71,6 +73,7 @@ }, "devDependencies": { "@types/express": "^4.17.17", + "@types/express-session": "^1.18.1", "@types/jest": "27.0.2", "@types/jsonwebtoken": "^8.5.9", "@types/jwk-to-pem": "^2.0.1", diff --git a/src/@types/express-session.d.ts b/src/@types/express-session.d.ts new file mode 100644 index 0000000..6531c85 --- /dev/null +++ b/src/@types/express-session.d.ts @@ -0,0 +1,8 @@ +import 'express-session'; + +declare module 'express-session' { + interface SessionData { + state: string | undefined; + code_verifier: string | undefined; + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 46f1701..1f18744 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import { AppModule } from './app.module'; import { writeFileSync } from 'fs'; import { execSync } from 'child_process'; import { INestApplication } from '@nestjs/common'; +import session from 'express-session'; async function bootstrap() { DadosferaLogger.setupLogger({ serviceName: 'maestro', @@ -25,6 +26,12 @@ async function bootstrap() { }, }); app.use(helmet()); + app.use(session({ + secret: process.env.SESSION_SECRET, + resave: false, + saveUninitialized: false, + + })); configureSwagger(app); await app.listen(3333); if (process.env.KILL_AFTER_START) await app.close(); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index ed9d1fc..33c243b 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -27,6 +27,7 @@ import { AuthEnableTotpMfaRequest, AuthDisableTotpMfaRequest, AuthVerifyTotpMfaRequest, + IdProvider, } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/messages'; import { PERMISSIONS_GROUPS } from 'src/authentication/permissions.enum'; @@ -52,7 +53,7 @@ import jwt from 'jsonwebtoken'; import { LanguageEnum } from 'src/utils/languages.enum'; import { Language } from 'src/decorators/language.decorator'; import { ApiInternalOnlyEndpoint } from 'src/decorators/swagger.decorator'; -import { CreateIdentityProvider } from './dtos/openid'; +import { CreatedIdentityProvider, CreateIdentityProvider } from './dtos/openid'; @ApiTags('Auth') @ApiHeaders([{ name: 'dadosfera-lang', enum: LanguageEnum, required: false }]) @@ -112,11 +113,10 @@ export class AuthController { @Post('/idp') @HttpCode(HttpStatus.OK) - @ApiOkResponse({ type: AuthRefreshAccessTokenRes }) - @RequireAllPermissions(PERMISSIONS_GROUPS.SNOWFLAKE.permissions.OPEN) + @ApiOkResponse({ type: CreatedIdentityProvider }) async addIdentityProvider( @User() user: RequestUser, - @Body() body: CreateIdentityProvider, + @Body() body: IdProvider, @Language() language: LanguageEnum, ) { this.logger.info('/auth - RefreshAccessToken'); @@ -129,6 +129,38 @@ export class AuthController { return this.authClient.createIdentityProvider(body, metadata); } + @Get('/idp/:name') + @HttpCode(HttpStatus.OK) + @Redirect() + async loginIdp( + @Body() body: { name: string }, + @Req() req: Request, + ) { + this.logger.info('/auth - RefreshAccessToken'); + + const redirectUrl = this.authClient.loginIdp(body.name, req); + return { + url: redirectUrl, + }; + } + + @Get('/idp/:name/callback') + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ type: AuthRefreshAccessTokenRes }) + @Redirect() + @RequireAllPermissions(PERMISSIONS_GROUPS.SNOWFLAKE.permissions.OPEN) + async callbackIdp( + @Param('name') name: string, + @Req() req: Request, + ) { + this.logger.info('/auth - RefreshAccessToken'); + + const redirectUrl = this.authClient.authorizeIdp(name, req); + return { + url: redirectUrl, + }; + } + @ApiInternalOnlyEndpoint() @Post('/sso/snowflake') @RequireAllPermissions(PERMISSIONS_GROUPS.SNOWFLAKE.permissions.OPEN) diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 098f369..1f15be6 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -17,14 +17,17 @@ import { AuthResetPasswordRequest, AuthVerifyResetPasswordCodeRequest, AuthConfirmResetPasswordRequest, - CreateIdentityProviderRequest, + IdProvider, } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/messages'; import { DucClient } from '../duc/client.config'; import { Metadata } from '@grpc/grpc-js'; +import * as client from 'openid-client' +import { Request } from 'express'; -import { CreateIdentityProvider } from './dtos/openid'; @Injectable() export class AuthClientService implements OnModuleInit { + + logger: DadosferaLogger; @@ -177,9 +180,84 @@ export class AuthClientService implements OnModuleInit { ); } - createIdentityProvider(body: CreateIdentityProvider, metadata: Metadata) { + createIdentityProvider(body: IdProvider, metadata: Metadata) { return lastValueFrom( this.identityProviderService.CreateIdentityProvider(body, metadata), ); } + + + async loginIdp(name: string, req: Request) { + const idp = await lastValueFrom( + this.identityProviderService.FindIdentityProvider({ name }), + ); + + const config: client.Configuration = await client.discovery( + new URL(idp.issueUrl), + idp.clientId, + idp.clientSecret, + ) + + const redirect_uri = `http://localhost:3000/auth/idp/${name}/callback`; + const scope = 'openid email'; + + const code_verifier: string = client.randomPKCECodeVerifier() + const code_challenge: string = + await client.calculatePKCECodeChallenge(code_verifier) + let state: string; + const parameters: Record = { + redirect_uri, + scope, + code_challenge, + code_challenge_method: 'S256', + } + req.session.code_verifier = code_verifier; + + if (!config.serverMetadata().supportsPKCE()) { + /** + * We cannot be sure the server supports PKCE so we're going to use state too. + * Use of PKCE is backwards compatible even if the AS doesn't support it which + * is why we're using it regardless. Like PKCE, random state must be generated + * for every redirect to the authorization_endpoint. + */ + state = client.randomState() + parameters.state = state + req.session.state = state; + } + + const authUrl = client.buildAuthorizationUrl(config, parameters); + return authUrl; + } + + async authorizeIdp(name: string, req: Request) { + const code_verifier_session = req.session.code_verifier; + const state_session = req.session.state; + const url = new URL("http://localhost:3000" + req.url); + + const idp = await lastValueFrom( + this.identityProviderService.FindIdentityProvider({ name }), + ); + + const config: client.Configuration = await client.discovery( + new URL(idp.issueUrl), + idp.clientId, + idp.clientSecret, + ) + + const tokens: client.TokenEndpointResponse = await client.authorizationCodeGrant( + config, + url, + { + pkceCodeVerifier: code_verifier_session, + expectedState: state_session, + }, + ) + + delete req.session.code_verifier; + delete req.session.state; + + console.log('Token Endpoint Response', tokens) + + return tokens; + } } diff --git a/src/modules/auth/dtos/openid.ts b/src/modules/auth/dtos/openid.ts index 67a48d8..c5cc838 100644 --- a/src/modules/auth/dtos/openid.ts +++ b/src/modules/auth/dtos/openid.ts @@ -1,6 +1,10 @@ import { ApiProperty } from "@nestjs/swagger"; -export class CreateIdentityProvider { +export class CreatedIdentityProvider { + + @ApiProperty() + name: string; + @ApiProperty() clientId: string;