From c66c5acc16502de2494f3b8dd790c4a60c38ff6d Mon Sep 17 00:00:00 2001 From: prernagp Date: Tue, 9 Jul 2024 13:09:02 +0530 Subject: [PATCH] fix(authentication-service): added idp server controller for login and discovery endpoint BREAKING CHANGE: --- services/authentication-service/.env.example | 10 +- .../.vscode/settings.json | 4 +- services/authentication-service/openapi.json | 93 +++- services/authentication-service/openapi.md | 229 +++++++- services/authentication-service/package.json | 1 + .../authentication-service/src/component.ts | 2 + .../src/controllers/index.ts | 2 + .../controllers/identity-server.controller.ts | 266 ++++++++++ .../src/modules/auth/controllers/index.ts | 1 + .../auth/controllers/login.controller.ts | 47 +- .../auth/models/idp-auth-request.dto.ts | 36 ++ .../modules/auth/models/idp-auth.method.ts | 10 + .../auth/models/idp-configuration.dto.ts | 83 +++ .../src/modules/auth/models/index.ts | 3 + .../src/services/idp-login.service.ts | 489 ++++++++++++++++++ .../src/services/index.ts | 3 + 16 files changed, 1217 insertions(+), 62 deletions(-) create mode 100644 services/authentication-service/src/modules/auth/controllers/identity-server.controller.ts create mode 100644 services/authentication-service/src/modules/auth/models/idp-auth-request.dto.ts create mode 100644 services/authentication-service/src/modules/auth/models/idp-auth.method.ts create mode 100644 services/authentication-service/src/modules/auth/models/idp-configuration.dto.ts create mode 100644 services/authentication-service/src/services/idp-login.service.ts diff --git a/services/authentication-service/.env.example b/services/authentication-service/.env.example index e38b9e2be3..baeac6124f 100644 --- a/services/authentication-service/.env.example +++ b/services/authentication-service/.env.example @@ -76,4 +76,12 @@ AZURE_AUTH_COOKIE_KEY= #iv is 12 bit -AZURE_AUTH_COOKIE_IV= \ No newline at end of file +AZURE_AUTH_COOKIE_IV= +#COGNITO +COGNITO_AUTH_CALLBACK_URL= +COGNITO_AUTH_CLIENT_DOMAIN= +COGNITO_AUTH_CLIENT_ID= +COGNITO_AUTH_CLIENT_SECRET= +COGNITO_AUTH_REGION= + +API_BASE_URL= \ No newline at end of file diff --git a/services/authentication-service/.vscode/settings.json b/services/authentication-service/.vscode/settings.json index 07313667ec..4d57e61980 100644 --- a/services/authentication-service/.vscode/settings.json +++ b/services/authentication-service/.vscode/settings.json @@ -5,8 +5,8 @@ "editor.trimAutoWhitespace": true, "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.organizeImports": true, - "source.fixAll.eslint": true + "source.organizeImports": "explicit", + "source.fixAll.eslint": "explicit" }, "files.exclude": { diff --git a/services/authentication-service/openapi.json b/services/authentication-service/openapi.json index e511bcc4e5..291191d047 100644 --- a/services/authentication-service/openapi.json +++ b/services/authentication-service/openapi.json @@ -7,6 +7,40 @@ "contact": {} }, "paths": { + "/.well-known/openid-configuration": { + "get": { + "x-controller-name": "IdentityServerController", + "x-operation-name": "getConfig", + "tags": [ + "IdentityServerController" + ], + "security": [ + { + "HTTPBearer": [] + } + ], + "description": "To get the openid configuration", + "responses": { + "200": { + "description": "OpenId Configuration", + "content": {} + }, + "400": { + "description": "The syntax of the request entity is incorrect." + }, + "401": { + "description": "Invalid Credentials." + }, + "404": { + "description": "The entity requested does not exist." + }, + "422": { + "description": "The syntax of the request entity is incorrect" + } + }, + "operationId": "IdentityServerController.getConfig" + } + }, "/active-users/{range}": { "get": { "x-controller-name": "LoginActivityController", @@ -1545,6 +1579,38 @@ "operationId": "LogoutController.cognitoLogout" } }, + "/connect/auth": { + "post": { + "x-controller-name": "IdentityServerController", + "x-operation-name": "connectAuth", + "tags": [ + "IdentityServerController" + ], + "description": "POST Call for idp login", + "responses": { + "200": { + "description": "Token Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + } + }, + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/IdpAuthRequest" + } + } + } + }, + "operationId": "IdentityServerController.connectAuth" + } + }, "/google/logout": { "post": { "x-controller-name": "LogoutController", @@ -1946,7 +2012,6 @@ ], "additionalProperties": false }, - "Function": {}, "AuthRefreshTokenRequest": { "title": "AuthRefreshTokenRequest", "type": "object", @@ -2255,6 +2320,7 @@ ], "additionalProperties": false }, + "Function": {}, "ForgetPasswordDto": { "title": "ForgetPasswordDto", "type": "object", @@ -2471,6 +2537,31 @@ ], "additionalProperties": false }, + "IdpAuthRequest": { + "title": "IdpAuthRequest", + "type": "object", + "description": "This is signature for idp authentication request.", + "properties": { + "client_id": { + "type": "string", + "description": "This property is supposed to be a string and is a required field" + }, + "client_secret": { + "type": "string", + "description": "This property is supposed to be a string and is a required field" + }, + "auth_method": { + "type": "string", + "description": "This property is supposed to be a string and is a required field" + } + }, + "required": [ + "client_id", + "client_secret", + "auth_method" + ], + "additionalProperties": false + }, "loopback.Count": { "type": "object", "title": "loopback.Count", diff --git a/services/authentication-service/openapi.md b/services/authentication-service/openapi.md index a494cf65ec..8f4992dcd9 100644 --- a/services/authentication-service/openapi.md +++ b/services/authentication-service/openapi.md @@ -30,6 +30,177 @@ Base URLs: - HTTP Authentication, scheme: bearer +

IdentityServerController

