Article

Microsoft Entra ID連携でSSO認証を実装する方法

高田晃太郎
Microsoft Entra ID連携でSSO認証を実装する方法

Microsoftの公開資料では、多要素認証(MFA)によりアカウント侵害の約99.9%を防止できるとされています¹。ゼロトラストの考え方では、信頼の起点をネットワークからアイデンティティへと移すのが前提であり²、シングルサインオン(SSO: Single Sign-On)は開発チームにとってアーキテクチャの常識になりました。Microsoft Entra ID(旧Azure AD)は、OIDC(OpenID Connect)やSAMLといった標準プロトコルをサポートし³⁵、クラウド/オンプレ双方で広く利用されています。SaaS群をまとめる「ログイン基盤」としてだけでなく、権限管理・監査・性能までを含めて設計することが、ビジネス価値と運用効率を両立させる鍵です。本記事ではCTOの視点で、設計指針から言語別の実装、運用チューニング、効果の評価までを、実装に踏み出せる粒度で整理します。初学者にも読みやすいよう、専門用語は簡潔に補足します(例:OIDCは“ログインの標準”、SAMLは“XMLベースの認証連携”、IdPは“認証サーバー”を指します)。

Entra IDで実現するSSO設計:プロトコル選定と境界設計

実装方針を決める最初のステップは、プロトコル(OIDC/SAML)とフロー(Authorization Code with PKCEなど)の適合性確認です。WebアプリやAPIを自前でコントロールできるなら、一般に**OIDC(OAuth 2.0 Authorization Code Flow with PKCE)**が第一候補になります⁴。OIDCは、IDトークン(本人確認)、アクセストークン(APIアクセス)、リフレッシュトークン(長期セッション)を役割分担でき、SPAやモバイルアプリとも整合します。PKCEは「盗まれても再利用されにくい」仕組みで、コード横取り攻撃への耐性を高めます。既存のSaaSやレガシー製品でSAMLしか選べない場合は、Entra IDのエンタープライズアプリ連携でSAMLを使い⁵、社内アプリは中長期でOIDCへ統一していくのが、移行コストと運用負荷のバランスを取りやすい方針になります。

テナント境界とアプリ登録の粒度は早めに固めます。まずはシングルテナントで始めつつ、将来的なB2Bやマルチテナント化に備え、アプリID URI(APIを一意に識別するURI)やスコープ(APIの操作範囲)を先に設計しておくと移行リスクを抑えられます⁶。認可の表現はアプリロールを主軸にし⁷、グループは運用利便のための割り当て補助に限定するとスケールしやすく、トークンの肥大化も避けられます。なお、グループをIDトークンに埋め込む場合は、Entra IDのGroup Overage閾値を超えるとhasgroupsフラグに置き換わる点に注意し⁸、詳細が必要なときはMicrosoft Graph APIで補完するのが実務的です⁹。

セキュリティ制御はアプリだけに閉じず、条件付きアクセス(ユーザー・デバイス・場所・リスクでアクセス制御)、MFAサインインリスクなどはテナントポリシーに寄せると、アプリ側のコードは認可判定に集中できます¹⁰。セッション戦略は、サーバーセッションのCookie保護(SameSite=Lax/Strict、Secure、HttpOnly)に加え、OIDCのnonce・stateの検証やPKCEのcode_verifier検証を組み合わせ⁴、再認証やサイレントリフレッシュはトークンキャッシュのヒット率を見ながら調整します。

OIDCかSAMLか:移行も見据えた実務的な判断

モダンなWeb/APIではOIDCを標準とし³、SAMLは相互運用や段階的移行の“互換層”として扱うのが現実的です⁵。SAMLはブラウザ中心のIdP発信フローで実装が比較的シンプルですが、API連携やネイティブアプリとの親和性、拡張性はOIDCが優位です。セキュリティはどちらもTLS前提で堅牢にできますが、パラメータ改ざん耐性やフロントチャネル検証の明示化など、実装のしやすさと監査ログの粒度ではOIDCが扱いやすい場面が多いのが実感値です。

トークンとクレームの最小化がパフォーマンスを決める

