ブログをお引越ししてみています

ブログを試験的にこちらにお引越ししています。というおしらせでした。

garakuta-toolbox.com

暫くお引越し先のブログ環境で運用してみて、よさそうだったら本格的にお引越ししようかなと思っています。 現状はてなブログに存在する記事たちについては当分そのままにしておく予定です。これらの記事をお引越しするかどうかは未定です。

ではでは

Vue CLIのWebComponentビルドをVue3でも使いたい

Vue CLIには ビルドターゲットとして Web Componentを選択できる機能があり、これを使うとVueのSFC(単一ファイルコンポーネント)をWeb Componentとして書き出してくれます。

cli.vuejs.org

このビルドモードには以下の特徴があります。

ところが、Vue CLIでVue3を使い、Web Componentビルドを試すと、以下のようになります。

>>> % npx vue-cli-service build --target wc-async --name test 'src/components/*.vue'

⠋  Building for production as web component (async)...
✔  Building for production as web component (async)...
 ERROR  Vue 3 support of the web component target is still under development.

このコマンドはVue3ではまだ使えません。Issueにもサポートの要望が上がっています。

github.com

ところで、Vue3からはVueコンポーネントをWeb Componentとして取り扱えるようにするための各種環境が公式にサポートされるようになりました。

v3.ja.vuejs.org

以下のように記述することで、VueコンポーネントをWebComponentとして利用することができるようになります。

import { defineCustomElement } from 'vue'
import TestComponent from './TestComponent.ce.vue'

const el= defineCustomElement(TestComponent )

customElements.define('test-component', el)

また、上記ドキュメントでも言及されていますが、Viteには「Glob Import」という機能があり、これを使うと特定のディレクトリ配下にあるコンポーネントをImportする処理を簡単に書くことができます。

/**
 * const modules = {
 *  './components/hoge.vue' : () => import('./components/hoge.vue')
 *  './components/fuga.vue' : () => import('./components/fuga.vue')
 * }
 * 以下の命令は上記のソースコードに等しい
 */
const modules = import.meta.glob('./components/*.vue')

(webpackでも同様のことができそうな雰囲気ですが、今回は試していません。)

modules に格納されるモジュールはPromiseを解決してロードさせることからも示される通り、非同期で読み込まれます。

Viteを使えばVue CLIのWeb Componentビルドと同等の要件を満たせそうなビルド結果が得られそうなので、Viteをセットアップしておきます。( 最初の Vite プロジェクトを生成する

プロジェクトのディレクトリ内は概ね以下のような構成になると思います。

.
|-- README.md
|-- index.html
|-- package-lock.json
|-- package.json
|-- src
|   |-- components
|   `-- main.js
`-- vite.config.js

ここで Vue CLIのWeb Componentビルドの要件を思い出し、「SFCごとのjsファイルと、一つのロードスクリプトを出力する」「ページ内で必要なWebComponentがリクエストされる」というビルド結果が得られるようにエントリポイントとなる src/main.js を書き下してみます。 (ざっくりしたソースコードですがお許しください)

import { defineCustomElement } from 'vue'

// comopnents 配下のすべてのVueファイルを読み込む
const modules = import.meta.glob('./components/*.vue')

for (const [path] of Object.entries(modules)) {
  const el = document.getElementsByTagName(convertPathToElementName(path))

  // タグがHTMLの中から見つかったらモジュールをFetchする
  if (el.length > 0) {
    modules[path]().then((mod) => {
      const el = defineCustomElement(mod.default)

      customElements.define(convertPathToElementName(path), el)
    })
  }
}

/**
 * パスからCustom Elementのタグ名を決定する
 * @param {string} path パス ex) './components/HogeFuga.vue'
 * @return {string} タグ名 ex) 'hoge-fuga'
 */
function convertPathToElementName(path) {
  // パスから必要な部分を取り出してsnake-caseになるようにしている
  return path.split('/')
    .pop().
    replaceAll(/([A-Z])/g, '-$1').
    toLowerCase().
    substr(1).
    replace(/\.vue/, '').
    replace(' ', '')
}

vite.config.js は以下のようになります。

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      // プロパティを指定しない場合は `*.ce.vue` というファイルがCustom Elementとして扱われる
      // WebComponentとして扱いたいSFCのファイル名を *.ce.vue にするならば指定は不要
      // Custom Elementとして扱われる場合、StyleがShadow DOMの中に挿入される
      // ref: https://github.com/vitejs/vite/tree/main/packages/plugin-vue#readme
      customElement: true, 
    })
  ]
})

Viteプロジェクト内の src/components/ 内にいくつかのコンポーネントを定義してみます。

src
|-- components
|   |-- TestButton.ce.vue
|   `-- TestComponent.ce.vue
`-- main.js

Viteプロジェクト内のルートディレクトリに存在するHTMLファイルにWebComponentを埋め込んでみます。

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <link rel="icon" href="/favicon.ico" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Vite App</title>
      </head>
      <body>
        <test-button>ほげほげふがふが</test-button>
        <test-component />
        <script type="module" src="/src/main.js"></script>
      </body>
    </html>

これで npm run build してみると、SFCとして定義されたVueコンポーネントがWebComponentとして配置されている様子を確認できます。また、一部のWebComponentをHTMLから除外しつつブラウザの開発者ツールで確認すると、必要なぶんのJavaScriptファイルだけが取得されていることも確認できると思います。

Vue CLIのWeb ComponentビルドのVue3サポートに不安がありましたが、Vue CLIのWeb Componentビルドを使って開発していたプロジェクトも Vueの移行ビルド を使いつつ、上記のように製品のビルドをViteに任せることでVue3化することができそうだということがわかってひとまず安心したりしていました。

今回はお試しということで、細かい調整などはしていませんがなんとなく雰囲気が分かったので記事にしてみたという感じでした。ではでは~

みんなでつくるダンジョンのログインでTwitterアカウントを利用できるようになりました

こちらの記事で触れていましたが、きょうからTwitterアカウントでログインできるようになりました

