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

このドキュメントでは、ブロッキング Cloud Run 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. Google Cloud コンソールで Identity Platform の [設定] ページに移動します。

    [設定] ページに移動

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

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

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

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

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

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

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

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

    登録に対応するには:

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

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

    import gcipCloudFunctions from 'gcip-cloud-functions';
    
    const authClient = new gcipCloudFunctions.Auth();
    
    exports.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
      // TODO
    });
    
  6. package.json を開いて、次の依存関係ブロックを追加します。SDK の最新バージョンについては、gcip-cloud-functions をご覧ください。

    {
      "type": "module",
      "name": ...,
      "version": ...,
    
      "dependencies": {
        "gcip-cloud-functions": "^0.2.0"
      }
    }
    
  7. 関数のエントリ ポイントを beforeSignIn に設定します。

  8. [デプロイ] をクリックして関数を公開します。

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

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

また、Google Cloud CLI または REST API を使用して関数を作成、管理することもできます。Google Cloud CLI を使用して関数をデプロイする方法については、Cloud Run functionsのドキュメントをご覧ください。

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

beforeSignIn イベントと beforeCreate イベントは、ユーザーのログインに関する情報を含む User オブジェクトと EventContext オブジェクトを提供します。コード内で、これらの値を使用して、オペレーションの続行を許可するかどうかを決定します。

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

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

名前 説明
locale アプリの言語 / 地域。言語 / 地域を設定するには、クライアント 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.
import gcipCloudFunctions from 'gcip-cloud-functions';
// Initialize the Auth client.
const authClient = new gcipCloudFunctions.Auth();
// Http trigger with Cloud Run 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.endsWith('@acme.com')) {
    throw new gcipCloudFunctions.https.HttpsError('invalid-argument', `Unauthorized email "${user.email}"`);
  }
});

デフォルト メッセージとカスタム メッセージのどちらを使用する場合でも、Cloud Run 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 はい いいえ

更新トークン

ブロッキング関数でリフレッシュ トークンを使用するには、まず Google Cloud コンソールの [Include token credentials] プルダウン メニューの [トリガー] セクションでチェックボックスをオンにする必要があります。

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

exports.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

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

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

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

Node.js

exports.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);
    });
  }
});

exports.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

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

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

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

Node.js

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

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

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

Node.js

exports.beforeCreate = 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

    exports.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

exports.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');
const gcipCloudFunctions = require('gcip-cloud-functions');

// ...
// Initialize Google OAuth client.
const keys = require('./oauth2.keys.json');
const oAuth2Client = new OAuth2Client(
  keys.web.client_id,
  keys.web.client_secret
);

exports.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();
          });
      });
    })
  }
});

次のステップ