Quickstart: Web BFF#

この Quickstart では web_bff Client を Management API に登録し、Hosted Login の Email OTP で認証する Node BFF を作る。例では Production 形式の HTTPS Canonical Issuer を使う。

0. 前提#

  1. Dashboard (https://dashboard.zeroword.smartcrab.ai) にサインインし、Workspace、Project、Test Environment を用意する。Workspace ID と Project ID は Dashboard で確認できる。
  2. Dashboard の Workspace メニュー「API キー」(/w/<workspaceId>/api-keys) で、clients:write と environments:read を持つ Management API key を発行する。secret は作成直後の画面にだけ表示される。同じ Workspace に固定された既存 key に api_keys:write があれば、POST /v1/api-keys から同じ scope を持つ key も発行できる。API key は Management API を参照する。
  3. Environment ID は Dashboard で確認するか、environments:read scope を持つ key で GET /v1/environments?project_id=<projectId> を呼び、対象 Environment の id を取得する。GET /v1/environments/{environmentId} の issuer を AUTH_ISSUER に使う。

1. Client を登録する#

Management API key には clients:write scope が必要である。AUTH_PLATFORM_API_KEY に key を設定し、<workspaceId> と <environmentId> を置き換えて実行する。

この例の http://localhost callback は開発用の例外である。localhost 以外の Web callback は HTTPS を登録する。

curl -sS -X POST 'https://api.zeroword.smartcrab.ai/v1/clients' \
  -H "Authorization: Bearer ${AUTH_PLATFORM_API_KEY}" \
  -H 'Idempotency-Key: quickstart-web-bff-create-01' \
  -H 'Content-Type: application/json' \
  --data '{
    "environment_id": "<environmentId>",
    "client_type": "web_bff",
    "name": "My app Web BFF",
    "redirect_uris": ["http://localhost:4100/auth/callback"],
    "allowed_scopes": ["openid", "profile", "email", "offline_access"],
    "token_endpoint_auth_method": "client_secret_basic"
  }'

作成 response の id と client_secret を保存する。client_secret は作成 response に一度だけ含まれる。Management API では token_endpoint_auth_method が必須で、省略時の既定値はない。web_bff では client_secret_basic を指定する。認証 endpoint はこの方式と PKCE S256 を使う。redirect_uris は BFF の AUTH_REDIRECT_URI と byte-for-byte で一致させる。

同じ Idempotency-Key は同じ論理操作の再送にだけ使う。同じ key を異なる request body で使うと 409 idempotency_key_conflict になり、method や path/query が異なる操作にも別の key を発行する。

2. BFF を作る#

次の環境変数を設定する。AUTH_ISSUER は GET /v1/environments/{environmentId} の issuer field にある Canonical Issuer であり、Interaction Domain ではない。

export AUTH_ISSUER='https://id.zeroword.smartcrab.ai/e/<environmentId>'
export AUTH_CLIENT_ID='<clientId>'
export AUTH_CLIENT_SECRET='<clientSecret>'
export AUTH_REDIRECT_URI='http://localhost:4100/auth/callback'
export AUTH_COOKIE_SECRET="$(openssl rand -hex 32)"

server.ts は Node の標準 HTTP server と @auth-platform/server だけを使う。GET /me から未認証なら Hosted Login へ送り、callback 後に session user を JSON response と server log に出力する。

import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import { createServerAuth } from "@auth-platform/server";

const auth = createServerAuth({
  issuer: process.env.AUTH_ISSUER!,
  clientId: process.env.AUTH_CLIENT_ID!,
  clientSecret: process.env.AUTH_CLIENT_SECRET!,
  redirectUri: process.env.AUTH_REDIRECT_URI!,
  cookieSecret: process.env.AUTH_COOKIE_SECRET!,
  postLoginRedirect: "/me",
});

const authHandlers: Record<string, (request: Request) => Promise<Response>> = {
  "/auth/login": auth.loginHandler,
  "/auth/callback": auth.callbackHandler,
  "/auth/logout": auth.logoutHandler,
};

const dispatch = async (incoming: IncomingMessage, outgoing: ServerResponse): Promise<void> => {
  const requestHeaders = new Headers();
  for (const [name, value] of Object.entries(incoming.headers)) {
    if (value !== undefined) requestHeaders.set(name, Array.isArray(value) ? value.join(", ") : value);
  }
  const host = incoming.headers.host ?? "localhost:4100";
  const request = new Request(new URL(incoming.url ?? "/", `http://${host}`), {
    method: incoming.method ?? "GET",
    headers: requestHeaders,
  });
  const path = new URL(request.url).pathname;
  let response: Response;
  const authHandler = authHandlers[path];

  if (authHandler !== undefined) {
    response = await authHandler(request);
  } else if (path === "/me") {
    const outcome = await auth.getSession(request);
    if (outcome.status === "authenticated") {
      console.log("session user:", outcome.session.user);
      response = Response.json({ user: outcome.session.user });
      if (outcome.setCookie !== undefined) response.headers.set("set-cookie", outcome.setCookie);
    } else {
      response = Response.redirect(new URL("/auth/login", request.url), 302);
    }
  } else {
    response = new Response("Not found", { status: 404 });
  }

  outgoing.statusCode = response.status;
  response.headers.forEach((value, name) => {
    if (name.toLowerCase() !== "set-cookie") outgoing.setHeader(name, value);
  });
  const cookies = response.headers.getSetCookie();
  if (cookies.length > 0) outgoing.setHeader("set-cookie", cookies);
  outgoing.end(Buffer.from(await response.arrayBuffer()));
};

createServer((request, response) => {
  void dispatch(request, response);
}).listen(4100, "localhost", () => console.log("BFF listening on http://localhost:4100"));

Node 22 以降の TypeScript strip-types 対応 runtime では次のように起動できる。

node --experimental-strip-types server.ts

3. Hosted Login で Email OTP を試す#

ブラウザーで http://localhost:4100/me を開く。BFF は AUTH_ISSUER の authorize endpoint へ redirect し、Hosted Login に進む。Email OTP を選んでメールアドレスと受信した 6 桁 code を入力すると、callback が code を token と交換し、__Host-auth-session cookie を設定する。最後に /me の JSON と BFF の標準出力に次のような user が現れる。

{
  "user": {
    "sub": "usr_<user-id>",
    "email": "user@example.com",
    "emailVerified": true
  }
}

BFF session cookie は Secure、HttpOnly、SameSite=Lax、Path=/ で、Domain 属性を付けない。Client secret と cookie secret をブラウザーへ渡さない。