本記事記述および実装内容は、多くの部分をAIで処理しており、人間チェックは甘いです
はじめに
「Microsoft Teams のタブから、自前の Web アプリ(Azure App Service)を呼び出したい。しかも Teams のサインインユーザー(Entra ID)と連携したい」——よくある要望ですが、実際にやってみると App Service Easy Auth の落とし穴 と Teams manifest の地雷 で何度か足を取られます。
この記事では、Python Streamlit のダミーチャット(LLM なし)を Azure App Service にホストし、Easy Auth (Microsoft Entra ID) で保護した上で、Teams の Personal Tab から SSO 込みで呼び出すまでを、ハマりどころ中心にまとめます。
対象読者は次のような方です。
- Teams タブに自前の Web アプリ(App Service)を載せたい
- App Service Easy Auth (Entra) と Teams を連携させたい
- Streamlit を iframe で Teams に埋め込めるのか知りたい
- 「Teams 経由だけ許可、直リンクは禁止」ができるのか気になる
結論
- ✅ Streamlit を App Service (Linux/B1) にホストし、Easy Auth (Entra) で保護 → Teams Personal Tab / 標準ブラウザの双方からログイン成功。
- ✅ アプリ側は認証コードを書かず、Easy Auth が注入する
X-MS-CLIENT-PRINCIPAL-*ヘッダを読むだけでログインユーザーを表示できる。 - ⚠️ 最大のハマり: ログイン後に
/.auth/login/aad/callbackが 401 になる。原因は Entra アプリ登録の 「IDトークン発行」が無効だったこと(Easy Auth はcode+id_tokenハイブリッドフローを使うため必須)。 - ⚠️ Teams への zip アップロードで「マニフェスト解析エラー」。原因は manifest v1.17 で廃止済みの
packageNameが残っていたこと。 - ℹ️ 「Teams 経由だけ許可、直アクセス禁止」は Easy Auth 単体では不可。やるなら Teams SSO トークン検証が必要(後述の3手法比較)。
アーキテクチャ
アプリ本体(Streamlit)
ポイントは、アプリ自身は認証ロジックを持たないことです。トークン検証もログイン画面も Easy Auth が前段で担当し、アプリは「認証済み前提でヘッダを読むだけ」。Streamlit 1.37+ の st.context.headers でリクエストヘッダにアクセスできます。
import base64
import json
import streamlit as st
def get_identity():
"""Easy Auth が注入するヘッダから Entra ユーザーを取得する。"""
headers = dict(st.context.headers or {})
name = headers.get("X-MS-CLIENT-PRINCIPAL-NAME") # UPN
claims = {}
principal_b64 = headers.get("X-MS-CLIENT-PRINCIPAL") # base64(JSON)
if principal_b64:
decoded = json.loads(base64.b64decode(principal_b64).decode("utf-8"))
for c in decoded.get("claims", []):
typ = c.get("typ", "")
short = typ.rsplit("/", 1)[-1] if "/" in typ else typ
claims[short] = c.get("val", "")
return name, claims
def dummy_reply(text: str) -> str:
"""LLM を使わないダミー応答(エコー)。"""
return f"受け取りました: 「{text}」({len(text)} 文字)"
user_name, claims = get_identity()
with st.sidebar:
if user_name:
st.success(f"ログイン中: {user_name}")
st.json(claims or {"info": "claims なし"})
st.title("💬 ダミーチャット")
if "messages" not in st.session_state:
st.session_state.messages = []
for m in st.session_state.messages:
with st.chat_message(m["role"]):
st.markdown(m["content"])
if prompt := st.chat_input("メッセージを入力..."):
st.session_state.messages.append({"role": "user", "content": prompt})
st.session_state.messages.append({"role": "assistant", "content": dummy_reply(prompt)})
st.rerun()
App Service Linux では起動コマンドを指定します(既定ポートは 8000、iframe 用に CORS/XSRF を緩和、Streamlit の WebSocket を有効化)。
python -m streamlit run app.py --server.port 8000 --server.address 0.0.0.0 \
--server.headless true --server.enableCORS false \
--server.enableXsrfProtection false --browser.gatherUsageStats false
インフラ(Bicep)の要点
App Service Plan(B1 Linux) + Web App + Easy Auth V2 を Bicep で構成します。Easy Auth は authsettingsV2 で Entra プロバイダを設定します。
resource authSettings 'Microsoft.Web/sites/config@2024-04-01' = {
parent: site
name: 'authsettingsV2'
properties: {
platform: { enabled: true }
globalValidation: {
requireAuthentication: true
unauthenticatedClientAction: 'RedirectToLoginPage'
redirectToProvider: 'azureactivedirectory'
}
identityProviders: {
azureActiveDirectory: {
enabled: true
registration: {
openIdIssuer: '${environment().authentication.loginEndpoint}<tenant-id>/v2.0'
clientId: '<client-id>'
clientSecretSettingName: 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET'
}
validation: {
allowedAudiences: [ 'api://<client-id>', '<client-id>' ]
}
}
}
login: { tokenStore: { enabled: true } }
}
}
Entra App Registration は Bicep では作れません。 azd の preprovision / postprovision フックで az ad app + Microsoft Graph PATCH を使って構成します(scope access_as_user、Teams クライアントの事前承認、リダイレクト URI など)。
ハマったポイントと解決策
1. ログイン後 callback が 401(最重要)
Entra ログイン画面までは出るのに、戻ってくると /.auth/login/aad/callback が 401。標準ブラウザでも再現したので iframe の問題ではありませんでした。
原因: App Service Easy Auth の既定フローは response_type=code+id_token(OpenID Connect ハイブリッド)。IDトークンの発行が必須ですが、Entra アプリ登録の enableIdTokenIssuance が false だったため、認可後のコールバックが拒否されていました。
解決は、アプリ登録で IDトークン発行を有効化するだけです。
az rest --method PATCH \
--uri "https://graph.microsoft.com/v1.0/applications/<app-object-id>" \
--headers "Content-Type=application/json" \
--body '{"web":{"implicitGrantSettings":{"enableIdTokenIssuance":true,"enableAccessTokenIssuance":false}}}'
Portal では Entra ID → アプリの登録 → 認証 → 「暗黙的な許可およびハイブリッド フロー」→ 「ID トークン」にチェック、の状態です。
Graph の正しいプロパティ名は implicitGrantSettings です。implicitGrant という名前は無効で PATCH が拒否されます(これも一度踏みました)。
2. Teams への zip アップロードで「マニフェスト解析エラー」
zip をアップロードすると「マニフェスト解析エラー メッセージが利用できません」とだけ表示され、原因が分かりません。
原因: Teams manifest schema v1.17 は additionalProperties: false。旧バージョンで使っていた packageName プロパティが余分として弾かれていました。公式スキーマで事前検証すると一発で分かります。
import json
from jsonschema import Draft7Validator
schema = json.load(open("teams-1.17.schema.json")) # 公式 v1.17 schema
inst = json.load(open("manifest.json"))
for e in Draft7Validator(schema).iter_errors(inst):
print("NG:", list(e.path), "->", e.message)
# → Additional properties are not allowed ('packageName' was unexpected)
packageName を削除して解決しました。Teams タブ用の manifest 抜粋は以下です。
{
"manifestVersion": "1.17",
"id": "<client-id>",
"staticTabs": [
{
"entityId": "chat",
"name": "Chat",
"contentUrl": "https://<app-name>.azurewebsites.net/",
"scopes": ["personal"]
}
],
"validDomains": ["<app-name>.azurewebsites.net"],
"webApplicationInfo": {
"id": "<client-id>",
"resource": "api://<client-id>"
}
}
3. その他の小さな罠
az / azd 周りで踏んだ細かいハマり
-
azd env get-value <key>はキー未存在時にエラー文を stdout に出す。VAR=$(azd env get-value K || echo "")だとエラー文を変数に取り込むので、GUID 形式チェックで妥当性を見るのが安全。 -
Microsoft Graph の application PATCH は、scope と
preAuthorizedApplicationsを同一 PATCH に含めるとPermission Id cannot be foundになる。scope を先に PATCH → preAuthorized を別 PATCH の2段階で解決。 -
az webapp auth update(V1系)は V2 構成済みアプリに対して Bad Request。V2 はauthsettingsV2の REST で操作する。 -
curl で
/が 401:Accept: text/htmlの無い API 形式リクエストは Easy Auth が 401 を返す。ブラウザ(Accept: text/html)では正常に 302。仕様です。 -
B1 のクォータ不足: あるリージョンで B1 の VM クォータが 0 で provision 失敗。
az appservice plan create --sku B1の実投入で判定し、別リージョンにフォールバック。ARM のdeployment validateはクォータを検出しません。
検証結果
| シナリオ | 結果 |
|---|---|
| インフラ provision(B1 Linux + Easy Auth V2) | ✅ |
| 未認証アクセス → Entra ログインへ 302 | ✅ |
| Teams / 標準ブラウザでログイン&ユーザー表示 | ✅ |
| Streamlit ダミーチャット動作(LLM 不使用) | ✅ |
| Entra App 登録(scope / 事前承認 / redirect / id_token) | ✅ |
| Teams manifest パッケージ生成・サイドロード | ✅ |
懸念していた iframe のサードパーティ Cookie 制約(Teams Web タブで Easy Auth の Cookie がブロックされ得る問題)は、この環境では顕在化せず、Teams Web タブでもそのままログインできました。
おまけ: 「Teams 経由だけ許可、直アクセス禁止」はできる?
「ブラウザで直接 URL を開いても使えないようにしたい」という要望。結論、Easy Auth 単体では不可です(Teams 経由でも直アクセスでも同じ Entra ユーザーが同じようにログインするため区別できない)。X-Frame-Options / CSP frame-ancestors は「どこに埋め込みを許すか」の制御で、方向が逆なので効きません。
実現方法は強度別に3つあります。
| 観点 | A: HTTPヘッダ判定 | B: Teams JS SDK 判定 | C: Teams SSO トークン検証 |
|---|---|---|---|
| 仕組み |
Sec-Fetch-Dest/Referer で推測 |
microsoftTeams.app.initialize() の成否 |
getAuthToken() のトークンの azp=Teams を検証 |
| 強度 | 弱(偽装可) | 中 | 強(本物の境界) |
| 実装コスト | 小 | 中 | 大 |
| Streamlit 相性 | △(WS再実行で不安定) | △(ラッパー要) | ✕〜△(別フロント推奨) |
| Easy Auth との関係 | 併用可 | 併用可 | 要切替 |
確実に直アクセスを禁止したいなら C(Teams SSO トークン検証)一択です。getAuthToken() は Teams の中でしか取得できず、トークンの azp(呼び出し元クライアント)が Teams クライアント ID になります。これをサーバ検証すれば「Teams 経由」だけを通せます。ただし Easy Auth の Cookie 方式からトークン方式への切替が必要で、Streamlit では薄い認証フロント+検証 API を別に置く設計が現実的です。
まとめ
- Streamlit を App Service に載せ、Easy Auth (Entra) で保護して Teams タブから呼ぶ、は 十分実用的。アプリは Easy Auth ヘッダを読むだけでユーザー連携できる。
- 一番ハマるのは
enableIdTokenIssuance。Easy Auth を使うなら Entra アプリ登録で IDトークン発行を ON にする、を覚えておくと丸ごと一日節約できます。 - Teams manifest は 新しい schema の
additionalProperties:falseに注意。余分プロパティ(packageName等)は削除し、公式スキーマで事前検証。 - 「Teams 経由のみ許可」はセキュリティ要件なら Teams SSO トークン検証(方法C)まで踏み込む必要がある。
参考リンク
