ブロッキング関数を使用した認証フローのカスタマイズ

このドキュメントでは、Cloud Functions のブロックを使用して Identity Platform 認証を拡張する方法について説明します。

ブロッキング関数を使用することで、アプリの登録やログインの結果を変更するカスタムコードを実行できます。たとえば、ユーザーが特定の条件を満たしていない場合にユーザーが認証されないようにしたり、クライアント アプリに戻る前にユーザーの情報を更新したりできます。

始める前に

Identity Platform でアプリを作成します。その方法については、クイックスタートをご覧ください。

ブロッキング関数について

ブロッキング関数は、次の 2 つのイベントで登録できます。

  • beforeCreate: 新しいユーザーが Identity Platform データベースに保存される前と、トークンがクライアント アプリに返される前にトリガーします。

  • beforeSignIn: ユーザー認証情報の検証後、Identity Platform が ID トークンをクライアント アプリに返す前にトリガーします。アプリで多要素認証が使用されている場合、この関数は 2 番目の要素の確認後に関数を表示します。新しいユーザーを作成すると、beforeCreate に加えて beforeSignIn もトリガーされます。

ブロッキング関数を使用する際は、次の点に注意してください。

  • 関数は 7 秒以内に応答する必要があります。7 秒が経過すると、Identity Platform はエラーを返し、クライアント オペレーションは失敗します。

  • 200 以外の HTTP レスポンス コードはクライアント アプリに渡されます。関数が返すエラーに対応するようにクライアント コードを設定します。

  • 関数は、テナント内にあるものを含む、プロジェクト内のすべてのユーザーに適用されます。Identity Platform では、ユーザーに関する情報(ユーザーが所属しているテナントを含む)が関数に示されるため、それに従って適切に対処できます。

  • 別の ID プロバイダをアカウントにリンクすると、登録済みの beforeSignIn 関数が再トリガーされます。

  • 匿名認証とカスタム認証は、ブロッキング関数をサポートしていません。

ブロッキング関数の作成

ブロッキング関数を作成する方法は次のとおりです。

  1. Cloud Console で Identity Platform の [設定] ページに移動します。

    [設定] ページに移動

  2. [トリガー] タブを選択します。

  3. ユーザー登録のブロッキング関数を作成するには、[作成前(beforeCreate)] の下にある [関数] プルダウンを選択して、[関数の作成] をクリックします。ユーザー ログインのブロッキング関数を作成するには、[ログイン前(beforeSignIn)] の下に関数を作成します。

  4. 新しい関数を作成します。

    1. 関数の名前を入力します。

    2. [トリガー] フィールドで、[HTTP] を選択します。

    3. [認証] フィールドで、[未認証の呼び出しを許可する] を選択します。

    4. [次へ] をクリックします。

  5. インライン エディタを使用して、index.js を開きます。サンプル helloWorld コードを削除して、次のいずれかに置き換えます。

    登録に対応するには:

    import * as gcipCloudFunctions from 'gcip-cloud-functions';
    
    const authClient = new gcipCloudFunctions.Auth();
    
    exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
      // TODO
    });
    

    ログインに対応するには:

    import * as gcipCloudFunctions from 'gcip-cloud-functions';
    
    const authClient = new gcipCloudFunctions.Auth();
    
    exports.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
      // TODO
    });
    
  6. package.json を開き、次を追加します。

    "dependencies": {
      "gcip-cloud-functions": "^0.0.1"
    }
    
  7. [デプロイ] をクリックして関数を公開します。

  8. Identity Platform のブロッキング関数のページで、[保存] をクリックします。

関数の実装方法については、以下のセクションをご覧ください。関数を更新するたびに関数を再デプロイする必要があります。

また、gcloud コマンドライン ツールまたは REST API を使用して、関数の作成と管理を行うこともできます。gcloud コマンドライン ツールを使用して関数をデプロイする方法については、Cloud Functions のドキュメントをご覧ください。

ユーザー情報とコンテキスト情報の取得

beforeSignIn イベントと beforeCreate イベントは、ユーザーのログインに関する情報を含む User オブジェクトと EventContext オブジェクトを提供します。これらの値をコード内で使用して、操作を続行できるかどうかを判断してください。

User オブジェクトで使用可能なプロパティのリストについては、UserRecord API リファレンスをご覧ください。

EventContext オブジェクトには次のプロパティが含まれています。

