.NET Framework에서 MSAL.NET과 Microsoft.Identity.Web을 통합하십시오.

이 가이드에서는 .NET Framework, .NET Standard 2.0, 그리고 클래식 .NET 애플리케이션(.NET 4.7.2 이상)에서 MSAL.NET과 함께 Microsoft.Identity.Web 토큰 캐시 및 인증서 패키지를 사용하는 방법을 보여줍니다.

개요 이해

Microsoft.Identity.Web 1.17+부터 ASP.NET Core 환경이 아닌 곳에서도 MSAL.NET과 함께 Microsoft.Identity.Web 유틸리티 패키지를 사용할 수 있습니다.

패키지 혜택 식별

특징 이익
토큰 캐시 직렬화 메모리 내, SQL Server, Redis, Cosmos DB, PostgreSQL에 재사용 가능한 캐시 어댑터
인증서 도우미 KeyVault, 파일 시스템 또는 인증서 저장소에서 간소화된 인증서 로드
클레임 확장 조작을 위한 ClaimsPrincipal 유틸리티 메서드
.NET Standard 2.0 .NET Framework 4.7.2 이상, .NET Core 및 .NET 5 이상과 호환
최소 종속성 ASP.NET Core 종속성이 없는 대상 패키지

지원되는 시나리오 검토

다음 시나리오는 대상 유틸리티 패키지에서 지원됩니다.

  • .NET 프레임워크 콘솔 애플리케이션(디먼 시나리오)
  • Desktop Applications(.NET Framework)
  • Worker Services(.NET Framework)
  • .NET 표준 2.0 라이브러리(플랫폼 간 호환성)
  • Non-web MSAL.NET 애플리케이션

메모

ASP.NET MVC/Web API 애플리케이션의 경우 대신 OWIN 통합 참조하세요.


패키지 선택

시나리오와 일치하는 패키지를 선택합니다.

MSAL.NET 핵심 패키지 식별

Package Purpose 종속성 .NET 대상
Microsoft. Identity.Web.TokenCache 토큰 캐시 직렬화기, ClaimsPrincipal 확장 최소 .NET Standard 2.0
Microsoft. Identity.Web.Certificate 인증서 로드 유틸리티 최소 .NET Standard 2.0

패키지 설치

다음 방법 중 하나를 사용하여 프로젝트에 패키지를 추가합니다.

패키지 관리자 Console:

# Token cache serialization
Install-Package Microsoft.Identity.Web.TokenCache

# Certificate management
Install-Package Microsoft.Identity.Web.Certificate

.NET CLI:

dotnet add package Microsoft.Identity.Web.TokenCache
dotnet add package Microsoft.Identity.Web.Certificate

핵심 패키지 제한 사항 이해

핵심 Microsoft.Identity.Web 패키지에는 다음과 같은 ASP.NET Core 종속성(Microsoft.AspNetCore.*)이 포함됩니다.

  • ASP.NET Framework와 호환되지 않습니다.
  • 불필요하게 패키지 크기 늘리기
  • 종속성 충돌 만들기

.NET Framework 및 .NET 표준 시나리오에 대상 패키지를 대신 사용합니다.


토큰 캐시 직렬화 구성

토큰 캐시 어댑터 이해

Microsoft.Identity.Web은 MSAL.NET의 토큰 캐시 어댑터를 원활하게 작동하도록 제공합니다.

토큰 캐시를 사용하여 기밀 클라이언트 빌드

다음 예제에서는 기밀 클라이언트 애플리케이션을 만들고 메모리 내 토큰 캐시를 연결합니다.

using Microsoft.Identity.Client;
using Microsoft.Identity.Web;
using Microsoft.Identity.Web.TokenCacheProviders;

public class MsalAppBuilder
{
    private static IConfidentialClientApplication _app;

    public static IConfidentialClientApplication BuildConfidentialClientApplication()
    {
        if (_app == null)
        {
            string clientId = ConfigurationManager.AppSettings["AzureAd:ClientId"];
            string clientSecret = ConfigurationManager.AppSettings["AzureAd:ClientSecret"];
            string tenantId = ConfigurationManager.AppSettings["AzureAd:TenantId"];

            // Create the confidential client application
            _app = ConfidentialClientApplicationBuilder.Create(clientId)
                .WithClientSecret(clientSecret)
                .WithTenantId(tenantId)
                .WithAuthority(AzureCloudInstance.AzurePublic, tenantId)
                .Build();

            // Add token cache serialization (choose one option below)
            _app.AddInMemoryTokenCache();
        }

        return _app;
    }
}