+ +## IdentityServerController.getConfig + + + +> Code samples + +```javascript + +const headers = { + 'Authorization':'Bearer {access-token}' +}; + +fetch('/.well-known/openid-configuration', +{ + method: 'GET', + + headers: headers +}) +.then(function(res) { + return res.json(); +}).then(function(body) { + console.log(body); +}); + +``` + +```javascript--nodejs +const fetch = require('node-fetch'); + +const headers = { + 'Authorization':'Bearer {access-token}' +}; + +fetch('/.well-known/openid-configuration', +{ + method: 'GET', + + headers: headers +}) +.then(function(res) { + return res.json(); +}).then(function(body) { + console.log(body); +}); + +``` + +`GET /.well-known/openid-configuration` + +To get the openid configuration + +> Example responses + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|OpenId Configuration|None| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|The syntax of the request entity is incorrect.|None| +|401|[Unauthorized](https://tools.ietf.org/html/rfc7235#section-3.1)|Invalid Credentials.|None| +|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|The entity requested does not exist.|None| +|422|[Unprocessable Entity](https://tools.ietf.org/html/rfc2518#section-10.3)|The syntax of the request entity is incorrect|None| + +

Response Schema

+ + + +## IdentityServerController.connectAuth + + + +> Code samples + +```javascript +const inputBody = '{ + "client_id": "string", + "client_secret": "string", + "auth_method": "string" +}'; +const headers = { + 'Content-Type':'application/x-www-form-urlencoded', + 'Accept':'application/json' +}; + +fetch('/connect/auth', +{ + method: 'POST', + body: inputBody, + headers: headers +}) +.then(function(res) { + return res.json(); +}).then(function(body) { + console.log(body); +}); + +``` + +```javascript--nodejs +const fetch = require('node-fetch'); +const inputBody = { + "client_id": "string", + "client_secret": "string", + "auth_method": "string" +}; +const headers = { + 'Content-Type':'application/x-www-form-urlencoded', + 'Accept':'application/json' +}; + +fetch('/connect/auth', +{ + method: 'POST', + body: JSON.stringify(inputBody), + headers: headers +}) +.then(function(res) { + return res.json(); +}).then(function(body) { + console.log(body); +}); + +``` + +`POST /connect/auth` + +POST Call for idp login + +> Body parameter + +```yaml +client_id: string +client_secret: string +auth_method: string + +``` + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|body|body|[IdpAuthRequest](#schemaidpauthrequest)|false|none| + +> Example responses + +> 200 Response + +```json +{ + "accessToken": "string", + "refreshToken": "string", + "expires": 0, + "pubnubToken": "string" +} +``` + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|Token Response|[TokenResponse](#schematokenresponse)| + + +

LoginActivityController

## LoginActivityController.getActiveUsers @@ -4122,22 +4293,6 @@ AuthTokenRequest |code|string|true|none|none| |clientId|string|true|none|none| -

Function

- - - - - - -```json -null - -``` - -### Properties - -*None* -

AuthRefreshTokenRequest

@@ -4461,6 +4616,22 @@ AuthUser |status|0| |status|4| +

Function

+ + + + + + +```json +null + +``` + +### Properties + +*None* +

ForgetPasswordDto

@@ -4715,6 +4886,32 @@ ActiveUsersFilter |userIdentity|string|true|none|none| |userIdentifier|object|true|none|none| +

IdpAuthRequest

+ + + + + + +```json +{ + "client_id": "string", + "client_secret": "string", + "auth_method": "string" +} + +``` + +IdpAuthRequest + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|client_id|string|true|none|This property is supposed to be a string and is a required field| +|client_secret|string|true|none|This property is supposed to be a string and is a required field| +|auth_method|string|true|none|This property is supposed to be a string and is a required field| +

loopback.Count