名前 説明
locale アプリの言語 / 地域。言語 / 地域を設定するには、Client SDK を使用するか、REST API で言語 / 地域のヘッダーを渡します。 fr または sv-SE
ipAddress エンドユーザーが登録またはログインに使用したデバイスの IP アドレス。 114.14.200.1
userAgent ブロッキング関数をトリガーするユーザー エージェント。 Mozilla/5.0 (X11; Linux x86_64)
eventId イベントの一意の ID。 rWsyPtolplG2TBFoOkkgyg
eventType イベントタイプ。これにより、イベント名(beforeSignInbeforeCreate など)と使用する関連付けられているログイン方法(Google やメール/パスワードなど)に関する情報が提供されます。 providers/cloud.auth/eventTypes/user.beforeSignIn:password
authType 常に USER です。 USER
resource Identity Platform プロジェクトまたはテナント。 projects/project-id/tenants/tenant-id
timestamp イベントがトリガーされた時間(RFC 3339 文字列形式)。 Tue, 23 Jul 2019 21:10:57 GMT
additionalUserInfo ユーザーに関する情報を含むオブジェクト。 AdditionalUserInfo
credential ユーザーの認証情報に関する情報を含むオブジェクト。 AuthCredential

登録またはログインをブロックする

登録またはログインの試行をブロックするには、関数で HttpsError をスローします。次に例を示します。

Node.js

throw new gcipCloudFunctions.https.HttpsError('permission-denied');

次の表に、発生可能なエラーとデフォルトのエラー メッセージを示します。

名前 コード メッセージ
invalid-argument 400 クライアントが無効な引数を指定しました。
failed-precondition 400 現在のシステム状態ではリクエストを実行できません。
out-of-range 400 クライアントが無効な範囲を指定しました。
unauthenticated 401 OAuth トークンの欠落、無効、期限切れ。
permission-denied 403 クライアントに十分な権限がありません。
not-found 404 指定されたリソースが見つかりません。
aborted 409 同時実行の競合(読み取り - 変更 - 書き込みの競合など)。
already-exists 409 クライアントが作成しようとしたリソースはすでに存在します。
resource-exhausted 429 リソース割り当てが不足しているか、レート制限に達しています。
cancelled 499 リクエストはクライアントによってキャンセルされました。
data-loss 500 復元できないデータ損失またはデータ破損です。
unknown 500 不明なサーバーエラーです。
internal 500 内部サーバーエラーです。
not-implemented 501 API メソッドはサーバーによって実装されていません。
unavailable 503 サービスを利用できません。
deadline-exceeded 504 リクエスト期限を超えました。

カスタム エラー メッセージを指定することもできます。

Node.js

throw new gcipCloudFunctions.https.HttpsError('permission-denied', 'Unauthorized request origin!');

次の例では、特定のドメインのメンバーではないユーザーによるアプリへの登録をブロックする方法を示しています。

Node.js

// Import the Cloud Auth Admin module.
const gcipCloudFunctions = require('gcip-cloud-functions');
// Initialize the Auth client.
const authClient = new gcipCloudFunctions.Auth();
// Http trigger with Cloud Functions.
exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  // If the user is authenticating within a tenant context, the tenant ID can be determined from
  // user.tenantId or from context.resource, eg. 'projects/project-id/tenant/tenant-id-1'
  // Only users of a specific domain can sign up.
  if (user.email.indexOf('@acme.com') === -1) {
    throw new gcipCloudFunctions.https.HttpsError('invalid-argument', `Unauthorized email "${user.email}"`);
});

デフォルト メッセージとカスタム メッセージのどちらを使用する場合でも、Cloud Functions はエラーをラップし、内部エラーとしてクライアントに返します。たとえば、関数内で次のエラーが発生した場合。

throw new gcipCloudFunctions.https.HttpsError('invalid-argument', `Unauthorized email user@evil.com}`);

次のようなエラーがクライアント アプリに返されます(Client SDK を使用している場合は、内部エラーとしてラップされます)。

{
  "error": {
    "code": 400,
    "message": "BLOCKING_FUNCTION_ERROR_RESPONSE : HTTP Cloud Function returned an error. Code: 400, Status: \"INVALID_ARGUMENT\", Message: \"Unauthorized email user@evil.com\"",
    "errors": [
      {
        "message": "BLOCKING_FUNCTION_ERROR_RESPONSE : HTTP Cloud Function returned an error. Code: 400, Status: \"INVALID_ARGUMENT\", Message: \"Unauthorized email user@evil.com\"",
        "domain": "global",
        "reason": "invalid"
      }
    ]
  }
}