토큰 캐시 옵션 선택

배포 시나리오에 가장 적합한 캐시 공급자를 선택합니다.

메모리 내 토큰 캐시 구성

다음 예제에서는 간단한 메모리 내 캐시를 추가합니다.

using Microsoft.Identity.Web.TokenCacheProviders;

_app.AddInMemoryTokenCache();

크기 제한을 가진 인메모리 캐시 (Microsoft.Identity.Web 1.20+):

using Microsoft.Extensions.Caching.Memory;

_app.AddInMemoryTokenCache(services =>
{
    // Configure memory cache options
    services.Configure<MemoryCacheOptions>(options =>
    {
        options.SizeLimit = 5000000;  // 5 MB limit
    });
});

특징:

  • 빠른 액세스
  • 외부 종속성 없음
  • 프로세스 간에 공유되지 않음
  • 앱 다시 시작 중 손실됨

사용 사례: 단일 인스턴스 콘솔 앱, 데스크톱 애플리케이션


분산 메모리 내 토큰 캐시 구성

다음 코드를 사용하여 다중 인스턴스 환경에 분산 메모리 내 캐시를 추가합니다.

_app.AddDistributedTokenCaches(services =>
{
    // Requires: Microsoft.Extensions.Caching.Memory (NuGet)
    services.AddDistributedMemoryCache();
});

특징:

  • 앱 인스턴스 간에 공유
  • 부하 분산 시나리오에 더 적합
  • 추가 NuGet 패키지 필요
  • 앱 다시 시작에서 여전히 손실됨

사용 사례: 허용되는 토큰 다시 획득을 사용하는 다중 인스턴스 서비스


SQL Server 토큰 캐시 구성

다음 코드를 사용하여 영구 분산 SQL Server 캐시를 추가합니다.

using Microsoft.Extensions.Caching.SqlServer;

_app.AddDistributedTokenCaches(services =>
{
    // Requires: Microsoft.Extensions.Caching.SqlServer (NuGet)
    services.AddDistributedSqlServerCache(options =>
    {
        options.ConnectionString = ConfigurationManager.ConnectionStrings["TokenCache"].ConnectionString;
        options.SchemaName = "dbo";
        options.TableName = "TokenCache";

        // IMPORTANT: Set expiration above token lifetime
        // Access tokens typically expire after 1 hour
        options.DefaultSlidingExpiration = TimeSpan.FromMinutes(90);
    });
});

다음 SQL을 실행하여 필요한 캐시 테이블을 만듭니다.

-- Create the cache table
CREATE TABLE [dbo].[TokenCache] (
    [Id] NVARCHAR(449) NOT NULL,
    [Value] VARBINARY(MAX) NOT NULL,
    [ExpiresAtTime] DATETIMEOFFSET NOT NULL,
    [SlidingExpirationInSeconds] BIGINT NULL,
    [AbsoluteExpiration] DATETIMEOFFSET NULL,
    PRIMARY KEY ([Id])
);

-- Create index for performance
CREATE INDEX [Index_ExpiresAtTime] ON [dbo].[TokenCache] ([ExpiresAtTime]);

특징:

  • 재시작 후에도 지속됨
  • 여러 인스턴스에서 공유됨
  • 안정적이고 확장성 있는
  • SQL Server 설정 필요

사용 사례: 프로덕션 디먼 서비스, 예약된 작업, 다중 인스턴스 작업자


Redis 토큰 캐시 구성

다음 코드를 사용하여 고성능 Redis 분산 캐시를 추가합니다.

using StackExchange.Redis;
using Microsoft.Extensions.Caching.StackExchangeRedis;

_app.AddDistributedTokenCaches(services =>
{
    // Requires: Microsoft.Extensions.Caching.StackExchangeRedis (NuGet)
    services.AddStackExchangeRedisCache(options =>
    {
        options.Configuration = ConfigurationManager.AppSettings["Redis:ConnectionString"];
        options.InstanceName = "TokenCache_";
    });
});

다음 예제에서는 프로덕션 준비 Redis 구성을 보여 줍니다.

