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

このドキュメントでは、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 イベントの一意の識別子。 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 でログインすると、次の認証情報が渡されます。

  • アクセス トークン: 別のアクセス トークンと交換できるアクセス トークンが返されます。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();
          });
      });
    })
  }
});

次のステップ