(参考資料) EcAuth 外部IdPプロキシAPI仕様書
Google・Apple・LINE 等の外部 IdP との連携設計。EcAuth がフェデレーションハブとして機能する仕組み。
概要
EcAuthは、クライアント(EC-CUBEプラグイン)に対して外部IdP(Google、Facebook等)のアクセストークンを直接露出させることなく、安全にユーザー情報を取得するためのプロキシAPIを提供します。
アーキテクチャ
graph TB
A[EC-CUBEクライアント] -->|EcAuthトークンのみ| B[EcAuth プロキシAPI]
B --> C[トークン管理DB]
C --> B
B -->|Googleトークン| D[Google API]
B -->|Facebookトークン| E[Facebook API]
B -->|LINEトークン| F[LINE API]
D --> B
E --> B
F --> B
B -->|統合されたプロファイル| A
API仕様
1. 外部プロファイル取得API
エンドポイント
GET /api/user/external-profiles
リクエストヘッダー
Authorization: Bearer {ecauth_access_token}
Content-Type: application/json
レスポンス
{
"profiles": [
{
"provider": "google",
"email": "user@gmail.com",
"name": "John Doe",
"picture": "https://lh3.googleusercontent.com/...",
"additional_info": {
"locale": "ja",
"verified_email": true
},
"last_updated": "2025-01-15T10:30:00Z"
},
{
"provider": "facebook",
"email": "user@facebook.com",
"name": "John Doe",
"picture": "https://graph.facebook.com/...",
"additional_info": {
"locale": "ja_JP"
},
"last_updated": "2025-01-15T10:30:00Z"
}
]
}ステータスコード
200 OK: 成功401 Unauthorized: 認証エラー404 Not Found: ユーザーが見つからない500 Internal Server Error: サーバーエラー
2. 特定プロバイダーのプロファイル取得API
エンドポイント
GET /api/user/external-profiles/{provider}
パスパラメータ
provider:google,facebook,line,yahoo-japan,apple,microsoftのいずれか
レスポンス
{
"provider": "google",
"email": "user@gmail.com",
"name": "John Doe",
"picture": "https://lh3.googleusercontent.com/...",
"additional_info": {
"locale": "ja",
"verified_email": true,
"given_name": "John",
"family_name": "Doe"
},
"last_updated": "2025-01-15T10:30:00Z"
}データベース設計
UserExternalTokensテーブル
CREATE TABLE UserExternalTokens (
Id INT IDENTITY(1,1) PRIMARY KEY,
UserId NVARCHAR(450) NOT NULL,
Provider NVARCHAR(50) NOT NULL,
AccessTokenEncrypted VARBINARY(MAX) NOT NULL,
RefreshTokenEncrypted VARBINARY(MAX),
ExpiresAt DATETIME2 NOT NULL,
CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
UpdatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
CONSTRAINT UK_User_Provider UNIQUE (UserId, Provider),
INDEX IX_UserId (UserId),
INDEX IX_ExpiresAt (ExpiresAt)
);ExternalProfileCacheテーブル(オプション)
CREATE TABLE ExternalProfileCache (
Id INT IDENTITY(1,1) PRIMARY KEY,
UserId NVARCHAR(450) NOT NULL,
Provider NVARCHAR(50) NOT NULL,
ProfileData NVARCHAR(MAX) NOT NULL, -- JSON形式
CachedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
ExpiresAt DATETIME2 NOT NULL,
CONSTRAINT UK_UserCache_Provider UNIQUE (UserId, Provider),
INDEX IX_ExpiresAt (ExpiresAt)
);実装詳細
C# 実装例
コントローラー
[ApiController]
[Route("api/user")]
[Authorize]
public class ExternalProfileController : ControllerBase
{
private readonly IExternalProfileService _profileService;
private readonly ILogger<ExternalProfileController> _logger;
public ExternalProfileController(
IExternalProfileService profileService,
ILogger<ExternalProfileController> logger)
{
_profileService = profileService;
_logger = logger;
}
[HttpGet("external-profiles")]
public async Task<IActionResult> GetAllExternalProfiles()
{
try
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized();
}
var profiles = await _profileService.GetAllProfilesAsync(userId);
return Ok(new { profiles });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get external profiles");
return StatusCode(500, new { error = "Internal server error" });
}
}
[HttpGet("external-profiles/{provider}")]
public async Task<IActionResult> GetExternalProfile(string provider)
{
try
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized();
}
var profile = await _profileService.GetProfileAsync(userId, provider);
if (profile == null)
{
return NotFound();
}
return Ok(profile);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get external profile for {Provider}", provider);
return StatusCode(500, new { error = "Internal server error" });
}
}
}サービス実装
public interface IExternalProfileService
{
Task<List<ExternalProfile>> GetAllProfilesAsync(string userId);
Task<ExternalProfile> GetProfileAsync(string userId, string provider);
}
public class ExternalProfileService : IExternalProfileService
{
private readonly ApplicationDbContext _db;
private readonly ITokenEncryptionService _encryption;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<ExternalProfileService> _logger;
public ExternalProfileService(
ApplicationDbContext db,
ITokenEncryptionService encryption,
IHttpClientFactory httpClientFactory,
ILogger<ExternalProfileService> logger)
{
_db = db;
_encryption = encryption;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<List<ExternalProfile>> GetAllProfilesAsync(string userId)
{
var tokens = await _db.UserExternalTokens
.Where(t => t.UserId == userId)
.ToListAsync();
var profiles = new List<ExternalProfile>();
foreach (var token in tokens)
{
try
{
var profile = await GetProfileFromProvider(token);
if (profile != null)
{
profiles.Add(profile);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to get profile from {Provider} for user {UserId}",
token.Provider, userId);
}
}
return profiles;
}
public async Task<ExternalProfile> GetProfileAsync(string userId, string provider)
{
var token = await _db.UserExternalTokens
.FirstOrDefaultAsync(t => t.UserId == userId && t.Provider == provider);
if (token == null)
{
return null;
}
return await GetProfileFromProvider(token);
}
private async Task<ExternalProfile> GetProfileFromProvider(UserExternalToken token)
{
// キャッシュチェック
var cached = await GetCachedProfile(token.UserId, token.Provider);
if (cached != null)
{
return cached;
}
// トークンの有効期限チェックと更新
if (token.ExpiresAt < DateTime.UtcNow)
{
token = await RefreshToken(token);
}
// プロバイダー別の処理
ExternalProfile profile = token.Provider.ToLower() switch
{
"google" => await GetGoogleProfile(token),
"facebook" => await GetFacebookProfile(token),
"line" => await GetLineProfile(token),
_ => throw new NotSupportedException($"Provider {token.Provider} is not supported")
};
// キャッシュ保存
await SaveToCache(profile);
return profile;
}
private async Task<ExternalProfile> GetGoogleProfile(UserExternalToken token)
{
var client = _httpClientFactory.CreateClient();
var accessToken = _encryption.Decrypt(token.AccessTokenEncrypted);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
var response = await client.GetAsync(
"https://www.googleapis.com/oauth2/v2/userinfo");
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
var googleProfile = JsonSerializer.Deserialize<GoogleUserInfo>(json);
return new ExternalProfile
{
Provider = "google",
Email = googleProfile.Email,
Name = googleProfile.Name,
Picture = googleProfile.Picture,
AdditionalInfo = new Dictionary<string, object>
{
["locale"] = googleProfile.Locale,
["verified_email"] = googleProfile.VerifiedEmail,
["given_name"] = googleProfile.GivenName,
["family_name"] = googleProfile.FamilyName
},
LastUpdated = DateTime.UtcNow
};
}
throw new Exception($"Failed to get Google profile: {response.StatusCode}");
}
private async Task<ExternalProfile> GetFacebookProfile(UserExternalToken token)
{
var client = _httpClientFactory.CreateClient();
var accessToken = _encryption.Decrypt(token.AccessTokenEncrypted);
var response = await client.GetAsync(
$"https://graph.facebook.com/v18.0/me?fields=id,name,email,picture&access_token={accessToken}");
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
var fbProfile = JsonSerializer.Deserialize<FacebookUserInfo>(json);
return new ExternalProfile
{
Provider = "facebook",
Email = fbProfile.Email,
Name = fbProfile.Name,
Picture = fbProfile.Picture?.Data?.Url,
AdditionalInfo = new Dictionary<string, object>
{
["id"] = fbProfile.Id
},
LastUpdated = DateTime.UtcNow
};
}
throw new Exception($"Failed to get Facebook profile: {response.StatusCode}");
}
private async Task<ExternalProfile> GetLineProfile(UserExternalToken token)
{
var client = _httpClientFactory.CreateClient();
var accessToken = _encryption.Decrypt(token.AccessTokenEncrypted);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
var response = await client.GetAsync("https://api.line.me/v2/profile");
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
var lineProfile = JsonSerializer.Deserialize<LineUserInfo>(json);
return new ExternalProfile
{
Provider = "line",
Email = null, // LINEはemailを別途取得する必要がある
Name = lineProfile.DisplayName,
Picture = lineProfile.PictureUrl,
AdditionalInfo = new Dictionary<string, object>
{
["userId"] = lineProfile.UserId,
["statusMessage"] = lineProfile.StatusMessage
},
LastUpdated = DateTime.UtcNow
};
}
throw new Exception($"Failed to get LINE profile: {response.StatusCode}");
}
private async Task<UserExternalToken> RefreshToken(UserExternalToken token)
{
if (string.IsNullOrEmpty(token.RefreshTokenEncrypted))
{
throw new InvalidOperationException("No refresh token available");
}
var refreshToken = _encryption.Decrypt(token.RefreshTokenEncrypted);
// プロバイダー別のリフレッシュ処理
var newTokens = token.Provider.ToLower() switch
{
"google" => await RefreshGoogleToken(refreshToken),
"facebook" => await RefreshFacebookToken(refreshToken),
"line" => await RefreshLineToken(refreshToken),
_ => throw new NotSupportedException($"Provider {token.Provider} does not support token refresh")
};
// データベース更新
token.AccessTokenEncrypted = _encryption.Encrypt(newTokens.AccessToken);
token.ExpiresAt = DateTime.UtcNow.AddSeconds(newTokens.ExpiresIn);
token.UpdatedAt = DateTime.UtcNow;
_db.UserExternalTokens.Update(token);
await _db.SaveChangesAsync();
return token;
}
private async Task<ExternalProfile> GetCachedProfile(string userId, string provider)
{
var cache = await _db.ExternalProfileCache
.FirstOrDefaultAsync(c => c.UserId == userId &&
c.Provider == provider &&
c.ExpiresAt > DateTime.UtcNow);
if (cache != null)
{
return JsonSerializer.Deserialize<ExternalProfile>(cache.ProfileData);
}
return null;
}
private async Task SaveToCache(ExternalProfile profile)
{
var cache = new ExternalProfileCache
{
UserId = profile.UserId,
Provider = profile.Provider,
ProfileData = JsonSerializer.Serialize(profile),
CachedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddMinutes(15) // 15分キャッシュ
};
_db.ExternalProfileCache.Add(cache);
await _db.SaveChangesAsync();
}
}セキュリティ考慮事項
1. トークンの暗号化
public interface ITokenEncryptionService
{
byte[] Encrypt(string plainText);
string Decrypt(byte[] cipherText);
}
public class TokenEncryptionService : ITokenEncryptionService
{
private readonly byte[] _key;
private readonly byte[] _iv;
public TokenEncryptionService(IConfiguration configuration)
{
// Azure Key Vaultから取得することを推奨
_key = Convert.FromBase64String(configuration["Encryption:Key"]);
_iv = Convert.FromBase64String(configuration["Encryption:IV"]);
}
public byte[] Encrypt(string plainText)
{
using var aes = Aes.Create();
aes.Key = _key;
aes.IV = _iv;
var encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
using var msEncrypt = new MemoryStream();
using var csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write);
using var swEncrypt = new StreamWriter(csEncrypt);
swEncrypt.Write(plainText);
return msEncrypt.ToArray();
}
public string Decrypt(byte[] cipherText)
{
using var aes = Aes.Create();
aes.Key = _key;
aes.IV = _iv;
var decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
using var msDecrypt = new MemoryStream(cipherText);
using var csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read);
using var srDecrypt = new StreamReader(csDecrypt);
return srDecrypt.ReadToEnd();
}
}2. レート制限
// Startup.cs または Program.cs
services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("external-profile-api", opt =>
{
opt.PermitLimit = 100;
opt.Window = TimeSpan.FromMinutes(1);
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
opt.QueueLimit = 2;
});
});
// コントローラー
[EnableRateLimiting("external-profile-api")]
public class ExternalProfileController : ControllerBase
{
// ...
}3. 最小権限の原則
返却するプロファイル情報は必要最小限に留める:
public class SafeExternalProfile
{
public string Provider { get; set; }
public string DisplayName { get; set; }
public string Avatar { get; set; }
// センシティブな情報(メールアドレス、電話番号等)は含めない
}EC-CUBEプラグイン実装例
<?php
namespace Plugin\EcAuth\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Psr\Log\LoggerInterface;
class ExternalProfileService
{
private $client;
private $logger;
private $ecauthApiUrl;
public function __construct(LoggerInterface $logger)
{
$this->client = new Client();
$this->logger = $logger;
$this->ecauthApiUrl = getenv('ECAUTH_API_URL') ?: 'https://api.ecauth.example';
}
/**
* 全ての外部プロファイルを取得
*/
public function getAllProfiles($accessToken)
{
try {
$response = $this->client->request('GET',
$this->ecauthApiUrl . '/api/user/external-profiles',
[
'headers' => [
'Authorization' => 'Bearer ' . $accessToken,
'Accept' => 'application/json',
],
'timeout' => 10,
]
);
$body = json_decode($response->getBody()->getContents(), true);
return $body['profiles'] ?? [];
} catch (RequestException $e) {
$this->logger->error('Failed to get external profiles', [
'error' => $e->getMessage(),
'response' => $e->hasResponse() ?
$e->getResponse()->getBody()->getContents() : null
]);
throw new \RuntimeException('外部プロファイルの取得に失敗しました');
}
}
/**
* 特定プロバイダーのプロファイルを取得
*/
public function getProfile($accessToken, $provider)
{
try {
$response = $this->client->request('GET',
$this->ecauthApiUrl . '/api/user/external-profiles/' . $provider,
[
'headers' => [
'Authorization' => 'Bearer ' . $accessToken,
'Accept' => 'application/json',
],
'timeout' => 10,
]
);
return json_decode($response->getBody()->getContents(), true);
} catch (RequestException $e) {
if ($e->hasResponse() && $e->getResponse()->getStatusCode() === 404) {
return null;
}
$this->logger->error('Failed to get profile from ' . $provider, [
'error' => $e->getMessage()
]);
throw new \RuntimeException('プロファイルの取得に失敗しました');
}
}
/**
* EC-CUBEのカスタマー情報と統合
*/
public function mergeWithCustomer($customerId, $profiles)
{
// EC-CUBEのカスタマー情報と外部プロファイルを統合
// 実装は要件に応じてカスタマイズ
}
}エラーハンドリング
エラーレスポンス形式
{
"error": {
"code": "PROFILE_FETCH_ERROR",
"message": "Failed to fetch profile from external provider",
"details": {
"provider": "google",
"reason": "token_expired"
}
}
}エラーコード一覧
| コード | 説明 | HTTPステータス |
|---|---|---|
UNAUTHORIZED |
認証エラー | 401 |
PROVIDER_NOT_FOUND |
プロバイダーが見つからない | 404 |
TOKEN_EXPIRED |
トークン期限切れ | 401 |
REFRESH_FAILED |
トークンリフレッシュ失敗 | 500 |
PROFILE_FETCH_ERROR |
プロファイル取得エラー | 500 |
RATE_LIMIT_EXCEEDED |
レート制限超過 | 429 |
テスト
単体テスト例
[TestClass]
public class ExternalProfileServiceTests
{
private Mock<ApplicationDbContext> _mockDb;
private Mock<ITokenEncryptionService> _mockEncryption;
private Mock<IHttpClientFactory> _mockHttpClientFactory;
private ExternalProfileService _service;
[TestInitialize]
public void Setup()
{
_mockDb = new Mock<ApplicationDbContext>();
_mockEncryption = new Mock<ITokenEncryptionService>();
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
_service = new ExternalProfileService(
_mockDb.Object,
_mockEncryption.Object,
_mockHttpClientFactory.Object,
new NullLogger<ExternalProfileService>()
);
}
[TestMethod]
public async Task GetGoogleProfile_ReturnsValidProfile()
{
// Arrange
var userId = "test-user-id";
var mockToken = new UserExternalToken
{
UserId = userId,
Provider = "google",
AccessTokenEncrypted = new byte[] { 1, 2, 3 },
ExpiresAt = DateTime.UtcNow.AddHours(1)
};
_mockEncryption.Setup(x => x.Decrypt(It.IsAny<byte[]>()))
.Returns("valid-access-token");
var mockHttpClient = new Mock<HttpClient>();
// ... HTTPクライアントのモック設定
// Act
var result = await _service.GetProfileAsync(userId, "google");
// Assert
Assert.IsNotNull(result);
Assert.AreEqual("google", result.Provider);
}
}参考資料
- Google OAuth 2.0 API
- Facebook Graph API
- LINE Login API
- OAuth 2.0 Security Best Practices
- Azure Key Vault
このドキュメントには以下の内容が含まれています:
1. **概要とアーキテクチャ**: プロキシAPIの目的と全体構成
2. **API仕様**: エンドポイント、リクエスト/レスポンス形式
3. **データベース設計**: トークン保存とキャッシュ用テーブル
4. **実装詳細**: C#でのコントローラーとサービスの実装例
5. **セキュリティ**: トークン暗号化、レート制限、最小権限の原則
6. **EC-CUBEプラグイン**: PHP実装例
7. **エラーハンドリング**: エラーコードと形式
8. **テスト**: 単体テストの例
9. **参考資料**: 関連APIドキュメントへのリンク
このドキュメントは要件定義書の「個人情報非保持設計」と「セキュリティ要件」に準拠した実装となっています。