アプリはエラーを検出して適切に処理する必要があります。次に例を示します。

JavaScript

// Blocking functions can also be triggered in a multi-tenant context before user creation.
// firebase.auth().tenantId = 'tenant-id-1';
firebase.auth().createUserWithEmailAndPassword('johndoe@example.com', 'password')
  .then((result) => {
    result.user.getIdTokenResult()
  })
  .then((idTokenResult) => {
    console.log(idTokenResult.claim.admin);
  })
  .catch((error) => {
    if (error.code !== 'auth/internal-error' && error.message.indexOf('Cloud Function') !== -1) {
      // Display error.
    } else {
      // Registration succeeds.
    }
  });

ユーザーの変更

登録またはログインの試行をブロックする代わりに、この操作の続行を許可できますが、Identity Platform のデータベースに保存される User オブジェクトは変更され、クライアントに返されます。

ユーザーを変更するには、変更するフィールドを含むイベント ハンドラからオブジェクトを返します。次のフィールドを変更できます。

  • displayName
  • disabled
  • emailVerified
  • photoURL
  • customClaims
  • sessionClaimsbeforeSignIn のみ)

sessionClaims を除き、変更されたすべてのフィールドは Identity Platform のデータベースに保存されます。つまり、これらのフィールドはレスポンス トークンに含まれ、ユーザー セッション間で保持されます。

次の例では、デフォルトの表示名の設定方法を示しています。

Node.js

exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  return {
    // If no display name is provided, set it to "Guest".
    displayName: user.displayName || 'Guest';
  };
});

beforeCreatebeforeSignIn の両方のイベント ハンドラを登録すると、beforeSignInbeforeCreate の後に実行されることに注意してください。beforeCreate で更新されたユーザー フィールドが beforeSignIn に表示されます。両方のイベント ハンドラで sessionClaims 以外のフィールドを設定すると、beforeSignIn に設定されている値によって beforeCreate に設定された値が上書きされます。sessionClaims でのみ、現在のセッションのトークン クレームは伝わりますが、データベースに保存や保管が行われることはありません。

たとえば、sessionClaims を設定した場合は、beforeSignIn によって beforeCreate クレームとともに返され、マージされます。それらがマージされ、sessionClaims キーが customClaims 内のキーと一致すると、一致する customClaimssessionClaims キーによってトークンのクレームで上書きされます。ただし、以降のリクエストでは customClaims キーがデータベースに引き続き保持されます。

サポートされている OAuth 認証情報とデータ

OAuth 認証情報とデータを渡して、さまざまな ID プロバイダから関数をブロックできます。次の表は、各 ID プロバイダでサポートされる認証情報とデータをまとめたものです。

ID プロバイダ ID トークン アクセス トークン 有効期限 トークン シークレット リフレッシュ トークン ログイン クレーム
Google × ×
Facebook × × × ×
Twitter × × × ×
GitHub × × × × ×
Microsoft × ×
LinkedIn × × × ×
Yahoo! × ×
Apple × ×
SAML × × × × ×
OIDC ×

リフレッシュ トークン

ブロッキング関数でリフレッシュ トークンを使用するには、まず Cloud Console の [トークンの認証情報を含める] プルダウン メニューの [トリガー] セクションでチェックボックスをオンにする必要があります。

ID トークンやアクセス トークンなど、OAuth 認証情報で直接ログインする場合、どの ID プロバイダでもリフレッシュ トークンが返されません。この場合、クライアント側の OAuth 認証情報がブロック関数に渡されます。ただし、3-legged フローの場合、ID プロバイダでサポートされている場合は、リフレッシュ トークンを使用できます。

以降のセクションでは、各 ID プロバイダのタイプと、サポートされている認証情報とデータについて説明します。

一般的な OIDC プロバイダ

ユーザーが一般的な OIDC プロバイダを使用してログインすると、次の認証情報が渡されます。

  • ID トークン: id_token フローが選択されている場合に提供されます。
  • アクセス トークン: コードフローが選択されている場合に提供されます。コードフローは、現時点では REST API を通じてのみサポートされています。
  • 更新トークン: offline_access スコープが選択されている場合に提供されます。

例:

const provider = new firebase.auth.OAuthProvider('oidc.my-provider');
provider.addScope('offline_access');
firebase.auth().signInWithPopup(provider);

Google

