🐈

Wix Headlessを試す(2)認証を加える

2023/08/19に公開

はじめに

Wix Headlessを試す(1)最小構成のテンプレをデプロイしてみるの続きになります。
前回はWix Headlessのテンプレートで一番シンプルなものを選び、それをNetlifyにデプロイしてみました。

今回はサイト会員による認証(OAuth)を試すためにログイン処理を組み込んでみます。
また、ログインすることでサイト会員の権限が得られるため、コレクションを使った簡単な検証もしてみたいと思います。

前回利用したテンプレであるwix-cms-nextjs-template
に、Tutorial: Session Token Managementの内容をベースに、認証を付け足してみようと思います。

あくまで検証作業を進めていくための基盤作りのため、その点ご了承ください。かなり緩い作業になっております。
(サンプルコード含め、そのまま本番運用で利用できるような品質ではありません)

前提

基本的には前回の内容を踏まえているので、そちらからご覧ください。
Wix Headlessを試す(1)最小構成のテンプレをデプロイしてみる

元のテンプレートが
wix-cms-nextjs-template
でTypeScriptを使っていますが、今回参考にしたチュートリアルはJSになっています。
すぐにわかる部分は型付けしてますが、混在して見苦ししいかもしれませんがご了承ください。

フレームワークとしてNext.js 13 が利用されています。
原則SSRですが、明示的に指定することでクライアントサイド処理ができます。
わかりにくいので大見出しにクライアントサイドからサーバーサイドか記載しています。

OAuth自体については詳細は扱いません。単に煩雑になるということもありますが、Wix Headlessでの実装意図というか、まだ一部の機能しか使っていないため、諸々理解し切れていない部分が残っているので、かなり大雑把な記述になっています。(普段OAuthにあまり馴染みのないかたが話についてこれる程度)
ここは後日追記するなり、別に記事を作る機会があればと思っています。

パッケージ

Step 1: Set up the Wix Headless environment
で指定されているパッケージをインストールしますが、前回の続きなので差分だけインストールします。

yarn add @wix/members
yarn add js-cookie
yarn add @types/js-cookie

「許可されたリダイレクトドメイン」「許可された認証リダイレクト URI」の登録

「許可されたリダイレクトドメイン」

Wix がリダイレクトすることのできるドメインです(例:ログインまたは支払いページなど)。

「許可された認証リダイレクト URI」

認証後に訪問者をリダイレクトするための有効な URI のリストを提供してください。
注意: Wix はこのリストと完全に一致する URI にのみリダイレクトします。

前回作成した Client IDの設定に戻り追加します。

ナビゲーションバーの修正(クライアントサイド)

ログインボタン、ログアウトボタン、それに関連した処理を追加します。
ファイル名はapp/components/Layout/NavBar/NavBar.tsxです。

APIクライアントの追加

1. Import the SDK modules and create an API client
のコードと大枠に違いありませんが、TypeScriptの型チェックにひっかかってしまうのでCookieからtokenを取得する処理回りに手を入れています。

ここでは、サイトの識別子(Client ID)[1]、ユーザーのトークンを与えて、APIクライアントを取得しています。トークンが匿名ユーザーのものか、ログイン済みのユーザーのものかで今後の処理で取得できる権限が変わってきます。

app/components/Layout/NavBar/NavBar.tsx
import Cookies from 'js-cookie';

import { createClient, OAuthStrategy } from '@wix/api-client';
import { members } from '@wix/members';

const isEmptyObject = (obj: any) => {
  return Object.keys(obj).length === 0 && obj.constructor === Object;
};

function parseTokensFromCookies() {
  const session = Cookies.get('session');
  if (session) {
    try {
      return JSON.parse(session);
    } catch (error) {
      console.error('Error parsing tokens from cookies:', error);
    }
  }
  return null;
}

const myWixClient = createClient({
  modules: { members },
  auth: OAuthStrategy({
    clientId: process.env.NEXT_PUBLIC_WIX_CLIENT_ID!,
    tokens: parseTokensFromCookies(),
  }),
});