IDトークンに多くの属性(クレーム)を入れたくなりますが、サイズは認証時間とネットワークコストに直結します。必須のサブジェクト(sub)、発行者(iss)、受信者(aud)、有効期限(exp)に加え、メールやアプリロールなど最小限のクレームに絞るのが基本です。細かな権限や所属はAPIコール時にGraphで取得する方が、長期の変更や監査に柔軟に対応できます。クレームを整理するだけで初回ログインの往復が数百ミリ秒程度短くなるケースは少なくありません。

実装編:主要スタックでのEntra ID SSO完全例

ここからは主要スタックでの実装を、すべてインポートを含む完全コードとして提示します。設定値は環境変数で受け取り、例外処理とセキュアなCookie設定を必ず含めます。キーワードとなるポイントは「OIDCの正しいフロー」「state・nonce・PKCEの検証」「セッション保護」です。

Node.js(Express + @azure/msal-node)

import express from 'express';
import session from 'express-session';
import crypto from 'crypto';
import { ConfidentialClientApplication, Configuration, AuthorizationUrlRequest, AuthorizationCodeRequest } from '@azure/msal-node';

const app = express();
app.use(session({
  secret: process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'),
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 1000 }
}));

const config: Configuration = {
  auth: {
    clientId: process.env.AZURE_CLIENT_ID!,
    authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/v2.0`,
    clientSecret: process.env.AZURE_CLIENT_SECRET!
  },
  system: { loggerOptions: { loggerCallback: () => {} } }
};

const msalApp = new ConfidentialClientApplication(config);

app.get('/login', async (req, res) => {
  try {
    const state = crypto.randomBytes(16).toString('hex');
    const authUrlParams: AuthorizationUrlRequest = {
      scopes: ['openid', 'profile', 'email'],
      redirectUri: process.env.REDIRECT_URI!,
      state,
      prompt: 'select_account'
    };
    (req.session as any).authState = state;
    const authUrl = await msalApp.getAuthCodeUrl(authUrlParams);
    res.redirect(authUrl);
  } catch (e) {
    console.error('Auth URL error', e);
    res.status(500).send('Authentication initialization failed');
  }
});

app.get('/redirect', async (req, res) => {
  try {
    const { code, state } = req.query as any;
    if (!code || state !== (req.session as any).authState) throw new Error('Invalid state or code');
    const tokenRequest: AuthorizationCodeRequest = {
      code,
      scopes: ['openid', 'profile', 'email'],
      redirectUri: process.env.REDIRECT_URI!
    };
    const result = await msalApp.acquireTokenByCode(tokenRequest);
    (req.session as any).account = result.account;
    (req.session as any).idToken = result.idToken;
    (req.session as any).accessToken = result.accessToken;
    res.redirect('/');
  } catch (e) {
    console.error('Callback error', e);
    res.status(401).send('Authentication failed');
  }
});

app.get('/logout', async (req, res) => {
  const logoutUrl = `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/oauth2/v2.0/logout?post_logout_redirect_uri=${encodeURIComponent(process.env.POST_LOGOUT_REDIRECT_URI!)}`;
  req.session.destroy(() => res.redirect(logoutUrl));
});

app.get('/me', (req, res) => {
  if (!(req.session as any).idToken) return res.status(401).send('Unauthorized');
  res.json({
    sub: (req.session as any).account?.homeAccountId,
    idToken: (req.session as any).idToken
  });
});

app.listen(3000, () => console.log('Server started on https://localhost:3000'));

この構成ではstate検証とセキュアCookieを有効化し、リダイレクトの例外処理を簡潔にまとめています。プロダクションではMSALのトークンキャッシュを外部化し¹¹、プロセス再起動やスケールアウトでもキャッシュのヒット率を確保します。

ASP.NET Core(Microsoft.Identity.Web)

using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Identity.Web;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApp(options =>
    {
        builder.Configuration.Bind("AzureAd", options);
        options.Events = new OpenIdConnectEvents
        {
            OnRemoteFailure = ctx => {
                Console.Error.WriteLine($"OIDC Error: {ctx.Failure?.Message}");
                ctx.Response.Redirect("/error");
                ctx.HandleResponse();
                return Task.CompletedTask;
            }
        };
    });

builder.Services.AddRazorPages();
var app = builder.Build();

app.UseHttpsRedirection();
app.UseCookiePolicy(new CookiePolicyOptions{ MinimumSameSitePolicy = SameSiteMode.Lax });
app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages().RequireAuthorization();
app.Run();

appsettings.jsonにはテナント、クライアントID、シークレット、コールバックURLを記載します。イベントハンドラで外部IdPエラーを捕捉し、ユーザーを安全にエラー画面へ誘導します。

Next.js(next-auth + Azure AD Provider)

import NextAuth, { NextAuthOptions } from 'next-auth';
import AzureADProvider from 'next-auth/providers/azure-ad';

export const authOptions: NextAuthOptions = {
  providers: [
    AzureADProvider({
      clientId: process.env.AZURE_CLIENT_ID!,
      clientSecret: process.env.AZURE_CLIENT_SECRET!,
      tenantId: process.env.AZURE_TENANT_ID!,
      authorization: {
        params: { scope: 'openid profile email' }
      }
    })
  ],
  callbacks: {
    async jwt({ token, account }) {
      if (account) token.accessToken = account.access_token;
      return token;
    },
    async session({ session, token }) {
      (session as any).accessToken = token.accessToken;
      return session;
    }
  },
  session: { strategy: 'jwt', maxAge: 60 * 60 },
  cookies: { sessionToken: { options: { httpOnly: true, sameSite: 'lax', secure: true } } }
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

NextAuthはOIDCのボイラープレートを吸収しつつ、セッションをJWTで持てます。アクセストークンの寿命管理と再発行はAPI側の検証ロジックと整合させます。

Python FastAPI(Authlib)

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import RedirectResponse
from authlib.integrations.starlette_client import OAuth
import os

app = FastAPI()
oauth = OAuth()
authority = f"https://login.microsoftonline.com/{os.getenv('AZURE_TENANT_ID')}/v2.0"

oauth.register(
    name='azure',
    server_metadata_url=f"{authority}/.well-known/openid-configuration",
    client_id=os.getenv('AZURE_CLIENT_ID'),
    client_secret=os.getenv('AZURE_CLIENT_SECRET'),
    client_kwargs={'scope': 'openid profile email'}
)

@app.get('/login')
async def login(request: Request):
    redirect_uri = os.getenv('REDIRECT_URI')
    return await oauth.azure.authorize_redirect(request, redirect_uri)

@app.get('/callback')
async def callback(request: Request):
    try:
        token = await oauth.azure.authorize_access_token(request)
        user = token.get('userinfo')
        if not user:
            raise HTTPException(status_code=401, detail='No userinfo')
        request.session['user'] = dict(user)
        return RedirectResponse('/')
    except Exception as e:
        print('OIDC error', e)
        raise HTTPException(status_code=401, detail='Authentication failed')

AuthlibはOpenID Providerのメタデータから設定を自動発見できるため、Entra IDとの相性が良好です。例外はHTTPExceptionに正規化し、監査ログ出力も忘れないようにします。

Nginx + oauth2-proxy(レガシーWebの前段SSO)

# oauth2-proxy.cfg
provider = "azure"
upstream = "http://127.0.0.1:8080"
http_address = ":4180"
email_domains = ["*"]
redirect_url = "https://app.example.com/oauth2/callback"
client_id = "<client_id>"
client_secret = "<client_secret>"
azure_tenant = "<tenant_id>"
cookie_secure = true
cookie_samesite = "lax"
scope = "openid profile email"
# nginx.conf (抜粋)
location /oauth2/ {
  proxy_pass       http://127.0.0.1:4180;
  proxy_set_header Host $host;
}
location / {
  auth_request /oauth2/auth;
  error_page 401 = /oauth2/start;
  proxy_pass http://legacy_app;
}

アプリを改修できない場合でも、oauth2-proxyを前段に置けば短期間でEntra ID連携が可能です。クッキー属性を堅牢にし、ヘッダー経由で下流アプリにユーザー識別子を安全に渡します。

Java Spring Boot(spring-security-oauth2-client)

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
# application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          azure:
            client-id: ${AZURE_CLIENT_ID}
            client-secret: ${AZURE_CLIENT_SECRET}
            scope: openid,profile,email
            provider: azure
        provider:
          azure:
            issuer-uri: https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0
// SecurityConfig.java
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.context.annotation.Bean;

@Configuration
public class SecurityConfig {
  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
      .authorizeHttpRequests(auth -> auth
        .requestMatchers("/public/**").permitAll()
        .anyRequest().authenticated())
      .oauth2Login(c -> c.failureHandler((req, res, ex) -> {
        ex.printStackTrace();
        res.sendRedirect("/error");
      }))
      .logout(l -> l.logoutSuccessUrl("/"));
    return http.build();
  }
}

issuer-uriでv2.0エンドポイントを指定し、OpenIDメタデータに追随させます。例外はfailureHandlerで捕捉し、ユーザー体験を損なわない導線に整えます。

運用の勘所:Graph連携、トークンキャッシュ、パフォーマンス検証

権限はトークンに詰め込み過ぎず、必要時にGraphから補完します。アプリロールで粗い境界を切り、細かな所属や属性はAPIコールで照会する方が、変更に強く、監査でも説明しやすくなります。以下はNode.jsでOn-Behalf-Of(OBO:ユーザーの代わりにAPIが別のAPIを呼ぶ)によりGraphを呼ぶ例です。

OBOでMicrosoft Graphからグループ情報を取得

import { ConfidentialClientApplication } from '@azure/msal-node';
import fetch from 'node-fetch';

const cca = new ConfidentialClientApplication({
  auth: {
    clientId: process.env.AZURE_CLIENT_ID!,
    authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/v2.0`,
    clientSecret: process.env.AZURE_CLIENT_SECRET!
  }
});