services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = ConfigurationManager.AppSettings["Redis:ConnectionString"];
    options.InstanceName = "MyDaemonApp_";

    // Optional: Configure Redis options
    options.ConfigurationOptions = new ConfigurationOptions
    {
        AbortOnConnectFail = false,
        ConnectTimeout = 5000,
        SyncTimeout = 5000
    };
});

특징:

  • 매우 빠름
  • 인스턴스 간에 공유
  • 영구적(Redis 지속성을 사용하도록 설정)
  • Redis 서버 필요

사용 사례: 대용량 디먼 앱, 분산 시스템, 마이크로 서비스


Cosmos DB 토큰 캐시 구성

다음 코드를 사용하여 전역적으로 분산된 Cosmos DB 캐시를 추가합니다.

using Microsoft.Extensions.Caching.Cosmos;

_app.AddDistributedTokenCaches(services =>
{
    // Requires: Microsoft.Extensions.Caching.Cosmos (preview)
    services.AddCosmosCache(options =>
    {
        options.ContainerName = "TokenCache";
        options.DatabaseName = "IdentityCache";
        options.ClientBuilder = new CosmosClientBuilder(
            ConfigurationManager.AppSettings["CosmosConnectionString"]);
        options.CreateIfNotExists = true;
    });
});

특징:

  • 전역적으로 분산
  • 고가용성
  • 자동 크기 조정
  • Redis보다 대기 시간이 더 짧음
  • 더 높은 비용

사용 사례: 전역 디먼 서비스, 지리적으로 분산된 애플리케이션


PostgreSQL 토큰 캐시 구성

다음 코드를 사용하여 분산 PostgreSQL 캐시를 추가합니다.

_app.AddDistributedTokenCaches(services =>
{
    // Requires: Microsoft.Extensions.Caching.Postgres (NuGet)
    services.AddDistributedPostgresCache(options =>
    {
        options.ConnectionString = ConfigurationManager.ConnectionStrings["PostgresCache"].ConnectionString;
        options.SchemaName = ConfigurationManager.AppSettings["PostgresCache:SchemaName"];
        options.TableName = ConfigurationManager.AppSettings["PostgresCache:TableName"];
        options.CreateIfNotExists = bool.Parse(
            ConfigurationManager.AppSettings["PostgresCache:CreateIfNotExists"] ?? "true");

        // Set expiration above token lifetime.
        // Access tokens typically expire after 1 hour.
        options.DefaultSlidingExpiration = TimeSpan.FromMinutes(90);
    });
});

특징:

  • 재시작 후에도 지속됨
  • 여러 인스턴스에서 공유됨
  • 친숙한 SQL 의미 체계
  • Azure Database for PostgreSQL와 함께 작동하다
  • PostgreSQL 서버 필요

사용 사례: 이미 PostgreSQL을 주 데이터베이스로 사용하고 있는 애플리케이션 또는 Azure Database for PostgreSQL 사용하는 Azure 호스팅 서비스


전체 디먼 애플리케이션 빌드

다음 예제에서는 클라이언트 자격 증명 및 SQL Server 토큰 캐시를 사용하여 토큰을 획득하는 전체 디먼 애플리케이션을 보여 줍니다.

using Microsoft.Identity.Client;
using Microsoft.Identity.Web;
using Microsoft.Identity.Web.TokenCacheProviders;
using System;
using System.Threading.Tasks;

namespace DaemonApp
{
    class Program
    {
        private static IConfidentialClientApplication _app;

        static async Task Main(string[] args)
        {
            // Build confidential client with token cache
            _app = BuildConfidentialClient();

            // Acquire token for app-only access
            string[] scopes = new[] { "https://graph.microsoft.com/.default" };

            try
            {
                var result = await _app.AcquireTokenForClient(scopes)
                    .ExecuteAsync();

                Console.WriteLine($"Token acquired successfully!");
                Console.WriteLine($"Token source: {result.AuthenticationResultMetadata.TokenSource}");
                Console.WriteLine($"Expires on: {result.ExpiresOn}");

                // Use token to call API
                await CallProtectedApi(result.AccessToken);
            }
            catch (MsalServiceException ex)
            {
                Console.WriteLine($"Error acquiring token: {ex.ErrorCode}");
                Console.WriteLine($"CorrelationId: {ex.CorrelationId}");
            }
        }