ログイン・ログアウト時の処理追加

Navbarコンポーネントに以下の処理を追加します。

login()では、OAuth認証のためのURLを取得し、以下のような認証画面(Wix側管理)にリダイレクトします。

同時に、コールバック処理で認証に使うOAuthデータを Local Storage に保存しています。
(Wix側でのログイン確認後、逆にWixからパラメタ付きでリダイレクトされる)

logout()では、ログアウト処理に必要なURLを取得し、リダイレクトします。同時にCookieで保持していたトークンを破棄しています。

displayAuthNav() では既存のHTML・スタイルを流用しつつ、ログイン状態に応じて、ログイン・ログアウトボタンを出し分けています。ここは既存のコードに取り込むため、サンプルコードに手を加えています。

fetchMember()では、ログイン状態を取得し、ログイン状態であればログインユーザーの情報を取得しています。

app/components/Layout/NavBar/NavBar.tsx
export function NavBar() {
///////////
// 略
///////////
  async function login() {
    const data = myWixClient.auth.generateOAuthData(
      `${window.location.origin}/callback`,
      window.location.href
    );
    localStorage.setItem('oauthRedirectData', JSON.stringify(data));
    const res = await myWixClient.auth.getAuthUrl(data);
    const authUrl = res.authUrl;
    window.location.href = authUrl;
  }

  async function logout() {
    const { logoutUrl } = await myWixClient.auth.logout(window.location.href);
    Cookies.remove('session');
    window.location.href = logoutUrl;
  }

  function displayAuthNav() {
    return (
      <>
        <StyledNavLink
          isActive={false}
          href="/"
          onClick={(e) => {
            e.preventDefault();
            setIsMenuShown(false);
            isEmptyObject(member) ? login() : logout();
          }}
        >
          {isEmptyObject(member) ? 'Login' : 'Logout'}
        </StyledNavLink>
        <span className="absolute -bottom-5 md:hidden border-b-2 w-48 left-[calc(50%_-_theme(space.24))]" />
      </>
    );
  }

  const [member, setMember] = useState({});

  async function fetchMember() {
    const isLogin = myWixClient.auth.loggedIn();

    const data = isLogin ? await myWixClient.members.getMyMember() : {};
    const member = data.member;
    if (member) {
      setMember(member);
    }
  }

  useEffect(() => {
    fetchMember();
  }, []);
//////////
// 略
//////////
}

callback(クライアントサイド)

先ほど作ったログインボタンを押下すると、Wixが管理するログイン画面に遷移します。ここで新規アカウント作成なり、ログインをすると、今回作成するコールバックページに戻ってきます。

新規ページ作成

ベースとなるテンプレートにはないページなので
app/callback/page.tsx
というページを新規に作成します。

'use client';
import Cookies from 'js-cookie';
import { useEffect, useState } from 'react';

でわかるようにクライアント側の処理になります。

APIクライアントの作成

同じような処理の繰り返しになってしまいますが、APIクライアントを作成します。
(共通化しても良かったですね)

verifyLogin()

Local Storageに保存していたOAuthデータ、遷移元から渡されるパラメタを使って、リクエストパラメタの検証、トークンを取得してます。
サンプルコードにはwhileを使ってリトライしてますが、この書き方だと障害発生時に無限ループになりかねないように見えるので、私はコメント外して使っています。
本番運用時は真面目に調べてリトライ処理を書きたいと思います。。。
temporary workaroundとサンプルコードにコメントあるのですが、もう少し詳しく書いてほしいな。。。)

処理が完了したら元のページにリダイレクトします。

'use client';
import Cookies from 'js-cookie';
import { useEffect, useState } from 'react';
import { createClient, OAuthStrategy } from '@wix/api-client';
import { parse } from 'path';

function parseTokensFromCookies() {
  const session = Cookies.get('session');
  if (session) {
    try {
      return JSON.parse(session);
    } catch (error) {
      console.error('Error parsing tokens from cookies:', error);
    }
  }
  return null;
}

