メインコンテンツまでスキップ

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 };
  }
}

エラーレスポンス

ステータスコード条件
401UNAUTHORIZEDuserが設定されていない(未認証)
403FORBIDDENユーザーが必要な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

  1. 保護されたルートには @Authorized() を使う — 基本的な認証要件のために、手動で currentUser() をチェックしない

  2. role checkは粗く保つ — 機能レベルのアクセスには @Authorized を、リソースレベルのロジックにはサービスを使う

  3. fail closed(不明な場合は拒否する) — 迷ったらアクセスを拒否する。付与するより取り消す方が難しい

  4. authorizationの失敗をログに残す — セキュリティ監視のためにアクセス失敗の試行を追跡する

  5. 両方のパスをテストする — 認証済み・未認証の両方のシナリオを必ずテストする