mcpField
E
extension-authorization

extension-authorization

github.com/caffeinelabsセキュリティ228.6k 回インストール1 週間前に更新
リポジトリ

extension-authorization について

Caffeine AI アプリ向けの認証・ロールベース認可拡張です。ミックスインで標準の認可エンドポイントを追加し、最初に認証したユーザーを管理者に設定します。管理者、ユーザー、ゲストのロールに応じた制御に対応します。

主な機能

  1. 01ロールベースのアクセス制御
  2. 02ミックスインによる認可エンドポイント
  3. 03管理者・ユーザー・ゲストのロール
  4. 04最初の認証ユーザーを管理者に設定
  5. 05検証済みID属性の取得

活用シーン

  1. 01管理者専用操作の制限
  2. 02ユーザーデータの読み書き保護
  3. 03ロールやSSOドメインによる機能制限

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/caffeinelabs/skills --skill extension-authorization

インストール後、Claude Code で/extension-authorizationを入力して読み込まれたか確認します。

同梱ファイルmigration/v0.x.y-to-v1.x.y.md

詳細

認可

Caffeine AI向けの認可拡張です。

概要

このスキルでは、ミックスインパターンを使用したロールベースのアクセス制御による認証・認可システムを追加します。MixinAuthorizationミックスインは、標準的な認可エンドポイントを自動的に提供します。

バックエンド

ロールベースのアクセス制御による認証システムです。

事前に用意されたライブラリmo:caffeineai-authorization/access-control.moがあります。ロールベースのアクセス制御によるコア認証機能を提供します。

モジュール API

module {
  public type UserRole = {
    #admin;
    #user;
    #guest;
  };

  public type AccessControlState = { /* internal state */ };

  public func initState() : AccessControlState;
  public func getUserRole(state : AccessControlState, caller : Principal) : UserRole;
  public func assignRole(state : AccessControlState, caller : Principal, user : Principal, role : UserRole);
  public func isAdmin(state : AccessControlState, caller : Principal) : Bool;
  public func hasPermission(state : AccessControlState, caller : Principal, requiredRole : UserRole) : Bool;
};

初期化はMixinAuthorizationが内部で処理するため、initializeを直接呼び出さないでください。最初にログインした認証済みユーザーが自動的に管理者になります。トークンやシークレットは必要ありません。

include MixinAuthorization(accessControlState)の行は、カスタムミックスインファイルではなく、必ずmain.moに記述してください。

オプトアウト/カスタム認証

ユーザーから、この認可システムをカスタム認証(たとえば、ユーザー名/パスワード認証、Internet Identityを使用しない認証、またはMixinAuthorizationを使用しない認証)に置き換えるよう求められた場合は、プロジェクトからcaffeineai-authorization mopsパッケージを削除してください。パッケージによってlintルールが提供されているため、include-authorizationのlint要件を取り除く方法としてサポートされているのはこれだけです。抑制コメントを追加したり、include MixinAuthorization(accessControlState)を省略したままパッケージをインストールしておいたりしないでください。