const myWixClient = createClient({
  auth: OAuthStrategy({
    clientId: process.env.NEXT_PUBLIC_WIX_CLIENT_ID!,
    tokens: parseTokensFromCookies(),
  }),
});

export default function Callback() {
  const [nextPage, setNextPage] = useState(null);
  const [errorMessage, setErrorMessage] = useState(null);

  async function verifyLogin() {
    const json = localStorage.getItem('oauthRedirectData');
    let data;
    if (json) {
      data = JSON.parse(json);
    }
    localStorage.removeItem('oauthRedirectData');

    try {
      const { code, state } = myWixClient.auth.parseFromUrl();
      let tokens = await myWixClient.auth.getMemberTokens(code, state, data);
      //while (!tokens?.refreshToken?.value) {
        // temporary workaround
      //  tokens = await myWixClient.auth.getMemberTokens(code, state, data);
      //}
      Cookies.set('session', JSON.stringify(tokens));
      window.location.href = data?.originalUri || '/';
    } catch (e: unknown) {
      setNextPage(data?.originalUri || '/');
    }
  }

  useEffect(() => {
    verifyLogin();
  }, []);

  return (
    <article>
      {errorMessage && (
        <>
          <span>{errorMessage}</span>
          <br />
          <br />
        </>
      )}
      {nextPage ? <a href={nextPage}>Continue</a> : <>Loading...</>}
    </article>
  );
}

middleware(サーバーサイド)

middleware.tsを配置するパスを間違えて動作してない状態でテストしていたのですが、以下のコードはなくても動作してしまいました。。。
(指定された場所に配置すると、サーバーにリクエストがあるたびに実行されます)

内容としてはセッションの初期化で、クッキーがクライアントから送信されてこない場合に、未ログイン=匿名ユーザーのトークンを作成しています。

動くのだから問題ないといえなくもないですが(APIの内部的に、トークンが未指定の場合は自動的に初期化してくれている、トークンがなくても匿名ユーザー権限で動作するように実装されている、など考えられなくもない)、初期化して明示的にトークンを渡す方が正しい気もするので、特に支障がなければ実装しておくと良いと思います。

middleware.ts
import { createClient, OAuthStrategy } from '@wix/api-client';
import { NextResponse } from 'next/server';

export async function middleware(request) {
  // generate a session for the visitor if no session exists
  if (!request.cookies.get('session')) {
    const response = NextResponse.next();
    const myWixClient = createClient({
      auth: OAuthStrategy({ clientId: process.env.NEXT_PUBLIC_WIX_CLIENT_ID! }),
    });
    response.cookies.set(
      'session',
      JSON.stringify(await myWixClient.auth.generateVisitorTokens())
    );
    return response;
  }
}

前回実装部分(useWixClientServer.ts)の修正(サーバーサイド)

前回ニュース記事をAPIから取得する部分は、都度トークンを作成する実装だったので、Cookieから取得し、ログインユーザーに対応できるようにしました。

この修正をしないと、いくらログインしても匿名ユーザーのトークンでAPIを呼び出してしまいます。
(機能毎にAPIクライアントを作成するのではなく、そもそもクライアント・サーバーサイド考慮した上で共通化すればいいんですけどね)

getWixClient()のコメントアウト部分が古い実装です。
parseTokensFromCookies()はフロント部分から移植したのでCookieの操作をjs-cookieから置き換えました。

この修正でログインユーザーの権限がニュース記事にも適用されました。

app/hooks/useWixClientServer.ts
import { createClient, OAuthStrategy } from '@wix/api-client';
import { dataItems } from '@wix/data-items';
import { cookies } from 'next/headers'

export const getWixClient = async () => {
  const wixClient = createClient({
    modules: { dataItems },
    auth: OAuthStrategy({
      clientId: process.env.NEXT_PUBLIC_WIX_CLIENT_ID!,
      // 修正(Cookieから取得)
      tokens: parseTokensFromCookies()
    }),
  });
  //const tokens = await wixClient.auth.generateVisitorTokens();
  //wixClient.auth.setTokens(tokens);
  return wixClient;
};

