import { ClientSecretCredential } from "@azure/identity"; import { Client } from "@microsoft/microsoft-graph-client"; import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials"; import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { AdB2cResponse, AdB2cUser } from "./types/types"; import { isPromiseRejectedResult } from "./utils/utils"; import { Context } from "../../common/log"; import { ADB2C_SIGN_IN_TYPE } from "../../constants"; export type ConflictError = { reason: "email"; message: string; }; export class Adb2cTooManyRequestsError extends Error {} export const isConflictError = (arg: unknown): arg is ConflictError => { const value = arg as ConflictError; if (value.message === undefined) { return false; } if (value.reason === "email") { return true; } return false; }; @Injectable() export class AdB2cService { private readonly logger = new Logger(AdB2cService.name); private readonly tenantName: string; private graphClient: Client; constructor(private readonly configService: ConfigService) { this.tenantName = this.configService.getOrThrow("TENANT_NAME"); // ADB2Cへの認証情報 const credential = new ClientSecretCredential( this.configService.getOrThrow("ADB2C_TENANT_ID"), this.configService.getOrThrow("ADB2C_CLIENT_ID"), this.configService.getOrThrow("ADB2C_CLIENT_SECRET") ); const authProvider = new TokenCredentialAuthenticationProvider(credential, { scopes: ["https://graph.microsoft.com/.default"], }); this.graphClient = Client.initWithMiddleware({ authProvider }); } /** * Creates user AzureADB2Cにユーザーを追加する * @param email 管理ユーザーのメールアドレス * @param password 管理ユーザーのパスワード * @param username 管理ユーザーの名前 * @returns user */ async createUser( context: Context, email: string, password: string, username: string ): Promise<{ sub: string } | ConflictError> { this.logger.log( `[IN] [${context.getTrackingId()}] ${this.createUser.name}` ); const retryCount: number = 3; let retry = 0; while (retry < retryCount) { try { // ユーザをADB2Cに登録 const newUser = await this.graphClient.api("users/").post({ accountEnabled: true, displayName: username, passwordPolicies: "DisableStrongPassword", passwordProfile: { forceChangePasswordNextSignIn: false, password: password, }, identities: [ { signinType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, issuer: `${this.tenantName}.onmicrosoft.com`, issuerAssignedId: email, }, ], }); this.logger.log( `[${context.getTrackingId()}] [ADB2C CREATE] newUser: ${newUser}` ); return { sub: newUser.id }; } catch (e) { this.logger.error(`[${context.getTrackingId()}] error=${e}`); if (e?.statusCode === 400 && e?.body) { const error = JSON.parse(e.body); // エラーが競合エラーである場合は、メールアドレス重複としてエラーを返す if (error?.details?.find((x) => x.code === "ObjectConflict")) { return { reason: "email", message: "ObjectConflict" }; } } if (++retry < retryCount) { this.logger.log(`ADB2Cエラー発生。5秒sleepしてリトライします (${retry}/${retryCount})...`); await new Promise(resolve => setTimeout(resolve, 5000)); } else { this.logger.log(`リトライ数が上限に達したのでエラーを返却します`); throw e; } } finally { this.logger.log( `[OUT] [${context.getTrackingId()}] ${this.createUser.name}` ); } } } /** * Gets users * @param externalIds * @returns users */ async getUsers( context: Context ): Promise<{ users: AdB2cUser[]; hasNext: boolean }> { this.logger.log(`[IN] [${context.getTrackingId()}] ${this.getUsers.name}`); try { const res: AdB2cResponse = await this.graphClient .api(`users/`) .select(["id", "displayName", "identities"]) .filter(`creationType eq 'LocalAccount'`) .get(); return { users: res.value, hasNext: !!res["@odata.nextLink"] }; } catch (e) { this.logger.error(`error=${e}`); const { statusCode } = e; if (statusCode === 429) { throw new Adb2cTooManyRequestsError(); } throw e; } finally { this.logger.log(`[OUT] ${this.getUsers.name}`); } } /** * Azure AD B2Cからユーザ情報を削除する * @param externalId 外部ユーザーID * @param context コンテキスト */ async deleteUser(externalId: string, context: Context): Promise { this.logger.log( `[IN] [${context.getTrackingId()}] ${ this.deleteUser.name } | params: { externalId: ${externalId} };` ); try { // https://learn.microsoft.com/en-us/graph/api/user-delete?view=graph-rest-1.0&tabs=javascript#example await this.graphClient.api(`users/${externalId}`).delete(); this.logger.log( `[${context.getTrackingId()}] [ADB2C DELETE] externalId: ${externalId}` ); // キャッシュからも削除する // 移行ツール特別対応:キャッシュ登録は行わないので削除も不要 /* try { await this.redisService.del(context, makeADB2CKey(externalId)); } catch (e) { // キャッシュからの削除に失敗しても、ADB2Cからの削除は成功しているため例外はスローしない this.logger.error(`[${context.getTrackingId()}] error=${e}`); } */ } catch (e) { this.logger.error(`[${context.getTrackingId()}] error=${e}`); throw e; } finally { this.logger.log( `[OUT] [${context.getTrackingId()}] ${this.deleteUser.name}` ); } } /** * Azure AD B2Cからユーザ情報を削除する(複数) * @param externalIds 外部ユーザーID */ async deleteUsers(context: Context, externalIds: string[]): Promise { this.logger.log( `[IN] [${context.getTrackingId()}] ${ this.deleteUsers.name } | params: { externalIds: ${externalIds} };` ); try { // 複数ユーザーを一括削除する方法がないため、1人ずつで削除を行う const results = await Promise.allSettled( externalIds.map(async (externalId) => { await this.graphClient.api(`users/${externalId}`).delete(); await new Promise((resolve) => setTimeout(resolve, 15)); // 15ms待つ this.logger.log(`[[ADB2C DELETE] externalId: ${externalId}`); }) ); // 失敗したプロミスのエラーをログに記録 results.forEach((result, index) => { // statusがrejectedでない場合は、エラーが発生していないためログに記録しない if (result.status !== "rejected") { return; } const failedId = externalIds[index]; if (isPromiseRejectedResult(result)) { const error = result.reason.toString(); this.logger.error(`Failed to delete user ${failedId}: ${error}`); } else { this.logger.error(`Failed to delete user ${failedId}`); } }); } catch (e) { this.logger.error(`error=${e}`); throw e; } finally { this.logger.log(`[OUT] ${this.deleteUsers.name}`); } } }