ユーザーが Google でログインすると、次の認証情報が渡されます。

  • ID トークン
  • アクセス トークン
  • 更新トークン: 次のカスタム パラメータがリクエストされた場合にのみ提供されます。
    • access_type=offline
    • prompt=consent、ユーザーが以前に同意していて、新しいスコープをリクエストしていない場合

例:

const provider = new firebase.auth.GoogleAuthProvider();
provider.setCustomParameters({
  'access_type': 'offline',
  'prompt': 'consent'
});
firebase.auth().signInWithPopup(provider);

詳しくは、Google 更新トークンをご確認ください。

Facebook

ユーザーが Facebook でログインすると、次の認証情報が渡されます。

GitHub

ユーザーが GitHub でログインすると、次の認証情報が渡されます。

  • アクセス トークン: 取り消すまで、期限切れになりません。

Microsoft

ユーザーが Microsoft でログインすると、次の認証情報が渡されます。

  • ID トークン
  • アクセス トークン
  • 更新トークン: offline_access スコープが選択されている場合、ブロッキング関数に渡されます。

例:

const provider = new firebase.auth.OAuthProvider('microsoft.com');
provider.addScope('offline_access');
firebase.auth().signInWithPopup(provider);

Yahoo!

ユーザーが Yahoo でログインすると、次の認証情報がカスタム パラメータやスコープなしで渡されます。

  • ID トークン
  • アクセス トークン
  • 更新トークン

LinkedIn

ユーザーが LinkedIn でログインすると、次の認証情報が渡されます。

  • アクセス トークン

Apple

ユーザーが Apple でログインすると、次の認証情報がカスタム パラメータやスコープなしで渡されます。

  • ID トークン
  • アクセス トークン
  • 更新トークン

一般的な使用方法

次の例は、関数をブロックする一般的なユースケースを示します。

特定のドメインからの登録のみを許可する

次の例では、example.com ドメインに属していないユーザーがアプリに登録できないようにする方法を示しています。

Node.js

export.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  if (!user.email || user.email.indexOf('@example.com') === -1) {
    throw new gcipCloudFunctions.https.HttpsError(
      'invalid-argument', `Unauthorized email "${user.email}"`);
  }
});

未確認のアドレスを使用するユーザーを登録からブロックする

次の例では、未確認のアドレスを使用しているユーザーがアプリに登録できないようにする方法を示しています。

Node.js

export.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  if (user.email && !user.emailVerified) {
    throw new gcipCloudFunctions.https.HttpsError(
      'invalid-argument', `Unverified email "${user.email}"`);
  }
});

登録時にメール確認を必須にする

次の例では、登録後にユーザーにメールアドレスの確認を求める方法を示しています。

Node.js

export.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  const locale = context.locale;
  if (user.email && !user.emailVerified) {
    // Send custom email verification on sign-up.
    return admin.auth().generateEmailVerificationLink(user.email).then((link) => {
      return sendCustomVerificationEmail(user.email, link, locale);
    });
  }
});

export.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
 if (user.email && !user.emailVerified) {
   throw new gcipCloudFunctions.https.HttpsError(
     'invalid-argument', `"${user.email}" needs to be verified before access is granted.`);
  }
});

特定の ID プロバイダのメールアドレスを確認済みとして処理する

次の例では、特定の ID プロバイダからのユーザーのメールアドレスを確認済みとみなす方法を示しています。

Node.js

export.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  if (user.email && !user.emailVerified && context.eventType.indexOf(':facebook.com') !== -1) {
    return {
      emailVerified: true,
    };
  }
});

特定の IP アドレスからのログインをブロックする

次の例では、特定の IP アドレス範囲からのログインをブロックする方法を示しています。

Node.js

export.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
  if (isSuspiciousIpAddress(context.ipAddress)) {
    throw new gcipCloudFunctions.https.HttpsError(
      'permission-denied', 'Unauthorized access!');
  }
});

カスタム クレームとセッション クレームの設定

次の例では、カスタム クレームとセッション クレームを設定する方法を示しています。

Node.js

export.beforeSignIn = authClient.functions().beforeCreateHandler((user, context) => {
  if (context.credential &&
      context.credential.providerId === 'saml.my-provider-id') {
    return {
      // Employee ID does not change so save in persistent claims (stored in
      // Auth DB).
      customClaims: {
        eid: context.credential.claims.employeeid,
      },
      // Copy role and groups to token claims. These will not be persisted.
      sessionClaims: {
        role: context.credential.claims.role,
        groups: context.credential.claims.groups,
      }
    }
  }
});

IP アドレスをトラッキングして不審なアクティビティをモニタリングする

