From 9fdba2198ddb34ec4448155c7dffab9b7b5b9805 Mon Sep 17 00:00:00 2001 From: SaibazKhan Date: Fri, 12 Jun 2026 19:24:22 +0530 Subject: [PATCH] ad changes --- MICROSOFT_ACTIVE_DIRECTORY_OAUTH_FLOW.md | 616 ++++++++++++++++++ src/constants/error-messages.ts | 3 +- ...ive-directory-authentication.controller.ts | 104 +++ src/entities/user.entity.ts | 14 +- ...microsoft-active-directory-oauth.helper.ts | 83 +++ src/helpers/user-helper.ts | 6 +- src/index.ts | 1 + ...crosoft-active-directory-oauth.strategy.ts | 63 ++ src/services/authentication.service.ts | 78 +++ .../default-settings-provider.service.ts | 59 ++ src/services/user.service.ts | 61 ++ src/solid-core.module.ts | 4 + 12 files changed, 1089 insertions(+), 3 deletions(-) create mode 100644 MICROSOFT_ACTIVE_DIRECTORY_OAUTH_FLOW.md create mode 100644 src/controllers/microsoft-active-directory-authentication.controller.ts create mode 100644 src/helpers/microsoft-active-directory-oauth.helper.ts create mode 100644 src/passport-strategies/microsoft-active-directory-oauth.strategy.ts diff --git a/MICROSOFT_ACTIVE_DIRECTORY_OAUTH_FLOW.md b/MICROSOFT_ACTIVE_DIRECTORY_OAUTH_FLOW.md new file mode 100644 index 00000000..9b503ce8 --- /dev/null +++ b/MICROSOFT_ACTIVE_DIRECTORY_OAUTH_FLOW.md @@ -0,0 +1,616 @@ +# Microsoft Active Directory OAuth Flow + +This document explains how Microsoft Active Directory, also called Azure AD or Microsoft Entra ID, OAuth login works in Solid Core and Solid Core UI. + +## Important Short Answer + +On button click, frontend does not call the backend with `axios` or `fetch`. + +Frontend only builds this URL: + +```ts +const backendApiUrl = ( + env("NEXT_PUBLIC_BACKEND_API_URL") || env("API_URL") +).replace(/\/+$/, ""); + +const getOAuthConnectUrl = (provider: string) => + `${backendApiUrl}/api/iam/${provider}/connect`; +``` + +For Microsoft Active Directory, this becomes: + +```text +http://localhost:3000/api/iam/microsoft-active-directory/connect +``` + +Then frontend does: + +```ts +router.push(getOAuthConnectUrl("microsoft-active-directory")); +``` + +So frontend creates a URL and navigates the browser to that URL. Browser navigation itself makes the `GET /connect` request. + +This is correct for OAuth because OAuth needs full browser redirects: + +```text +Frontend page +-> Backend /connect +-> Microsoft login page +-> Backend /connect/callback +-> Frontend callback page +-> Backend /authenticate +``` + +## Why It Is Not A Normal API Call + +Normal login with username/password can use an API call: + +```text +frontend axios/fetch -> backend -> response JSON +``` + +OAuth login cannot work like that for the first step because the user must leave our app and open Microsoft login in the browser. + +So the first step is navigation: + +```text +browser location changes to backend /connect +``` + +After Microsoft login completes, the final token exchange uses an API call: + +```text +frontend -> backend /authenticate?accessCode=... +``` + +## Files Involved + +Backend files: + +```text +src/controllers/microsoft-active-directory-authentication.controller.ts +src/passport-strategies/microsoft-active-directory-oauth.strategy.ts +src/helpers/microsoft-active-directory-oauth.helper.ts +src/services/user.service.ts +src/services/authentication.service.ts +src/services/settings/default-settings-provider.service.ts +``` + +Frontend files: + +```text +src/components/common/SocialMediaLogin.tsx +src/routes/pages/auth/InitiateMicrosoftActiveDirectoryOauthPage.tsx +src/components/auth/MicrosoftActiveDirectoryAuthChecking.tsx +src/adapters/auth/signInWithOAuthAccessCode.ts +src/routes/solidRoutes.tsx +``` + +## Backend Endpoints + +### 1. Start Microsoft Active Directory OAuth + +```http +GET /api/iam/microsoft-active-directory/connect +``` + +Controller method: + +```ts +@Public() +@UseGuards(MicrosoftActiveDirectoryOauthGuard) +@Get("connect") +async connect() { + await this.validateConfiguration(); +} +``` + +This endpoint starts OAuth. The guard runs before the method body. + +The guard: + +```ts +export class MicrosoftActiveDirectoryOauthGuard + extends AuthGuard("microsoft-active-directory") {} +``` + +This tells Passport to use the strategy named: + +```text +microsoft-active-directory +``` + +### 2. Microsoft Callback + +```http +GET /api/iam/microsoft-active-directory/connect/callback +``` + +Controller method: + +```ts +@Public() +@Get("connect/callback") +@UseGuards(MicrosoftActiveDirectoryOauthGuard) +async microsoftActiveDirectoryAuthCallback(@Req() req, @Res() res) { + const config = await this.validateConfiguration(); + const user = req.user; + + return res.redirect( + this.buildFrontendRedirectUrl(config.redirectURL, user["accessCode"]), + ); +} +``` + +Microsoft redirects to this endpoint after successful login. + +This route also uses `MicrosoftActiveDirectoryOauthGuard`, but now Microsoft sends a `code` in the URL. Passport uses that code to get an access token and profile from Microsoft. + +### 3. Authenticate With accessCode + +```http +GET /api/iam/microsoft-active-directory/authenticate?accessCode= +``` + +Controller method: + +```ts +@Public() +@Get("authenticate") +async microsoftActiveDirectoryAuth(@Query("accessCode") accessCode: string) { + await this.validateConfiguration(); + return this.authService.signInUsingMicrosoftActiveDirectory(accessCode); +} +``` + +Frontend calls this after it receives `accessCode` from the callback redirect. + +This returns application JWT tokens. + +## Complete Request Flow + +### Step 1: User Clicks Button + +Frontend file: + +```text +src/components/common/SocialMediaLogin.tsx +``` + +Click handler: + +```ts +onClick={() => router.push(getOAuthConnectUrl("microsoft-active-directory"))} +``` + +This creates: + +```text +http://localhost:3000/api/iam/microsoft-active-directory/connect +``` + +Then browser opens that URL. + +Note: yahan frontend direct API call nahi kar raha. Sirf URL bana kar browser ko us URL par bhej raha hai. + +### Step 2: Backend /connect Runs Guard + +Backend route: + +```http +GET /api/iam/microsoft-active-directory/connect +``` + +Before `connect()` runs, Nest runs: + +```ts +MicrosoftActiveDirectoryOauthGuard +``` + +Guard calls Passport strategy: + +```ts +AuthGuard("microsoft-active-directory") +``` + +Strategy config: + +```ts +super({ + clientID, + clientSecret, + callbackURL, + tenant, + scope: MICROSOFT_ACTIVE_DIRECTORY_OAUTH_SCOPES, + addUPNAsEmail: true, +}); +``` + +Passport redirects the browser to Microsoft login page. + +### Step 3: Microsoft Login Page Opens + +Browser leaves our app and opens Microsoft login. + +Microsoft receives values like: + +```text +client_id +redirect_uri +scope +tenant +response_type=code +``` + +The `redirect_uri` comes from: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL +``` + +Example: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL=http://localhost:3000/api/iam/microsoft-active-directory/connect/callback +``` + +This exact URL must be registered in Azure App Registration. + +### Step 4: Microsoft Redirects Back To Backend + +After successful Microsoft login, Microsoft redirects browser to: + +```text +http://localhost:3000/api/iam/microsoft-active-directory/connect/callback?code=... +``` + +This is the callback route. + +### Step 5: Guard Runs Again On Callback + +The callback route also has: + +```ts +@UseGuards(MicrosoftActiveDirectoryOauthGuard) +``` + +Now the guard sees Microsoft `code`. + +Passport exchanges that `code` with Microsoft and gets: + +```text +access token +profile +``` + +Then Passport calls the strategy `validate()` method. + +### Step 6: Strategy validate() Creates accessCode + +Backend file: + +```text +src/passport-strategies/microsoft-active-directory-oauth.strategy.ts +``` + +Method: + +```ts +async validate(_accessToken, _refreshToken, profile, done) { + const loginAccessCode = uuid(); + + const user = { + provider: "microsoftActiveDirectory", + providerId, + email, + name, + picture, + accessCode: loginAccessCode, + }; + + await this.userService.resolveUserOnOauthMicrosoftActiveDirectory({ + ...user, + accessToken: _accessToken, + refreshToken: null, + }); + + done(null, user); +} +``` + +This method creates a temporary `accessCode`. This is not the JWT. It is only a one-time code used by frontend to complete login. + +### Step 7: User Is Created Or Updated + +Backend file: + +```text +src/services/user.service.ts +``` + +Method: + +```ts +resolveUserOnOauthMicrosoftActiveDirectory() +``` + +It checks user by email: + +```text +if user does not exist: + create user + save Microsoft Active Directory id/token/profile picture + save accessCode + initialize default role + +if user exists: + update Microsoft Active Directory id/token/profile picture + update accessCode +``` + +Important fields saved: + +```text +accessCode +microsoftActiveDirectoryId +microsoftActiveDirectoryAccessToken +microsoftActiveDirectoryProfilePicture +lastLoginProvider +``` + +### Step 8: Backend Redirects To Frontend + +After strategy validation, callback controller gets `req.user`. + +Then it redirects to frontend: + +```ts +this.buildFrontendRedirectUrl(config.redirectURL, user["accessCode"]) +``` + +`config.redirectURL` comes from: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL +``` + +Example: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL=http://localhost:3001/auth/initiate-microsoft-active-directory-oauth +``` + +Final redirect URL becomes: + +```text +http://localhost:3001/auth/initiate-microsoft-active-directory-oauth?accessCode= +``` + +This URL is frontend, not backend. + +### Step 9: Frontend Reads accessCode + +Frontend route: + +```text +/auth/initiate-microsoft-active-directory-oauth +``` + +Frontend file: + +```text +src/routes/pages/auth/InitiateMicrosoftActiveDirectoryOauthPage.tsx +``` + +It renders: + +```ts + +``` + +That component reads: + +```ts +const accessCode = searchParams.get("accessCode"); +``` + +### Step 10: Frontend Calls Backend /authenticate + +Frontend file: + +```text +src/adapters/auth/signInWithOAuthAccessCode.ts +``` + +It calls: + +```ts +solidGet( + `${apiUrl}/api/iam/${provider}/authenticate?accessCode=${encodeURIComponent(accessCode)}` +) +``` + +For Microsoft Active Directory: + +```text +GET /api/iam/microsoft-active-directory/authenticate?accessCode= +``` + +This is the real frontend API call. + +### Step 11: Backend Validates accessCode And Returns JWT + +Backend file: + +```text +src/services/authentication.service.ts +``` + +Method: + +```ts +signInUsingMicrosoftActiveDirectory(accessCode) +``` + +It does: + +```text +1. Find user by accessCode +2. Check account is not blocked +3. Validate saved Microsoft access token using Microsoft Graph /me +4. Verify Microsoft profile id/email matches the saved user +5. Generate application JWT accessToken and refreshToken +6. Return user and tokens +``` + +After frontend receives tokens, it stores session the same way as existing OAuth login. + +## callbackURL vs redirectURL + +These two are different and both are required. + +### callbackURL + +Used by Microsoft to come back to backend: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL=http://localhost:3000/api/iam/microsoft-active-directory/connect/callback +``` + +This must be registered in Azure App Registration. + +### redirectURL + +Used by backend to send the user back to frontend: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL=http://localhost:3001/auth/initiate-microsoft-active-directory-oauth +``` + +This does not go in Azure redirect URI list unless your Azure app directly redirects to frontend, which this backend flow does not do. + +## Required Environment Variables + +Backend: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_ID= +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_SECRET= +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT_ID= +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL=http://localhost:3000/api/iam/microsoft-active-directory/connect/callback +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL=http://localhost:3001/auth/initiate-microsoft-active-directory-oauth +``` + +Frontend: + +```env +VITE_BACKEND_API_URL=http://localhost:3000 +VITE_API_URL=http://localhost:3000 +VITE_BASE_URL=http://localhost:3001 +``` + +## Azure App Registration Setup + +In Azure Portal: + +```text +Microsoft Entra ID +-> App registrations +-> Your app +-> Authentication +-> Add platform +-> Web +-> Redirect URIs +``` + +Add exactly: + +```text +http://localhost:3000/api/iam/microsoft-active-directory/connect/callback +``` + +The value must exactly match `IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL`. + +Exact means same: + +```text +protocol: http vs https +host: localhost +port: 3000 +path: /api/iam/microsoft-active-directory/connect/callback +trailing slash: no extra slash +``` + +## Common Mistakes + +### redirect_uri is not valid + +Reason: + +```text +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL does not match Azure redirect URI. +``` + +Fix: + +```text +Register the exact callback URL in Azure. +``` + +### Using frontend URL as callbackURL + +Wrong: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL=http://localhost:3001/auth/initiate-microsoft-active-directory-oauth +``` + +Correct: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL=http://localhost:3000/api/iam/microsoft-active-directory/connect/callback +``` + +### Using backend API URL as redirectURL + +Wrong: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL=http://localhost:3000/api/auth/initiate-microsoft-active-directory-oauth +``` + +Correct: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL=http://localhost:3001/auth/initiate-microsoft-active-directory-oauth +``` + +### Empty client id crashes strategy + +Wrong: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_ID= +``` + +If OAuth is not configured, leave it absent or let the strategy use dummy startup values. If OAuth should work, set the real Azure client id. + +## Final Flow Summary + +```text +1. User clicks Microsoft Active Directory button +2. Frontend builds backend /connect URL +3. Frontend navigates browser to backend /connect +4. Backend Passport guard redirects browser to Microsoft +5. User logs in on Microsoft +6. Microsoft redirects browser to backend /connect/callback with code +7. Backend exchanges code for Microsoft token/profile +8. Backend creates/updates user and stores temporary accessCode +9. Backend redirects browser to frontend redirectURL with accessCode +10. Frontend reads accessCode +11. Frontend calls backend /authenticate?accessCode=... +12. Backend verifies user/token and returns application JWT +13. Frontend stores JWT session +``` + diff --git a/src/constants/error-messages.ts b/src/constants/error-messages.ts index a64e7f04..8f888d0e 100644 --- a/src/constants/error-messages.ts +++ b/src/constants/error-messages.ts @@ -28,6 +28,7 @@ export const ERROR_MESSAGES = { ACCESS_DENIED: 'Access denied', INVALID_USER_PROFILE: 'Invalid user profile', GOOGLE_OAUTH_PROFILE_FETCH_FAILED: 'Failed to fetch user profile from Google OAuth service', + MICROSOFT_ACTIVE_DIRECTORY_OAUTH_PROFILE_FETCH_FAILED: 'Failed to fetch user profile from Microsoft Active Directory OAuth service', LOGOUT_FAILED: 'Logout failed due to an unexpected error.', INVALID_CREDENTIALS: 'Invalid credentials', @@ -130,4 +131,4 @@ export const ERROR_MESSAGES = { `You do not have permission to import ${model ?? 'these'} records.`, } as Record string>, -}; \ No newline at end of file +}; diff --git a/src/controllers/microsoft-active-directory-authentication.controller.ts b/src/controllers/microsoft-active-directory-authentication.controller.ts new file mode 100644 index 00000000..a742d404 --- /dev/null +++ b/src/controllers/microsoft-active-directory-authentication.controller.ts @@ -0,0 +1,104 @@ +import { + Controller, + Get, + InternalServerErrorException, + Query, + Req, + Res, + UseGuards, +} from "@nestjs/common"; +import { ApiQuery, ApiTags } from "@nestjs/swagger"; +import { Request, Response } from "express"; +import { + DEFAULT_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT, + MicrosoftActiveDirectoryAuthConfiguration, + isMicrosoftActiveDirectoryOAuthConfigured, +} from "../helpers/microsoft-active-directory-oauth.helper"; +import { AuthenticationService } from "../services/authentication.service"; +import { SettingService } from "../services/setting.service"; +import { Public } from "src/decorators/public.decorator"; +import { Auth } from "../decorators/auth.decorator"; +import { AuthType } from "../enums/auth-type.enum"; +import { MicrosoftActiveDirectoryOauthGuard } from "../passport-strategies/microsoft-active-directory-oauth.strategy"; +import { UserService } from "../services/user.service"; +import type { SolidCoreSetting } from "../services/settings/default-settings-provider.service"; + +@Auth(AuthType.None) +@ApiTags("Solid Core") +@Controller("iam/microsoft-active-directory") +export class MicrosoftActiveDirectoryAuthenticationController { + constructor( + private readonly userService: UserService, + private readonly authService: AuthenticationService, + private readonly settingService: SettingService, + ) {} + + private async getConfiguration(): Promise { + return { + clientID: this.settingService.getConfigValue("MICROSOFT_ACTIVE_DIRECTORY_CLIENT_ID"), + clientSecret: this.settingService.getConfigValue("MICROSOFT_ACTIVE_DIRECTORY_CLIENT_SECRET"), + tenant: this.settingService.getConfigValue("MICROSOFT_ACTIVE_DIRECTORY_TENANT_ID" ) ?? DEFAULT_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT, + callbackURL: this.settingService.getConfigValue("MICROSOFT_ACTIVE_DIRECTORY_CALLBACK_URL"), + redirectURL: this.settingService.getConfigValue("MICROSOFT_ACTIVE_DIRECTORY_REDIRECT_URL"), + }; + } + + private buildFrontendRedirectUrl(redirectURL: string, accessCode: string) { + const separator = redirectURL.includes("?") ? "&" : "?"; + return `${redirectURL}${separator}accessCode=${encodeURIComponent(accessCode)}`; + } + + private async validateConfiguration() { + const config = await this.getConfiguration(); + if (!isMicrosoftActiveDirectoryOAuthConfigured(config)) { + throw new InternalServerErrorException("Microsoft Active Directory OAuth is not configured"); + } + return config; + } + + @Public() + @UseGuards(MicrosoftActiveDirectoryOauthGuard) + @Get("connect") + async connect() { + await this.validateConfiguration(); + } + + @Public() + @Get("connect/callback") + @UseGuards(MicrosoftActiveDirectoryOauthGuard) + async microsoftActiveDirectoryAuthCallback( + @Req() req: Request, + @Res() res: Response, + ) { + const config = await this.validateConfiguration(); + const user = req.user; + return res.redirect( + this.buildFrontendRedirectUrl(config.redirectURL, user["accessCode"]), + ); + } + + @Public() + @Get("dummy-redirect") + async dummyMicrosoftActiveDirectoryAuthRedirect( + @Query("accessCode") accessCode, + ) { + await this.validateConfiguration(); + const user = await this.userService.findOneByAccessCode(accessCode); + + if (user) { + delete user["password"]; + } + + return user; + } + + @Public() + @Get("authenticate") + @ApiQuery({ name: "accessCode", required: true, type: String }) + async microsoftActiveDirectoryAuth( + @Query("accessCode") accessCode: string, + ) { + await this.validateConfiguration(); + return this.authService.signInUsingMicrosoftActiveDirectory(accessCode); + } +} diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index 67478f1d..d9c0ab7f 100755 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -88,6 +88,18 @@ export class User extends CommonEntity { // don't send to client microsoftProfilePicture: string; + @Column({ type: "varchar", nullable: true }) + // don't send to client + microsoftActiveDirectoryId: string; + + @Column({ type: "varchar", nullable: true }) + // don't send to client + microsoftActiveDirectoryAccessToken: string; + + @Column({ type: "varchar", nullable: true }) + // don't send to client + microsoftActiveDirectoryProfilePicture: string; + @Column({ default: true }) @Expose() active: boolean = true; @@ -192,4 +204,4 @@ export class User extends CommonEntity { @Expose() apiKeys: UserApiKey[]; -} \ No newline at end of file +} diff --git a/src/helpers/microsoft-active-directory-oauth.helper.ts b/src/helpers/microsoft-active-directory-oauth.helper.ts new file mode 100644 index 00000000..e941b352 --- /dev/null +++ b/src/helpers/microsoft-active-directory-oauth.helper.ts @@ -0,0 +1,83 @@ +export type MicrosoftActiveDirectoryAuthConfiguration = { + clientID: string; + clientSecret: string; + tenant: string; + callbackURL: string; + redirectURL: string; +}; + +export const DEFAULT_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT = "common"; +export const MICROSOFT_ACTIVE_DIRECTORY_OAUTH_SCOPES = [ + "openid", + "profile", + "email", + "User.Read", +]; + +type MicrosoftActiveDirectoryOauthProfileValue = { + value?: string; +}; + +export type MicrosoftActiveDirectoryOauthProfile = { + id?: string; + displayName?: string; + emails?: MicrosoftActiveDirectoryOauthProfileValue[]; + photos?: MicrosoftActiveDirectoryOauthProfileValue[]; + _json?: { + id?: string; + displayName?: string; + mail?: string; + userPrincipalName?: string; + email?: string; + preferred_username?: string; + picture?: string; + }; +}; + +export const isMicrosoftActiveDirectoryOAuthConfigured = ( + config: MicrosoftActiveDirectoryAuthConfiguration, +): boolean => { + return !!( + config.clientID && + config.clientSecret && + config.tenant && + config.callbackURL && + config.redirectURL + ); +}; + +export const getMicrosoftActiveDirectoryOAuthProfileId = ( + profile: MicrosoftActiveDirectoryOauthProfile, +): string | null => { + return profile.id || profile._json?.id || null; +}; + +export const getMicrosoftActiveDirectoryOAuthEmail = ( + profile: MicrosoftActiveDirectoryOauthProfile, +): string | null => { + const email = + profile.emails?.find((item) => !!item.value)?.value || + profile._json?.mail || + profile._json?.userPrincipalName || + profile._json?.email || + profile._json?.preferred_username || + null; + + return email?.trim().toLowerCase() || null; +}; + +export const getMicrosoftActiveDirectoryOAuthDisplayName = ( + profile: MicrosoftActiveDirectoryOauthProfile, +): string | null => { + return profile.displayName || profile._json?.displayName || null; +}; + +export const getMicrosoftActiveDirectoryOAuthPicture = ( + profile: MicrosoftActiveDirectoryOauthProfile, +): string | null => { + return ( + profile.photos?.find((item) => !!item.value)?.value || + profile._json?.picture || + null + ); +}; diff --git a/src/helpers/user-helper.ts b/src/helpers/user-helper.ts index 001d8726..52a2d737 100644 --- a/src/helpers/user-helper.ts +++ b/src/helpers/user-helper.ts @@ -15,6 +15,10 @@ export function getUserExcludedFields(): string[] { "facebookId", "microsoftAccessToken", "microsoftId", + "microsoftProfilePicture", + "microsoftActiveDirectoryAccessToken", + "microsoftActiveDirectoryId", + "microsoftActiveDirectoryProfilePicture", "forgotPasswordConfirmedAt", "verificationTokenOnForgotPassword", "verificationTokenOnForgotPasswordExpiresAt", @@ -38,4 +42,4 @@ export function getUserExcludedFields(): string[] { "userViewMetadataIds", "userViewMetadataCommand", ]; -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 4cf4a4fd..8d53ffc5 100755 --- a/src/index.ts +++ b/src/index.ts @@ -269,6 +269,7 @@ export * from './listeners/user-registration.listener' export * from './passport-strategies/google-oauth.strategy' export * from './passport-strategies/facebook-oauth.strategy' export * from './passport-strategies/microsoft-oauth.strategy' +export * from './passport-strategies/microsoft-active-directory-oauth.strategy' export * from './services/selection-providers/list-of-values-selection-providers.service' diff --git a/src/passport-strategies/microsoft-active-directory-oauth.strategy.ts b/src/passport-strategies/microsoft-active-directory-oauth.strategy.ts new file mode 100644 index 00000000..239e43bc --- /dev/null +++ b/src/passport-strategies/microsoft-active-directory-oauth.strategy.ts @@ -0,0 +1,63 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { AuthGuard, PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-microsoft'; +import { DEFAULT_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT, MICROSOFT_ACTIVE_DIRECTORY_OAUTH_SCOPES, MicrosoftActiveDirectoryAuthConfiguration, getMicrosoftActiveDirectoryOAuthDisplayName, getMicrosoftActiveDirectoryOAuthEmail, getMicrosoftActiveDirectoryOAuthPicture, getMicrosoftActiveDirectoryOAuthProfileId, isMicrosoftActiveDirectoryOAuthConfigured } from 'src/helpers/microsoft-active-directory-oauth.helper'; +import { v4 as uuid } from 'uuid'; +import { UserService } from '../services/user.service'; + +const DUMMY_CLIENT_ID = 'DUMMY_CLIENT_ID'; +const DUMMY_CLIENT_SECRET = 'DUMMY_CLIENT_SECRET'; +const DUMMY_CALLBACK_URL = 'DUMMY_CALLBACK_URL'; + +@Injectable() +export class MicrosoftActiveDirectoryOauthGuard extends AuthGuard('microsoft-active-directory') { } + + +@Injectable() +export class MicrosoftActiveDirectoryOAuthStrategy extends PassportStrategy(Strategy, 'microsoft-active-directory') { + private readonly logger = new Logger(MicrosoftActiveDirectoryOAuthStrategy.name); + constructor(private readonly userService: UserService) { + // TODO: Have added default dummy values for the configuration, since the configuration is not mandatory. + // Perhaps a cleaner way needs to be figured out + const clientID = process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_ID || DUMMY_CLIENT_ID; + const clientSecret = process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_SECRET || DUMMY_CLIENT_SECRET; + const tenant = process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT_ID || DEFAULT_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT; + const callbackURL = process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL || DUMMY_CALLBACK_URL; + const redirectURL = process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL; + + super({ clientID, clientSecret, callbackURL, tenant, scope: MICROSOFT_ACTIVE_DIRECTORY_OAUTH_SCOPES, addUPNAsEmail: true }); + + const microsoftActiveDirectoryOauth: MicrosoftActiveDirectoryAuthConfiguration = { clientID, clientSecret, tenant, callbackURL, redirectURL } + if (!isMicrosoftActiveDirectoryOAuthConfigured(microsoftActiveDirectoryOauth)) { + this.logger.debug('Microsoft Active Directory OAuth strategy is not configured'); + } + } + + async validate(_accessToken: string, _refreshToken: string, profile: any, done: any): Promise { + const providerId = getMicrosoftActiveDirectoryOAuthProfileId(profile); + + if (!providerId) { + return done(new UnauthorizedException('Microsoft Active Directory OAuth profile is missing an id'), false); + } + + // generate a unique access code. + const loginAccessCode: string = uuid(); + + const user = { + provider: 'microsoftActiveDirectory', + providerId, + email: getMicrosoftActiveDirectoryOAuthEmail(profile), + name: getMicrosoftActiveDirectoryOAuthDisplayName(profile) || getMicrosoftActiveDirectoryOAuthEmail(profile) || 'Microsoft Active Directory User', + picture: getMicrosoftActiveDirectoryOAuthPicture(profile), + accessCode: loginAccessCode, + }; + + // store the access code and the access token in the database. + // while doing this we also check if the user exists in the database if not we create one. + // if exists then we update the user and store the specified access code & token. + await this.userService.resolveUserOnOauthMicrosoftActiveDirectory({ ...user, accessToken: _accessToken, refreshToken: null }); + + done(null, user); + } + +} diff --git a/src/services/authentication.service.ts b/src/services/authentication.service.ts index 4d46daa2..d9a2ddec 100755 --- a/src/services/authentication.service.ts +++ b/src/services/authentication.service.ts @@ -1997,6 +1997,84 @@ export class AuthenticationService { }; } + async validateUserUsingMicrosoftActiveDirectory(user: User) { + if ( + !user.microsoftActiveDirectoryAccessToken || + !user.microsoftActiveDirectoryId + ) { + throw new UnauthorizedException(ERROR_MESSAGES.USER_NOT_FOUND); + } + + try { + const response = await this.httpService.axiosRef.get( + `https://graph.microsoft.com/v1.0/me?$select=id,displayName,mail,userPrincipalName`, + { + headers: { + Authorization: `Bearer ${user.microsoftActiveDirectoryAccessToken}`, + }, + }, + ); + const userProfile = response.data; + const profileEmail = (userProfile.mail || userProfile.userPrincipalName) + ?.trim() + .toLowerCase(); + const userEmail = user.email?.trim().toLowerCase(); + + if ( + userProfile.id === user.microsoftActiveDirectoryId && + (!userEmail || profileEmail === userEmail) + ) { + return userProfile; + } else { + throw new UnauthorizedException(ERROR_MESSAGES.INVALID_USER_PROFILE); + } + } catch (error: any) { + if (error instanceof UnauthorizedException) { + throw error; + } + + throw new UnauthorizedException( + ERROR_MESSAGES.MICROSOFT_ACTIVE_DIRECTORY_OAUTH_PROFILE_FETCH_FAILED, + ); + } + } + + async signInUsingMicrosoftActiveDirectory(accessCode: string) { + const user = await this.userRepository.findOne({ + where: { + accessCode: accessCode, + }, + relations: { + roles: true, + }, + }); + + if (!user) { + throw new UnauthorizedException(ERROR_MESSAGES.USER_NOT_FOUND); + } + this.checkAccountBlocked(user); + + try { + await this.validateUserUsingMicrosoftActiveDirectory(user); + } catch (e) { + await this.incrementFailedAttempts(user); + throw e; + } + + await this.resetFailedAttempts(user); + const tokens = await this.generateTokens(user); + return { + user: { + email: user.email, + mobile: user.mobile, + username: user.username, + id: user.id, + roles: user.roles.map((role) => role.name), + }, + ...tokens, + }; + } + async signInUsingApple(accessCode: string) { const user = await this.userRepository.findOne({ where: { diff --git a/src/services/settings/default-settings-provider.service.ts b/src/services/settings/default-settings-provider.service.ts index 99728e7e..728bfe6f 100644 --- a/src/services/settings/default-settings-provider.service.ts +++ b/src/services/settings/default-settings-provider.service.ts @@ -43,6 +43,16 @@ const getSolidCoreSettings = (isProd: boolean) => sortOrder: 50, controlType: "boolean", }, + { + moduleName: "solid-core", + key: "iamMicrosoftActiveDirectoryOAuthEnabled", + value: false, + level: SettingLevel.SystemAdminEditable, + label: "Allow Login / Signup With Microsoft Active Directory", + group: "authentication-settings", + sortOrder: 50, + controlType: "boolean", + }, { moduleName: "solid-core", key: "authPagesLayout", @@ -703,6 +713,55 @@ const getSolidCoreSettings = (isProd: boolean) => level: SettingLevel.SystemAdminReadonly, }, + // microsoft-active-directory-oauth-settings-provider.service.ts + { + moduleName: "solid-core", + key: "MICROSOFT_ACTIVE_DIRECTORY_CLIENT_ID", + value: process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_ID, + level: SettingLevel.SystemAdminReadonly, + label: "Microsoft Active Directory OAuth Client ID", + group: "oauth-settings", + sortOrder: 70, + controlType: "shortText", + }, + { + moduleName: "solid-core", + key: "MICROSOFT_ACTIVE_DIRECTORY_CLIENT_SECRET", + value: process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_SECRET, + level: SettingLevel.SystemEnv, + }, + { + moduleName: "solid-core", + key: "MICROSOFT_ACTIVE_DIRECTORY_TENANT_ID", + value: + process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT_ID || "common", + level: SettingLevel.SystemAdminReadonly, + label: "Microsoft Active Directory OAuth Tenant ID", + group: "oauth-settings", + sortOrder: 80, + controlType: "shortText", + }, + { + moduleName: "solid-core", + key: "MICROSOFT_ACTIVE_DIRECTORY_CALLBACK_URL", + value: process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL, + level: SettingLevel.SystemAdminReadonly, + label: "Microsoft Active Directory OAuth Callback URL", + group: "oauth-settings", + sortOrder: 90, + controlType: "shortText", + }, + { + moduleName: "solid-core", + key: "MICROSOFT_ACTIVE_DIRECTORY_REDIRECT_URL", + value: process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL, + level: SettingLevel.SystemAdminReadonly, + label: "Microsoft Active Directory OAuth Redirect URL", + group: "oauth-settings", + sortOrder: 100, + controlType: "shortText", + }, + // iam-settings-provider.service.ts { moduleName: "solid-core", diff --git a/src/services/user.service.ts b/src/services/user.service.ts index b527ef68..e1ca9c07 100755 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -30,6 +30,23 @@ export class UserService extends CRUDService { return normalized || "facebook_user"; } + private buildMicrosoftActiveDirectoryUsernameBase( + email?: string, + providerId?: string, + name?: string, + ): string { + const source = + email || + name || + (providerId ? `microsoft_active_directory_${providerId}` : ""); + const normalized = source + .trim() + .toLowerCase() + .replace(/[^a-z0-9@._-]+/g, "_") + .replace(/^_+|_+$/g, ""); + return normalized || "microsoft_active_directory_user"; + } + private async resolveUniqueUsername( preferredUsername: string, // fallbackUsername: string, @@ -397,6 +414,50 @@ export class UserService extends CRUDService { return user; } + async resolveUserOnOauthMicrosoftActiveDirectory( + oauthUserDto: OauthUserDto, + ): Promise { + const user = await this.repo.findOne({ + where: { + email: oauthUserDto.email, + }, + relations: { + roles: true, + }, + }); + + if (!user) { + const newUser = new User(); + newUser.username = oauthUserDto.email; + newUser.email = oauthUserDto.email; + newUser.fullName = oauthUserDto.name; + newUser.lastLoginProvider = oauthUserDto.provider; + newUser.accessCode = oauthUserDto.accessCode; + newUser.microsoftActiveDirectoryAccessToken = oauthUserDto.accessToken; + newUser.microsoftActiveDirectoryId = oauthUserDto.providerId; + newUser.microsoftActiveDirectoryProfilePicture = oauthUserDto.picture; + + const savedUser = await this.repo.save(newUser); + + await this.initializeRolesForNewUser( + [this.settingService.getConfigValue("defaultRole")], + savedUser, + ); + } else { + const entity = await this.repo.preload({ + id: user.id, + lastLoginProvider: oauthUserDto.provider, + accessCode: oauthUserDto.accessCode, + microsoftActiveDirectoryAccessToken: oauthUserDto.accessToken, + microsoftActiveDirectoryId: oauthUserDto.providerId, + microsoftActiveDirectoryProfilePicture: oauthUserDto.picture, + }); + + await this.repo.save(entity); + } + return user; + } + async findUsersByRole( roleName: string, relations: any = {}, diff --git a/src/solid-core.module.ts b/src/solid-core.module.ts index e572689b..a8ea5de9 100644 --- a/src/solid-core.module.ts +++ b/src/solid-core.module.ts @@ -62,8 +62,10 @@ import { ActionMetadataService } from "./services/action-metadata.service"; import { FacebookAuthenticationController } from "./controllers/facebook-authentication.controller"; import { MicrosoftAuthenticationController } from "./controllers/microsoft-authentication.controller"; +import { MicrosoftActiveDirectoryAuthenticationController } from "./controllers/microsoft-active-directory-authentication.controller"; import { FacebookOAuthStrategy } from "./passport-strategies/facebook-oauth.strategy"; import { MicrosoftOAuthStrategy } from "./passport-strategies/microsoft-oauth.strategy"; +import { MicrosoftActiveDirectoryOAuthStrategy } from "./passport-strategies/microsoft-active-directory-oauth.strategy"; import { GupshupOtpWhatsappService } from "./services/whatsapp/GupshupOtpWhatsappService"; import { MetaCloudWhatsappService } from "./services/whatsapp/MetaCloudWhatsappService"; @@ -477,6 +479,7 @@ import { DashboardUserLayoutRepository } from './repositories/dashboard-user-lay GoogleAuthenticationController, FacebookAuthenticationController, MicrosoftAuthenticationController, + MicrosoftActiveDirectoryAuthenticationController, ImportTransactionController, ImportTransactionErrorLogController, ListOfValuesController, @@ -654,6 +657,7 @@ import { DashboardUserLayoutRepository } from './repositories/dashboard-user-lay GoogleOauthStrategy, FacebookOAuthStrategy, MicrosoftOAuthStrategy, + MicrosoftActiveDirectoryOAuthStrategy, UserRegistrationListener, TestQueuePublisher, TestQueueSubscriber,