ASP.NET Core MVC + Identity에서 Microsoft Entra ID 로그인과 자동 회원가입 구현하기

  • 31 minutes to read

ASP.NET Core MVC 프로젝트에서 기본 ASP.NET Core Identity를 유지하면서 Microsoft Entra ID(이전 Azure AD)를 외부 로그인 공급자로 추가하면 다음과 같은 구성을 만들 수 있습니다.

ASP.NET Core MVC
        │
        ├── 로컬 로그인
        │     └── AspNetUsers + Password
        │
        └── Microsoft Entra ID
              └── Microsoft 365 계정
                       │
                       ▼
                 AspNetUsers
                       │
                 AspNetUserLogins

이 방식의 중요한 특징은 Microsoft Entra ID가 인증(Authentication)을 담당하고, ASP.NET Core Identity가 애플리케이션 내부 사용자와 권한을 계속 관리한다는 것입니다.

따라서 Microsoft 계정으로 처음 로그인한 사용자를 AspNetUsers에 자동 등록하고, 이후에는 AspNetUserLogins를 통해 Microsoft 계정과 로컬 Identity 사용자를 연결할 수 있습니다.

이 글에서는 ASP.NET Core MVC 기본 템플릿의 Individual Accounts 인증을 기준으로 다음 기능을 완성합니다.

  • 기존 이메일/비밀번호 로그인 유지
  • Microsoft Entra ID 로그인 추가
  • Microsoft 365 조직 계정 로그인
  • 첫 로그인 시 AspNetUsers 자동 생성
  • AspNetUserLogins 자동 연결
  • Microsoft 비밀번호를 애플리케이션 DB에 저장하지 않음
  • Entra의 Tenant ID + Object ID를 외부 사용자 식별자로 사용
  • Hybrid / Entra-only 확장 가능

Microsoft는 ASP.NET Core 웹 애플리케이션의 Entra 로그인에 OpenID Connect를 사용하며, 새로운 애플리케이션에서는 Authorization Code Flow와 PKCE를 사용하는 방식을 권장합니다. (Microsoft Learn)


1. 프로젝트 생성

Visual Studio에서 다음 프로젝트를 생성합니다.

Create a new project

ASP.NET Core Web App (Model-View-Controller)

Framework:
.NET 10

Authentication type:
Individual Accounts

.NET CLI를 사용한다면 다음과 같습니다.

dotnet new mvc -n VisualAcademy.EntraMvc -au Individual -f net10.0
cd VisualAcademy.EntraMvc

이 글에서는 예제 프로젝트명을 다음과 같이 사용합니다.

VisualAcademy.EntraMvc

.NET 8 또는 .NET 9에서도 기본 구조는 동일합니다.

ASP.NET Core 공식 문서에서도 외부 인증을 Identity와 함께 사용할 때 Individual Accounts 프로젝트를 출발점으로 사용합니다. (Microsoft Learn)


2. 먼저 기본 Identity 구조 이해하기

Individual Accounts로 프로젝트를 생성하면 SQL Server 등의 Identity 저장소에 일반적으로 다음 테이블이 만들어집니다.

AspNetUsers
AspNetRoles
AspNetUserRoles
AspNetUserClaims
AspNetUserLogins
AspNetUserTokens
AspNetRoleClaims

로컬 사용자라면:

AspNetUsers
────────────────────────
Email
UserName
PasswordHash
...

에 비밀번호 Hash가 저장됩니다.

Microsoft Entra 사용자는 조금 다릅니다.

Microsoft Entra ID
        │
        │ 인증 성공
        ▼
AspNetUsers
        │
        └── AspNetUserLogins

예를 들어 다음과 같이 저장됩니다.

AspNetUsers
────────────────────────────────────
Id            8b912...
UserName      user@contoso.com
Email         user@contoso.com
PasswordHash  NULL

그리고 외부 계정 연결은:

AspNetUserLogins
────────────────────────────────────────────
LoginProvider        MicrosoftEntra
ProviderKey          <TenantId>:<ObjectId>
ProviderDisplayName  Microsoft 365
UserId               8b912...

에 저장합니다.

UserManager.AddLoginAsync는 바로 이러한 외부 로그인 정보를 Identity 사용자와 연결하기 위해 제공되는 ASP.NET Core Identity API입니다. (Microsoft Learn)


3. Microsoft Entra ID에서 App Registration 만들기

이제 portal.azure.com에 접속합니다.

Microsoft Entra ID 메뉴

다음 메뉴로 이동합니다.

Azure Portal
    ↓
Microsoft Entra ID
    ↓
App registrations
    ↓
New registration

App Registration 메뉴

애플리케이션 이름은 예를 들어 다음처럼 입력합니다.

VisualAcademy MVC Development

Supported account types

회사 또는 조직 내부 애플리케이션이라면:

Accounts in this organizational directory only

Single tenant를 선택합니다.

Microsoft 공식 ASP.NET Core Quickstart에서도 조직 내부 애플리케이션을 Single Tenant로 등록하는 방법을 안내합니다. (Microsoft Learn)


4. Redirect URI 등록

Register an Application 메뉴

먼저 MVC 프로젝트의 HTTPS 포트를 확인합니다.

다음 파일을 엽니다.

Properties/launchSettings.json

예를 들어 다음과 같다고 가정하겠습니다.

"applicationUrl": "https://localhost:7215;http://localhost:5215"

그러면 Entra App Registration에 등록할 URI는:

https://localhost:7215/signin-oidc

입니다.

Redirect URI 등록

App Registration 생성 화면에서:

Redirect URI

Platform:
Web

URI:
https://localhost:7215/signin-oidc

를 입력합니다.

그리고 Register를 누릅니다.