        private static IConfidentialClientApplication BuildConfidentialClient()
        {
            var app = ConfidentialClientApplicationBuilder
                .Create(ConfigurationManager.AppSettings["ClientId"])
                .WithClientSecret(ConfigurationManager.AppSettings["ClientSecret"])
                .WithTenantId(ConfigurationManager.AppSettings["TenantId"])
                .Build();

            // Add SQL Server token cache for persistence
            app.AddDistributedTokenCaches(services =>
            {
                services.AddDistributedSqlServerCache(options =>
                {
                    options.ConnectionString = ConfigurationManager
                        .ConnectionStrings["TokenCache"].ConnectionString;
                    options.SchemaName = "dbo";
                    options.TableName = "TokenCache";
                    options.DefaultSlidingExpiration = TimeSpan.FromMinutes(90);
                });
            });

            return app;
        }

        private static async Task CallProtectedApi(string accessToken)
        {
            // Your API call logic
        }
    }
}

인증서 관리

인증서 적재 이해하기

Microsoft. Identity.Web은 클라이언트 자격 증명 흐름에 대한 다양한 원본에서 인증서 로드를 간소화합니다.

DefaultCertificateLoader를 사용하여 인증서 로드

다음 예제에서는 Azure Key Vault 인증서를 로드하고 기밀 클라이언트 애플리케이션을 만드는 방법을 보여 줍니다.

using Microsoft.Identity.Web;
using Microsoft.Identity.Client;

public class CertificateHelper
{
    public static IConfidentialClientApplication CreateAppWithCertificate()
    {
        string clientId = ConfigurationManager.AppSettings["AzureAd:ClientId"];
        string tenantId = ConfigurationManager.AppSettings["AzureAd:TenantId"];

        // Define certificate source
        var certDescription = CertificateDescription.FromKeyVault(
            keyVaultUrl: "https://my-keyvault.vault.azure.net",
            keyVaultCertificateName: "MyCertificate"
        );

        // Load certificate
        ICertificateLoader certificateLoader = new DefaultCertificateLoader();
        certificateLoader.LoadIfNeeded(certDescription);

        // Create confidential client with certificate
        var app = ConfidentialClientApplicationBuilder.Create(clientId)
            .WithCertificate(certDescription.Certificate)
            .WithTenantId(tenantId)
            .Build();

        // Add token cache
        app.AddInMemoryTokenCache();

        return app;
    }
}

인증서 원본 선택

Azure Key Vault에서 불러오기

자격 증명 모음 URL 및 인증서 이름을 지정하여 Azure Key Vault 저장된 인증서를 로드합니다.

var certDescription = CertificateDescription.FromKeyVault(
    keyVaultUrl: "https://my-keyvault.vault.azure.net",
    keyVaultCertificateName: "MyApplicationCert"
);

ICertificateLoader loader = new DefaultCertificateLoader();
loader.LoadIfNeeded(certDescription);

var app = ConfidentialClientApplicationBuilder.Create(clientId)
    .WithCertificate(certDescription.Certificate)
    .WithTenantId(tenantId)
    .Build();

사전 요구 사항:

  • Key Vault 액세스 권한이 있는 관리 ID 또는 서비스 주체
  • Azure.Identity NuGet 패키지
  • Key Vault 권한: 인증서에 대한 Get

인증서 저장소에서 로드

Windows 인증서 저장소에서 고유 이름으로 인증서를 로드합니다.

var certDescription = CertificateDescription.FromStoreWithDistinguishedName(
    distinguishedName: "CN=MyApp.contoso.com",
    storeName: StoreName.My,
    storeLocation: StoreLocation.CurrentUser
);

ICertificateLoader loader = new DefaultCertificateLoader();
loader.LoadIfNeeded(certDescription);

var app = ConfidentialClientApplicationBuilder.Create(clientId)
    .WithCertificate(certDescription.Certificate)
    .WithTenantId(tenantId)
    .Build();

지문으로 인증서를 찾을 수도 있습니다.

var certDescription = CertificateDescription.FromStoreWithThumbprint(
    thumbprint: "ABCDEF1234567890ABCDEF1234567890ABCDEF12",
    storeName: StoreName.My,
    storeLocation: StoreLocation.LocalMachine
);

파일 시스템에서 로드

