Custom Authentication
Zeltの組み込みprimitiveを使って、独自の認証を構築します。パッケージは不要です。
Custom Authを使うべき場面
- APIキー認証
- 独自フローによるOAuth/OIDC
- mTLSや証明書ベースの認証
- 独自の認証システム
- シンプルなプロトタイプ
コアPrimitive
| 関数 | 説明 |
|---|---|
setUser(user, roles) | request contextに認証済みユーザーを設定する |
currentUser() | 現在のユーザーを取得する |
currentRoles() | 現在のユーザーのroleを取得する |
@Authorized(roles?) | ルートに認証/roleを要求する |
これらは @zeltjs/core から利用でき、追加のパッケージは不要です。
APIキー認証
認証にデータベースアクセスや他のinjected serviceが必要な場合は、class middlewareを使います。
Basic API Key Middleware
@Middleware
export class ApiKeyAuthMiddleware {
constructor(private apiKeyRepo = inject(ApiKeyRepository)) {}
async use(next: Next, req = request()): Promise<Response | undefined> {
const apiKey = req.header('X-API-Key');
if (apiKey) {
const client = await this.apiKeyRepo.findByKey(apiKey);
if (client) {
setUser(
{ id: client.id, name: client.name, type: 'api' },
client.scopes // 例: ['read:users', 'write:posts']
);
}
}
await next();
return undefined;
}
}
With Revocation Check and Usage Tracking
@Middleware
export class ApiKeyAuthMiddleware {
constructor(private apiKeyService = inject(ApiKeyService)) {}
async use(next: Next, req = request()): Promise<Response | undefined> {
const apiKey = req.header('X-API-Key');
if (!apiKey) {
await next();
return undefined;
}
const client = await this.apiKeyService.findByKey(apiKey);
if (!client) {
throw new HTTPException(401, { message: 'Invalid API key' });
}
if (client.revokedAt) {
throw new HTTPException(401, { message: 'API key revoked' });
}
await this.apiKeyService.updateLastUsed(apiKey);
setUser(
{ id: client.id, name: client.name, type: 'api', tier: client.tier },
client.scopes
);
await next();
return undefined;
}
}
Basic認証
@Middleware
export class BasicAuthMiddleware {
constructor(private userService = inject(UserService)) {}
async use(next: Next, req = request()): Promise<Response | undefined> {
const auth = req.header('Authorization');
if (auth?.startsWith('Basic ')) {
const base64 = auth.slice(6);
const decoded = atob(base64);
const [username, password] = decoded.split(':');
const user = await this.userService.validateCredentials(username, password);
if (user) {
setUser({ id: user.id, name: user.name }, user.roles);
}
}
await next();
return undefined;
}
}
OAuth連携
With an OAuth Library
OAuth連携では、認証情報には @Config を、サービスには @Injectable を使います。
@Middleware
export class OAuthMiddleware {
constructor(
private oauth = inject(OAuth2Service),
private userRepo = inject(UserRepository)
) {}
async use(next: Next, req = request()): Promise<Response | undefined> {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (token) {
try {
const tokenInfo = await this.oauth.verifyAccessToken(token);
const user = await this.userRepo.findByOAuthId(tokenInfo.sub);
if (user) {
setUser(
{ id: user.id, name: user.name, email: user.email },
user.roles
);
}
} catch {
// 無効なトークン — userなしで続行
}
}
await next();
return undefined;
}
}
OAuth Callback Handler
@Controller('/auth')
class OAuthController {
constructor(
private oauth = inject(OAuth2Service),
private userRepo = inject(UserRepository),
private sessionService = inject(SessionService)
) {}
@Get('/callback')
async callback(req = request()) {
const code = req.queryParam('code');
const _state = req.queryParam('state');
const tokens = await this.oauth.exchangeCode(code);
const userInfo = await this.oauth.getUserInfo(tokens.access_token);
let user = await this.userRepo.findByOAuthId(userInfo.sub);
if (!user) {
user = await this.userRepo.create({
oauthId: userInfo.sub,
name: userInfo.name,
email: userInfo.email,
});
}
const token = await this.sessionService.createSession(user);
return { token };
}
}
複数プロバイダー認証
1つのmiddlewareで複数の認証方式をサポートします。@zeltjs/auth-jwt が提供する JwtService を使います。
@Middleware
export class MultiAuthMiddleware {
constructor(
private apiKeyRepo = inject(ApiKeyRepository),
private jwtService = inject(JwtService)
) {}
async use(next: Next, req = request()): Promise<Response | undefined> {
const auth = req.header('Authorization');
const apiKey = req.header('X-API-Key');
// まずAPIキーを試す
if (apiKey) {
const client = await this.apiKeyRepo.findByKey(apiKey);
if (client) {
setUser({ id: client.id, type: 'api' }, client.scopes);
await next();
return undefined;
}
}
// 次にBearerトークン(JWT)を試す
if (auth?.startsWith('Bearer ')) {
const token = auth.slice(7);
try {
const payload = await this.jwtService.verify(token);
setUser({ id: payload.sub, type: 'user' }, payload.roles as string[]);
} catch {
// 無効なトークン
}
}
await next();
return undefined;
}
}
リクエスト署名(HMAC)
安全なサーバー間通信のために。
@Middleware
export class HmacAuthMiddleware {
constructor(
private clientRepo = inject(ClientRepository),
private cryptoService = inject(CryptoService)
) {}
async use(next: Next, req = request()): Promise<Response | undefined> {
const signature = req.header('X-Signature');
const timestamp = req.header('X-Timestamp');
const clientId = req.header('X-Client-ID');
if (!signature || !timestamp || !clientId) {
await next();
return undefined;
}
// タイムスタンプをチェック(5分間のウィンドウ)
const now = Date.now();
const requestTime = parseInt(timestamp, 10);
if (Math.abs(now - requestTime) > 5 * 60 * 1000) {
throw new HTTPException(401, { message: 'Request expired' });
}
// クライアントのsecretを取得
const client = await this.clientRepo.findById(clientId);
if (!client) {
throw new HTTPException(401, { message: 'Unknown client' });
}
// 署名を検証
const body = await req.bodyRaw();
const payload = `${timestamp}.${body}`;
const expected = await this.cryptoService.hmacSha256(client.secret, payload);
if (!this.cryptoService.timingSafeEqual(signature, expected)) {
throw new HTTPException(401, { message: 'Invalid signature' });
}
setUser({ id: client.id, name: client.name }, client.permissions);
await next();
return undefined;
}
}
Custom Authをテストする
テストではuser contextをモックします。
describe('Protected routes', () => {
it('returns user data when authenticated', async () => {
const testApp = await onTest(app);
const res = await testApp.http.request('/users/me');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: '123', name: 'Test User' });
});
});
Best Practices
- middlewareではfail open — 認証がないことをエラーにしない。access controlは
@Authorizedに任せる - 一定時間比較を使う — secretや署名の比較には
timingSafeEqualを使う - タイムスタンプを検証する — 署名付きリクエストでは、古いタイムスタンプを拒否してリプレイ攻撃を防ぐ
- 認証の失敗をログに残す — ただし、パスワードや完全なトークンなど機微なデータはログに残さない
- 関心を分離する — middlewareは認証(誰か?)を、
@Authorizedは認可(できるか?)を担当する