/signin-oidc는 우리가 직접 만드는 MVC Controller Action이 아닙니다.

Microsoft Entra
       ↓
https://localhost:7215/signin-oidc
       ↓
ASP.NET Core OpenID Connect Middleware

처럼 ASP.NET Core 인증 Middleware가 처리하는 특별한 Callback Path입니다.

Microsoft 공식 ASP.NET Core Entra 예제에서도 기본 callback으로 /signin-oidc를 사용합니다. (Microsoft Learn)


5. Tenant ID와 Client ID 확인

Tenant ID와 Client ID

App Registration을 만든 후 Overview로 이동합니다.

여기에 두 개의 중요한 GUID가 있습니다.

Application (client) ID

Directory (tenant) ID

예를 들어:

Application (client) ID
11111111-2222-3333-4444-555555555555

Directory (tenant) ID
aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee

입니다.

ASP.NET Core에서는 각각:

Application (client) ID
        ↓
ClientId


Directory (tenant) ID
        ↓
TenantId

로 사용합니다.


6. Client Secret 생성

다음으로:

App registration
    ↓
Certificates & secrets
    ↓
Client secrets
    ↓
New client secret

을 선택합니다.

예:

Description
VisualAcademy Local Development

Expires
6 months

Add를 누르면 Secret이 생성됩니다.

여기서 반드시 Value를 복사해야 합니다.

Secret ID   ❌

Value       ✅

생성 직후에만 전체 Value를 확인할 수 있습니다.

다만 Client Secret은 개발과 테스트에는 편리하지만 Production에서는 인증서나 federated credential 같은 더 안전한 credential이 권장됩니다. (Microsoft Learn)


7. OpenID Connect 패키지 추가

프로젝트에서 다음 패키지를 추가합니다.

dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect

Target Framework와 같은 major version을 사용하는 것이 좋습니다.

예를 들어 .NET 10 프로젝트라면 10.x 계열을 사용합니다.

이 글에서는 기존 ASP.NET Core Identity의 External Login Pipeline을 그대로 활용하기 위해 ASP.NET Core의 OpenID Connect Handler를 직접 연결합니다.

Microsoft Entra만 사용하는 새 애플리케이션이라면 Microsoft가 제공하는 Microsoft.Identity.Web도 좋은 선택입니다. Microsoft의 최신 Quickstart는 Microsoft.Identity.Web을 기본적인 Entra 통합 방법으로 사용합니다. (Microsoft Learn)


8. Authentication 설정 클래스 만들기

다음 파일을 만듭니다.

Security/AuthenticationSettings.cs
namespace VisualAcademy.EntraMvc.Security;

public sealed class AuthenticationSettings
{
    public string Mode { get; set; } = "Hybrid";

    public MicrosoftEntraSettings MicrosoftEntra { get; set; } = new();

    public bool AllowLocalLogin =>
        string.Equals(Mode, "Local", StringComparison.OrdinalIgnoreCase) ||
        string.Equals(Mode, "Hybrid", StringComparison.OrdinalIgnoreCase);

    public bool AllowMicrosoftEntra =>
        MicrosoftEntra.Enabled &&
        (
            string.Equals(Mode, "Hybrid", StringComparison.OrdinalIgnoreCase) ||
            string.Equals(Mode, "Entra", StringComparison.OrdinalIgnoreCase)
        );
}

public sealed class MicrosoftEntraSettings
{
    public bool Enabled { get; set; }

    public string Instance { get; set; }
        = "https://login.microsoftonline.com/";

    public string TenantId { get; set; } = "";

    public string ClientId { get; set; } = "";

    public string ClientSecret { get; set; } = "";

    public string CallbackPath { get; set; }
        = "/signin-oidc";

    public string SignedOutCallbackPath { get; set; }
        = "/signout-callback-oidc";

    public string DisplayName { get; set; }
        = "Microsoft 365";

    public bool AutoLinkExistingUserByEmail { get; set; } = true;

    public bool AutoCreateUsers { get; set; } = true;
}

여기서:

Instance
TenantId
ClientId
ClientSecret
CallbackPath
SignedOutCallbackPath

는 Entra/OIDC 연결 설정입니다.

반면:

Mode
Enabled
DisplayName
AutoLinkExistingUserByEmail
AutoCreateUsers

는 애플리케이션의 자체 정책입니다.


9. appsettings.json 설정

appsettings.json에 다음 내용을 추가합니다.

{
  "AuthenticationSettings": {
    "Mode": "Hybrid",
    "MicrosoftEntra": {
      "Enabled": true,
      "Instance": "https://login.microsoftonline.com/",
      "TenantId": "YOUR-TENANT-ID",
      "ClientId": "YOUR-APPLICATION-CLIENT-ID",
      "ClientSecret": "",
      "CallbackPath": "/signin-oidc",
      "SignedOutCallbackPath": "/signout-callback-oidc",
      "DisplayName": "Microsoft 365",
      "AutoLinkExistingUserByEmail": true,
      "AutoCreateUsers": true
    }
  }
}

실제 값으로:

YOUR-TENANT-ID

와:

YOUR-APPLICATION-CLIENT-ID

를 교체합니다.

예:

"TenantId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"ClientId": "11111111-2222-3333-4444-555555555555"

10. Client Secret은 appsettings.json에 넣지 않는다

다음처럼 비워 두는 것을 권장합니다.

"ClientSecret": ""

개발 PC에서는 User Secrets를 사용합니다.

dotnet user-secrets init

그리고:

dotnet user-secrets set "AuthenticationSettings:MicrosoftEntra:ClientSecret" "YOUR-CLIENT-SECRET-VALUE"

그러면 실제 Secret은 Git Repository에 들어가지 않습니다.

