エンタープライズ導入を加速する、SaaSプロダクトのSSO/SAML認証実装ガイド
はじめに
SaaSプロダクトをエンタープライズ企業に提案する際、必ずと言っていいほど要件として挙がるのが「SSO(シングルサインオン)対応」です。特に従業員数が100名を超える企業では、社内の認証基盤(Active Directory、Okta、Azure ADなど)と連携し、一元的なアカウント管理を行うことが標準的な要件となっています。
しかし、SSO/SAML認証の実装は、通常のID/パスワード認証とは大きく異なる仕組みであり、セキュリティ要件も厳格です。実装を誤ると、セキュリティホールやユーザビリティの低下を招く可能性があります。
本記事では、SaaSプロダクトにSSO/SAML認証を実装する際の設計パターン、実装手順、そして運用上の注意点を、私たちの実践経験をもとに解説します。
SSO/SAML認証とは?
SSO(Single Sign-On)は、一度の認証で複数のアプリケーションやサービスにアクセスできる仕組みです。SAML(Security Assertion Markup Language)は、SSO を実現するための標準プロトコルの一つで、特にエンタープライズ環境で広く採用されています。
SAML認証では、以下の3つの主要な役割が存在します:
- SP(Service Provider): 認証を必要とするサービス(あなたのSaaSプロダクト)
- IdP(Identity Provider): 認証を提供するサービス(Okta、Azure AD、Google Workspaceなど)
- User: エンドユーザー
基本的な認証フローは次のようになります:
- ユーザーがSP(あなたのSaaS)にアクセス
- SPがIdPに認証要求(SAML Request)を送信
- ユーザーがIdPで認証
- IdPがSPに認証結果(SAML Response)を返送
- SPが認証結果を検証し、セッションを確立
まとめ
SSO/SAML認証の実装における重要なポイントを以下にまとめます:
- エンタープライズSaaS導入の必須要件として、SSO/SAML対応は避けて通れない
- SAML認証フローの理解と、セキュリティ要件(署名検証、暗号化、タイムスタンプ検証)の徹底が重要
- マルチテナント環境では、テナントごとのIdP設定管理とドメイン検証が必須
- プロビジョニング(SCIM)とセッション管理の適切な設計が運用効率を左右する
- 実装にはライブラリの活用が推奨されるが、セキュリティパラメータの理解は不可欠
SSO/SAML認証の実装アーキテクチャ
マルチテナント環境における設計パターン
SaaSプロダクトでは、複数の企業(テナント)が同一のアプリケーションを利用します。このマルチテナント環境において、SSO/SAML認証を実装する際には、テナントごとに異なるIdP設定を管理する必要があります。
以下は、典型的なマルチテナントSaaSにおけるSSO設定テーブルの設計例です:
| カラム名 | 型 | 説明 |
|---|---|---|
| tenant_id | UUID | テナント識別子 |
| idp_entity_id | VARCHAR | IdPのエンティティID |
| idp_sso_url | VARCHAR | IdPのSSO URL |
| idp_certificate | TEXT | IdP公開鍵証明書 |
| sp_entity_id | VARCHAR | SP(自社サービス)のエンティティID |
| acs_url | VARCHAR | Assertion Consumer Service URL |
| name_id_format | VARCHAR | NameIDフォーマット(email、persistentなど) |
| attribute_mapping | JSON | SAMLアトリビュートとユーザー情報のマッピング |
| is_enabled | BOOLEAN | SSO有効フラグ |
| created_at | TIMESTAMP | 作成日時 |
| updated_at | TIMESTAMP | 更新日時 |

