Access Control
@Authorized decoratorは、ルートに対して認証とroleの要件を強制します。
基本的な使い方
認証を要求する
引数なしで @Authorized() を使うと、認証済みユーザーであれば誰でも許可されます。
@Controller('/dashboard')
class DashboardController {
@Authorized()
@Get('/')
index() {
return { stats: [] };
}
}
userが設定されていない場合、401 Unauthorized を返します。
{
"code": "UNAUTHORIZED",
"message": "Authentication required"
}
特定のRoleを要求する
アクセスを制限するには、role名を渡します。
@Controller('/admin')
class AdminController {
@Authorized(['admin'])
@Get('/users')
listUsers() {
return { users: [] };
}
}
ユーザーが必要なroleを持っていない場合、403 Forbidden を返します。
{
"code": "FORBIDDEN",
"message": "Insufficient permissions"
}
Roleのマッチング
OR Logic(いずれかのRole)
デフォルトでは、ユーザーが指定されたroleのいずれかを持っていればアクセスが許可されます。
@Controller('/admin')
class AdminController {
@Authorized(['admin', 'moderator'])
@Delete('/posts/:id')
removePost() {
// 'admin' OR 'moderator' が必要
}
}
AND Logic(すべてのRole)
AND logicにするには、複数の @Authorized decoratorを使います。
@Controller('/content')
class ContentController {
@Authorized(['verified'])
@Authorized(['premium'])
@Get('/exclusive-content')
exclusiveContent() {
// 'verified' AND 'premium' が必要
}
}
またはハンドラー内でチェックします。
@Controller('/content')
class ContentController {
@Authorized()
@Get('/exclusive-content')
exclusiveContent(roles = currentRoles()) {
if (!roles.includes('verified') || !roles.includes('premium')) {
throw new HTTPException(403, { message: 'Premium verified users only' });
}
return { content: '...' };
}
}
Decoratorの配置
Methodレベル
特定のルートに適用します。
@Controller('/posts')
class PostController {
@Get('/')
list() {
// Public — 認証不要
}
@Authorized()
@Post('/')
create() {
// 認証が必要
}
@Authorized(['admin'])
@Delete('/:id')
delete() {
// admin roleが必要
}
}
他のDecoratorとの併用
@Authorized は他のmethod decoratorと組み合わせて使えます。
@Controller('/api')
class ApiController {
@Authorized()
@RateLimit({ limit: 100, windowSec: 60, key: 'posts' })
@Post('/posts')
async create(req = request(CreatePostSchema)) {
const data = await req.body();
return { created: true };
}
}
エラーレスポンス
| ステータス | コード | 条件 |
|---|---|---|
| 401 | UNAUTHORIZED | userが設定されていない(未認証) |
| 403 | FORBIDDEN | ユーザーが必要なroleを持っていない |
エラーメッセージをカスタマイズする
エラーハンドラーでauthorizationのエラーを処理します。
const app = createApp([http({
controllers: [DashboardController, AdminController],
// @ts-expect-error shorthand error handler example
onError: (error: Error, c: RequestContext) => {
if (error instanceof HTTPException) {
if (error.status === 401) {
return c.json({
error: 'Please log in to continue',
loginUrl: '/auth/login',
}, 401);
}
if (error.status === 403) {
return c.json({
error: 'You do not have permission to access this resource',
requiredRoles: error.message,
}, 403);
}
}
throw error;
},
})]);
よくあるパターン
任意認証の公開ルート
@Authorized を使わず、手動でuserをチェックします。
@Controller('/posts')
class PostController {
constructor(private postRepo = inject(PostRepository)) {}
@Get('/:id')
async getPost(req = request()) {
const id = req.pathParam('id');
const user = currentUser() as User | undefined;
const post = await this.postRepo.findById(id);
return {
...post,
canEdit: user?.id === post.authorId,
};
}
}
Owner限定アクセス
@Authorized と所有権チェックを組み合わせます。
@Controller('/posts')
class PostController {
constructor(private postRepo = inject(PostRepository)) {}
@Authorized()
@Put('/:id')
async updatePost(req = request(UpdateSchema)) {
const id = req.pathParam('id');
const data = await req.body();
const user = currentUser() as User;
const post = await this.postRepo.findById(id);
if (post.authorId !== user.id && !currentRoles().includes('admin')) {
throw new HTTPException(403, { message: 'Not your post' });
}
return this.postRepo.update(id, data);
}
}
Role Hierarchy
hierarchy内のいずれかのroleをチェックします。
const isEditor = (roles: readonly string[]) =>
roles.some(r => ['admin', 'editor'].includes(r));
@Controller('/posts')
class PostController {
@Authorized()
@Put('/:id')
updatePost(roles = currentRoles()) {
if (!isEditor(roles)) {
throw new HTTPException(403, { message: 'Editors only' });
}
// ...
}
}
Resource-Scoped Authorization
複雑なシナリオでは、ロジックをサービスへ移します。
@Injectable()
class PostAuthorizationService {
canView(post: Post): boolean {
if (post.isPublic) return true;
const user = currentUser() as User | undefined;
return user?.id === post.authorId;
}
canEdit(post: Post): boolean {
const user = currentUser() as User | undefined;
const roles = currentRoles();
if (roles.includes('admin')) return true;
return user?.id === post.authorId;
}
canDelete(): boolean {
const roles = currentRoles();
return roles.includes('admin');
}
}
@Controller('/posts')
class PostController {
constructor(
private postRepo = inject(PostRepository),
private authService = inject(PostAuthorizationService)
) {}
@Authorized()
@Delete('/:id')
async delete(req = request()) {
const id = req.pathParam('id');
const post = await this.postRepo.findById(id);
if (!this.authService.canDelete()) {
throw new HTTPException(403, { message: 'Cannot delete this post' });
}
await this.postRepo.delete(id);
return { deleted: true };
}
}
保護されたルートをテストする
認証なしの場合
it('returns 401 for unauthenticated requests', async () => {
const res = await readyApp.http.request('/dashboard');
expect(res.status).toBe(401);
});
認証ありの場合
request context内でuserを注入するmiddlewareを使います — setUser() はテストのセットアップ内ではなく、リクエスト処理中に呼び出す必要があります。
it('returns data for authenticated users', async () => {
const res = await readyApp.http.request('/dashboard', { headers: { 'X-Test-User': 'true' } });
expect(res.status).toBe(200);
});
Role要件をテストする
it('returns 403 for non-admin users', async () => {
const res = await readyApp.http.request('/admin/users', { headers: { 'X-Test-Role': 'user' } });
expect(res.status).toBe(403);
});
it('allows admin access', async () => {
const res = await readyApp.http.request('/admin/users', { headers: { 'X-Test-Role': 'admin' } });
expect(res.status).toBe(200);
});
Best Practices
-
保護されたルートには
@Authorized()を使う — 基本的な認証要件のために、手動でcurrentUser()をチェックしない -
role checkは粗く保つ — 機能レベルのアクセスには
@Authorizedを、リソースレベルのロジックにはサービスを使う -
fail closed(不明な場合は拒否する) — 迷ったらアクセスを拒否する。付与するより取り消す方が難しい
-
authorizationの失敗をログに残す — セキュリティ監視のためにアクセス失敗の試行を追跡する
-
両方のパスをテストする — 認証済み・未認証の両方のシナリオを必ずテストする