Microsoft도 secret을 source code나 appsettings.json에 저장하지 않는 방식을 권장합니다. (Microsoft Learn)


11. Program.cs에 Microsoft Entra 등록

이제 핵심 부분입니다.

Program.cs 상단에 필요한 namespace를 추가합니다.

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using VisualAcademy.EntraMvc.Security;

그리고 전체적인 Identity 등록은 기존 템플릿 구조를 유지합니다.

예를 들어:

var builder = WebApplication.CreateBuilder(args);

var connectionString =
    builder.Configuration.GetConnectionString("DefaultConnection")
    ?? throw new InvalidOperationException(
        "Connection string 'DefaultConnection' not found.");

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));

builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services
    .AddDefaultIdentity<IdentityUser>(options =>
    {
        options.SignIn.RequireConfirmedAccount = true;
    })
    .AddEntityFrameworkStores<ApplicationDbContext>();

builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();

기존 Identity 설정을 제거하지 않는 것이 중요합니다.


12. 설정 읽기

다음 코드를 추가합니다.

var authenticationSettings =
    builder.Configuration
        .GetSection("AuthenticationSettings")
        .Get<AuthenticationSettings>()
    ?? new AuthenticationSettings();

builder.Services.Configure<AuthenticationSettings>(
    builder.Configuration.GetSection("AuthenticationSettings"));

그리고 Microsoft 로그인을 등록합니다.

var authenticationBuilder =
    builder.Services.AddAuthentication();

if (authenticationSettings.AllowMicrosoftEntra)
{
    var entra = authenticationSettings.MicrosoftEntra;

    if (string.IsNullOrWhiteSpace(entra.TenantId))
    {
        throw new InvalidOperationException(
            "Microsoft Entra TenantId is required.");
    }

    if (string.IsNullOrWhiteSpace(entra.ClientId))
    {
        throw new InvalidOperationException(
            "Microsoft Entra ClientId is required.");
    }

    if (string.IsNullOrWhiteSpace(entra.ClientSecret))
    {
        throw new InvalidOperationException(
            "Microsoft Entra ClientSecret is required.");
    }

    var instance = entra.Instance.TrimEnd('/');

    authenticationBuilder.AddOpenIdConnect(
        "MicrosoftEntra",
        entra.DisplayName,
        options =>
        {
            options.SignInScheme =
                IdentityConstants.ExternalScheme;

            options.Authority =
                $"{instance}/{entra.TenantId}/v2.0";

            options.ClientId =
                entra.ClientId;

            options.ClientSecret =
                entra.ClientSecret;

            options.CallbackPath =
                entra.CallbackPath;

            options.SignedOutCallbackPath =
                entra.SignedOutCallbackPath;

            options.ResponseType =
                OpenIdConnectResponseType.Code;

            options.UsePkce = true;

            options.SaveTokens = false;

            options.MapInboundClaims = false;

            options.Scope.Clear();
            options.Scope.Add("openid");
            options.Scope.Add("profile");
            options.Scope.Add("email");

            options.TokenValidationParameters =
                new TokenValidationParameters
                {
                    NameClaimType = "name",
                    RoleClaimType = "roles",
                    ValidateIssuer = true
                };

            options.Events =
                new OpenIdConnectEvents
                {
                    OnTokenValidated = context =>
                    {
                        var principal = context.Principal;

                        if (principal?.Identity
                            is not ClaimsIdentity identity)
                        {
                            context.Fail(
                                "Claims identity was not created.");

                            return Task.CompletedTask;
                        }

                        var tenantId =
                            principal.FindFirst("tid")?.Value;

                        var objectId =
                            principal.FindFirst("oid")?.Value;

                        if (string.IsNullOrWhiteSpace(tenantId) ||
                            string.IsNullOrWhiteSpace(objectId))
                        {
                            context.Fail(
                                "The Microsoft Entra token does not contain tid or oid.");

                            return Task.CompletedTask;
                        }

                        var providerKey =
                            $"{tenantId}:{objectId}";

                        foreach (var claim in
                                 identity
                                     .FindAll(ClaimTypes.NameIdentifier)
                                     .ToList())
                        {
                            identity.RemoveClaim(claim);
                        }

                        identity.AddClaim(
                            new Claim(
                                ClaimTypes.NameIdentifier,
                                providerKey));

                        return Task.CompletedTask;
                    }
                };
        });
}

여기에서 매우 중요한 부분이:

options.SignInScheme = IdentityConstants.ExternalScheme;

입니다.

Microsoft Entra가 인증한 결과를 바로 애플리케이션 로그인 Cookie로 사용하는 것이 아니라:

Microsoft Entra
       ↓
OpenID Connect
       ↓
Identity External Cookie
       ↓
ExternalLogin
       ↓
AspNetUsers
       ↓
Application Cookie

순서로 처리합니다.

IdentityConstants.ExternalScheme은 ASP.NET Core Identity가 외부 인증 정보를 처리하기 위해 사용하는 외부 인증 Cookie Scheme입니다. (Microsoft Learn)


13. 왜 Tenant ID + Object ID를 사용하는가?

Microsoft 로그인 사용자에게는 다음과 같은 Claim이 들어옵니다.

tid
oid
name
preferred_username
email
...

예:

tid
aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee

oid
12345678-abcd-1234-abcd-123456789012

이를:

aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:
12345678-abcd-1234-abcd-123456789012

와 같은 외부 Identity Key로 사용합니다.

Email은 나중에 변경될 수 있습니다.

반면:

Tenant ID + Object ID

는 사용자를 연결하기에 더 적합한 식별자입니다.

따라서 이메일은:

사용자 프로필
초기 자동 매칭