garakuta-toolbox.hatenablog.com

  • 新規アカウントを作成する場合は アカウント作成 ページでTwitter認証でサインアップを選べます
  • 既存アカウントにTwitterアカウントを紐づける場合は ログイン したあとに アカウント設定 ページで連携できます
    • アカウント設定ページで再度認証(ユーザー名 / パスワードの入力)を求められます

ログイン画面の見た目なども微妙に変わったりしているので、万が一「ログインできない」というときは @piyorinpa までお知らせください。

ではでは~

TypeScriptのConditional Typesで引数に応じた型を選択的に出しわける

趣味のライブラリ開発を行っていたときに試行錯誤したはなしです。せっかくなのでブログにまとめました。

たとえば以下の仕様を満たすクラス Dispatcher を作ります。

  • イベントを購読したり発行するDispatcherクラス
    • 複数のイベントと、それに対応する処理を subscribe メソッドと publish メソッドで登録できます
  • subscriberを登録する subscribe メソッド
    • subscriber は、イベントの種別とコールバック関数を持ちます
  • イベントを配信する publish メソッド
    • publish時にイベントに応じた引数(イベント変数)をセットすることで、subscriberのコールバック関数に引数を渡しつつ呼び出します
    • イベントごとにイベント変数の型が決定されます

これらの仕様を満たすように、こんなかんじのクラスと型を定義します。

type EventBase = {name: string}
type Subscriber = {
  event: EventBase,
  callback: (params: any) => void
}

class Dispatcher {
  private subscribers = new Map<string, Array<Subscriber>>()

  publish(event: EventBase, params: any) {
    // subscribers に登録されたイベントのうち、eventに一致するsubscriberを取り出して
    // コールバック関数を呼び出す
    this.subscribers.get(event.name)?.forEach(item => item.callback(params))
  }

  subscribe(item: Subscriber) {
    // subscriebrs に subscriber を登録する
    this.subscribers.has(item.event.name) ?
      this.subscribers.get(item.event.name)?.push(item) :
      this.subscribers.set(item.event.name, [item])
  }
}

subscribeでイベントを登録します。

const GetDocumentEvent = {name: 'GetDocument'}

const subscriber = {
  event: GetDocumentEvent,
  callback: (params: any) => {console.log('Parameter :', params)}
}

dispatcher.subscribe(subscriber)

publish でイベントを配信します。先ほど登録した subscriber のコールバック関数にpublishメソッドの引数 params をコールバック関数の引数にセットして呼び出します。 (以降、subscriberのcallback関数の引数を「イベント変数」と呼ぶことにします)

// subscriber.callback が params = {id: 1} の引数を渡されつつ呼び出される
dispatcher.publish(GetDocumentEvent, {id: 1})

// 実行結果: Parameter: {id: 1} がプリントされる

ここまでの実装を TypeScript Playground で確認できます。

ところで、Dispatcher クラスに着目すると、publishメソッドのparamsが any型です。このparams(イベント変数)は、publishするイベントによって型を固定したいのですが、このままだと eventとparamsの型の対応が記述できていないので、どんな型の変数を代入してもエラーになりません。

どうにかして、publish メソッドの params引数に、対応するイベントのイベント変数型と一致しない値を与えたときは型エラーになるように定義したいです。

const GetDocumentEvent = {name: 'GetDocument'}
type GetDocumentEventSubscriber = {
  event: EventBase,
  callback: (params: {id: number}) => void
}

const PutDocumentEvent= {name: 'PutDocument'}
type PutDocumentEventSubscriber = {
  event: EventBase,
  callback: (params: {id: number, content: string}) => void
}

// [TODO] Dispatcher クラスをいい感じに定義する

dispatcher.publish(GetDocumentEvent, {id: 1})
dispatcher.publish(PutDocumentEvent, {id: 1, content: 'bar'})

dispatcher.publish(GetDocumentEvent, {id: 1, content: 'bar'}) // Type Error にしたい

イベント型とイベント変数型をDispatcherクラスに複数登録できるようにする

「イベントに応じて」pubishメソッドのparamsの型を決定するには、まずは各イベントを適切に型として定義しつつ、Dispatcherクラスがこのイベント型たちを知っている必要があります。

EventBaseと型が一致するように、こんな感じにイベントを定義してみます。

type EventBase = {name: string}

// as const を付けないと typeof の結果が {name: string} となる
// as const を付けることで typeof の結果が {name: 'GetDocument'} となる
const GetDocumentEvent = {name: 'GetDocument'} as const
const PutDocumentEvent = {name: 'PutDocumentEvent'} as const

type GetDocumentEventType = typeof GetDocumentEvent 
type PutDocumentEventType = typeof PutDocumentEvent

paramsの型も同様に定義してみます。

// PutDocumentEvent に対応するイベント変数型
type GetDocumentEventParams = {
  id: number
}

// PutDocumentEvent に対応するイベント変数型
type PutDocumentEventParams = {
  id: number,
  content: string
}

イベントとparamsの型を紐づけられるように、Subscriber型を書き直し、イベント型とイベント変数型を指定できるようにします。

export type Subscriber<E, P> = {
  event: E,
  callback: (params: P) => void
}

上記の定義を使ってイベントごとのSubscriber型を定義します。

type GetDocumentEventSubscriber = Subscriber<GetDocumentEventType , GetDocumentEventParams>
type PutDocumentEventSubscriber = Subscriber<PutDocumentEventType, PutDocumentEventParams>

これで「イベント型とイベント変数型の対応関係」が表現できるようになりました。これを Dispatcher形に与えられるようにするために、ジェネリクス型Sを定義します。 ( extends で 型SはSubscriberを拡張したものであると明示します)

また、Dispatcher内のsubscribersに指定する型も、Sに置きなおします。