function parseTokensFromCookies() {
  //const session = Cookies.get('session');
  const cookieStore = cookies()
  const session = cookieStore.get('session')
  if (session && session.value) {
    try {
      return JSON.parse(session.value);
    } catch (error) {
      console.error('Error parsing tokens from cookies:', error);
    }
  } else {
    console.log('nosession')
  }
  return null;
}

検証

ログインユーザーのトークンが扱えるようになったのでニュース記事のコレクション(DBのテーブルのようなもの)で検証したいと思います。

組み合わせは以下の通りです。

  • 未ログインユーザー x 公開コレクション
  • 未ログインユーザー x 「サイト会員限定」コレクション
  • ログインユーザー x 公開コレクション
  • ログインユーザー x 「サイト会員限定」コレクション

会員登録したユーザーがサイト会員です。
未ログインユーザーは「公開」コンテンツのみアクセス可能です。
WixのCMSではコレクション毎に権限設定ができます。
そこで以下のようにダッシュボードにてコレクションの権限を変更しながら各パターンを検証します。

コレクションの権限変更方法

検証は以下の方法でコレクションの権限を変更しながら実施します。

ダッシュボード、左サイドメニューから「CMS」を選択します。

今回利用する「News」を選択し、遷移後の画面で「その他のアクション」→「権限・プライバシー」を押下します。

一番上のプルダンメニューで、頻出するパターンで権限設定をすることができます。

2番目より下のプルダウンで細かい設定が可能です。ここを変更した場合、自動的に一番上のプルダウンがカスタムになります。

では上記の手順でコレクションの権限を変更しながら検証します。

未ログインユーザー x 公開コレクション

ニュース記事が表示されます。

未ログインユーザー x 「サイト会員限定」コレクション

例外が発生します。

流石に実用的ではないので、実際に会員限定処理を実装するときはAPI呼び出し前の条件分岐なり、例外処理を加えたいと思います。

ログインユーザー x 公開コレクション

ニュース記事が表示されます。

ログインユーザー x 「サイト会員限定」コレクション

ニュース記事が表示されます。

まとめ

今回はログイン機能を追加し、OAuth認証の簡単な流れ・実装と、ログイン・未ログイン権限で実行・アクセスできる処理の違いの例をコレクションで紹介しました。

まだまだ実用的なサイトからは程遠いので、また機会があれば機能追加をしてみたいと思います。

[補足]Client IDについて

前回利用したテンプレートのREADME.mdには

After creating an OAuth app, store the Client ID in a secure location.
Note: Do not push the client ID to your source control.

とあり、上記の記述と、wix-cms-nextjs-templateのコードだけ読んで作業している時は、Client IDがAPIキー(secretキー)のような印象を受けました。(前回の記事では認証もなく、API接続はサーバーサイドだったので)

一方で今回のチュートリアルや公式ドキュメントではOAuthと記載されているので(なんなら別のサンプルコードではハードコーディングされている)、今回の記事ではクライアントサイドに Client ID を置いて実装しています。

ただ、システム、単体のコード、初期開発では問題なくても、運用面では注意したい点があります。
例えば、Wix Headlessを想定していない既存サイトに、後から Client IDを発行するようなケースです。
この場合、サイト上で利用しないため漏洩を免れているような、公開状態のコレクションが存在する可能性があります。

あるいはWix Headlessで開発したサイトも、権限に詳しくないオペレーターが、後からCMSを操作し、公開コレクションに個人情報を配置してしまう事故もありえます。

しばらくすると事例が積み重なり、ベストプラクティス(実装面、運用面)のようなものも整ってくるように思いますが、現在はまだまだ事例が少ないので、やや慎重な利用が必要かと思います。新しい技術にはつきものですが。

一応の断り書きは書いていますが、記事を書いている私自身試行錯誤しながらの作業なので、記事の内容は鵜呑みにせず、お取り扱いにはご注意ください。

また、フィードバック歓迎歓迎してますのでお気軽にコメントいただければ幸いです。

脚注
  1. [補足]Client ID についてを参照 ↩︎

Discussion