용으로 사용하고,

실제 이후 로그인 식별은:

tid + oid

로 수행합니다.


14. Identity의 ExternalLogin 페이지 Scaffold

자동 회원가입 로직을 직접 구현하려면 Identity의 External Login 페이지를 Scaffold하는 것이 편리합니다.

Visual Studio에서:

Project
    ↓
Add
    ↓
New Scaffolded Item
    ↓
Identity

를 선택합니다.

다음 페이지를 선택합니다.

Account/Login
Account/ExternalLogin

Data context class는:

ApplicationDbContext

를 선택합니다.

그러면:

Areas
└── Identity
    └── Pages
        └── Account
            ├── Login.cshtml
            ├── Login.cshtml.cs
            ├── ExternalLogin.cshtml
            └── ExternalLogin.cshtml.cs

가 만들어집니다.


15. 자동 가입의 핵심: ExternalLogin.cshtml.cs

이제 Microsoft 로그인이 성공했지만 Identity User가 아직 없는 경우 자동으로 AspNetUsers를 생성합니다.

ExternalLogin.cshtml.cs를 다음과 같이 구성할 수 있습니다.

using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Options;
using VisualAcademy.EntraMvc.Security;

namespace VisualAcademy.EntraMvc.Areas.Identity.Pages.Account;

public class ExternalLoginModel : PageModel
{
    private readonly SignInManager<IdentityUser> _signInManager;
    private readonly UserManager<IdentityUser> _userManager;
    private readonly AuthenticationSettings _authenticationSettings;
    private readonly ILogger<ExternalLoginModel> _logger;

    public ExternalLoginModel(
        SignInManager<IdentityUser> signInManager,
        UserManager<IdentityUser> userManager,
        IOptions<AuthenticationSettings> authenticationSettings,
        ILogger<ExternalLoginModel> logger)
    {
        _signInManager = signInManager;
        _userManager = userManager;
        _authenticationSettings = authenticationSettings.Value;
        _logger = logger;
    }

    [TempData]
    public string? ErrorMessage { get; set; }

    public IActionResult OnPost(
        string provider,
        string? returnUrl = null)
    {
        returnUrl ??= Url.Content("~/");

        var redirectUrl = Url.Page(
            "./ExternalLogin",
            pageHandler: "Callback",
            values: new
            {
                returnUrl
            });

        var properties =
            _signInManager
                .ConfigureExternalAuthenticationProperties(
                    provider,
                    redirectUrl);

        return new ChallengeResult(
            provider,
            properties);
    }

    public async Task<IActionResult> OnGetCallbackAsync(
        string? returnUrl = null,
        string? remoteError = null)
    {
        returnUrl ??= Url.Content("~/");

        if (!string.IsNullOrWhiteSpace(remoteError))
        {
            ErrorMessage =
                $"External authentication error: {remoteError}";

            return RedirectToPage(
                "./Login",
                new
                {
                    ReturnUrl = returnUrl
                });
        }

        var info =
            await _signInManager
                .GetExternalLoginInfoAsync();

        if (info == null)
        {
            ErrorMessage =
                "Unable to load external login information.";

            return RedirectToPage(
                "./Login",
                new
                {
                    ReturnUrl = returnUrl
                });
        }

        //
        // 1. 이미 연결된 Microsoft 계정인지 확인
        //
        var signInResult =
            await _signInManager
                .ExternalLoginSignInAsync(
                    info.LoginProvider,
                    info.ProviderKey,
                    isPersistent: false,
                    bypassTwoFactor: true);

        if (signInResult.Succeeded)
        {
            _logger.LogInformation(
                "User signed in with {LoginProvider}.",
                info.LoginProvider);

            return LocalRedirect(returnUrl);
        }

        if (signInResult.IsLockedOut)
        {
            return RedirectToPage("./Lockout");
        }

        //
        // 이 글에서는 MicrosoftEntra 공급자에 대해
        // 자동 가입을 수행한다.
        //
        if (!string.Equals(
                info.LoginProvider,
                "MicrosoftEntra",
                StringComparison.Ordinal))
        {
            ErrorMessage =
                "Unsupported external login provider.";

            return RedirectToPage("./Login");
        }

        var entra =
            _authenticationSettings.MicrosoftEntra;

        //
        // 2. Entra에서 Email 또는 UPN 추출
        //
        var email =
            GetEmailOrUserPrincipalName(
                info.Principal);

        if (string.IsNullOrWhiteSpace(email))
        {
            ErrorMessage =
                "Microsoft Entra did not provide an email address or user principal name.";

            return RedirectToPage("./Login");
        }

        //
        // 3. 기존 AspNetUsers 검색
        //
        var user =
            await _userManager
                .FindByEmailAsync(email);

        var createdNow = false;

        if (user == null)
        {
            //
            // 4. 자동 가입 허용 여부 확인
            //
            if (!entra.AutoCreateUsers)
            {
                ErrorMessage =
                    "Microsoft sign-in succeeded, but this application does not allow automatic account creation.";

                return RedirectToPage("./Login");
            }

            user = new IdentityUser
            {
                UserName = email,
                Email = email,

                //
                // Entra에서 이미 사용자를 인증했으므로
                // 로컬 Identity의 별도 이메일 확인 절차를
                // 생략하는 정책을 사용할 수 있다.
                //
                EmailConfirmed = true
            };

            var createResult =
                await _userManager
                    .CreateAsync(user);

            if (!createResult.Succeeded)
            {
                ErrorMessage =
                    BuildIdentityErrorMessage(
                        "Unable to create local user.",
                        createResult);

                return RedirectToPage("./Login");
            }

            createdNow = true;

            _logger.LogInformation(
                "Created Identity user {Email} from Microsoft Entra.",
                email);
        }
        else
        {
            //
            // 기존 로컬 사용자와 Email로 자동 연결할지 결정
            //
            if (!entra.AutoLinkExistingUserByEmail)
            {
                ErrorMessage =
                    "A local account with this email already exists. Automatic account linking is disabled.";

                return RedirectToPage("./Login");
            }
        }

        //
        // 5. Microsoft Entra ↔ Identity User 연결
        //
        var existingLoginUser =
            await _userManager
                .FindByLoginAsync(
                    info.LoginProvider,
                    info.ProviderKey);

        if (existingLoginUser == null)
        {
            var addLoginResult =
                await _userManager
                    .AddLoginAsync(
                        user,
                        info);

            if (!addLoginResult.Succeeded)
            {
                ErrorMessage =
                    BuildIdentityErrorMessage(
                        "Unable to link Microsoft Entra account.",
                        addLoginResult);

                return RedirectToPage("./Login");
            }
        }
        else if (existingLoginUser.Id != user.Id)
        {
            ErrorMessage =
                "This Microsoft Entra account is already connected to another local user.";

            return RedirectToPage("./Login");
        }

        //
        // 6. Identity Application Cookie 생성
        //
        await _signInManager.SignInAsync(
            user,
            isPersistent: false,
            info.LoginProvider);

        _logger.LogInformation(
            "User {Email} signed in using Microsoft Entra. CreatedNow={CreatedNow}",
            email,
            createdNow);

        return LocalRedirect(returnUrl);
    }

