1
0
mirror of https://github.com/bitwarden/directory-connector synced 2025-12-05 23:53:21 +00:00
Files
directory-connector/src/services/azure-directory.service.ts

554 lines
17 KiB
TypeScript

import * as https from "https";
import * as querystring from "querystring";
import * as graph from "@microsoft/microsoft-graph-client";
import * as graphType from "@microsoft/microsoft-graph-types";
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { StateService } from "../abstractions/state.service";
import { DirectoryType } from "../enums/directoryType";
import { AzureConfiguration } from "../models/azureConfiguration";
import { GroupEntry } from "../models/groupEntry";
import { SyncConfiguration } from "../models/syncConfiguration";
import { UserEntry } from "../models/userEntry";
import { BaseDirectoryService } from "./baseDirectory.service";
import { IDirectoryService } from "./directory.service";
const AzurePublicIdentityAuhtority = "login.microsoftonline.com";
const AzurePublicGraphEndpoint = "https://graph.microsoft.com";
const AzureGovermentIdentityAuhtority = "login.microsoftonline.us";
const AzureGovernmentGraphEndpoint = "https://graph.microsoft.us";
const NextLink = "@odata.nextLink";
const DeltaLink = "@odata.deltaLink";
const ObjectType = "@odata.type";
const UserSelectParams = "?$select=id,mail,userPrincipalName,displayName,accountEnabled";
enum UserSetType {
IncludeUser,
ExcludeUser,
IncludeGroup,
ExcludeGroup,
}
export class AzureDirectoryService extends BaseDirectoryService implements IDirectoryService {
private client: graph.Client;
private dirConfig: AzureConfiguration;
private syncConfig: SyncConfiguration;
private accessToken: string;
private accessTokenExpiration: Date;
constructor(
private logService: LogService,
private i18nService: I18nService,
private stateService: StateService,
) {
super();
this.init();
}
async getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> {
const type = await this.stateService.getDirectoryType();
if (type !== DirectoryType.AzureActiveDirectory) {
return;
}
this.dirConfig = await this.stateService.getDirectory<AzureConfiguration>(
DirectoryType.AzureActiveDirectory,
);
if (this.dirConfig == null) {
return;
}
this.syncConfig = await this.stateService.getSync();
if (this.syncConfig == null) {
return;
}
let users: UserEntry[];
if (this.syncConfig.users) {
users = await this.getCurrentUsers();
const deletedUsers = await this.getDeletedUsers(force, !test);
users = users.concat(deletedUsers);
}
let groups: GroupEntry[];
if (this.syncConfig.groups) {
const setFilter = await this.createAadCustomSet(this.syncConfig.groupFilter);
groups = await this.getGroups(setFilter);
users = this.filterUsersFromGroupsSet(users, groups, setFilter, this.syncConfig);
}
return [groups, users];
}
private async getCurrentUsers(): Promise<UserEntry[]> {
let entries: UserEntry[] = [];
let users: graphType.User[];
const setFilter = this.createCustomUserSet(this.syncConfig.userFilter);
const userIdsToExclude = new Set<string>();
// Only get users for the groups provided in includeGroup filter
if (setFilter != null && setFilter[0] === UserSetType.IncludeGroup) {
users = await this.getUsersByGroups(setFilter);
// Get the users in the excludedGroups and filter them out from all users
} else if (setFilter != null && setFilter[0] === UserSetType.ExcludeGroup) {
(await this.getUsersByGroups(setFilter)).forEach((user: graphType.User) =>
userIdsToExclude.add(user.id),
);
const userReq = this.client.api("/users" + UserSelectParams);
users = await this.getUsersByResource(userReq);
} else {
const userReq = this.client.api("/users" + UserSelectParams);
users = await this.getUsersByResource(userReq);
}
if (users != null) {
entries = await this.buildUserEntries(users, userIdsToExclude, setFilter);
}
return entries;
}
private async getDeletedUsers(force: boolean, saveDelta: boolean): Promise<UserEntry[]> {
const entryIds = new Set<string>();
const entries: UserEntry[] = [];
let res: any = null;
const token = await this.stateService.getUserDelta();
if (!force && token != null) {
try {
const deltaReq = this.client.api(token);
res = await deltaReq.get();
} catch {
res = null;
}
}
if (res == null) {
const userReq = this.client.api("/users/delta" + UserSelectParams);
res = await userReq.get();
}
const setFilter = this.createCustomUserSet(this.syncConfig.userFilter);
// eslint-disable-next-line
while (true) {
const users: graphType.User[] = res.value;
if (users != null) {
for (const user of users) {
if (user.id == null || entryIds.has(user.id)) {
continue;
}
const entry = this.buildUser(user);
if (!entry.deleted) {
continue;
}
if (
setFilter != null &&
(setFilter[0] === UserSetType.IncludeUser ||
setFilter[0] === UserSetType.ExcludeUser) &&
(await this.filterOutUserResult(setFilter, entry))
) {
continue;
}
entries.push(entry);
entryIds.add(user.id);
}
}
if (res[NextLink] == null) {
if (res[DeltaLink] != null && saveDelta) {
await this.stateService.setUserDelta(res[DeltaLink]);
}
break;
} else {
const nextReq = this.client.api(res[NextLink]);
res = await nextReq.get();
}
}
return entries;
}
private async createAadCustomSet(filter: string): Promise<[boolean, Set<string>]> {
if (filter == null || filter === "") {
return null;
}
const mainParts = filter.split("|");
if (mainParts.length < 1 || mainParts[0] == null || mainParts[0].trim() === "") {
return null;
}
const parts = mainParts[0].split(":");
if (parts.length !== 2) {
return null;
}
const keyword = parts[0].trim().toLowerCase();
let exclude = true;
if (keyword === "include") {
exclude = false;
} else if (keyword === "exclude") {
exclude = true;
} else if (keyword === "excludeadministrativeunit") {
exclude = true;
} else if (keyword === "includeadministrativeunit") {
exclude = false;
} else {
return null;
}
const set = new Set<string>();
const entryIds = new Set<string>();
const pieces = parts[1].split(",");
if (keyword === "excludeadministrativeunit" || keyword === "includeadministrativeunit") {
for (const p of pieces) {
let auMembers = await this.client
.api(`${this.getGraphApiEndpoint()}/v1.0/directory/administrativeUnits/${p}/members`)
.get();
// eslint-disable-next-line
while (true) {
for (const auMember of auMembers.value) {
const groupId = auMember.id;
if (auMember["@odata.type"] === "#microsoft.graph.group" && !entryIds.has(groupId)) {
set.add(auMember.displayName.toLowerCase());
entryIds.add(groupId);
}
}
if (auMembers[NextLink] == null) {
break;
} else {
const nextLinkReq = this.client.api(auMembers[NextLink]);
auMembers = await nextLinkReq.get();
}
}
}
} else {
for (const p of pieces) {
set.add(p.trim().toLowerCase());
}
}
return [exclude, set];
}
private createCustomUserSet(filter: string): [UserSetType, Set<string>] {
if (filter == null || filter === "") {
return null;
}
const mainParts = filter.split("|");
if (mainParts.length < 1 || mainParts[0] == null || mainParts[0].trim() === "") {
return null;
}
const parts = mainParts[0].split(":");
if (parts.length !== 2) {
return null;
}
const keyword = parts[0].trim().toLowerCase();
let userSetType = UserSetType.IncludeUser;
if (keyword === "include") {
userSetType = UserSetType.IncludeUser;
} else if (keyword === "exclude") {
userSetType = UserSetType.ExcludeUser;
} else if (keyword === "includegroup") {
userSetType = UserSetType.IncludeGroup;
} else if (keyword === "excludegroup") {
userSetType = UserSetType.ExcludeGroup;
} else {
return null;
}
const set = new Set<string>();
const pieces = parts[1].split(",");
for (const p of pieces) {
set.add(p.trim().toLowerCase());
}
return [userSetType, set];
}
private async filterOutUserResult(
setFilter: [UserSetType, Set<string>],
user: UserEntry,
): Promise<boolean> {
if (setFilter == null) {
return false;
}
let userSetTypeExclude = null;
if (setFilter[0] === UserSetType.IncludeUser) {
userSetTypeExclude = false;
} else if (setFilter[0] === UserSetType.ExcludeUser) {
userSetTypeExclude = true;
}
if (userSetTypeExclude != null) {
return this.filterOutResult([userSetTypeExclude, setFilter[1]], user.email);
}
return false;
}
private buildUser(user: graphType.User): UserEntry {
const entry = new UserEntry();
entry.referenceId = user.id;
entry.externalId = user.id;
entry.email = user.mail;
if (
user.userPrincipalName &&
(entry.email == null || entry.email === "" || entry.email.indexOf("onmicrosoft.com") > -1)
) {
entry.email = user.userPrincipalName;
}
if (entry.email != null) {
entry.email = entry.email.trim().toLowerCase();
}
entry.disabled = user.accountEnabled == null ? false : !user.accountEnabled;
if ((user as any)["@removed"] != null && (user as any)["@removed"].reason === "changed") {
entry.deleted = true;
}
return entry;
}
private async getGroups(setFilter: [boolean, Set<string>]): Promise<GroupEntry[]> {
const entryIds = new Set<string>();
const entries: GroupEntry[] = [];
const groupsReq = this.client.api("/groups");
let res = await groupsReq.get();
// eslint-disable-next-line
while (true) {
const groups: graphType.Group[] = res.value;
if (groups != null) {
for (const group of groups) {
if (group.id == null || entryIds.has(group.id)) {
continue;
}
if (this.filterOutResult(setFilter, group.displayName)) {
continue;
}
const entry = await this.buildGroup(group);
entries.push(entry);
entryIds.add(group.id);
}
}
if (res[NextLink] == null) {
break;
} else {
const nextReq = this.client.api(res[NextLink]);
res = await nextReq.get();
}
}
return entries;
}
private async getUsersByResource(usersRequest: graph.GraphRequest) {
const users: graphType.User[] = [];
let res = await usersRequest.get();
res.value.forEach((user: graphType.User) => users.push(user));
while (res[NextLink] != null) {
const nextReq = this.client.api(res[NextLink]);
res = await nextReq.get();
res.value.forEach((user: graphType.User) => users.push(user));
}
return users;
}
private async getUsersByGroups(setFilter: [UserSetType, Set<string>]): Promise<graphType.User[]> {
const users: graphType.User[] = [];
for (const group of setFilter[1]) {
const groupUsersReq = this.client.api(
`/groups/${group}/transitiveMembers` + UserSelectParams,
);
users.push(...(await this.getUsersByResource(groupUsersReq)));
}
return users;
}
private async buildUserEntries(
users: graphType.User[],
userIdsToExclude: Set<string>,
setFilter: [UserSetType, Set<string>],
) {
const entryIds = new Set<string>();
const entries: UserEntry[] = [];
for (const user of users) {
if (user.id == null || entryIds.has(user.id) || userIdsToExclude.has(user.id)) {
continue;
}
const entry = this.buildUser(user);
if (
setFilter != null &&
(setFilter[0] === UserSetType.IncludeUser || setFilter[0] === UserSetType.ExcludeUser) &&
(await this.filterOutUserResult(setFilter, entry))
) {
continue;
}
if (!this.isInvalidUser(entry)) {
entries.push(entry);
entryIds.add(user.id);
}
}
return entries;
}
private isInvalidUser(user: UserEntry): boolean {
return !user.disabled && !user.deleted && (user.email == null || user.email.indexOf("#") > -1);
}
private async buildGroup(group: graphType.Group): Promise<GroupEntry> {
const entry = new GroupEntry();
entry.referenceId = group.id;
entry.externalId = group.id;
entry.name = group.displayName;
const memReq = this.client.api("/groups/" + group.id + "/members");
let memRes = await memReq.get();
// eslint-disable-next-line
while (true) {
const members: any = memRes.value;
if (members != null) {
for (const member of members) {
if (member[ObjectType] === "#microsoft.graph.group") {
entry.groupMemberReferenceIds.add((member as graphType.Group).id);
} else if (member[ObjectType] === "#microsoft.graph.user") {
entry.userMemberExternalIds.add((member as graphType.User).id);
}
}
}
if (memRes[NextLink] == null) {
break;
} else {
const nextMemReq = this.client.api(memRes[NextLink]);
memRes = await nextMemReq.get();
}
}
return entry;
}
private init() {
this.client = graph.Client.init({
authProvider: (done) => {
if (
this.dirConfig.applicationId == null ||
this.dirConfig.key == null ||
this.dirConfig.tenant == null
) {
done(new Error(this.i18nService.t("dirConfigIncomplete")), null);
return;
}
const identityAuthority =
this.dirConfig.identityAuthority != null
? this.dirConfig.identityAuthority
: AzurePublicIdentityAuhtority;
if (
identityAuthority !== AzurePublicIdentityAuhtority &&
identityAuthority !== AzureGovermentIdentityAuhtority
) {
done(new Error(this.i18nService.t("dirConfigIncomplete")), null);
return;
}
if (!this.accessTokenIsExpired()) {
done(null, this.accessToken);
return;
}
this.accessToken = null;
this.accessTokenExpiration = null;
const data = querystring.stringify({
client_id: this.dirConfig.applicationId,
client_secret: this.dirConfig.key,
grant_type: "client_credentials",
scope: `${this.getGraphApiEndpoint()}/.default`,
});
const req = https
.request(
{
host: identityAuthority,
path: "/" + this.dirConfig.tenant + "/oauth2/v2.0/token",
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(data),
},
},
(res) => {
res.setEncoding("utf8");
res.on("data", (chunk: string) => {
const d = JSON.parse(chunk);
if (res.statusCode === 200 && d.access_token != null) {
this.setAccessTokenExpiration(d.access_token, d.expires_in);
done(null, d.access_token);
} else if (d.error != null && d.error_description != null) {
const shortError = d.error_description?.split("\n", 1)[0];
const err = new Error(d.error + " (" + res.statusCode + "): " + shortError);
// eslint-disable-next-line
console.error(d.error_description);
done(err, null);
} else {
const err = new Error("Unknown error (" + res.statusCode + ").");
done(err, null);
}
});
},
)
.on("error", (err) => {
done(err, null);
});
req.write(data);
req.end();
},
});
}
private accessTokenIsExpired() {
if (this.accessToken == null || this.accessTokenExpiration == null) {
return true;
}
// expired if less than 2 minutes til expiration
const now = new Date();
return this.accessTokenExpiration.getTime() - now.getTime() < 120000;
}
private setAccessTokenExpiration(accessToken: string, expSeconds: number) {
if (accessToken == null || expSeconds == null) {
return;
}
this.accessToken = accessToken;
const exp = new Date();
exp.setSeconds(exp.getSeconds() + expSeconds);
this.accessTokenExpiration = exp;
}
private getGraphApiEndpoint(): string {
return this.dirConfig.identityAuthority === AzureGovermentIdentityAuhtority
? AzureGovernmentGraphEndpoint
: AzurePublicGraphEndpoint;
}
}