パッケージを削除する際は、mo:caffeineai-authorization/*のインポート、accessControlStateの初期化、include MixinAuthorization(accessControlState)、およびこのコンポーネントに属するAccessControlガードの呼び出しもすべて削除してください。これらを、ユーザーが求めるカスタム認証・認可チェックに置き換えてください。

main.moでのセットアップ

import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import Types "types";
import ProfileMixin "mixins/Profile";

actor {
  let accessControlState : AccessControl.AccessControlState;
  include MixinAuthorization(accessControlState, null);

  let userProfiles : Map.Map<Principal, Types.UserProfile>;

  include ProfileMixin(accessControlState, userProfiles);
};

マイグレーションチェーンの先頭:

import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";

module {
  type UserProfile = {
    name : Text;
  };

  type NewActor = {
    accessControlState : AccessControl.AccessControlState;
    userProfiles : Map.Map<Principal, UserProfile>;
  };

  public func migration(_old : {}) : NewActor {
    {
      accessControlState = AccessControl.initState();
      userProfiles = Map.empty<Principal, UserProfile>();
    };
  };
};

types.moでの型定義

module {
  public type UserProfile = {
    name : Text;
  };
};

カスタムミックスインの例(mixins/Profile.mo)

フロントエンドでは、getCallerUserProfile、saveCallerUserProfile、getUserProfileが必要です。権限を確認できるよう、accessControlStateをミックスインに渡してください。

import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import AccessControl "mo:caffeineai-authorization/access-control";
import Types "../types";

mixin (
  accessControlState : AccessControl.AccessControlState,
  userProfiles : Map.Map<Principal, Types.UserProfile>,
) {
  public query ({ caller }) func getCallerUserProfile() : async ?Types.UserProfile {
    if (not AccessControl.hasPermission(accessControlState, caller, #user)) {
      Runtime.trap("Unauthorized");
    };
    userProfiles.get(caller);
  };

  public shared ({ caller }) func saveCallerUserProfile(profile : Types.UserProfile) : async () {
    if (not AccessControl.hasPermission(accessControlState, caller, #user)) {
      Runtime.trap("Unauthorized");
    };
    userProfiles.add(caller, profile);
  };

  public query ({ caller }) func getUserProfile(user : Principal) : async ?Types.UserProfile {
    if (caller != user and not AccessControl.isAdmin(accessControlState, caller)) {
      Runtime.trap("Unauthorized: Can only view your own profile");
    };
    userProfiles.get(user);
  };
};

ガードパターン

各パブリック関数に適切なガードを適用してください。

// Admin-only:
if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
  Runtime.trap("Unauthorized: Only admins can perform this action");
};

// Users only:
if (not AccessControl.hasPermission(accessControlState, caller, #user)) {
  Runtime.trap("Unauthorized: Only users can perform this action");
};

// Any user including guests: No check needed

設計ガイドライン

  • 匿名のプリンシパルはゲストとして扱います。
  • assignRoleには、管理者のみが実行できるガードが内部で含まれています。
  • データを変更する認証済みエンドポイントにはshared({ caller })を使用してください。
  • データを取得する認証済みエンドポイントにはquery({ caller })を使用してください。
  • 必要に応じて所有権を確認してください。
  • 認可に失敗した場合はRuntime.trapを使用してください。

メール属性

MixinAuthorization は、サインイン時にユーザーの検証済みの Internet Identity 属性(名前とメールアドレス)を取得できます。null の代わりに2番目の引数としてコールバックを渡します。このコールバックは、属性の一式が検証された後、サインインごとに1回実行されます。

mo:identity-attributes ミックスインを直接使用しないでください。必ず MixinAuthorization を経由してください。コールバックには、呼び出し元のプリンシパルと検証済み属性が渡されます。

{
  name : ?Text;   // verified display name, when present
  email : ?Text;  // always the verified address -- sourced from II's `verified_email`, never the unverified `email` key
  sso : ?Text;    // SSO domain when the identity came from SSO, otherwise null
}

フィールド名は email ですが、ここに格納されるのは II の verified_email の値だけです。未検証の email キーは読み取られません。コールバックでは attrs.email を使用してください(attrs.verified_email フィールドはありません)。

取得される属性は、フロントエンドで使用したサインイン方法によって異なります(extension-core-infrastructure スキルの extension-authorization と互換性のある login() オプションを参照してください)。

  • login()(通常の Internet Identity):verified_email を要求し、最初に Internet Identity の同意画面を表示します。email が設定されるのは、ユーザーのアイデンティティが Google または Microsoft アカウントにリンクされている場合のみです。パスキーのみのアイデンティティと Apple のアイデンティティには設定されません。sso は null です。
  • login({ provider: 'google' }):ユーザーの Google アカウントの検証済みの name と email を取得します。sso は null です。
  • login({ provider: 'microsoft' }):ユーザーの Microsoft アカウントの検証済みの name と email を取得します。sso は null です。
  • login({ ssoDomain: 'acme.com' }):会社のアイデンティティプロバイダーの検証済みの name と email を取得します。sso にはドメイン(例:"acme.com")が格納されます。会社のメンバーに機能を限定したり、ドメインに基づいてロールを自動割り当てしたりするには、attrs.sso を使用してください。

すべてのサインイン方法で email は任意として扱ってください。値がある場合は表示または使用し、値がないユーザーでも機能が動作するようにしてください。ここで取得したアドレスに対して、メールアドレスの検証手順を追加しないでください。

これらを独自の状態に保存し、値を取得するためのゲッターを公開してください。

import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";

actor {
  let accessControlState : AccessControl.AccessControlState;

  let emails : Map.Map<Principal, Text>;

  include MixinAuthorization(
    accessControlState,
    ?(func(caller : Principal, attrs : { name : ?Text; email : ?Text; sso : ?Text }) {
      switch (attrs.email) {
        case (?email) { emails.add(caller, email) };
        case null {};
      };
    }),
  );

  public query ({ caller }) func getCallerEmail() : async ?Text {
    emails.get(caller);
  };
};

属性の検証に必要な trusted_attribute_signers と frontend_origins の canister 環境変数は、Caffeine プラットフォームによって自動的に設定されます。自分で設定する必要はありません。

フロントエンドでのメールアドレスの取得

サインイン後、他の認証済みアクターメソッドと同じようにゲッターを呼び出します。

const { data: callerEmail } = useQuery<string | null>({
  queryKey: ['callerEmail'],
  queryFn: () => actor.getCallerEmail(),
  enabled: !!actor && isAuthenticated,
});

フロントエンド

ロールベースのアクセス制御を備えた認証システムです。

サインイン方法

サインインには、@caffeineai/core-infrastructure の useInternetIdentity フックを使用します(API と UI のパターンの詳細は extension-core-infrastructure スキルを参照してください)。Google、Microsoft、または会社/ワークスペースの SSO サインインがリクエストされていない限り、通常の login() を使用してください。

ルール:

  • Google、Microsoft、または SSO によるサインインがリクエストされた場合、サインイン画面にはリクエストされた直接サインインの選択肢(Google ボタン、Microsoft ボタン、または SSO ドメイン入力欄)を表示するとともに、通常の login() の「Internet Identity でサインイン」ボタンをフォールバックとして残してください。Google または Microsoft アカウントを持たないユーザーや、登録済みの会社ドメインがないユーザーも、サインインできる必要があります。
  • すでにユーザーがいる既存アプリから、Internet Identity の選択肢を削除しないでください。プリンシパルはユーザーの Internet Identity から導出されます。II のアイデンティティが Google または Microsoft に紐付いているユーザーは、どちらのボタンからサインインしても同じプリンシパルになります。しかし、パスキーを使った II のアイデンティティで登録したユーザーが、そのアイデンティティにリンクされていない Google または Microsoft アカウントで認証すると、異なるプリンシパルになります。その場合、ユーザーのデータが見つからないように見えてしまいます。両方のボタンを残すことで、既存ユーザーはいつもどおりの方法でサインインできます。

ユーザープロフィールの設定

Internet Identity を使用する場合、ユーザーにプリンシパル ID が割り当てられるのはログイン後です。匿名プリンシパルはゲストとして扱われます。プリンシパル ID は人が読める形式ではないため、新しいプリンシパルで初めてログインしたときに、ユーザーに名前を尋ねてください。

プロフィール用のバックエンド API:

  • getCallerUserProfile(): Promise<UserProfile | null> — プロフィールが存在しない場合は null を返します
  • saveCallerUserProfile(profile: UserProfile): Promise<void> — 名前とプロフィールデータを保存します
  • getUserProfile(user: Principal): Promise<UserProfile | null> — 別のユーザーのプロフィールを取得します

ルール:

  • ログイン時にユーザーのプロフィールがすでに存在する場合は、名前を再度尋ねないでください
  • プリンシパル ID の代わりにユーザーのプロフィール名を表示してください
  • アプリケーションデータを表示する前に、ユーザーがログインしていることを必ず確認してください
  • ログアウト時には、キャッシュされたユーザープロフィールを含め、キャッシュされたアプリケーションデータをすべて消去してください
プロフィール設定モーダルのちらつきを防ぐ
export function useGetCallerUserProfile() {
  const { actor, isFetching: actorFetching } = useActor();

  const query = useQuery<UserProfile | null>({
    queryKey: ['currentUserProfile'],
    queryFn: async () => {
      if (!actor) throw new Error('Actor not available');
      return actor.getCallerUserProfile();
    },
    enabled: !!actor && !actorFetching,
    retry: false,
  });

  return {
    ...query,
    isLoading: actorFetching || query.isLoading,
    isFetched: !!actor && query.isFetched,
  };
}

次に、コンポーネント内で次のようにします:

const showProfileSetup = isAuthenticated && !profileLoading && isFetched && userProfile === null;

認証状態のライフサイクル

useInternetIdentity フックは 2 種類の状態を公開します。適切な状態を使用してください。

シナリオloginStatusisAuthenticated
ページ読み込み時、保存済みセッションなし"idle"false
ページ読み込み時、保存済みセッションを復元中"initializing"false → true
再読み込み後に保存済みセッションを復元"idle"true
対話型ログイン中(ポップアップが開いている)"logging-in"false
対話型ログインが完了した直後"success"true
ログインポップアップが失敗/キャンセルされた"loginError"false
セッションの有効期限切れ(アイドル/TTL/失効)"expired"false
別のタブでサインアウト"idle"false

重要: isLoginSuccess(loginStatus === "success")が true になるのは、ポップアップから対話型ログインを行った後だけです。ページ再読み込み時に保存済みの ID が復元された場合は、true になりません。認証済み UI と未認証 UI の切り替えに isLoginSuccess を決して使用せず、常に isAuthenticated を使用してください。セッションの有効期限が切れた状態(loginStatus === "expired")は、意図的なログアウトではありません。

ログインボタンに関する主な状態:

  • isInitializing — AuthClient は IndexedDB から読み込み中です。クライアントの準備が整う前にクリックされないよう、ボタンを無効にしてください。
  • isLoggingIn — II ポップアップが開いています。ポップアップの重複を防ぐため、ボタンを無効にしてください。

ログインコンポーネント

import { useInternetIdentity } from '@caffeineai/core-infrastructure';
import { useQueryClient } from '@tanstack/react-query';

export default function LoginButton() {
  const { login, clear, isAuthenticated, isInitializing, isLoggingIn } = useInternetIdentity();
  const queryClient = useQueryClient();

  const handleAuth = () => {
    if (isAuthenticated) {
      clear();
      queryClient.clear();
    } else {
      login();
    }
  };

  return (
    <button
      onClick={handleAuth}
      disabled={isInitializing || isLoggingIn}
      className={`px-6 py-2 rounded-full transition-colors font-medium ${
        isAuthenticated
          ? 'bg-gray-200 hover:bg-gray-300 text-gray-800'
          : 'bg-blue-600 hover:bg-blue-700 text-white'
      } disabled:opacity-50`}
    >
      {isInitializing ? 'Loading...' : isAuthenticated ? 'Logout' : 'Login'}
    </button>
  );
}

login() 関数と clear() 関数はファイア・アンド・フォーゲット方式です(処理全体を追跡する Promise は返しません)。フックの isLoggingIn/isInitializing 状態が非同期処理のライフサイクルを追跡するため、ローカルの useState/isPending ロジックでラップしないでください。

アプリで Google または Microsoft のサインイン、あるいは会社/ワークスペースの SSO を求める場合は、同じフックをオプション付きで使用してください:login({ provider: 'google' })、login({ provider: 'microsoft' })、または login({ ssoDomain: 'acme.com' })。どの方法でも同じ ID とセッションの動作になります。サインイン UI のパターン全体については、extension-core-infrastructure スキルを参照してください。

認証済み UI の表示条件には isAuthenticated を使用してください(新規ログインとページ再読み込み時に復元されたセッションの両方に対応します):

{isAuthenticated ? (
  <AuthenticatedApp />
) : (
  <LoginScreen />
)}

現在のユーザーとデータ作成者の比較

import { useInternetIdentity } from '@caffeineai/core-infrastructure';
import type { Principal } from '@icp-sdk/core/principal';

const { identity } = useInternetIdentity();

const isAuthor = (authorPrincipal: Principal): boolean => {
  if (!identity) return false;
  return authorPrincipal.toString() === identity.getPrincipal().toString();
};

アクセス制御 UI

管理者専用または個人用のアプリケーションでは、権限のないユーザーがアプリケーションにアクセスしようとしたときに、AccessDeniedScreen コンポーネントを表示してください。

エラー処理

バックエンドの Debug.trap 呼び出しによる認可エラーを UI で適切に処理し、ユーザーに適切なエラーメッセージを表示してください。

注:最初の管理者の初期化は @caffeineai/core-infrastructure で自動的に行われます。最初にログインした認証済みユーザーが管理者になります。トークンやシークレットは不要です。

コメント