- class Dispatcher {
+ class Dispatcher<S extends Subscriber<EventBase, any>> {
-    private subscribers = new Map<string, Array<Subscriber>>()
+    private subscribers = new Map<string, Array<S>>()

  // ...省略...

-    subscribe(item: Subscriber) {
+    subscribe(item: S) {
}

ここまでの実装で、「Dispatcherクラスがつかうイベント型およびイベント変数型を定義」できました。インスタンス化するときはこのように、Dispatcherクラスの中で使いたいSubscriber型のUnion Typeを与えます。

// Dispatcherで使うSubscriber型のUnion Type
type DocumentEventSubscriberTypes  =
  GetDocumentEventSubscriber |
  PutDocumentEventSubscriber

const dispatcher = new Dispatcher<DocumentEventSubscriberTypes>()

ここまでの実装は TypeScript Playground で確認できます。

Conditional Types でUnion Typeから特定のSubscribe型を抽出する

publishメソッドにおいて、「与えられたイベント型」を取得できるように修正します。以下のように書くことで、コンパイラが決定した引数 event の型をEとして参照できるようになります(TypeScriptのリファレンス に詳しい解説があります)。

-  publish(event: EventBase, params: any) {
+  publish<E extends SubscribedEvents<S>>(event: E, params: any) {

最終的に活用したいイベント変数型(=publishメソッドのparams変数の型)はSubscriber型のcallbackメンバが(型Pとして)持っています。

export type Subscriber<E, P> = {
  event: E,
  callback: (params: P) => void   // <-- このPを publishメソッドのparamsの型としてつかいたい
}

また、Dispatcherクラスにはジェネリクス型Sとして、Dispatcherクラスの中で使われ得るSubscriber型のUnion Typeが渡されていることを思い出します。 これらを活用していい感じにするために、まずはジェネリクス型Sから与えられたイベント型に対応するSubscriber型を抽出する方法を考えてみます。

export type Subscriber<E, P> = {
  event: E,      // <-- 与えられたイベント型とEが一致するSubscriber型を
                 //     Subscriber型のUnion Type(Dispatcherのジェネリクス型S)から抽出したい
  callback: (params: P) => void
}

TypeScriptには「与えられた型に応じて型を決定する」というロジックをかける Conditional Types という仕組みがあります。

www.typescriptlang.org

T型がU型に含まれる場合はFoo型はS型となり、そうでない場合はV型になります。

type Foo = T extends U ? S : V

例えば以下のように書くことができます。

type Hoge = 'hoge'
type Foo = Hoge extends string ? true : false  // 結果はTrue型になる
type Bar = Hoge extends number ? true : false  // 結果はFalse型になる

これは以下のようにもかけます。

type Foo<T> = T extends string ? true : false

type Hoge = Foo<'hoge'>         // 結果はTrue型
type Fuga = Foo<1>              // 結果はFalse型

const str = 'abc'
type Piyo = Foo<typeof str>     // 結果はTrue型

さらに、以下のようにUnion Typeを与えた場合、Union Typeごとに条件が評価されます。

type Foo<T> = T extends string ? true : false

type Hoge = Foo<'abc' | 123>   // 結果は(True | False) 型。つまりboolean型。

// 上記は以下のように書いたときと同じ結果
type Fuga = Foo<'abc'> | Foo<123>

これを利用すると、あるUnion Typeから特定の型を含む型を抽出できます。SubscriebrのUnion TypeであるSのうち、イベント型Eを含む型を抽出する型定義は以下のように書けます。

実行結果は TypeScript Playgroundで確認できます。

type SubscribesFromEvent<S, E> = S extends {event: E} ? S : never

// Test1型は GetDocumentEventSubscriber 型になる
type Test1 = SubscribesFromEvent<DocumentEventSubscriberTypes, GetDocumentEventType>  
// Test2型は PutDocumentEventSubscriber 型になる
type Test2 = SubscribesFromEvent<DocumentEventSubscriberTypes, PutDocumentEventType>

ちなみに、「あるUnion Typeから特定の型を含む型を抽出」するユーティリティ型 Extract<Type, Union> がTypeScriptでは標準で定義されています。

www.typescriptlang.org

これを使うと以下のように書き直すこともできます。

// Test1型は GetDocumentEventSubscriber 型になる
type Test1 = Extract<DocumentEventSubscriberTypes, {event: GetDocumentEventType}>
// Test2型は PutDocumentEventSubscriber 型になる
type Test2 = Extract<DocumentEventSubscriberTypes, {event: PutDocumentEventType}>

つまり、ユーティリティ型 Extract<Type, Union> の定義は以下と同様です。

type Extract<T, U> = T extends U ? T : never

これで、「Subscribe型のUnion Typeのうち、あるイベント型から特定のSubscribe型を抽出」できました。

つぎは、抽出したSubscriber型のうち、イベント変数型Pを抽出する方法を考えます。

Subscribe型からイベント変数型Pを抽出する

publishメソッドのイベント型を決定するためには、Subscriber型から以下のP型を抽出する必要があります。これも Conditional Types を使います。

export type Subscriber<E, P> = {
  event: E,
  callback: (params: P) => void   // <-- このPを publishメソッドのparamsの型としてつかいたい
}

Conditional Types では infer 句によって、extends 句で比較したときに解決された型を戻り型として使うことができます。(関連ドキュメント

type Foo<T> = T extends (p: infer Params) => void ? Params : never

type Hoge = Foo<(param: string) => void>   // Hoge は string型
type Fuga = Foo<(param: number) => void>   // Fuga は number 型
type Piyo = Foo<string>                    // Piyo は never型

これを利用して、以下のような型を定義してみます。

// S['callback'] と書くとS型のうちcallbackメンバの型を取得できる
// https://www.typescriptlang.org/docs/handbook/2/indexed-access-types.html
// 
// S['callback'] を参照できるようにするため、'S extends Subscriber<EventBase, any>' と記述し
// S型がSubscriber型の拡張型であることを明示する
type ParamsFromSubscriber<S extends Subscriber<EventBase, any>> =  S['callback'] extends (p: infer Params) => void ? Params : never

このようにするとSubscriber型からイベント変数型を抽出できます。

実行結果は TypeScript Playgroundで確認できます。

// GetDocumentEventParams 型になる
type Test1 = ParamsFromSubscriber<GetDocumentEventSubscriber>

// PutDocumentEventParams 型になる
type Test2 = ParamsFromSubscriber<PutDocumentEventSubscriber>

ここまでくれば、あとはこれらを組み合わせることで問題が解決できそうです。

イベント型に応じてイベント変数型を決定する

これまでで定義した、Subscriber型のUnion Typeからイベント型に基づきSubscriber型を一つ決定する SubscribesFromEvent 型と、 Subscriber型で使われているイベント変数型を抽出する ParamsFromSubscriber 型を組み合わせて、 CallbackParamsFromEvent 型を定義します。

type CallbackParamsFromEvent<S extends Subscriber<EventBase, any>, E extends EventBase> = ParamsFromSubscriber<SubscribesFromEvent<S, E>>

CallbackParamsFromEvent 型を使うことで、あるイベント型からSubscriber型のUnion Typeを参照してイベント変数型を決定できるようになりました。 Dispatcherクラスのpublishメソッドを以下のように書き直します。

-  publish<E extends SubscribedEvents<S>>(event: E, params: any) {
+  publish<E extends SubscribedEvents<S>>(event: E, params: CallbackParamsFromEvent<S, E>) {

これで any型を排除することができました。最終的な実装結果は TypeScript Playground を確認してください。

以下のように型を活用できていることがわかると思います。

/* GetDocumentEvent */
const GetDocumentEvent = {name: 'GetDocument'} as const
type GetDocumentEventType = typeof GetDocumentEvent 
type GetDocumentEventParams = {
  id: number
}
type GetDocumentEventSubscriber = Subscriber<GetDocumentEventType, GetDocumentEventParams>

/* PutDocumentEvent */
const PutDocumentEvent = {name: 'PutDocumentEvent'} as const
type PutDocumentEventType = typeof PutDocumentEvent
type PutDocumentEventParams = {
  id: number,
  content: string
}
type PutDocumentEventSubscriber = Subscriber<PutDocumentEventType, PutDocumentEventParams>

export type DocumentEventSubscriberTypes  =
  GetDocumentEventSubscriber |
  PutDocumentEventSubscriber

const dispatcher = new Dispatcher<DocumentEventSubscriberTypes>()

// 型チェックをパスする
dispatcher.publish(GetDocumentEvent, {id: 1})
dispatcher.publish(PutDocumentEvent, {id: 1, content: 'Content'})

// イベント型に紐づいたイベント変数型を与えていないので型エラーになる
dispatcher.publish(GetDocumentEvent, {foo: 1})
dispatcher.publish(PutDocumentEvent, {id: 1})

publish メソッドで登録できるイベントを制限する

このままだと、以下のように未登録のイベント型を登録できてしまいます。

const dispatcher = new Dispatcher<DocumentEventSubscriberTypes>()

// DocumentEventSubscriberTypes に PatchDocumentEvent は含まれていないとすれば、
// 以下はType Errorになるようにしたい
dispatcher.publish(PatchDocumentEvent, {id: 1, content: 'Content'})

これまで使ったConditional Typesを活用し、以下の型を新たに定義します。

type SubscribedEvents<S extends Subscriber<EventBase, any>> = Pick<S, 'event'>['event']

Pick<T, U> は T型からU型を抽出した型を返却するユーティリティ型です。これを使って、Subscriber型のUnion Typeからイベント型を抽出します。

www.typescriptlang.org

以下のようにpublishメソッドのイベント型を絞り込むことで、S型として指定したSubscriber型のUnion Typeに含まれないイベント型は登録できなくなります。

  class Dispatcher<S extends Subscriber<EventBase, any>> {
    private subscribers = new Map<string, Array<S>>()

-   publish<E extends EventBase>(event: E, params: CallbackParamsFromEvent<S, E>) {
+   publish<E extends SubscribedEvents<S>>(event: E, params: CallbackParamsFromEvent<S, E>) {
      this.subscribers.get(event.name)?.forEach(item => item.callback(params))
    }

  // ... 省略 ....
}

実行結果はTypeScript Playgroundで確認できます。

const dispatcher = new Dispatcher<DocumentEventSubscriberTypes>()

// 型チェックをパス
dispatcher.publish(GetDocumentEvent, {id: 1})
dispatcher.publish(PutDocumentEvent, {id: 1, content: 'Content'})

// 未登録の PatchDocumentEvent を渡したためType Error
dispatcher.publish(PatchDocumentEvent, {id: 1, content: 'Content'})

まとめ

Conditional Typesを活用することで、与えられた型に応じて型の場合分けをすることができます。 また、既存の型定義から型を抽出したり選択することで新たな型をプログラマブルに定義できます。 うまくつかえば、型の定義をあきらめずにライブラリを記述できるはずです。

特に、ライブラリを自作する際には、型定義ができない場合は「型定義できるようにうまく実装する」ことを心掛けることにより、 型によってソースコードの取り扱い方が明確に示されることから、よりよいソースコードになるかもしれません。

ではでは~

既存アプリケーションの認証基盤を Firebase Authentication にお引越し

そろそろ自作WebアプリケーションTwitterログインに対応したいなと思い続けてはや1年。重い腰を上げてやるぞ~となったのでやってゆきます。

このWebアプリケーションの構成は概ね以下の通り。これをどのように移行していくかを考えます。

  • Ruby on Rails製のAPIサーバ(以下リソースサーバと呼ぶ)とVue.js製フロントエンドで構成されている
  • 認証まわりはdevise gemを利用しており、リソースサーバと認証サーバは同居している
  • リソースサーバ宛のAPIリクエストの認証はセッションクッキーに基づき行っている

どうして置き換えるの?

上記構成だと OmniAuth gemをつかうことで目的は果たせそうですが、以下の理由で置き換えることにしました。

  • 今後ほかのWebアプリケーションを作りたくなったときに備えて認証機能をアプリケーションから切り離しておき、使いまわせるようにしたかった
  • 新しいログイン方法を採用したいときにそれを開発したくない
    • メールリンク / パスワード / 外部ID / 2要素認証 etc...
    • 認証周辺の処理は特にセキュリティへの配慮が必要なので自前で開発したくない。みんなが使っている安全なシステムに任せたい。
    • Firebase Authentication から Identity Platform に移行すれば二要素認証もサポートできそう?(Firebase Authenticationと Identity Platformは内部的には同一っぽいので、Firebase Authenticationで追加したユーザーは Identity Platformでも参照できる)

今回は、「Twitter認証をサポートしていること」かつ「ドキュメントが分かりやすく読みやすい」ということで外部認証基盤としてFirebase Authenticationを選択しました。

firebase.google.com

基本的には上記ドキュメントを参照しながら、必要に応じて Firebase JavaScript SDKのドキュメント を見て進めました。

認証情報の受け取りとリソースサーバの認証

認証情報の要求とリソースサーバでの認証情報の利用は、概ねこんな感じで行えそうです。

f:id:piyorinpa:20211222224023p:plain

  1. クライアント側(今回はWebフロントエンド)からFirebase Authenticationが提供する認証サーバにログインリクエストを行う
  2. 認証サーバから認証の成否+認証が成功していればユーザー情報が返却される
  3. ユーザー情報のうち、IDトークン(署名付きJWT)をリソースサーバに送信する
  4. リソースサーバ側で公開鍵を用いてIDトークンを検証
  5. IDトークンの検証の結果、認証サーバから払い出されたものであることを確認できたら、トークンのペイロードに含まれるユーザーIDからユーザーを特定してリソースを返却する

これを順番に行ってみます。

準備

まずは 公式ドキュメント を参考にしながら、WebフロントエンドアプリケーションにFirebase SDKをインストールします。

npm install --save firebase

アプリケーションを初期化する際に、Firebase SDKを初期化する必要があります。APIキーなどはFirebaseのWebコンソールなどで確認しましょう。

import { initializeApp } from 'firebase/app';

initializeApp({apiKey: 'xxxxxx', authDomain: 'hoge.fireabseapp.example'});

サインアップ(手順1, 2)

f:id:piyorinpa:20211222230720p:plain

Firebase Authentication は以下のフローによって認証を行う「メールリンク認証」をサポートしています。

  • ログイン画面にメールアドレスを入力する
  • 入力したメールアドレス宛に確認メールが送られる
  • メール内に含まれるURLにアクセスすることで認証が完了する

アカウントを従来の認証基盤からFirebase Authenticationに移行する際に、パスワードの取り扱いがネックになります。多くのウェブサービスではユーザー情報が漏えいした場合のリスクを低減するために、パスワードを不可逆な文字列に変換する関数を通して保存・検証しているかと思います。 このため、移行先と移行元で変換関数のアルゴリズムや各種パラメータを一致させなければパスワード情報の移行ができません。

過去記事にも示した通り、パスワード情報のインポートも頑張ればできます。)

メールリンク認証でひとまずログインしてもらって、後から新たにパスワードを設定してもらうことで面倒なパスワード情報の移行を回避できそうです。

firebase.google.com

予めFirebase AuthenticationのWebコンソールで「メール / パスワード」プロバイダを追加しておく必要があります。また、メール / パスワードプロバイダの「メールリンク(パスワードなしでログイン)」設定をONにしておく必要があります。

f:id:piyorinpa:20211222224835p:plain
Firebase AuthenticationのWebコンソールの設定画面

リファレンス実装はこんなかんじ。

// sign_up.html

import { getAuth, sendSignInLinkToEmail } from "firebase/auth";

const auth = getAuth();
const email  = 'account@example.com'

function sendVerificationMail() {
  try {
    const actionCodeSettings = {
      // メールに含まれるリンク先
      // このリンクに認証に必要なパラメータが付与されてメール本文に掲載される
      url: 'https://example.com/auth/confirm.html',
      handleCodeInApp: true
    };
    // メールの送信(この時点でアカウントが認証サーバ上に存在しなければ作られる)
    await sendSignInLinkToEmail(auth, email, actionCodeSettings);
    window.localStorage.setItem('emailForSignIn', email);
  } catch(e) {
    console.error(e);
  }
}

メールの送信はFirebase Authenticationが行ってくれます。あとは actionCodeSettings.url に指定したメール内リンクの遷移先の処理を書いていきます。

// confirm.html

import { getAuth, sendSignInLinkToEmail } from "firebase/auth";

const auth = getAuth();

function verify() {
  try {
    // メールリンク認証に必要なパラメータが存在する場合は処理を続行
    if (isSignInWithEmailLink(auth, window.location.href)) {
      // localStorageに保存しておいたメールアドレスを取り出す
      // [NOTE] メール送信を行ったデバイスと異なるデバイスでアクセスされた場合は
      // localStorageにメールアドレスが保存されていないので
      // 何らかの手段でメールアドレスを聞く必要がある
      const email = localStorage.getItem('emailForSignIn');

      // パラメータを検証してよさそうならユーザー情報を取得する
      const userCredential = await signInWithEmailLink(auth, email, window.location.href);

      // [WIP] この辺にリソースサーバ側のユーザーリソースを作成する処理を作る
  } catch(e) {
    console.error(e);
  }
}

window.onload = () => verify();

これだけでアカウント登録や認証ができてしまうなんて簡単ですね。登録されたアカウント情報はFirebase AuthenticationのWebコンソールでも確認できます。

あとは、得られた認証情報に含まれるuid(ユーザーを一意に特定するためのトークン)を用いてリソースサーバ側にユーザーリソースを作ればよさそうです。

お気づきかもしれませんが、メールリンク認証の場合はサインインとサインアップの処理はほぼ同じですね。ログイン時も同様に sendSignInLinkToEmail()signInWithEmailLink() を組み合わせることで認証情報を得られます。

パスワードの設定

メールリンク認証ではなく、メールアドレス / パスワード認証としたい場合は、サインアップ処理(メールリンク認証によるログイン)の直後に設定してもらうのがよいでしょう。

(サインアップ処理のあとにページ遷移をした場合、認証状態を永続化 していない場合はログイン状態がなくなってしまうことに注意。なので、リファレンス実装を行うときはサインアップと同一ページでパスワードを変更してみるといいと思います。)

import { updatePassword } from "firebase/auth";

async setPassword() {
  const password = 'passw0rd';

  await updatePassword(auth.currentUser, password);
}

f:id:piyorinpa:20211222233630p:plain

多くの認証基盤におけるメールアドレス / パスワード認証の場合、サインアップ時にメールを送信してメールアドレスの所有者確認をするかと思いますが、 上記手順で行うことで、Firebase Authenticationでもそれを再現できます。

メールの所有者確認ができているかどうかは、 auth.currentUser.emailVerified の真偽値で確認できるので、「メールの所有者確認ができていないユーザーには操作を制限する」という 実装をアプリケーション側で行うことも可能です。後述するIDトークンのペイロードからも確認できます。

(たとえば、Firebase Authenticationのドキュメント「パスワード認証」の項のとおりにメールアドレス / パスワードを登録したときは、確認メールの送信などによってメールアドレスの所有者確認をしないので、 auth.currentUser.emailVerifiedfalse になります)

パスワードを設定したあとのパスワード認証によるログインについては、公式ドキュメントの通り実装をすればよさそうです。

firebase.google.com

ログイン状態の永続化

ログイン処理の前に以下のように記述することで、ページ遷移してもログイン状態を維持できるようになります。 (ここでいうページ遷移とは、シングルページアプリケーションのようにクライアント側の制御でページを遷移させるものではなく、ハイパーリンクをクリックしてサーバからHTMLを取得してレンダリングされる状態のこと)

import { getAuth, browserSessionPersistence} from "firebase/auth";

const auth = getAuth();

await setPersistence(auth, browserSessionPersistence);

このとき、ログイン後にはクライアント側のIndexedDBにユーザー情報が保存されるようです。

詳しくはドキュメントを参照するとよさそうです。

firebase.google.com

IDトークンの検証(手順3, 4, 5)

認証サーバから得られた認証情報をリソースサーバで検証することで、ユーザーを特定したりする必要があります。「サインアップ」の項のメールリンクの検証部分に以下のようなソースコードを追加したとしましょう。

  // confirm.html

  import { getAuth, sendSignInLinkToEmail } from "firebase/auth";

  const auth = getAuth();

  function verify() {
    try {
      // メールリンク認証に必要なパラメータが存在する場合は処理を続行
      if (isSignInWithEmailLink(auth, window.location.href)) {
        // localStorageに保存しておいたメールアドレスを取り出す
        // メール送信を行ったデバイスと異なるデバイスでアクセスされた場合はlocalStorageにメールアドレスが保存されていないので
        // 何らかの手段でメールアドレスを聞く必要がある
        const email = localStorage.getItem('emailForSignIn');

        // パラメータを検証してよさそうならユーザー情報を取得する
        const userCredential = await signInWithEmailLink(auth, email, window.location.href);

-       // [WIP] この辺にリソースサーバ側のユーザーリソースを作成する処理を作る
+       // サインアップ処理
+       await fetch('https://api.example.com/auth/sign_up', {
+         method: 'POST',
+         headers: {
+           // (auth.currentUser.accessToken でもOK)
+           `Authorization: ${userCredential.accessToken}`  
+         }
+       })
      }
    } catch(e) {
      console.error(e);
    }
  }
   
  window.onload = () => verify();

上記Authorization Headerに設定した userCredential.accessToken は署名付きJWTで、ペイロードにuidが含まれています。 リソースサーバはRuby on Railsを用いて開発されていますが、FirebaseのRuby SDKは提供されていないため、自前で署名の検証やJWTのデコードを行う必要があります。 これをFirebase Authenticationのドキュメントに示される情報を参照しながら実装します。

firebase.google.com

Ruby on Rails の場合、たとえば以下のようにすることで検証できます(ruby-jwt gem をインストールする必要があります)。署名検証に成功すれば、JWTは確かにFirebase Authenticationから送信されていて、かつ改ざんされていないことが分かるので、 ペイロードに含まれる情報を用いてログインユーザーを決定できます。

module FirebaseIdTokenVerification
  extend ActiveSupport::Concern

  def verification_firebase_id_token
    # 公開鍵の取得
    certs = fetch_firebase_verification_cert

    # JWTのうちヘッダ部を取得する
    header = JSON.parse(Base64.decode64(headers[:token].split('.').first))

    # fetch_firebase_verification_cert で得られる公開鍵の中から必要なものを取り出す
    cert = OpenSSL::X509::Certificate.new(certs[header['kid']]).public_key

    # RS256 で署名検証
    decoded = JWT.decode(params[:token], cert, true, { algorithm: 'RS256' })

    # uidを含む検証済みペイロードが得られる
    @firebase_verified_payload = decoded[0]
  end

  
  def fetch_firebase_verification_cert
    body = Net::HTTP.get(URI.parse('https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com')) 
    JSON.parse(body)
  end
end

fetch_firebase_verification_cert は毎回リクエストするのではなく、Redisなどの揮発性ストレージに入れておいてもよいかもしれません。

さらに以下のようにAPIを定義することで、uidをキーに持つユーザーリソースを作成できます。

class AccountsController < ApplicationController
  include FirebaseIdTokenVerification

  before_action :verification_firebase_id_token

  # /auth/sign_up
  def create
    uid = @firebase_verified_payload['sub']
    user = User.create!(firebase_uid: uid)
  
    head :created
  end
end

このようにIDトークンを検証することで、ログインセッションを表現できます。

既存ユーザーのインポート

まだ試していませんが、筆者の過去記事の通り Firebase CLIを用いてCSVインポートができるので、データベースから適切な情報をエクスポートしてFirebase CLIを使ってアップロードすればよさそうです。

garakuta-toolbox.hatenablog.com

Firebase CLIのドキュメントはこちら。

firebase.google.com

まとめ

いろいろ試してみて、既に認証基盤を持った既存アプリケーションでも割と移行できそうであることが確認できました。 パスワードリセット機能やアカウント更新機能など、一通りそろっているので引き続き問題なく移行できそうです。

実装を進めて、Twitter認証できるように改修するぞ~というかんじです。ではでは~

デスクの高さを微調整したくて電動昇降デスクを買った

いろいろあって電動昇降デスクを買ったときの記録です。

f:id:piyorinpa:20211219115512j:plain
立ち作業ポジション

何で買ったの?

以前まで使っていたデスクは、JOIFAも推奨している 一般的な机の高さ72cmよりちょっと高い程度、かつ奥行きも60cmある「ふつう」の大きさのデスクでした。

しかしながら、私の身長や座高を考慮すると、キーボード入力作業をするにはちょっと高さが高いな~と感じていました。それじゃあキーボードトレーを導入してみようかといろいろ検索してみていました。

こういうやつ。

www.amazon.co.jp

でも、手持ちの机は天板の下にスチールの梁があるので、キーボードトレーを取り付けるスペースが天板の底に存在しなかったのでした。じゃあもういっそ机の高さが自由に変えられればいいんじゃない?という気持ちになり、 電動昇降デスクを買ってみることにしてみました。

何を買ったの?

フィスコムさんが販売している「パブロ2」を購入してみました。

www.office-com.jp

購入のポイントは以下の通り。

  • 万が一不良品を引いたときのスムーズなサポートが期待できそうな販売元で購入したい
    • (私はネット通販で不良品を引く率が高いほうなので、何かあったときにさっさと解決できることを重要視しがちになっています。)
  • 天板までの高さが65cm程度まで下がること
    • キーボード入力作業にはやや低い高さがいいなと思っていたので
    • もっと安い電動昇降デスクだと、最小高さが70cm~だったりするので注意が必要。たかが数センチ、されど数センチ。
  • 配送が軒先渡しであること
    • もともとほかに購入しようと思っていたデスクは車上渡し(トラックから玄関まで運ばないといけない)で、かつ集合住宅住まいなので、ひとりで運べる自信がなかったため
  • 高価すぎないこと
    • 電動昇降デスクの必要性に疑問があったので、失敗したときのリスクを減らしたかった
    • 可動部があると寿命が気になるところなので、高価なデスクを買って思ったより早く壊れちゃったときに悲しい気持ちになりそうだなと思ったので
    • (じぶんは一つのものを長く使いたいタイプの人間なので、保証や修理サービスが充実していれば、高価なデスクを試してみたいかも)

組み立てはどうだったの?

フィスコムのウェブサイトで購入すると追加料金を支払うことで組み立てオプションを利用できますが、私は(セールだったこともあり)Amazon経由で購入したので自分で組み立てる必要があります。 天板, 脚(電動リニアアクチュエータ), その他フレーム部材の3つの箱に梱包された部品が宅配でやってくるのですが、これがもうめちゃ重たい。

ひとりで組み立てられるの?という点については、ぎりぎりなんとかなった感じでした。私は力持ちとは対極にいる類の人間だし、背もそんなに高くないので、 成人男性ならまあ何とかなるかなくらいの雰囲気だと思います。でも、パーツひとつの重量もけっこうあるし、ひとりで組み立てるのにはそれなりに危険もあるので 二人以上で組み立てるに越したことはないと思います。天板幅120cmの組み立てでぎりぎりだったので、140cmのほうを買ったときはたぶんひとりじゃ組み立て厳しいかもという印象。

組み立てスペースを広めに確保しておくことをおすすめしたいです。狭い部屋だと組み立てにけっこう難儀するかも。

説明書の手順通りに組み立てるのではなく、脚フレームを設置場所に運んでから天板を頑張って取り付けるのがポイントだと思います。

一部のパーツがなかなか取りつかずだいぶ苦戦しました。脚とフレームを押し込んで固定するのですが、どう頑張っても入らない。。。 結局サポートの窓口にも連絡をしてアドバイスをいただきながら、左右のパーツを入れ替えたり、プラスチックハンマーでたたき入れるという力技でなんとか事なきを得ました。

パーツの個体差によっては組み立てにだいぶ苦戦する印象があるので、組み立てサポートを利用すればよかった~という感想です。

完成したときのようすがこちら。

快適に昇降させるために買ったもの

机の上から伸びたコード類に気を付けないと、昇降時にコードが切れたり机の上に載せたあれやこれやが落下したりする事故が発生しそうです。というわけで、以下の品物を買いました。

www.amazon.co.jp

見た目以上にデカくてゴツいつくりですが、幅120cmの天板にはぴったりサイズです。このトレーに載せた延長コードから電源を取るように結線することで、デスクから伸びる電源コードを1本にする作戦。

www.sanwa.co.jp

蛇腹のホースに切り込みが入っていて、ケーブルをどんどん押し込めるタイプのケーブルまとめるパーツです。机の天板から床に伸びるケーブルたちをこれでまとめる作戦。けっこうらくちんにまとめられました。

www.sanwa.co.jp

こういうファスナータイプの結束用品を買っておくと結構便利です。100円ショップでも売ってます。

頑張ってケーブリングした結果がこんなかんじ。

f:id:piyorinpa:20211219120254j:plain
渾身のケーブリング

とにかく机から伸びるケーブルを如何に減らすかを考えると、ストレスなく昇降させることができます。私の場合はデスクトップPCを床置きしている関係から いろいろなケーブルが伸びがちですが、ラップトップPCをデスクに載せて使っている方ならケーブリングの難易度はそこまででもない印象です。

動きはどんなかんじ?

  • 脚ごとに1つずつモータが付いてるタイプのようで、昇降スピードはそれなりにはやい。66cm -> 90cm まで10秒もかからないくらいなイメージでよい
  • 動作音は概ね静か。モータの始動時だけ「ゴトっ」という音が鳴るのがやや気になってたりしている
  • それなりの大きさの天板が上下するので迫力がある

まあまあ満足~というかんじでした。私自身が音に敏感なのもあり、モーターの始動音だけどうにかなれば完璧~というかんじです。

使ってみてどうなのよ

  • 机の高さが自由に変えられるので、着座して作業するときも高さを微調整できていい
    • 工作するとき / キーボード入力作業のとき / 立ち作業のとき の3ポジションを登録している
  • 立ったり座ったりしながら作業すると集中力が維持しやすいかもしれない(まだあまりよくわかってない)けど、腰の具合がよくなったなどの実感はあまりない
  • 立ち作業のときは足の裏に刺激がほしくなるので、足マッサージ踏板みたいなやつがほしくなった

今のところ買ってよかったかなと思っています。あとは可動部の寿命がどれくらいなのか~次第かなというきもち。もうちょっと様子を見てみたいなと思っています。

ひさびさの家具購入でちょっとたのしかったというお話でした。ではでは~

ひさしぶりの旅行に行ってきた

世の中の感染症関連が一時的に落ち着いてきているので、健康に気を付けたうえで、しばらくぶりの旅行に行きました。

とにかく最近まで外出がはばかられる雰囲気だったということもあり、ほどよい非日常感を得たいという気持ちが強かったので、ひとりでフェリーに乗ってどこかに行くことにしました。 ということで、東京から北九州まで2泊3日のフェリー便に乗ってきました。

f:id:piyorinpa:20211123142413j:plain
ふね~

www.otf.jp

東京を19:30に出発して、新門司港に翌々日の明け方5:35に到着します。到着地の新門司港から最寄り駅までは結構な距離があるので、(車と一緒に乗船せずに徒歩で乗船する場合は)フェリー会社が用意してくれる 乗り合いタクシーを利用するのが便利です。このタクシーは予め予約が必要 なので絶対に忘れずに予約しましょう。 (予約を忘れると、早朝に人も少ない港に降りた後、どうしていいかわからず心細い気持ちになりそうです。私は予約を忘れていてこれになりかけました。)

4000円の個室料金を支払うと2名まで寝られる個室を借りられるのですが、今回は追加料金なしの2等寝台に泊まりました。 カプセルホテルのような寝台が並ぶ大部屋でしたが、感染症対策で定員も半分で運用されているようで、混んでいる感はまったく感じませんでした。適度な個室感もあり、他人の目を気にせずに寝られます。 大部屋に28床あるうち、埋まっていたのは5床もない?くらいの感じでした(平日発着の便を利用したからかもしれません)。 部屋の雰囲気はフェリー会社の公式サイト で360°ビューを確認できます。

展望デッキに出ると、ひろーい空や海を眺めることができます。

f:id:piyorinpa:20211123142019j:plain
下から見る東京ゲートブリッジ

f:id:piyorinpa:20211123160358j:plain
あさのようす

心配していた船酔いについては、乗船した日は波も穏やかだったこともあり問題ありませんでした。酔い止め飴が船内で販売されていますが、飲み慣れた酔い止め薬を持っておくとお守り代わりになってよいと思います。 ただ、寝台に横になるとエンジンの周期的な「コクッ」という振動が気になり、慣れるまでは寝付けなかったりしました。

2等寝台は窓もなく狭いので、寝る以外の時間はパブリックスペースで過ごすことになります。レストランがない代わりに冷凍食品やパンなどが並ぶ自販機がたくさんあるので、そこで食料を調達して 食べたりします。ほかのお客さんをみていると、タッパーに食材を詰めて乗船している方もいたので、予め食料やお酒を調達したり準備しておくとよりよいなと思いました。持ち込みの食材を温める用の 電子レンジもありました。

ひたすら窓の外を眺めながら、ご飯を食べたりお酒を飲んだりします。とにかくだいたいぼーっとしていました(たのしい)。

f:id:piyorinpa:20211123143359j:plain
窓からのながめ

あとは、予めダウンロードしておいた動画などを視聴したり、ネットサーフィンしたり(WiFi設備はないので自分のモバイル回線を使うことになります。陸地が見えるときはつながるけど、つながらないときもたまにあります。) してひたすら時間をつぶすなどします。

船の中には大浴場もあり、24時間利用することができます。空いている時間を狙って利用したので、ほぼひとりで入浴できました。浴室もトイレも共用ですが、とてもきれいで抵抗なく利用できました。

はじめての長距離フェリー乗船でしたが、当初の目的である「ほどよい非日常感」を体験できてとてもたのしかったです。混んでいる船内での2泊は疲れるかも、という気もするので、可能なら空いているときを狙っていくのが良いと思いました。個人的にはフェリーでのゆったりとした移動はけっこう自分に合っていると感じたので、ほかの航路にも乗ってみたいな~となりました。

持っていくとよさそうなもの

今回乗船時に持っていったほうがよかったな(あるいは持っていてよかったな)と思ったものたちです。

  • バスタオル / フェイスタオル(船内の自販機でタオルを販売していますが、貸出のものはない。濡れたタオルは船内のコインランドリーで乾燥できる)
  • 現金(電子マネーやクレジットカード類は使えません)
    • 船内に両替機があるので、小銭類は必ずしも多めに持っていく必要なはいと思います
  • 酔い止め薬(持っていると安心できるので)
  • いい感じの食べ物を少々(途中下船できず、船の中で過ごす時間は長いので。船内に自由に使える電子レンジもありました)
    • カップ麺やパン・冷凍食品系の弁当、総菜、ビールなどの自販機が充実しているので、気にならない方は食べ物系の持ち込みは不要
    • ソフトドリンクやアメニティの販売も含めて自販機は結構充実していました
  • 寝間着(浴衣とかはないので)
  • 延長コンセント(2等寝台には100Vコンセントが一口ありますが、充電器などの形状によってはコンセント直上にある突起に干渉して挿せないこともありそうなので)
  • 耳栓(今回は使いませんでしたが、あると安心。船内の自販機でも売っていました)
  • 予めダウンロードした動画コンテンツや書籍類(インターネット接続が安定しないので、Netflixなどの動画サービスのダウンロード機能を使って予め準備しておくと良い)

そのほか

  • パソコンなどの大きめの貴重品を持っているときは個室を利用したほうが便利かも
    • スマートフォンや財布、カギなどをしまっておくのに便利な貴重品ロッカーはありますが、ラップトップPCは入らなさそう。

下船後の活動

夜明け前くらいに門司駅に無事着くことができたので、とりあえず門司港にむかって駅舎を眺めたり、さんぽをしたり。関門トンネル人道 で本州に一時戻ってみたり。

f:id:piyorinpa:20211123150432j:plain
門司港駅

f:id:piyorinpa:20211123150513j:plain
明け方のいいかんじの駅舎

特急で移動して温泉に入りつつおいしいものをたべたり地獄を見に行ったり

f:id:piyorinpa:20211123150700j:plain
ここが地獄か~

なつかしの天神をさんぽしたり、都会に突然現れる山に登ったり していました。

ひさびさの自宅以外の寝泊りで大変満足できました。ではでは。