    private static string? GetEmailOrUserPrincipalName(
        ClaimsPrincipal principal)
    {
        var email =
            principal.FindFirst("email")?.Value;

        if (!string.IsNullOrWhiteSpace(email))
        {
            return email;
        }

        var preferredUsername =
            principal.FindFirst(
                "preferred_username")?.Value;

        if (!string.IsNullOrWhiteSpace(preferredUsername))
        {
            return preferredUsername;
        }

        return principal
            .FindFirst(ClaimTypes.Email)
            ?.Value;
    }

    private static string BuildIdentityErrorMessage(
        string prefix,
        IdentityResult result)
    {
        var errors =
            string.Join(
                "; ",
                result.Errors.Select(
                    error => error.Description));

        return $"{prefix} {errors}";
    }
}

SignInManager.GetExternalLoginInfoAsync()은 현재 외부 인증 시도의 정보를 읽는 Identity API이고, UserManager.AddLoginAsync()가 이를 사용자 레코드와 연결합니다. (Microsoft Learn)


16. 실제 최초 로그인에서 일어나는 일

이제 다음 Microsoft 계정으로 최초 로그인한다고 가정합니다.

developer@contoso.com

아직 AspNetUsers에 사용자가 없습니다.

로그인 흐름은:

Login
  ↓
Sign in with Microsoft 365
  ↓
Microsoft Entra ID
  ↓
Password / MFA
  ↓
/signin-oidc
  ↓
External Identity Cookie
  ↓
ExternalLogin Callback
  ↓
AspNetUsers 검색
  ↓
없음
  ↓
AutoCreateUsers = true
  ↓
AspNetUsers 생성
  ↓
AspNetUserLogins 생성
  ↓
Application Cookie 생성
  ↓
로그인 완료

가 됩니다.


17. 실제 DB에는 무엇이 들어가는가?

AspNetUsers에는:

Email
developer@contoso.com

UserName
developer@contoso.com

PasswordHash
NULL

과 같은 사용자가 생성됩니다.

Microsoft 365 비밀번호는 저장되지 않습니다.

Microsoft 계정 연결은:

AspNetUserLogins

에 저장됩니다.

다음 SQL로 확인할 수 있습니다.

SELECT
    u.Id,
    u.UserName,
    u.Email,
    u.EmailConfirmed,
    u.PasswordHash,
    l.LoginProvider,
    l.ProviderKey,
    l.ProviderDisplayName
FROM AspNetUsers AS u
LEFT JOIN AspNetUserLogins AS l
    ON u.Id = l.UserId
ORDER BY u.Email;

결과 예:

Email
developer@contoso.com

PasswordHash
NULL

LoginProvider
MicrosoftEntra

ProviderKey
aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:
12345678-abcd-1234-abcd-123456789012

입니다.


18. 두 번째 로그인부터는 자동 가입을 하지 않는다

두 번째 Microsoft 로그인에서는:

Microsoft Entra
      ↓
tid + oid
      ↓
AspNetUserLogins
      ↓
이미 UserId 존재
      ↓
AspNetUsers
      ↓
로그인

이므로 AspNetUsers가 다시 생성되지 않습니다.

외부 로그인 정보는 외부 Provider와 Provider Key를 이용해서 기존 사용자를 찾을 수 있도록 설계되어 있습니다. (Microsoft Learn)


19. 기존 로컬 사용자가 있다면?

예를 들어 이미:

AspNetUsers

Email:
developer@contoso.com

PasswordHash:
AQAAAA...

사용자가 존재한다고 해보겠습니다.

설정이:

"AutoLinkExistingUserByEmail": true

이면 최초 Microsoft 로그인 때:

Microsoft
developer@contoso.com
        ↓
AspNetUsers 동일 Email 발견
        ↓
새 User 생성하지 않음
        ↓
AspNetUserLogins만 추가

됩니다.

따라서 이 사용자는 Hybrid 모드에서:

Local Password

또는

Microsoft 365

두 방식 모두 사용할 수 있습니다.


20. AutoCreateUsers 설정

개발 환경에서는:

"AutoCreateUsers": true

가 편리합니다.

Microsoft 인증이 성공하면 별도 회원가입 화면을 거치지 않고 사용자가 만들어집니다.