ドメイン検証とテナント識別
ユーザーがログイン画面でメールアドレスを入力した際に、そのドメイン(例:user@acme-corp.com の acme-corp.com)から適切なテナントとIdPを識別する必要があります。
この実装パターンとして、以下の方法が一般的です:
パターン1: ドメインベース識別
// ドメインからテナント設定を取得
async function getTenantByEmail(email: string): Promise<TenantSSOConfig | null> {
const domain = email.split('@')[1];
const tenant = await db.query(
'SELECT * FROM tenant_sso_configs WHERE verified_domains @> $1 AND is_enabled = true',
[JSON.stringify([domain])]
);
return tenant.rows[0] || null;
}
パターン2: テナント別サブドメイン
// acme-corp.yourapp.com のようなサブドメインからテナントを識別
function getTenantFromSubdomain(host: string): string | null {
const subdomain = host.split('.')[0];
return subdomain !== 'www' ? subdomain : null;
}
セキュリティ要件の実装
SAML認証において、セキュリティは最重要項目です。以下の要件を必ず実装してください:
1. SAML Responseの署名検証
IdPから受け取ったSAML Responseが改ざんされていないことを確認するため、デジタル署名の検証が必須です。
import * as saml from 'samlify';
const sp = saml.ServiceProvider({
entityID: 'https://yourapp.com',
assertionConsumerService: [{
Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
Location: 'https://yourapp.com/auth/saml/acs',
}],
});
const idp = saml.IdentityProvider({
entityID: config.idp_entity_id,
singleSignOnService: [{
Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
Location: config.idp_sso_url,
}],
x509Certificate: config.idp_certificate,
});
// SAML Responseを検証
try {
const result = await sp.parseLoginResponse(idp, 'post', {
body: req.body,
});
// 検証成功 - ユーザー情報を取得
const userEmail = result.extract.nameID;
const attributes = result.extract.attributes;
} catch (error) {
// 署名検証失敗
console.error('SAML validation failed:', error);
throw new Error('Invalid SAML response');
}
2. タイムスタンプの検証
SAML Responseには有効期限が設定されており、リプレイアタックを防ぐため、必ずタイムスタンプを検証します。
const samlConfig = {
// デフォルトは5分以内
acceptedClockSkewMs: 300000,
// SAML Responseの有効期限を検証
validateInResponseTo: true,
// NotBefore と NotOnOrAfter を厳密にチェック
strictTimeCheck: true,
};
3. InResponseToの検証
SAML RequestとResponseの紐付けを確認するため、InResponseToフィールドを検証します。これにより、未承認のSAML Responseの受け入れを防ぎます。
// SAML Request生成時にIDを保存
const requestId = generateUniqueId();
await redis.set(`saml:request:${requestId}`, userId, 'EX', 300);
// SAML Response検証時に確認
const responseRequestId = result.extract.inResponseTo;
const storedUserId = await redis.get(`saml:request:${responseRequestId}`);
if (!storedUserId) {
throw new Error('Invalid InResponseTo - request ID not found');
}
実装ステップ
Step 1: ライブラリの選定と初期設定
SAML認証を一から実装するのは複雑で危険です。実績のあるライブラリを使用することを強く推奨します。
Node.js / TypeScript の場合
samlify: 柔軟で設定が豊富passport-saml: Passport.js統合
Python の場合
python-saml3: OneLogin提供の標準ライブラリ
以下は、Express.js + samlifyの実装例です:
import express from 'express';
import * as saml from 'samlify';
import session from 'express-session';
const app = express();
// セッション設定(必須)
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true, // HTTPS必須
maxAge: 24 * 60 * 60 * 1000, // 24時間
},
}));
// SP(Service Provider)の設定
const sp = saml.ServiceProvider({
entityID: 'https://yourapp.com',
assertionConsumerService: [{
Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
Location: 'https://yourapp.com/auth/saml/acs',
}],
singleLogoutService: [{
Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
Location: 'https://yourapp.com/auth/saml/sls',
}],
privateKey: fs.readFileSync('./private-key.pem'),
privateKeyPass: process.env.SP_KEY_PASSWORD,
isAssertionEncrypted: true, // アサーションの暗号化を要求
wantMessageSigned: true, // メッセージ署名を要求
});
Step 2: ログインフローの実装
ユーザーがSSO経由でログインする際のフローを実装します。
// SSO ログイン開始エンドポイント
app.get('/auth/saml/login', async (req, res) => {
const email = req.query.email as string;
// メールアドレスからテナント設定を取得
const tenantConfig = await getTenantByEmail(email);
if (!tenantConfig || !tenantConfig.is_enabled) {
return res.status(400).json({ error: 'SSO not configured for this domain' });
}
// IdP設定を動的に生成
const idp = saml.IdentityProvider({
entityID: tenantConfig.idp_entity_id,
singleSignOnService: [{
Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
Location: tenantConfig.idp_sso_url,
}],
x509Certificate: tenantConfig.idp_certificate,
});
// SAML Requestを生成してIdPにリダイレクト
const { context } = sp.createLoginRequest(idp, 'redirect');
// Request IDを保存(InResponseTo検証用)
req.session.samlRequestId = context.id;
req.session.tenantId = tenantConfig.tenant_id;
return res.redirect(context);
});
// Assertion Consumer Service (ACS) エンドポイント
app.post('/auth/saml/acs', async (req, res) => {
const tenantId = req.session.tenantId;
const samlRequestId = req.session.samlRequestId;
if (!tenantId || !samlRequestId) {
return res.status(400).json({ error: 'Invalid session' });
}
const tenantConfig = await getTenantById(tenantId);
const idp = createIdP(tenantConfig);
try {
// SAML Responseを検証
const result = await sp.parseLoginResponse(idp, 'post', {
body: req.body,
});
// InResponseToの検証
if (result.extract.inResponseTo !== samlRequestId) {
throw new Error('InResponseTo mismatch');
}
// ユーザー情報の取得
const userEmail = result.extract.nameID;
const attributes = result.extract.attributes;
// ユーザーの作成または更新
const user = await findOrCreateUser({
email: userEmail,
firstName: attributes.firstName?.[0],
lastName: attributes.lastName?.[0],
tenantId,
});
// セッション確立
req.session.userId = user.id;
req.session.tenantId = tenantId;
return res.redirect('/dashboard');
} catch (error) {
console.error('SAML authentication failed:', error);
return res.status(401).json({ error: 'Authentication failed' });
}
});
Step 3: メタデータの提供
IdP側でSP(あなたのサービス)を登録する際、SP Metadataが必要です。SAMLライブラリを使ってメタデータを生成し、エンドポイントで公開します。
app.get('/auth/saml/metadata', (req, res) => {
res.type('application/xml');
res.send(sp.getMetadata());
});
このエンドポイントのURLを顧客に提供し、IdP側(Okta、Azure ADなど)で登録してもらいます。
プロビジョニング(SCIM)の実装
エンタープライズ企業では、社員の入退社に伴うアカウントの自動作成・削除が求められます。これを実現するのがSCIM(System for Cross-domain Identity Management)プロトコルです。
SCIM APIの実装例
import express from 'express';
const scimRouter = express.Router();
// SCIM認証ミドルウェア(Bearer Token)
scimRouter.use((req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token || !validateSCIMToken(token)) {
return res.status(401).json({
schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
status: '401',
detail: 'Invalid authentication',
});
}
next();
});
// ユーザー作成
scimRouter.post('/Users', async (req, res) => {
const { userName, name, emails, active } = req.body;
try {
const user = await createUser({
email: userName,
firstName: name.givenName,
lastName: name.familyName,
isActive: active,
tenantId: req.tenantId, // 認証から取得
});
return res.status(201).json({
schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
id: user.id,
userName: user.email,
name: {
givenName: user.firstName,
familyName: user.lastName,
},
emails: [{ value: user.email, primary: true }],
active: user.isActive,
meta: {
resourceType: 'User',
created: user.createdAt,
lastModified: user.updatedAt,
},
});
} catch (error) {
return res.status(400).json({
schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
status: '400',
detail: error.message,
});
}
});
// ユーザー更新
scimRouter.patch('/Users/:id', async (req, res) => {
const userId = req.params.id;
const { Operations } = req.body;
// SCIM PATCH操作の処理
for (const op of Operations) {
if (op.op === 'replace' && op.path === 'active') {
await updateUserStatus(userId, op.value);
}
}
const user = await getUserById(userId);
return res.json(formatSCIMUser(user));
});
// ユーザー削除
scimRouter.delete('/Users/:id', async (req, res) => {
await deleteUser(req.params.id);
return res.status(204).send();
});
app.use('/scim/v2', scimRouter);
セッション管理とシングルログアウト
セッション管理のベストプラクティス
SAML認証後のセッション管理は、通常の認証と同様に慎重に設計する必要があります。
推奨設定:
- セッションの有効期限: 8-24時間
- アイドルタイムアウト: 2時間
- セキュアクッキー:
httpOnly,secure,sameSite=strict
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 8 * 60 * 60 * 1000, // 8時間
},
store: new RedisStore({
client: redisClient,
prefix: 'sess:',
}),
}));
シングルログアウト(SLO)の実装
ユーザーがSaaS側でログアウトした際、IdP側にも通知し、他のSP(他のSaaSアプリ)のセッションも終了させる仕組みです。
// ログアウト開始
app.get('/auth/logout', async (req, res) => {
const tenantConfig = await getTenantById(req.session.tenantId);
const idp = createIdP(tenantConfig);
// SAML Logout Requestを生成
const { context } = sp.createLogoutRequest(idp, 'redirect', {
logoutNameID: req.session.userEmail,
});
// セッション破棄
req.session.destroy();
// IdPにリダイレクト
return res.redirect(context);
});
// Single Logout Service (SLS) エンドポイント
app.post('/auth/saml/sls', async (req, res) => {
const tenantConfig = await getTenantById(req.body.tenantId);
const idp = createIdP(tenantConfig);
try {
await sp.parseLogoutRequest(idp, 'post', req.body);
// セッション破棄処理
// ...
return res.redirect('/login');
} catch (error) {
console.error('SLO failed:', error);
return res.status(400).send('Logout failed');
}
});
運用上の注意点と課題
証明書の更新管理
IdPの公開鍵証明書には有効期限があります。証明書の更新を怠ると、ある日突然すべてのユーザーがログインできなくなる事態に陥ります。
対策:
- 証明書の有効期限を定期的に監視(30日前にアラート)
- 顧客向けの証明書更新手順ドキュメントを用意
- 複数の証明書をサポートし、ローリング更新を可能にする
// 証明書有効期限チェック(定期実行)
async function checkCertificateExpiry() {
const configs = await getAllTenantSSOConfigs();
for (const config of configs) {
const cert = new X509Certificate(config.idp_certificate);
const expiryDate = new Date(cert.validTo);
const daysUntilExpiry = Math.floor((expiryDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
if (daysUntilExpiry <= 30) {
await sendCertificateExpiryAlert(config.tenant_id, daysUntilExpiry);
}
}
}
エラーハンドリングとユーザー体験
SAML認証はIdPとの連携のため、様々なエラーが発生する可能性があります。
よくあるエラーと対処:
- 証明書の検証失敗: 証明書が更新されていない、または設定ミス
- タイムスタンプエラー: サーバー時刻のずれ(NTP同期を確認)
- InResponseToの不一致: セッションの有効期限切れ、または複数タブでのログイン試行
ユーザーには技術的な詳細を見せず、適切なエラーメッセージとサポート連絡先を表示します。
function handleSAMLError(error: Error): string {
if (error.message.includes('certificate')) {
return 'SSO設定に問題があります。管理者にお問い合わせください。';
} else if (error.message.includes('InResponseTo')) {
return 'セッションの有効期限が切れました。もう一度ログインしてください。';
} else {
return 'ログインに失敗しました。サポートにお問い合わせください。';
}
}
テスト環境の構築
本番環境で初めてSSO設定を行うのは危険です。開発・ステージング環境で十分にテストする必要があります。
推奨のテスト方法:
- SAMLトレーサー: ブラウザ拡張機能でSAML RequestとResponseを確認
- テスト用IdP: MockサービスやOktaの開発者アカウントを活用
- 自動テスト: SAML Responseのモックを使った統合テスト
よくある質問
SAMLとOAuthの違いは何ですか?
SAMLとOAuth 2.0は、どちらも認証・認可を扱うプロトコルですが、目的と用途が異なります。
- SAML: 主にエンタープライズ向けのSSO認証に使用。XMLベースで、ユーザー属性の詳細な受け渡しが可能。
- OAuth 2.0: 主にAPI認可に使用。JSONベースで、シンプルかつ軽量。消費者向けアプリケーションに適しています。
エンタープライズSaaSでは、両方をサポートすることが理想ですが、優先度としてはSAMLが高いと言えます。
JITプロビジョニングとSCIMの使い分けは?
JIT(Just-In-Time)プロビジョニングは、ユーザーが初めてSSOログインした際に、自動的にアカウントを作成する仕組みです。シンプルで実装が容易ですが、退職者のアカウント削除は手動で行う必要があります。
SCIMは、IdP側からプッシュ型でユーザーのライフサイクル(作成・更新・削除)を管理します。エンタープライズ企業では、退職者のアカウントを即座に無効化する要件があるため、SCIMの実装が推奨されます。
初期導入ではJITプロビジョニングから始め、顧客の要望に応じてSCIMを追加実装するのが現実的なアプローチです。
マルチテナント環境での証明書管理はどうすべきですか?
テナントごとに異なるIdPを使用する場合、証明書も個別に管理する必要があります。データベースに暗号化して保存し、アプリケーション起動時にメモリにキャッシュする設計が一般的です。
また、証明書のローテーション(更新)をサポートするため、旧証明書と新証明書の両方を一定期間並行して受け入れる「グレースピリオド」を設けることを推奨します。
// 複数証明書のサポート
const idpCertificates = [
config.idp_certificate_current,
config.idp_certificate_previous, // グレースピリオド用
].filter(Boolean);
for (const cert of idpCertificates) {
try {
await verifySAMLResponse(response, cert);
break; // 検証成功
} catch (error) {
continue; // 次の証明書で試行
}
}
IdP側の設定はどのように顧客に案内すればよいですか?
各IdP(Okta、Azure AD、Google Workspaceなど)ごとに、設定手順書を用意することを強く推奨します。以下の情報を含めてください:
- SP Metadata URL(または手動設定用のEntityID、ACS URL)
- アトリビュートマッピングの設定例
- SCIMエンドポイントとBearerトークン(SCIM対応の場合)
- 動作確認の手順
また、サポートチームがIdP側の画面を見ながら設定を支援できる体制を整えておくと、顧客の導入がスムーズに進みます。
おわりに
SSO/SAML認証の実装は、エンタープライズSaaS展開において避けて通れない技術要件です。セキュリティとユーザビリティの両立、マルチテナント環境での柔軟な設定管理、そして継続的な運用監視が成功の鍵となります。
本記事で紹介した実装パターンは、私たちがエンタープライズ顧客との実案件で得た知見をもとにしています。単なる技術実装だけでなく、証明書更新管理やエラーハンドリング、顧客サポートまで含めた包括的なアプローチが、実際のビジネスでは重要です。
私たちShineosでは、SaaSプロダクトのエンタープライズ対応支援、SSO/SAML認証の実装サポートを行っています。導入を検討されている方は、ぜひお気軽にご相談ください。
関連記事
- エンタープライズSaaSに必須な「堅牢なテナント分離」の設計パターンとセキュリティ実装
- マルチテナントSaaSアーキテクチャの設計指針 - スケーラブルなSaaS基盤の構築
- プロダクト開発におけるAPI設計のベストプラクティス - RESTful/GraphQLのデザインパターン