로컬 파일 시스템의 PFX 파일에서 인증서를 로드합니다.

var certDescription = CertificateDescription.FromPath(
    path: @"C:\Certificates\MyAppCert.pfx",
    password: ConfigurationManager.AppSettings["Certificate:Password"]
);

ICertificateLoader loader = new DefaultCertificateLoader();
loader.LoadIfNeeded(certDescription);

var app = ConfidentialClientApplicationBuilder.Create(clientId)
    .WithCertificate(certDescription.Certificate)
    .WithTenantId(tenantId)
    .Build();

보안 참고 사항: 암호를 하드 코딩하지 마세요. 보안 구성을 사용합니다.


Base64로 인코딩된 문자열에서 로드

구성에 저장된 Base64로 인코딩된 문자열에서 인증서를 로드합니다.

string base64Cert = ConfigurationManager.AppSettings["Certificate:Base64"];

var certDescription = CertificateDescription.FromBase64Encoded(
    base64EncodedValue: base64Cert,
    password: ConfigurationManager.AppSettings["Certificate:Password"]  // Optional
);

ICertificateLoader loader = new DefaultCertificateLoader();
loader.LoadIfNeeded(certDescription);

App.config에서 인증서 로드 구성

App.config 파일에서 인증서 설정을 정의하고 런타임에 로드합니다.

App.config:

<appSettings>
  <add key="AzureAd:ClientId" value="your-client-id" />
  <add key="AzureAd:TenantId" value="your-tenant-id" />

  <!-- Option 1: KeyVault -->
  <add key="Certificate:SourceType" value="KeyVault" />
  <add key="Certificate:KeyVaultUrl" value="https://my-vault.vault.azure.net" />
  <add key="Certificate:KeyVaultCertificateName" value="MyCert" />

  <!-- Option 2: Store -->
  <!--
  <add key="Certificate:SourceType" value="StoreWithThumbprint" />
  <add key="Certificate:CertificateThumbprint" value="ABCD..." />
  <add key="Certificate:CertificateStorePath" value="CurrentUser/My" />
  -->
</appSettings>

<connectionStrings>
  <add name="TokenCache"
       connectionString="Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=TokenCache;Integrated Security=True;" />
</connectionStrings>

다음 도우미 메서드를 사용하여 구성에 따라 인증서를 로드합니다.

public static CertificateDescription GetCertificateFromConfig()
{
    string sourceType = ConfigurationManager.AppSettings["Certificate:SourceType"];

    return sourceType switch
    {
        "KeyVault" => CertificateDescription.FromKeyVault(
            ConfigurationManager.AppSettings["Certificate:KeyVaultUrl"],
            ConfigurationManager.AppSettings["Certificate:KeyVaultCertificateName"]
        ),

        "StoreWithThumbprint" => CertificateDescription.FromStoreWithThumbprint(
            ConfigurationManager.AppSettings["Certificate:CertificateThumbprint"],
            StoreName.My,
            StoreLocation.CurrentUser
        ),

        _ => throw new ConfigurationErrorsException("Invalid certificate source type")
    };
}

샘플 애플리케이션 살펴보기

이러한 샘플을 검토하여 작업 구현을 확인합니다.

공식 Microsoft 샘플 검토

다음 표에는 토큰 캐싱 및 인증서 로드를 보여 주는 공식 샘플이 나와 있습니다.

샘플 플랫폼 설명
ConfidentialClientTokenCache 콘솔(.NET Framework) 토큰 캐시 직렬화 패턴
active-directory-dotnetcore-daemon-v2 콘솔(.NET Core) Key Vault 인증서 로드

모범 사례 준수

이러한 패턴을 적용하여 안정적이고 안전한 애플리케이션을 빌드합니다.

1. IConfidentialClientApplication에 싱글톤 패턴을 사용합니다.

단일 인스턴스를 만들고 애플리케이션 전체에서 다시 사용하세요.

private static IConfidentialClientApplication _app;

public static IConfidentialClientApplication GetApp()
{
    if (_app == null)
    {
        _app = ConfidentialClientApplicationBuilder.Create(clientId)
            .WithClientSecret(clientSecret)
            .WithTenantId(tenantId)
            .Build();

        _app.AddDistributedTokenCaches(/* ... */);
    }

    return _app;
}