반대로:

"AutoCreateUsers": false

이면:

Entra 인증 성공

하지만

AspNetUsers에 사용자 없음

→ 로그인 거부

가 됩니다.

기업 내부 업무 시스템에서는 Production에서 false를 사용하는 것도 좋은 정책입니다.


21. AutoLinkExistingUserByEmail의 의미

다음 설정:

"AutoLinkExistingUserByEmail": true

은 Microsoft 계정의 이메일과 기존 로컬 Identity 사용자의 이메일이 같으면 두 계정을 자동 연결한다는 뜻입니다.

편리하지만 보안 정책에 따라 결정해야 합니다.

특히 Multi-Tenant 환경에서 아무 조직이나 로그인할 수 있다면 이메일 기반 자동 연결을 무조건 허용하면 안 됩니다.

이 글처럼:

Single Tenant
+
회사가 관리하는 Microsoft 365

인 환경에서는 상대적으로 관리하기 쉽습니다.

실제 장기 식별자는 이메일이 아니라:

tid + oid

로 유지합니다.


22. Role은 자동으로 생성되지 않는다

자동 회원가입과 Role 부여는 별개의 문제입니다.

처음 로그인한 사용자는:

AspNetUsers             ✅

AspNetUserLogins        ✅

AspNetUserRoles         없음

일 수 있습니다.

따라서:

[Authorize]

페이지는 사용할 수 있어도:

[Authorize(Roles = "Administrators")]

페이지는 사용할 수 없습니다.

이는 정상적인 동작입니다.


23. 자동으로 기본 Role을 주고 싶다면

정책상 모든 Entra 사용자가 기본 Users 역할을 가져도 된다면 사용자 생성 직후 추가할 수 있습니다.

예:

if (createdNow)
{
    if (await _userManager.IsInRoleAsync(user, "Users") == false)
    {
        await _userManager.AddToRoleAsync(
            user,
            "Users");
    }
}

단, Role이 미리 존재해야 합니다.

하지만 기업 내부 애플리케이션이라면 자동 Role 부여보다는:

자동 가입
        ↓
기본 권한 없음
        ↓
Administrator가 Role 할당

방식이 더 보수적입니다.


24. Login 화면에는 어떻게 나타나는가?

ASP.NET Core Identity 기본 Login Page는 등록된 External Authentication Provider를 가져올 수 있습니다.

따라서:

.AddOpenIdConnect(
    "MicrosoftEntra",
    "Microsoft 365",
    ...)

처럼 Display Name을 등록하면 Login 페이지에:

Email
Password

[ Log in ]

-------------------

Use another service to log in

[ Microsoft 365 ]

같은 항목이 나타날 수 있습니다.

Custom Identity Login 페이지를 사용한다면:

await _signInManager
    .GetExternalAuthenticationSchemesAsync();

로 공급자 목록을 얻습니다.

ASP.NET Core Identity 공식 외부 로그인 문서도 이 External Provider 방식을 사용합니다. (Microsoft Learn)


25. Hybrid Mode

현재 설정:

"Mode": "Hybrid"

은 다음 구성을 의미하도록 애플리케이션에서 정의한 값입니다.

Login
 │
 ├── Local Identity
 │
 │     Email
 │
 │     Password
 │
 └── Microsoft Entra
       Microsoft 365

개발 초기에는 Hybrid가 가장 편리합니다.

Microsoft 설정에 문제가 생겨도 기존 Local Administrator로 로그인할 수 있기 때문입니다.


26. Entra-only Mode

나중에는:

"Mode": "Entra"

로 운영할 수도 있습니다.

목표는:

Login
   ↓
Microsoft Entra ID only

입니다.

다만 주의할 점이 있습니다.

Mode는 우리가 만든 애플리케이션 설정일 뿐입니다.

단순히:

"Mode": "Entra"

라고 썼다고 ASP.NET Core Identity의 Password Login API가 자동으로 사라지지는 않습니다.

따라서 Entra-only를 실제로 강제하려면 Scaffold한 LoginRegister 페이지에서도 Local 기능을 차단해야 합니다.

예를 들어 Login POST 시작 부분에:

if (!_authenticationSettings.AllowLocalLogin)
{
    return Forbid();
}

같은 정책 검사를 추가할 수 있습니다.

Register 역시:

Mode = Entra

일 때 노출하지 않는 편이 좋습니다.


27. 로그아웃

Local Identity에서:

await _signInManager.SignOutAsync();

을 호출하면 애플리케이션 Cookie에서는 로그아웃합니다.

하지만 Microsoft의 SSO Session은 브라우저에 남아 있을 수 있습니다.

그래서 다시 Microsoft 버튼을 클릭했을 때 비밀번호를 묻지 않고 바로 로그인될 수 있습니다.

이것은 오류가 아니라 SSO의 정상적인 특성입니다.

Microsoft 계정까지 로그아웃하는 기능이 필요한 경우 OIDC SignOut Challenge를 별도로 구현할 수 있습니다.

예:

[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult SignOutMicrosoft()
{
    return SignOut(
        new AuthenticationProperties
        {
            RedirectUri = "/"
        },
        IdentityConstants.ApplicationScheme,
        "MicrosoftEntra");
}

로그아웃 정책은 업무 시스템의 요구사항에 맞게 결정합니다.


28. Redirect URI 하나라도 다르면 로그인되지 않는다

가장 자주 만나는 오류가:

AADSTS50011

입니다.

대부분 Redirect URI 불일치입니다.

예를 들어 Azure Portal에는:

https://localhost:7215/signin-oidc

인데 실제 애플리케이션이:

https://localhost:44321/signin-oidc

이면 실패합니다.

다음 네 가지가 모두 정확히 같아야 합니다.

Scheme
Host
Port
Path

즉:

https
localhost
7215
/signin-oidc

가 모두 일치해야 합니다. Microsoft 공식 Quickstart에서도 Redirect URI 불일치를 대표적인 로그인 오류로 설명합니다. (Microsoft Learn)


29. 테스트 순서

처음에는 다음 순서로 확인하는 것이 좋습니다.

1. ASP.NET Core MVC 프로젝트 실행
        ↓
2. Local Identity 로그인 확인
        ↓
3. Azure Portal에서 App Registration 생성
        ↓
4. Tenant ID 설정
        ↓
5. Client ID 설정
        ↓
6. Client Secret을 User Secrets에 저장
        ↓
7. AutoCreateUsers = true
        ↓
8. Microsoft 365 버튼 클릭
        ↓
9. Microsoft 로그인
        ↓
10. AspNetUsers 자동 생성 확인
        ↓
11. AspNetUserLogins 생성 확인
        ↓
12. 로그아웃
        ↓
13. 같은 Microsoft 계정으로 재로그인
        ↓
14. AspNetUsers 중복 생성되지 않는지 확인

이후 기존 Local 사용자와 같은 이메일을 가진 Microsoft 계정으로 로그인하여 자동 Link도 테스트합니다.


30. 데이터베이스에서 반드시 확인할 것

다음 SQL은 테스트에 유용합니다.

SELECT
    u.Id,
    u.UserName,
    u.Email,
    u.EmailConfirmed,
    CASE
        WHEN u.PasswordHash IS NULL THEN 'External Only'
        ELSE 'Local Password Available'
    END AS AccountType,
    l.LoginProvider,
    l.ProviderKey,
    l.ProviderDisplayName
FROM AspNetUsers AS u
LEFT JOIN AspNetUserLogins AS l
    ON u.Id = l.UserId
ORDER BY u.Email;

Entra로 자동 생성된 사용자는 보통:

AccountType
External Only

로 보입니다.


31. Microsoft 비밀번호는 절대로 애플리케이션에 오지 않는다

이 구조에서 중요한 보안 특성입니다.

사용자가:

Microsoft 365 Password

를 입력하는 곳은:

login.microsoftonline.com

입니다.

애플리케이션에는:

ID Token
Authorization Code
Claims

등 인증 결과만 전달됩니다.

따라서 애플리케이션의 SQL Server에는 Microsoft 비밀번호가 저장되지 않습니다.


32. MFA 역시 Microsoft가 처리한다

조직에서 Microsoft Entra MFA를 적용하고 있다면:

MVC Application
      ↓
Microsoft Entra
      ↓
Password
      ↓
MFA
      ↓
Application

가 됩니다.

ASP.NET Core 애플리케이션에서 별도의 Microsoft MFA 화면을 구현할 필요가 없습니다.

Conditional Access 역시 Entra 정책으로 관리할 수 있습니다.


33. Production Azure Web App으로 옮기기

로컬 테스트에서는:

https://localhost:7215/signin-oidc

를 사용했습니다.

Azure Web App이:

https://visualacademy.azurewebsites.net

이라면 App Registration의 Web Redirect URI에:

https://visualacademy.azurewebsites.net/signin-oidc

를 추가합니다.

로그아웃 Callback을 사용한다면:

https://visualacademy.azurewebsites.net/signout-callback-oidc

도 추가합니다.

로컬 URI는 Development 테스트를 위해 그대로 남겨둘 수도 있습니다.


34. Azure Web App의 설정값

Azure Portal에서:

App Service
    ↓
Configuration
    ↓
Application settings

에 값을 넣을 수 있습니다.

ASP.NET Core의 중첩 Configuration은 __로 표현합니다.

예:

AuthenticationSettings__Mode
Hybrid

AuthenticationSettings__MicrosoftEntra__Enabled
true

AuthenticationSettings__MicrosoftEntra__TenantId
aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee

AuthenticationSettings__MicrosoftEntra__ClientId
11111111-2222-3333-4444-555555555555

AuthenticationSettings__MicrosoftEntra__ClientSecret
********

그러면 Production의 실제 Secret을 Git의 appsettings.json에 저장할 필요가 없습니다.


35. Production에서는 Client Secret보다 더 안전한 Credential 고려

이 글에서는 학습과 개발 편의를 위해:

ClientSecret

을 사용했습니다.

Production에서는 Microsoft가 다음과 같은 Credential을 권장합니다.

Certificate
Federated Credential
Managed Identity가 적용 가능한 구조

특히 Microsoft Identity Web은 Credential 관리 기능을 확장하기에 좋습니다. Microsoft 문서에서는 certificate가 client secret보다 안전한 credential 방식이라고 설명합니다. (Microsoft Learn)


36. Microsoft.Identity.Web을 사용하지 않은 이유

Microsoft Entra 전용 웹 애플리케이션을 새로 만든다면 공식 Quickstart처럼:

.AddMicrosoftIdentityWebApp(...)

을 사용하는 것이 편리합니다.

Microsoft의 현재 설정 예제는 보통:

"AzureAd": {
  "Instance": "https://login.microsoftonline.com/",
  "TenantId": "...",
  "ClientId": "...",
  "CallbackPath": "/signin-oidc"
}

형태입니다. (Microsoft Learn)

하지만 이 글의 목표는 조금 다릅니다.

이미:

ASP.NET Core Identity
AspNetUsers
AspNetRoles
Local Password Login

이 존재하는 MVC 프로젝트에 Entra를 External Login Provider로 붙이고:

Microsoft Authentication
        ↓
Identity External Login
        ↓
AspNetUsers 자동 생성

을 구현하는 것입니다.

그래서 ASP.NET Core OIDC Handler를 Identity의:

IdentityConstants.ExternalScheme

에 연결했습니다.


37. 최종 아키텍처

완성된 구조를 그려보면 다음과 같습니다.

                     ASP.NET Core MVC
                            │
                     Authentication
                            │
            ┌───────────────┴───────────────┐
            │                               │
            ▼                               ▼
     ASP.NET Core Identity          Microsoft Entra ID
            │                               │
      Email / Password                  Microsoft 365
            │                           Password / MFA
            │                               │
            │                         OpenID Connect
            │                               │
            │                         /signin-oidc
            │                               │
            └───────────────┬───────────────┘
                            ▼
                       AspNetUsers
                            │
                  ┌─────────┴─────────┐
                  ▼                   ▼
           AspNetUserLogins     AspNetUserRoles
                  │                   │
            MicrosoftEntra       Application Role
            TenantId:Oid

38. 자동 가입 시나리오

신규 Microsoft 사용자

Microsoft 로그인
     ↓
AspNetUserLogins 없음
     ↓
AspNetUsers 없음
     ↓
AutoCreateUsers = true
     ↓
AspNetUsers 생성
     ↓
AspNetUserLogins 생성
     ↓
로그인

기존 Local 사용자

Microsoft 로그인
     ↓
AspNetUserLogins 없음
     ↓
동일 Email의 AspNetUsers 존재
     ↓
AutoLinkExistingUserByEmail = true
     ↓
기존 사용자에 AspNetUserLogins 추가
     ↓
로그인

이미 연결된 사용자

Microsoft 로그인
     ↓
tid + oid
     ↓
AspNetUserLogins 발견
     ↓
AspNetUsers 찾음
     ↓
즉시 로그인

39. 운영 환경에서 권장하는 설정

개발과 학습 단계에서는:

"Mode": "Hybrid",
"AutoLinkExistingUserByEmail": true,
"AutoCreateUsers": true

가 편리합니다.

운영 환경에서는 보안 정책에 따라:

"Mode": "Entra",
"AutoLinkExistingUserByEmail": true,
"AutoCreateUsers": false

같은 구성을 사용할 수 있습니다.

이 경우 관리자가 미리 허용한 사용자만 Microsoft 계정과 연결됩니다.

반대로 사내 Microsoft 365 사용자라면 모두 애플리케이션을 사용할 수 있는 시스템이라면 AutoCreateUsers=true를 유지할 수도 있습니다.


40. 이 구조에서 꼭 기억할 세 가지

첫째, Microsoft Entra ID와 ASP.NET Core Identity는 경쟁 관계가 아닙니다.

Entra
→ 인증

ASP.NET Core Identity
→ 애플리케이션 사용자와 권한

으로 같이 사용할 수 있습니다.

둘째, Entra 사용자의 Microsoft 비밀번호는:

AspNetUsers.PasswordHash

에 저장되지 않습니다.

셋째, 실제 외부 Identity 연결은:

AspNetUserLogins

이 담당합니다.

즉 핵심 관계는:

Microsoft Entra
     │
 Tenant ID + Object ID
     │
     ▼
AspNetUserLogins
     │
     ▼
AspNetUsers
     │
     ▼
AspNetUserRoles

입니다.

이 구조를 이해하면 ASP.NET Core MVC에서 Microsoft 365 SSO를 구현하는 전체 흐름이 상당히 명확해집니다.

참고 Microsoft 문서

ASP.NET Core에서 Microsoft Entra 사용자 로그인 Quickstart

Microsoft Identity Web 개요

ASP.NET Core Identity 외부 로그인 공급자

ASP.NET Core OpenID Connect 인증 구성

Microsoft Entra 애플리케이션 등록

더 깊이 공부하고 싶다면
DevLec에서는 실무 중심의 C#, .NET, ASP.NET Core, Blazor, 데이터 액세스 강좌를 단계별로 제공합니다. 현재 수강 가능한 강좌 외에도 더 많은 과정이 준비되어 있습니다.
DevLec.com에서 자세한 커리큘럼을 확인해 보세요.
DevLec 공식 강의
C# Programming
C# 프로그래밍 입문
프로그래밍을 처음 시작하는 입문자를 위한 C# 기본기 완성 과정입니다.
ASP.NET Core 10.0
ASP.NET Core 10.0 시작하기 MVC Fundamentals Part 1 MVC Fundamentals Part 2
웹 애플리케이션의 구조와 MVC 패턴을 ASP.NET Core로 실습하며 익힐 수 있습니다.
Blazor Server
풀스택 웹개발자 과정 Part 1 풀스택 웹개발자 과정 Part 2 풀스택 웹개발자 과정 Part 3
실무에서 바로 활용 가능한 Blazor Server 기반 관리자·포털 프로젝트를 만들어 봅니다.
Data & APIs
Entity Framework Core 시작하기 ADO.NET Fundamentals Blazor Server Fundamentals Minimal APIs
데이터 액세스와 Web API를 함께 이해하면 실무 .NET 백엔드 개발에 큰 도움이 됩니다.
VisualAcademy Docs의 모든 콘텐츠, 이미지, 동영상의 저작권은 박용준에게 있습니다. 저작권법에 의해 보호를 받는 저작물이므로 무단 전재와 복제를 금합니다. 사이트의 콘텐츠를 복제하여 블로그, 웹사이트 등에 게시할 수 없습니다. 단, 링크와 SNS 공유, Youtube 동영상 공유는 허용합니다. www.VisualAcademy.com
박용준 강사의 모든 동영상 강의는 데브렉에서 독점으로 제공됩니다. www.devlec.com