export async function getGroupsOnBehalfOf(userAccessToken: string) {
  const obo = await cca.acquireTokenOnBehalfOf({
    oboAssertion: userAccessToken,
    scopes: ['https://graph.microsoft.com/.default']
  });
  const resp = await fetch('https://graph.microsoft.com/v1.0/me/memberOf', {
    headers: { Authorization: `Bearer ${obo.accessToken}` }
  });
  if (!resp.ok) throw new Error(`Graph error: ${resp.status}`);
  return resp.json();
}

Graph呼び出しは失敗時のリトライとレート制限(HTTP 429)へのバックオフを実装し¹²、サーキットブレーカーで下流障害を隔離します。スコープはアプリ権限ではなく委任権限で最小限に絞ると、監査とリスク低減に有利です⁶。

MSALトークンキャッシュのRedis外部化

import { ConfidentialClientApplication, TokenCacheContext } from '@azure/msal-node';
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const msal = new ConfidentialClientApplication({
  auth: { clientId: process.env.AZURE_CLIENT_ID!, authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/v2.0`, clientSecret: process.env.AZURE_CLIENT_SECRET! },
  cache: {
    cachePlugin: {
      beforeCacheAccess: async (ctx: TokenCacheContext) => {
        const data = await redis.get('msal_cache');
        if (data) ctx.tokenCache.deserialize(data);
      },
      afterCacheAccess: async (ctx: TokenCacheContext) => {
        if (ctx.cacheHasChanged) await redis.set('msal_cache', ctx.tokenCache.serialize(), { EX: 300 });
      }
    }
  }
});

プロセス外キャッシュにより再認証の往復を抑え、ピーク時のIdP負荷も軽減できます。キャッシュのTTLはトークン有効期限より短めに設定し、失効後の再発行でエラーが顕在化しないようにします¹¹。

実測に基づくパフォーマンス目安と改善ポイント

一般的な検証環境では、初回のインタラクティブログインはIdP往復とクレーム発行を含めておおむね1秒前後に収まり、その後のサイレントリフレッシュは数百ミリ秒未満で安定することが多い、という傾向が見られます。IDトークンのクレーム最小化とMSALキャッシュの外部化により、スループットが数十%程度改善するケースもあります。TLS再利用とHTTP/2の有効化、Cookieサイズ削減、静的アセットのCDN配信は体感向上に寄与します。測定はApplication Insights等の分散トレーシングとk6の負荷試験を併用し、サインインパスのP95を継続監視対象に置くと、改善余地を取りこぼしにくくなります。

ガバナンスとビジネス価値:ROIを語れる実装へ

SSOは体験向上だけでなく、運用コストに直結します。ユーザーは覚えるパスワードが減り、MFAを強制してもログイン体験は一貫します。開発側は認証をEntra IDに委譲し、アプリは認可とドメインロジックに集中できます。監査ログはEntra IDのサインインログや条件付きアクセスレポートに統合され、対応時間の短縮が期待できます。ヘルプデスクのパスワード関連チケットが減ると、1件あたりの対応コスト削減が積み上がり、投資対効果(ROI)の説明がしやすくなります。

現実的な移行順序は、外部SaaSを優先してEntra IDのギャラリー連携で束ね、自社アプリはOIDCで段階的に置き換える流れです。レガシーは前段にoauth2-proxyを立てて早期にカットオーバーし、バックエンドのOBOで権限分解を進めれば、利用者体験を崩さず技術負債を返済できます。新規開発では、アプリロール設計とスコープ命名、クレーム最小化、メトリクス定義(P95サインイン時間、トークンキャッシュヒット率、失敗率)を初期のDone条件に含めると、リリース後の手戻りを抑制できます。

ゼロトラストの観点では、ネットワークの信頼からユーザー・デバイス・アプリ・データの信頼に軸足を移し² ¹⁰、条件付きアクセスとデバイス準拠を活用します。Entra IDのPIMで特権昇格を一時的にし、運用チャネルはJust-In-Timeに限定することで、SSOの利便性と権限の最小化を両立できます。最終的に、SSOはセキュリティ投資の中心線として、監査・統制・生産性の三方良しを実現する基盤になります。

まとめ:今日から進めるEntra ID SSOの第一歩

要件が複雑に見えても、実装はシンプルに始められます。まずはOIDCで最小のクレームセットを定義し、MSALやMicrosoft.Identity.Webといった信頼できるライブラリでstate・nonce・PKCEの検証を確実に通します⁴。次にトークンキャッシュを外部化して再認証の往復を抑え¹¹、条件付きアクセスとMFAを組み合わせてセキュリティを底上げします¹⁰。グループやロールの設計をアプリロール中心に据えると⁷、機能追加や組織変更にも強くなります。あなたの組織では、どのアプリからSSOの恩恵が最も大きいでしょうか。最優先の対象を一つ選び、ここで示したコードをそのまま動かし、小さく計測しながら拡張していくことが、最短で価値に結びつく進め方です。

参考文献

  1. Microsoft Security Blog. One simple action you can take to prevent 99.9 percent of account attacks. https://www.microsoft.com/en-us/security/blog/2019/08/20/one-simple-action-you-can-take-to-prevent-99-9-percent-of-account-attacks/
  2. Microsoft Learn. Zero Trust overview. https://learn.microsoft.com/en-us/security/zero-trust/zero-trust-overview
  3. Microsoft Learn. Microsoft identity platform and OpenID Connect protocol. https://learn.microsoft.com/ja-jp/entra/identity-platform/v2-protocols-oidc
  4. Microsoft Learn. OAuth 2.0 authorization code flow in Microsoft identity platform. https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow
  5. Microsoft Learn. Single sign-on SAML protocol for enterprise apps. https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/saml-sso-overview
  6. Microsoft Learn. Permissions and consent in the Microsoft identity platform. https://learn.microsoft.com/en-us/entra/identity-platform/permissions-consent-overview
  7. Microsoft Learn. How to add app roles to your application and receive them in the token. https://learn.microsoft.com/ja-jp/entra/identity-platform/howto-add-app-roles-in-apps
  8. Microsoft Learn. Get a signed-in user’s groups in an access token (group overage and hasgroups). https://learn.microsoft.com/ja-jp/troubleshoot/entra/entra-id/app-integration/get-signed-in-users-groups-in-access-token
  9. Microsoft Graph docs. List memberOf. https://learn.microsoft.com/en-us/graph/api/user-list-memberof
  10. Microsoft Learn. What is Conditional Access? https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview
  11. Microsoft Learn. MSAL Node cache best practices. https://learn.microsoft.com/en-us/entra/msal/javascript/node/caching
  12. Microsoft Graph docs. Throttling guidance. https://learn.microsoft.com/en-us/graph/throttling