2. 적절한 토큰 캐시 만료를 설정합니다.

불필요한 재취득을 방지하기 위해 토큰 수명보다 긴 슬라이딩 만료 시간을 설정합니다.

// Access tokens typically expire after 1 hour
// Set cache expiration ABOVE token lifetime
options.DefaultSlidingExpiration = TimeSpan.FromMinutes(90);

3. 보안 인증서 스토리지 사용:

인증서를 Azure Key Vault 또는 올바르게 보호된 인증서 저장소에 저장합니다.

// Azure Key Vault (production)
var cert = CertificateDescription.FromKeyVault(keyVaultUrl, certName);

// Certificate store with proper permissions
var cert = CertificateDescription.FromStoreWithThumbprint(
    thumbprint, StoreName.My, StoreLocation.LocalMachine);

4. 적절한 오류 처리 구현:

MSAL 예외를 잡고 문제 해결을 위해 상관관계 ID를 기록하십시오.

try
{
    var result = await app.AcquireTokenForClient(scopes).ExecuteAsync();
}
catch (MsalServiceException ex)
{
    logger.Error($"Token acquisition failed. CorrelationId: {ex.CorrelationId}, ErrorCode: {ex.ErrorCode}");
    throw;
}

5. 프로덕션에 분산 캐시 사용:

분산 캐시는 인스턴스 간에 토큰을 공유하고 다시 시작에 걸쳐 유지됩니다.

// Correct for daemon services
app.AddDistributedTokenCaches(services =>
{
    services.AddDistributedSqlServerCache(/* ... */);
});

일반적인 실수 방지

1. 새 IConfidentialClientApplication 인스턴스를 반복적으로 만들지 마세요.

// Wrong - creates new instance every time
public void AcquireToken()
{
    var app = ConfidentialClientApplicationBuilder.Create(clientId).Build();
    // ...
}

// Correct - use singleton
private static readonly IConfidentialClientApplication _app = BuildApp();

2. 비밀을 하드 코딩하지 마세요.

// Wrong
.WithClientSecret("supersecretvalue123")

// Correct
.WithClientSecret(ConfigurationManager.AppSettings["AzureAd:ClientSecret"])

3. 다중 인스턴스 서비스에 메모리 내 캐시를 사용하지 마세요.

// Wrong for services with multiple instances
app.AddInMemoryTokenCache();

// Correct - use distributed cache
app.AddDistributedTokenCaches(services =>
{
    services.AddDistributedSqlServerCache(/* ... */);
});

4. 인증서 유효성 검사를 무시하지 마세요.

// Wrong - skips validation
ServicePointManager.ServerCertificateValidationCallback = (sender, cert, chain, errors) => true;

// Correct - validate certificates properly

ADAL.NET에서 마이그레이션합니다

주요 차이점을 검토한 후, 코드를 업데이트하여 Microsoft.Identity.Web과 함께 MSAL.NET을 사용하십시오.

주요 차이점 이해

Aspect ADAL.NET(사용되지 않음) MSAL.NET + Microsoft. Identity.Web
범위 리소스 기반(https://graph.microsoft.com) 범위 기반(https://graph.microsoft.com/.default)
토큰 캐시 수동 직렬화가 필요합니다 확장 메서드를 사용하여 기본으로 제공되는 어댑터
Certificates 수동으로 X509Certificate2 로드하기 DefaultCertificateLoader 여러 원본이 있는 경우
권한 생성 시 고정됨 요청별로 재정의할 수 있습니다.

마이그레이션 예제 비교

ADAL.NET(이전):

AuthenticationContext authContext = new AuthenticationContext(authority);
ClientCredential credential = new ClientCredential(clientId, clientSecret);
AuthenticationResult result = await authContext.AcquireTokenAsync(resource, credential);

Microsoft MSAL.NET과 Microsoft.Identity.Web (신규):

var app = ConfidentialClientApplicationBuilder.Create(clientId)
    .WithClientSecret(clientSecret)
    .WithTenantId(tenantId)
    .Build();

app.AddInMemoryTokenCache();  // Add token cache

string[] scopes = new[] { "https://graph.microsoft.com/.default" };
AuthenticationResult result = await app.AcquireTokenForClient(scopes).ExecuteAsync();

관련 시나리오에 대해 자세히 알아보려면 이러한 리소스를 사용합니다.