diff --git a/services/authentication-service/package.json b/services/authentication-service/package.json index 9b131daf5f..c3d33e9cbe 100644 --- a/services/authentication-service/package.json +++ b/services/authentication-service/package.json @@ -25,6 +25,7 @@ } }, "scripts": { + "start": "node -r source-map-support/register .", "prebuild": "npm run clean", "build": "lb-tsc && npm run openapi-spec && npm run apidocs", "build:watch": "lb-tsc --watch", diff --git a/services/authentication-service/src/component.ts b/services/authentication-service/src/component.ts index 7d1597ba76..0c0eeea2ec 100644 --- a/services/authentication-service/src/component.ts +++ b/services/authentication-service/src/component.ts @@ -116,6 +116,7 @@ import {repositories as sequelizeRepositories} from './repositories/sequelize'; import {MySequence} from './sequence'; import { ActiveUserFilterBuilderService, + IdpLoginService, LoginActivityHelperService, LoginHelperService, OtpService, @@ -189,6 +190,7 @@ export class AuthenticationServiceComponent implements Component { this.application .bind('services.loginActivityHelperService') .toClass(LoginActivityHelperService); + this.application.bind('services.IdpLoginService').toClass(IdpLoginService); //set the userActivity to false by default this.application diff --git a/services/authentication-service/src/controllers/index.ts b/services/authentication-service/src/controllers/index.ts index 854a486df4..e4ee5db7bd 100644 --- a/services/authentication-service/src/controllers/index.ts +++ b/services/authentication-service/src/controllers/index.ts @@ -8,6 +8,7 @@ import { CognitoLoginController, FacebookLoginController, GoogleLoginController, + IdentityServerController, InstagramLoginController, KeycloakLoginController, LoginController, @@ -41,4 +42,5 @@ export const controllers = [ CognitoLoginController, SamlLoginController, LoginActivityController, + IdentityServerController, ]; diff --git a/services/authentication-service/src/modules/auth/controllers/identity-server.controller.ts b/services/authentication-service/src/modules/auth/controllers/identity-server.controller.ts new file mode 100644 index 0000000000..0d841c3d74 --- /dev/null +++ b/services/authentication-service/src/modules/auth/controllers/identity-server.controller.ts @@ -0,0 +1,266 @@ +import {inject} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import { + get, + getModelSchemaRef, + HttpErrors, + param, + post, + requestBody, +} from '@loopback/rest'; +import { + AuthenticateErrorKeys, + CONTENT_TYPE, + ErrorCodes, + OPERATION_SECURITY_SPEC, + RevokedTokenRepository, + STATUS_CODE, + SuccessResponse, + X_TS_TYPE, +} from '@sourceloop/core'; +import { + authenticate, + authenticateClient, + AuthenticationBindings, + AuthErrorKeys, + STRATEGY, +} from 'loopback4-authentication'; +import {authorize} from 'loopback4-authorization'; +import { + AuthCodeBindings, + AuthServiceBindings, + CodeReaderFn, + IUserActivity, + LoginType, + RefreshTokenRepository, + RefreshTokenRequest, + UserRepository, + UserTenantRepository, +} from '../../..'; +import {IdpLoginService} from '../../../services'; +import { + AuthTokenRequest, + AuthUser, + IdpAuthMethod, + IdpAuthRequest, + IdpConfiguration, + TokenResponse, +} from '../models'; + +export class IdentityServerController { + constructor( + @inject('services.IdpLoginService') + private readonly idpLoginHelperService: IdpLoginService, + @inject(AuthCodeBindings.CODEREADER_PROVIDER) + private readonly codeReader: CodeReaderFn, + @inject('services.IdpLoginService') + private readonly idpLoginService: IdpLoginService, + @inject(AuthenticationBindings.CURRENT_USER) + private readonly user: AuthUser | undefined, + @repository(RevokedTokenRepository) + private readonly revokedTokens: RevokedTokenRepository, + @repository(RefreshTokenRepository) + public refreshTokenRepo: RefreshTokenRepository, + @repository(UserRepository) + public userRepo: UserRepository, + @repository(UserTenantRepository) + public userTenantRepo: UserTenantRepository, + @inject(AuthServiceBindings.MarkUserActivity, {optional: true}) + private readonly userActivity?: IUserActivity, + ) {} + + @authenticateClient(STRATEGY.CLIENT_PASSWORD) + @authorize({permissions: ['*']}) + @post('/connect/auth', { + description: 'POST Call for idp login', + responses: { + [STATUS_CODE.OK]: { + description: 'Token Response', + content: { + [CONTENT_TYPE.JSON]: { + schema: {[X_TS_TYPE]: TokenResponse}, + }, + }, + }, + }, + }) + async connectAuth( + @requestBody({ + content: { + [CONTENT_TYPE.FORM_URLENCODED]: { + schema: getModelSchemaRef(IdpAuthRequest), + }, + }, + }) + idpAuthRequest: IdpAuthRequest, + ): Promise { + switch (idpAuthRequest?.auth_method) { + case IdpAuthMethod.COGNITO: + await this.idpLoginHelperService.loginViaCognito(); + break; + case IdpAuthMethod.GOOGLE: + await this.idpLoginHelperService.loginViaGoogle(); + break; + case IdpAuthMethod.SAML: + await this.idpLoginHelperService.loginViaSaml(); + break; + case IdpAuthMethod.FACEBOOK: + await this.idpLoginHelperService.loginViaFacebook(); + break; + case IdpAuthMethod.APPLE: + await this.idpLoginHelperService.loginViaApple(); + break; + case IdpAuthMethod.AZURE: + await this.idpLoginHelperService.loginViaAzure(); + break; + case IdpAuthMethod.INSTAGRAM: + await this.idpLoginHelperService.loginViaInstagram(); + break; + case IdpAuthMethod.KEYCLOAK: + await this.idpLoginHelperService.loginViaKeycloak(); + break; + } + } + + @authorize({permissions: ['*']}) + @get('/.well-known/openid-configuration', { + security: OPERATION_SECURITY_SPEC, + description: 'To get the openid configuration', + responses: { + [STATUS_CODE.OK]: { + description: 'OpenId Configuration', + content: { + [CONTENT_TYPE.JSON]: IdpConfiguration, + }, + }, + ...ErrorCodes, + }, + }) + async getConfig(): Promise { + this.idpLoginHelperService.getOpenIdConfiguration(); + } + + @authorize({permissions: ['*']}) + @post('/connect/token', { + description: + 'Send the code received from the POST /auth/login api and get refresh token and access token (webapps)', + responses: { + [STATUS_CODE.OK]: { + description: 'Token Response', + content: { + [CONTENT_TYPE.JSON]: { + schema: {[X_TS_TYPE]: TokenResponse}, + }, + }, + }, + ...ErrorCodes, + }, + }) + async getToken(@requestBody() req: AuthTokenRequest): Promise { + return this.idpLoginService.generateToken(req); + } + + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @authorize({permissions: ['*']}) + @get('/connect/userinfo', { + security: OPERATION_SECURITY_SPEC, + description: 'To get the user details', + responses: { + [STATUS_CODE.OK]: { + description: 'User Object', + content: { + [CONTENT_TYPE.JSON]: AuthUser, + }, + }, + ...ErrorCodes, + }, + }) + async me(): Promise { + if (!this.user) { + throw new HttpErrors.Unauthorized(AuthErrorKeys.TokenInvalid); + } + delete this.user.deviceInfo; + return new AuthUser(this.user); + } + + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @authorize({permissions: ['*']}) + @post('/connect/endsession', { + security: OPERATION_SECURITY_SPEC, + description: 'To logout', + responses: { + [STATUS_CODE.OK]: { + description: 'Success Response', + content: { + [CONTENT_TYPE.JSON]: { + schema: {[X_TS_TYPE]: SuccessResponse}, + }, + }, + }, + ...ErrorCodes, + }, + }) + async logout( + @param.header.string('Authorization', { + description: + 'This is the access token which is required to authenticate user.', + }) + auth: string, + @requestBody({ + content: { + [CONTENT_TYPE.JSON]: { + schema: getModelSchemaRef(RefreshTokenRequest, { + partial: true, + }), + }, + }, + }) + req: RefreshTokenRequest, + ): Promise { + const token = auth?.replace(/bearer /i, ''); + if (!token || !req.refreshToken) { + throw new HttpErrors.UnprocessableEntity( + AuthenticateErrorKeys.TokenMissing, + ); + } + + const refreshTokenModel = await this.refreshTokenRepo.get(req.refreshToken); + if (!refreshTokenModel) { + throw new HttpErrors.Unauthorized(AuthErrorKeys.TokenExpired); + } + if (refreshTokenModel.accessToken !== token) { + throw new HttpErrors.Unauthorized(AuthErrorKeys.TokenInvalid); + } + await this.revokedTokens.set(token, {token}); + await this.refreshTokenRepo.delete(req.refreshToken); + if (refreshTokenModel.pubnubToken) { + await this.refreshTokenRepo.delete(refreshTokenModel.pubnubToken); + } + + const user = await this.userRepo.findById(refreshTokenModel.userId); + + const userTenant = await this.userTenantRepo.findOne({ + where: {userId: user.id}, + }); + + if (this.userActivity?.markUserActivity) + this.idpLoginHelperService.markUserActivity( + user, + userTenant, + { + ...user, + clientId: refreshTokenModel.clientId, + }, + LoginType.LOGOUT, + ); + return new SuccessResponse({ + success: true, + + key: refreshTokenModel.userId, + }); + } +} diff --git a/services/authentication-service/src/modules/auth/controllers/index.ts b/services/authentication-service/src/modules/auth/controllers/index.ts index f547be66f4..fae0438379 100644 --- a/services/authentication-service/src/modules/auth/controllers/index.ts +++ b/services/authentication-service/src/modules/auth/controllers/index.ts @@ -3,6 +3,7 @@ export * from './azure-login.controller'; export * from './cognito-login.controller'; export * from './facebook-login.controller'; export * from './google-login.controller'; +export * from './identity-server.controller'; export * from './instagram-login.controller'; export * from './keycloak-login.controller'; export * from './login.controller'; diff --git a/services/authentication-service/src/modules/auth/controllers/login.controller.ts b/services/authentication-service/src/modules/auth/controllers/login.controller.ts index 680a6c0b6f..60599041f1 100644 --- a/services/authentication-service/src/modules/auth/controllers/login.controller.ts +++ b/services/authentication-service/src/modules/auth/controllers/login.controller.ts @@ -51,7 +51,6 @@ import { import { AuthCodeBindings, AuthCodeGeneratorFn, - CodeReaderFn, JWTSignerFn, JWTVerifierFn, JwtPayloadFn, @@ -71,7 +70,7 @@ import { UserRepository, UserTenantRepository, } from '../../../repositories'; -import {LoginHelperService} from '../../../services'; +import {IdpLoginService, LoginHelperService} from '../../../services'; import { ActorId, ExternalTokens, @@ -121,6 +120,8 @@ export class LoginController { private readonly getJwtPayload: JwtPayloadFn, @inject('services.LoginHelperService') private readonly loginHelperService: LoginHelperService, + @inject('services.IdpLoginService') + private readonly idpLoginService: IdpLoginService, @inject(AuthCodeBindings.AUTH_CODE_GENERATOR_PROVIDER) private readonly getAuthCode: AuthCodeGeneratorFn, @inject(AuthCodeBindings.JWT_SIGNER) @@ -254,46 +255,8 @@ export class LoginController { ...ErrorCodes, }, }) - async getToken( - @requestBody() req: AuthTokenRequest, - @inject(AuthCodeBindings.CODEREADER_PROVIDER) - codeReader: CodeReaderFn, - ): Promise { - const authClient = await this.authClientRepository.findOne({ - where: { - clientId: req.clientId, - }, - }); - if (!authClient) { - throw new HttpErrors.Unauthorized(AuthErrorKeys.ClientInvalid); - } - try { - const code = await codeReader(req.code); - const payload = (await this.jwtVerifier(code, { - audience: req.clientId, - })) as ClientAuthCode; - if (payload.mfa) { - throw new HttpErrors.Unauthorized(AuthErrorKeys.UserVerificationFailed); - } - - if ( - payload.userId && - !(await this.userRepo.firstTimeUser(payload.userId)) - ) { - await this.userRepo.updateLastLogin(payload.userId); - } - - return await this.createJWT(payload, authClient, LoginType.ACCESS); - } catch (error) { - this.logger.error(error); - if (error.name === 'TokenExpiredError') { - throw new HttpErrors.Unauthorized(AuthErrorKeys.CodeExpired); - } else if (HttpErrors.HttpError.prototype.isPrototypeOf(error)) { - throw error; - } else { - throw new HttpErrors.Unauthorized(AuthErrorKeys.InvalidCredentials); - } - } + async getToken(@requestBody() req: AuthTokenRequest): Promise { + return this.idpLoginService.generateToken(req); } @authorize({permissions: ['*']}) diff --git a/services/authentication-service/src/modules/auth/models/idp-auth-request.dto.ts b/services/authentication-service/src/modules/auth/models/idp-auth-request.dto.ts new file mode 100644 index 0000000000..7d06733c11 --- /dev/null +++ b/services/authentication-service/src/modules/auth/models/idp-auth-request.dto.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2023 Sourcefuse Technologies +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT +/* eslint-disable @typescript-eslint/naming-convention */ + +import {model, property} from '@loopback/repository'; +import {CoreModel} from '@sourceloop/core'; +import {IdpAuthMethod} from './idp-auth.method'; +import {ModelPropertyDescriptionString} from './model-property-description.enum'; + +@model({ + description: 'This is signature for idp authentication request.', +}) +export class IdpAuthRequest extends CoreModel { + @property({ + type: 'string', + description: ModelPropertyDescriptionString.reqStrPropDesc, + required: true, + }) + client_id: string; //NOSONAR + + @property({ + type: 'string', + description: ModelPropertyDescriptionString.reqStrPropDesc, + required: true, + }) + client_secret: string; //NOSONAR + + @property({ + type: 'string', + description: ModelPropertyDescriptionString.reqStrPropDesc, + required: true, + }) + auth_method: IdpAuthMethod; //NOSONAR +} diff --git a/services/authentication-service/src/modules/auth/models/idp-auth.method.ts b/services/authentication-service/src/modules/auth/models/idp-auth.method.ts new file mode 100644 index 0000000000..f7b1cc30ba --- /dev/null +++ b/services/authentication-service/src/modules/auth/models/idp-auth.method.ts @@ -0,0 +1,10 @@ +export enum IdpAuthMethod { + COGNITO = 'COGNITO', + GOOGLE = 'GOOGLE', + SAML = 'SAML', + FACEBOOK = 'FACEBOOK', + APPLE = 'APPLE', + AZURE = 'AZURE', + INSTAGRAM = 'INSTAGRAM', + KEYCLOAK = 'KEYCLOAK', +} diff --git a/services/authentication-service/src/modules/auth/models/idp-configuration.dto.ts b/services/authentication-service/src/modules/auth/models/idp-configuration.dto.ts new file mode 100644 index 0000000000..0d5ed29f1d --- /dev/null +++ b/services/authentication-service/src/modules/auth/models/idp-configuration.dto.ts @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {model, property} from '@loopback/repository'; +import {CoreModel} from '@sourceloop/core'; +import {ModelPropertyDescriptionString} from './model-property-description.enum'; + +@model({ + description: 'This is signature for idp configuration.', +}) +export class IdpConfiguration extends CoreModel { + @property({ + type: 'string', + description: ModelPropertyDescriptionString.reqStrPropDesc, + required: true, + }) + authorization_endpoint: string; + + @property({ + type: 'string', + description: ModelPropertyDescriptionString.reqStrPropDesc, + required: true, + }) + end_session_endpoint: string; + + @property({ + type: 'array', + itemType: 'string', + description: ModelPropertyDescriptionString.reqStrPropDesc, + required: true, + }) + id_token_signing_alg_values_supported: string[]; + + @property({ + type: 'string', + description: ModelPropertyDescriptionString.reqStrPropDesc, + required: true, + }) + issuer: string; + + @property({ + type: 'string', + description: ModelPropertyDescriptionString.reqStrPropDesc, + required: true, + }) + jwks_uri: string; + + @property({ + type: 'array', + itemType: 'string', + description: ModelPropertyDescriptionString.reqStrPropDesc, + required: true, + }) + response_types_supported: string[]; + + @property({ + type: 'array', + itemType: 'string', + description: ModelPropertyDescriptionString.reqStrPropDesc, + required: true, + }) + scopes_supported: string[]; + + @property({ + type: 'string', + description: ModelPropertyDescriptionString.reqStrPropDesc, + required: true, + }) + token_endpoint: string; + + @property({ + type: 'array', + itemType: 'string', + description: ModelPropertyDescriptionString.reqStrPropDesc, + required: true, + }) + token_endpoint_auth_methods_supported: string[]; + + @property({ + type: 'string', + description: ModelPropertyDescriptionString.reqStrPropDesc, + required: true, + }) + userinfo_endpoint: string; +} diff --git a/services/authentication-service/src/modules/auth/models/index.ts b/services/authentication-service/src/modules/auth/models/index.ts index 6b2838d7f3..ba78819a36 100644 --- a/services/authentication-service/src/modules/auth/models/index.ts +++ b/services/authentication-service/src/modules/auth/models/index.ts @@ -2,6 +2,9 @@ export * from './auth-refresh-token-request.dto'; export * from './auth-token-request.dto'; export * from './auth-user.model'; export * from './client-auth-request.dto'; +export * from './idp-auth-request.dto'; +export * from './idp-auth.method'; +export * from './idp-configuration.dto'; export * from './login-request.dto'; export * from './model-property-description.enum'; export * from './otp-login-request.dto'; diff --git a/services/authentication-service/src/services/idp-login.service.ts b/services/authentication-service/src/services/idp-login.service.ts new file mode 100644 index 0000000000..db6418ae9a --- /dev/null +++ b/services/authentication-service/src/services/idp-login.service.ts @@ -0,0 +1,489 @@ +import {BindingScope, inject, injectable} from '@loopback/core'; +import {AnyObject, repository} from '@loopback/repository'; +import {HttpErrors, RequestContext} from '@loopback/rest'; +import {AuthenticateErrorKeys, ILogger, LOGGER} from '@sourceloop/core'; +import crypto from 'crypto'; +import { + authenticate, + AuthErrorKeys, + ClientAuthCode, + STRATEGY, +} from 'loopback4-authentication'; +import moment from 'moment'; +import {LoginType} from '../enums'; +import {AuthServiceBindings} from '../keys'; +import {AuthClient, LoginActivity, User, UserTenant} from '../models'; +import { + AuthTokenRequest, + AuthUser, + IdpConfiguration, + TokenResponse, +} from '../modules/auth'; +import { + AuthCodeBindings, + CodeReaderFn, + JwtPayloadFn, + JWTSignerFn, + JWTVerifierFn, +} from '../providers'; +import { + AuthClientRepository, + LoginActivityRepository, + RefreshTokenRepository, + RevokedTokenRepository, + UserRepository, + UserTenantRepository, +} from '../repositories'; +import {ActorId, ExternalTokens, IUserActivity} from '../types'; + +const clockSkew = 300; +const nonceTime = 3600; +const nonceCount = 10; + +@injectable({scope: BindingScope.TRANSIENT}) +export class IdpLoginService { + constructor( + @repository(AuthClientRepository) + public authClientRepository: AuthClientRepository, + @repository(UserRepository) + public userRepo: UserRepository, + @repository(UserTenantRepository) + public userTenantRepo: UserTenantRepository, + @repository(RefreshTokenRepository) + public refreshTokenRepo: RefreshTokenRepository, + @repository(RevokedTokenRepository) + public revokedTokensRepo: RevokedTokenRepository, + @inject(LOGGER.LOGGER_INJECT) public logger: ILogger, + @repository(LoginActivityRepository) + private readonly loginActivityRepo: LoginActivityRepository, + @inject(AuthServiceBindings.ActorIdKey) + private readonly actorKey: ActorId, + @inject.context() private readonly ctx: RequestContext, + @inject(AuthCodeBindings.CODEREADER_PROVIDER) + private readonly codeReader: CodeReaderFn, + @inject(AuthCodeBindings.JWT_VERIFIER, {optional: true}) + private readonly jwtVerifier: JWTVerifierFn, + @inject(AuthCodeBindings.JWT_SIGNER) + private readonly jwtSigner: JWTSignerFn, + @inject(AuthServiceBindings.JWTPayloadProvider) + private readonly getJwtPayload: JwtPayloadFn, + @inject(AuthServiceBindings.MarkUserActivity, {optional: true}) + private readonly userActivity?: IUserActivity, + ) {} + + @authenticate(STRATEGY.COGNITO_OAUTH2, { + callbackURL: process.env.COGNITO_AUTH_CALLBACK_URL, + clientDomain: process.env.COGNITO_AUTH_CLIENT_DOMAIN, + clientID: process.env.COGNITO_AUTH_CLIENT_ID, + clientSecret: process.env.COGNITO_AUTH_CLIENT_SECRET, + region: process.env.COGNITO_AUTH_REGION, + }) + async loginViaCognito(): Promise { + // do nothing + } + + @authenticate(STRATEGY.GOOGLE_OAUTH2, { + accessType: 'offline', + scope: ['profile', 'email'], + authorizationURL: process.env.GOOGLE_AUTH_URL, + callbackURL: process.env.GOOGLE_AUTH_CALLBACK_URL, + clientID: process.env.GOOGLE_AUTH_CLIENT_ID, + clientSecret: process.env.GOOGLE_AUTH_CLIENT_SECRET, + tokenURL: process.env.GOOGLE_AUTH_TOKEN_URL, + }) + async loginViaGoogle(): Promise { + // do nothing + } + + @authenticate(STRATEGY.SAML, { + accessType: 'offline', + scope: ['profile', 'email'], + callbackURL: process.env.SAML_CALLBACK_URL, + issuer: process.env.SAML_ISSUER, + cert: process.env.SAML_CERT, + entryPoint: process.env.SAML_ENTRY_POINT, + audience: process.env.SAML_AUDIENCE, + logoutUrl: process.env.SAML_LOGOUT_URL, + passReqToCallback: !!+(process.env.SAML_AUTH_PASS_REQ_CALLBACK ?? 0), + validateInResponseTo: !!+(process.env.VALIDATE_RESPONSE ?? 1), + idpIssuer: process.env.IDP_ISSUER, + logoutCallbackUrl: process.env.SAML_LOGOUT_CALLBACK_URL, + }) + async loginViaSaml(): Promise { + // do nothing + } + + @authenticate(STRATEGY.FACEBOOK_OAUTH2, { + accessType: 'offline', + authorizationURL: process.env.FACEBOOK_AUTH_URL, + callbackURL: process.env.FACEBOOK_AUTH_CALLBACK_URL, + clientID: process.env.FACEBOOK_AUTH_CLIENT_ID, + clientSecret: process.env.FACEBOOK_AUTH_CLIENT_SECRET, + tokenURL: process.env.FACEBOOK_AUTH_TOKEN_URL, + }) + async loginViaFacebook(): Promise { + // do nothing + } + + @authenticate(STRATEGY.APPLE_OAUTH2, { + accessType: 'offline', + scope: ['name', 'email'], + callbackURL: process.env.APPLE_AUTH_CALLBACK_URL, + clientID: process.env.APPLE_AUTH_CLIENT_ID, + teamID: process.env.APPLE_AUTH_TEAM_ID, + keyID: process.env.APPLE_AUTH_KEY_ID, + privateKeyLocation: process.env.APPLE_AUTH_PRIVATE_KEY_LOCATION, + }) + async loginViaApple(): Promise { + // do nothing + } + + @authenticate(STRATEGY.AZURE_AD, { + scope: ['profile', 'email', 'openid', 'offline_access'], + identityMetadata: process.env.AZURE_IDENTITY_METADATA, + clientID: process.env.AZURE_AUTH_CLIENT_ID, + responseType: 'code', + responseMode: 'query', + redirectUrl: process.env.AZURE_AUTH_REDIRECT_URL, + clientSecret: process.env.AZURE_AUTH_CLIENT_SECRET, + allowHttpForRedirectUrl: !!+( + process.env.AZURE_AUTH_ALLOW_HTTP_REDIRECT ?? 1 + ), + passReqToCallback: !!+(process.env.AZURE_AUTH_PASS_REQ_CALLBACK ?? 0), + validateIssuer: !!+(process.env.AZURE_AUTH_VALIDATE_ISSUER ?? 1), + useCookieInsteadOfSession: !!+( + process.env.AZURE_AUTH_COOKIE_INSTEAD_SESSION ?? 1 + ), + cookieEncryptionKeys: [ + { + key: process.env.AZURE_AUTH_COOKIE_KEY, + iv: process.env.AZURE_AUTH_COOKIE_IV, + }, + ], + isB2c: !!+(process.env.AZURE_AUTH_B2C_TENANT ?? 0), + clockSkew: +(process.env.AZURE_AUTH_CLOCK_SKEW ?? clockSkew), + loggingLevel: process.env.AZURE_AUTH_LOG_LEVEL, + loggingNoPII: !!+(process.env.AZURE_AUTH_LOG_PII ?? 1), + nonceLifetime: +(process.env.AZURE_AUTH_NONCE_TIME ?? nonceTime), + nonceMaxAmount: +(process.env.AZURE_AUTH_NONCE_COUNT ?? nonceCount), + issuer: process.env.AZURE_AUTH_ISSUER, + cookieSameSite: !!+(process.env.AZURE_AUTH_COOKIE_SAME_SITE ?? 0), + }) + async loginViaAzure(): Promise { + // do nothing + } + + @authenticate(STRATEGY.INSTAGRAM_OAUTH2, { + accessType: 'offline', + authorizationURL: process.env.INSTAGRAM_AUTH_URL, + callbackURL: process.env.INSTAGRAM_AUTH_CALLBACK_URL, + clientID: process.env.INSTAGRAM_AUTH_CLIENT_ID, + clientSecret: process.env.INSTAGRAM_AUTH_CLIENT_SECRET, + tokenURL: process.env.INSTAGRAM_AUTH_TOKEN_URL, + }) + async loginViaInstagram(): Promise { + // do nothing + } + + @authenticate(STRATEGY.KEYCLOAK, { + host: process.env.KEYCLOAK_HOST, + realm: process.env.KEYCLOAK_REALM, + clientID: process.env.KEYCLOAK_CLIENT_ID, + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET, + callbackURL: process.env.KEYCLOAK_CALLBACK_URL, + authorizationURL: `${process.env.KEYCLOAK_HOST}/auth/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/auth`, + tokenURL: `${process.env.KEYCLOAK_HOST}/auth/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`, + userInfoURL: `${process.env.KEYCLOAK_HOST}/auth/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/userinfo`, + }) + async loginViaKeycloak(): Promise { + // do nothing + } + + /** + * The function `getOpenIdConfiguration` returns an IdpConfiguration object with specific properties + * set based on environment variables. + * @returns An IdpConfiguration object with the specified properties and values is being returned. + */ + getOpenIdConfiguration() { + const config = new IdpConfiguration(); + config.issuer = ''; + config.authorization_endpoint = `${process.env.API_BASE_URL}/connect/auth`; + config.token_endpoint = `${process.env.API_BASE_URL}/connect/token`; + config.jwks_uri = ''; + config.end_session_endpoint = `${process.env.API_BASE_URL}/connect/endsession`; + config.response_types_supported = ['code']; + config.scopes_supported = ['openid', 'email', 'phone', 'profile']; + config.id_token_signing_alg_values_supported = ['RS256']; + config.token_endpoint_auth_methods_supported = [ + 'client_secret_basic', + 'client_secret_post', + ]; + config.userinfo_endpoint = `${process.env.API_BASE_URL}/connect/userinfo`; + return config; + } + + /** + * The function `generateToken` generates a JWT token for a client using a code + * and performs various authentication checks. + * @param {string} clientId - The `clientId` parameter in the `generateToken` + * function is a string that represents the client ID associated with the + * authentication request. It is used to identify the client making the request + * for generating a token. + * @param {string} code - The `code` parameter in the `generateToken` function is + * a string that represents the authorization code that will be used to generate + * a token for authentication and authorization purposes. This code is typically + * obtained during the authorization code flow in OAuth 2.0 when a user grants + * permission to a client application. + * @param {CodeReaderFn} codeReader - The `codeReader` parameter in the + * `generateToken` function is a function that reads a code and returns a result. + * It is injected using `@inject(AuthCodeBindings.CODEREADER_PROVIDER)` which + * means it is provided by a binding defined in the `AuthCodeBindings` namespace. + * The + * @returns The `generateToken` function is returning the result of calling + * `this.createJWT(payload, authClient, LoginType.ACCESS)` after performing + * various checks and operations. + */ + public async generateToken( + request: AuthTokenRequest, + ): Promise { + const authClient = await this.authClientRepository.findOne({ + where: { + clientId: request.clientId, + }, + }); + if (!authClient) { + throw new HttpErrors.Unauthorized(AuthErrorKeys.ClientInvalid); + } + try { + const resultCode = await this.codeReader(request.code); + const payload = (await this.jwtVerifier(resultCode, { + audience: request.clientId, + })) as ClientAuthCode; + if (payload.mfa) { + throw new HttpErrors.Unauthorized(AuthErrorKeys.UserVerificationFailed); + } + + if ( + payload.userId && + !(await this.userRepo.firstTimeUser(payload.userId)) + ) { + await this.userRepo.updateLastLogin(payload.userId); + } + + return await this.createJWT(payload, authClient, LoginType.ACCESS); + } catch (error) { + this.logger.error(error); + if (error.name === 'TokenExpiredError') { + throw new HttpErrors.Unauthorized(AuthErrorKeys.CodeExpired); + } else if (HttpErrors.HttpError.prototype.isPrototypeOf(error)) { + throw error; + } else { + throw new HttpErrors.Unauthorized(AuthErrorKeys.InvalidCredentials); + } + } + + return new TokenResponse(); + } + + /** + * The `createJWT` function generates a JWT token for a user with specified + * payload and authentication client, handling user authentication and token + * expiration. + * @param payload - The `payload` parameter in the `createJWT` function is an + * object that contains information about the user and external tokens. It has + * the following properties: + * @param {AuthClient} authClient - The `authClient` parameter in the `createJWT` + * function represents the client that is requesting the JWT token generation. It + * contains information about the client, such as the client ID, access token + * expiration time, and refresh token expiration time. This information is used + * to customize the JWT token generation process based + * @param {LoginType} loginType - The `loginType` parameter in the `createJWT` + * function represents the type of login being performed, such as regular login, + * social login, or any other specific type of authentication method. It helps in + * determining the context of the login operation and can be used to customize + * the behavior or processing logic based + * @param {string} [tenantId] - The `tenantId` parameter in the `createJWT` + * function is an optional parameter of type string. It is used to specify the ID + * of the tenant for which the JWT token is being created. If provided, it is + * used in the process of generating the JWT payload. If not provided, the + * @returns A `TokenResponse` object is being returned, which contains the + * `accessToken`, `refreshToken`, and `expires` properties. + */ + private async createJWT( + payload: ClientAuthCode & ExternalTokens, + authClient: AuthClient, + loginType: LoginType, + tenantId?: string, + ): Promise { + try { + const size = 32; + const ms = 1000; + let user: User | undefined; + if (payload.user) { + user = payload.user; + } else if (payload.userId) { + user = await this.userRepo.findById(payload.userId, { + include: [ + { + relation: 'defaultTenant', + }, + ], + }); + if (payload.externalAuthToken && payload.externalRefreshToken) { + (user as AuthUser).externalAuthToken = payload.externalAuthToken; + (user as AuthUser).externalRefreshToken = + payload.externalRefreshToken; + } + } else { + // Do nothing and move ahead + } + if (!user) { + throw new HttpErrors.Unauthorized( + AuthenticateErrorKeys.UserDoesNotExist, + ); + } + const data: AnyObject = await this.getJwtPayload( + user, + authClient, + tenantId, + ); + const accessToken = await this.jwtSigner(data, { + expiresIn: authClient.accessTokenExpiration, + }); + const refreshToken: string = crypto.randomBytes(size).toString('hex'); + // Set refresh token into redis for later verification + await this.refreshTokenRepo.set( + refreshToken, + { + clientId: authClient.clientId, + userId: user.id, + username: user.username, + accessToken, + externalAuthToken: (user as AuthUser).externalAuthToken, + externalRefreshToken: (user as AuthUser).externalRefreshToken, + tenantId: data.tenantId, + }, + {ttl: authClient.refreshTokenExpiration * ms}, + ); + + const userTenant = await this.userTenantRepo.findOne({ + where: {userId: user.id}, + }); + if (this.userActivity?.markUserActivity) + this.markUserActivity(user, userTenant, {...data}, loginType); + + return new TokenResponse({ + accessToken, + refreshToken, + expires: moment() + .add(authClient.accessTokenExpiration, 's') + .toDate() + .getTime(), + }); + } catch (error) { + this.logger.error(error); + if (HttpErrors.HttpError.prototype.isPrototypeOf(error)) { + throw error; + } else { + throw new HttpErrors.Unauthorized(AuthErrorKeys.InvalidCredentials); + } + } + } + + /** + * The function `markUserActivity` encrypts and stores user login activity, + * including IP address and payload, in a database. + * @param {User} user - The `user` parameter in the `markUserActivity` function + * represents the user who is performing the activity for which you are marking the + * login activity. This user object likely contains information about the user, + * such as their ID, name, email, etc. It is used to identify the actor of the + * @param {UserTenant | null} userTenant - The `userTenant` parameter in the + * `markUserActivity` function represents the tenant associated with the user. It + * can be either an object of type `UserTenant` or `null` if there is no specific + * tenant assigned to the user. The function uses this parameter to determine the + * actor and tenant + * @param {AnyObject} payload - The `payload` parameter in the `markUserActivity` + * function is the data that you want to encrypt and store as part of the user's + * login activity. In the provided code snippet, the `payload` is first converted + * to a JSON string using `JSON.stringify(payload)`. Then, it is + * @param {LoginType} loginType - The `loginType` parameter in the + * `markUserActivity` function represents the type of login activity being + * performed by the user. It is used to specify whether the user is logging in + * using a certain method or platform. Examples of `loginType` could include + * 'email', 'social', '2 + */ + public markUserActivity( + user: User, + userTenant: UserTenant | null, + payload: AnyObject, + loginType: LoginType, + ) { + const size = 16; + const encryptionKey = process.env.ENCRYPTION_KEY; + + if (encryptionKey) { + const iv = crypto.randomBytes(size); + + /* encryption of IP Address */ + const cipherIp = crypto.createCipheriv('aes-256-gcm', encryptionKey, iv); + const ip = + this.ctx.request.headers['x-forwarded-for']?.toString() ?? + this.ctx.request.socket.remoteAddress?.toString() ?? + ''; + const encyptIp = Buffer.concat([ + cipherIp.update(ip, 'utf8'), + cipherIp.final(), + ]); + const authTagIp = cipherIp.getAuthTag(); + const ipAddress = JSON.stringify({ + iv: iv.toString('hex'), + encryptedData: encyptIp.toString('hex'), + authTag: authTagIp.toString('hex'), + }); + + /* encryption of Paylolad Address */ + const cipherPayload = crypto.createCipheriv( + 'aes-256-gcm', + encryptionKey, + iv, + ); + const activityPayload = JSON.stringify(payload); + const encyptPayload = Buffer.concat([ + cipherPayload.update(activityPayload, 'utf8'), + cipherPayload.final(), + ]); + const authTagPayload = cipherIp.getAuthTag(); + const tokenPayload = JSON.stringify({ + iv: iv.toString('hex'), + encryptedData: encyptPayload.toString('hex'), + authTag: authTagPayload.toString('hex'), + }); + // make an entry to mark the users login activity + let actor: string; + let tenantId: string; + if (userTenant) { + actor = userTenant[this.actorKey]?.toString() ?? '0'; + tenantId = userTenant.tenantId; + } else { + actor = user['id']?.toString() ?? '0'; + tenantId = user.defaultTenantId; + } + const loginActivity = new LoginActivity({ + actor, + tenantId, + loginTime: new Date(), + tokenPayload, + loginType, + deviceInfo: this.ctx.request.headers['user-agent']?.toString(), + ipAddress, + }); + this.loginActivityRepo.create(loginActivity).catch(() => { + this.logger.error( + `Failed to add the login activity => ${JSON.stringify( + loginActivity, + )}`, + ); + }); + } + } +} diff --git a/services/authentication-service/src/services/index.ts b/services/authentication-service/src/services/index.ts index fc0953c0fc..0eabf4db74 100644 --- a/services/authentication-service/src/services/index.ts +++ b/services/authentication-service/src/services/index.ts @@ -3,10 +3,12 @@ // This software is released under the MIT License. // https://opensource.org/licenses/MIT import {ActiveUserFilterBuilderService} from './active-user-fliter-builder.service'; +import {IdpLoginService} from './idp-login.service'; import {LoginActivityHelperService} from './login-activity-helper.service'; import {LoginHelperService} from './login-helper.service'; import {OtpService} from './otp.service'; export * from './active-user-fliter-builder.service'; +export * from './idp-login.service'; export * from './login-activity-helper.service'; export * from './login-helper.service'; export * from './otp.service'; @@ -16,4 +18,5 @@ export const services = [ OtpService, ActiveUserFilterBuilderService, LoginActivityHelperService, + IdpLoginService, ];