トークンが盗まれないようにするには、ユーザーのログイン元の IP アドレスを追跡し、後続のリクエストでその IP アドレスと比較します。リクエストが不審な場所(たとえば、IP がさまざまな地域からのもの)である場合は、ユーザーに再度ログインを依頼できます。

  1. セッション クレームを使用して、ユーザーのログインに使用する IP アドレスを追跡します。

    Node.js

    export.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
      return {
        sessionClaims: {
          signInIpAddress: context.ipAddress,
        },
      };
    });
    
  2. ユーザーが Identity Platform で認証が必要なリソースにアクセスしようとすると、リクエスト内の IP アドレスとログインに使用された IP アドレスを比較します。

    Node.js

    app.post('/getRestrictedData', (req, res) => {
      // Get the ID token passed.
      const idToken = req.body.idToken;
      // Verify the ID token, check if revoked and decode its payload.
      admin.auth().verifyIdToken(idToken, true).then((claims) => {
        // Get request IP address
        const requestIpAddress = req.connection.remoteAddress;
        // Get sign-in IP address.
        const signInIpAddress = claims.signInIpAddress;
        // Check if the request IP address origin is suspicious relative to
        // the session IP addresses. The current request timestamp and the
        // auth_time of the ID token can provide additional signals of abuse,
        // especially if the IP address suddenly changed. If there was a sudden
        // geographical change in a short period of time, then it will give
        // stronger signals of possible abuse.
        if (!isSuspiciousIpAddressChange(signInIpAddress, requestIpAddress)) {
          // Suspicious IP address change. Require re-authentication.
          // You can also revoke all user sessions by calling:
          // admin.auth().revokeRefreshTokens(claims.sub).
          res.status(401).send({error: 'Unauthorized access. Please login again!'});
        } else {
          // Access is valid. Try to return data.
          getData(claims).then(data => {
            res.end(JSON.stringify(data);
          }, error => {
            res.status(500).send({ error: 'Server error!' })
          });
        }
      });
    });
    

ユーザーの写真をスクリーニングする

次の例では、ユーザーのプロフィール写真をサニタイズする方法を示しています。

Node.js

export.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  if (user.photoURL) {
    return isPhotoAppropriate(user.photoURL)
      .then((status) => {
        if (!status) {
          // Sanitize inappropriate photos by replacing them with guest photos.
          // Users could also be blocked from sign-up, disabled, etc.
          return {
            photoURL: PLACEHOLDER_GUEST_PHOTO_URL,
          };
        }
      });
});

画像の検出とサニタイズの詳細については、Cloud Vision のドキュメントをご覧ください。

ユーザーの ID プロバイダの OAuth 認証情報にアクセスする

次の例では、Google でログインしたユーザーのリフレッシュ トークンを取得し、それを使用して Google Calendar API を呼び出す方法を示しています。更新トークンは、オフライン アクセスのために保存されます。

Node.js

const {OAuth2Client} = require('google-auth-library');
const {google} = require('googleapis');
// ...
// Initialize Google OAuth client.
const keys = require('./oauth2.keys.json');
const oAuth2Client = new OAuth2Client(
  keys.web.client_id,
  keys.web.client_secret
);

export.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  if (context.credential &&
      context.credential.providerId === 'google.com') {
    // Store the refresh token for later offline use.
    // These will only be returned if refresh tokens credentials are included
    // (enabled by Cloud Console).
    return saveUserRefreshToken(
        user.uid,
        context.credential.refreshToken,
        'google.com'
      )
      .then(() => {
        // Blocking the function is not required. The function can resolve while
        // this operation continues to run in the background.
        return new Promise((resolve, reject) => {
          // For this operation to succeed, the appropriate OAuth scope should be requested
          // on sign in with Google, client-side. In this case:
          // https://www.googleapis.com/auth/calendar
          // You can check granted_scopes from within:
          // context.additionalUserInfo.profile.granted_scopes (space joined list of scopes).

          // Set access token/refresh token.
          oAuth2Client.setCredentials({
            access_token: context.credential.accessToken,
            refresh_token: context.credential.refreshToken,
          });
          const calendar = google.calendar('v3');
          // Setup Onboarding event on user's calendar.
          const event = {/** ... */};
          calendar.events.insert({
            auth: oauth2client,
            calendarId: 'primary',
            resource: event,
          }, (err, event) => {
            // Do not fail. This is a best effort approach.
            resolve();
          });
      });
    })
  }
});

次のステップ