🐡

忙しい人のための Sign In With Solana (SIWS)

2023/10/15に公開

gm! Epics DAO Co-founderのkishi.solです。

一番忙しい人のためにはもう実装を済ませてありますので、

$ npm i -g @skeet-framework/cli
$ skeet create <app-name>

// Solana Mobile Stack (Expo) + Web (Next.js) - Firestore を選択してください

これで必要なものはすべて揃うので、すぐにご自身のローカル環境でお試しいただけます。
そのままWeb3プロジェクトを開始することも可能になっています。

コードはこちらにございますので、ぜひこちらもご活用ください。

https://github.com/elsoul/skeet-solana-mobile-stack

ざっくり、Solanaウォレットを利用してこんな感じのログインを実装できるのが"Sign In With Solana"です。

相当忙しい人のために、まずは"Sign In With Solana"が何を実現しようとしているのか簡単に説明させてください。

"Sign In With Solana" (SIWS) とは

SIWSは、Solanaブロックチェーン上でアプリケーションがユーザーを認証するための標準化された方法です。
従来の「connect + signMessage」フローに代わる、より簡単で安全なワンクリックのサインイン方法を提供します。

現状のウォレットサインインの課題は以下のとおりです。

  • ユーザーエクスペリエンスは一貫していません。各dappが独自のメッセージ形式を持っているため、ユーザーは何を期待すればよいのかわかりません。
  • メッセージ形式の標準化がないため、ウォレットは混乱するようなプレーンテキストのメッセージを表示することになり、これがさらにユーザーを困惑させます。
  • 正当なdappであるかのように偽装した悪意のあるウェブサイトが、ユーザーをだましてメッセージに署名させることができ、ウォレットもユーザーも介入することができません。
  • 従来の「connect + signMessage」は、直感に反する複数のステップが必要です。

Sign In With Solana(SIWS)は、これらの課題に対する包括的な解決策を提供します。

SIWSの技術仕様は、EIP-4361(Sign In With Ethereum)をモデルにしていますが、その機能を超えて拡張されています。
SIWSは、メッセージ構築の責任をdapps(分散型アプリケーション)からウォレットに移すことで、一貫したユーザーフレンドリーなインターフェースと、エンドユーザーのセキュリティを強化します。

さらに、SIWSはメッセージ形式を標準化することで、ウォレットがメッセージデータを詳細に調査し、その正当性を確認するか、怪しい活動に対して警告を発することが可能になります。ドメインバインディングはSIWSの重要な特長であり、ウォレットがユーザーに警告を出すことで、あるウェブサイトが別のエンティティを偽装している場合に対処します。

SIWSの仕様はSolanaウォレットのPhantomがオープンソースとして公開、管理を行っています。

https://github.com/phantom/sign-in-with-solana

"Sign In With Solana" (SIWS) の実装例

比較的忙しい人にも、コードを見ながら実際のサインイン実装の流れを見てもらえたら嬉しいです。

フロントエンドのサインインのためのコードはこんな感じです。
コメントでやっていることを説明していきます。

Sign In With Solana - Frontend
// バックエンドから signInInput を取得します
const createResponse =
  await fetchSkeetFunctions<CreateSignInDataParams>(
    'skeet',
    'createSignInData',
    {},
  )
const signInResponse = await createResponse?.json()
const input: SolanaSignInInput = signInResponse?.signInData

// ウォレットに signInInput を送信し、サインインリクエストをトリガーします
const signInResult = await adapter.signIn(input)

const output: SolanaSignInOutput = {
  ...signInResult,
  // accountがreadonlyになっていてバックエンド側で消えるので、明示的に追加
  account: {
    address: signInResult.account.address,
    publicKey: signInResult.account.publicKey,
    chains: signInResult.account.chains,
    features: signInResult.account.features,
    label: signInResult.account.label,
    icon: signInResult.account.icon,
  },
}

// 生成された入力に対してバックエンドでサインイン出力を検証します
const verifyResponse = await fetchSkeetFunctions<VerifySIWSParams>(
  'skeet',
  'verifySIWS',
  { input, output },
)
// 200レスポンスを受け取れば認証成功です
const success = await verifyResponse?.json()

バックエンドコードはこんな感じになっています。ここではFirebase Functionsを利用しています。

createSignInData.ts
import { onRequest } from 'firebase-functions/v2/https'
import { publicHttpOption } from '@/routings/options'
import { TypedRequestBody } from '@/types/http'
import { CreateSignInDataParams } from '@/types/http/createSignInDataParams'
import { SolanaSignInInput } from '@solana/wallet-standard-features'

export const createSignInData = onRequest(
  publicHttpOption,
  async (req: TypedRequestBody<CreateSignInDataParams>, res) => {
    try {
      const now: Date = new Date()
      const uri = req.headers.origin || ''
      const currentUrl = new URL(uri)
      const domain = currentUrl.host
      const currentDateTime = now.toISOString()

      const signInData: SolanaSignInInput = {
        domain,
        statement:
          'Clicking Sign or Approve only means you have proved this wallet is owned by you. This request will not trigger any blockchain transaction or cost any gas fee.',
        version: '1',
        nonce: 'oBbLoEldZs',
        chainId: 'solana:mainnet',
        issuedAt: currentDateTime,
        resources: ['https://skeet.dev', 'https://phantom.app/'],
      }

      res.json({
        signInData,
      })
    } catch (error) {
      res.status(500).json({ status: 'error', message: String(error) })
    }
  },
)

verifySIWS.ts
import { onRequest } from 'firebase-functions/v2/https'
import { publicHttpOption } from '@/routings/options'
import { TypedRequestBody } from '@/types/http'
import { VerifySIWSParams } from '@/types/http/verifySIWSParams'
import {
  SolanaSignInInput,
  SolanaSignInOutput,
} from '@solana/wallet-standard-features'
import { verifySignIn } from '@solana/wallet-standard-util'

export const verifySIWS = onRequest(
  publicHttpOption,
  async (req: TypedRequestBody<VerifySIWSParams>, res) => {
    try {
      const backendInput: SolanaSignInInput = req.body.input
      const { output } = req.body

      const backendOutput: SolanaSignInOutput = {
        account: {
          ...output.account,
          publicKey: new Uint8Array(Object.values(output.account.publicKey)),
        },
        signature: new Uint8Array(Buffer.from(output.signature)),
        signedMessage: new Uint8Array(Buffer.from(output.signedMessage)),
      }

      if (!verifySignIn(backendInput, backendOutput)) {
        console.error('Sign In verification failed!')
        throw new Error('Sign In verification failed!')
      }

      res.json({
        status: 'success'
      })
    } catch (error) {
      res.status(500).json({ status: 'error', message: String(error) })
    }
  },
)

実際のコードは下記を参照してください。

  • フロントエンド

https://github.com/elsoul/skeet-solana-mobile-stack/blob/main/webapp/src/components/providers/SolanaWalletProvider.tsx

https://github.com/elsoul/skeet-solana-mobile-stack/blob/main/webapp/src/components/pages/auth/LoginScreen.tsx

  • バックエンド

https://github.com/elsoul/skeet-solana-mobile-stack/blob/main/functions/skeet/src/routings/http/createSignInData.ts

https://github.com/elsoul/skeet-solana-mobile-stack/blob/main/functions/skeet/src/routings/http/verifySIWS.ts

"Sign In With Solana" (SIWS) を Firebase と組み合わせて使う

もう少しお付き合いいただける方には、更に実践的な内容をお話させてください。

ブロックチェーンの可能性を拡げるため、特にWeb3ゲームの文脈では、フルオンチェーンにこだわるのではなく、要所でブロックチェーンを利用するスタイルの開発が主流になっています。

https://twitter.com/256hax/status/1619316234042093569

ブロックチェーンと今までのWeb技術を活用することで、UXの向上やコスト削減、そして新しい可能性を追求していくことができます。

Firebase のようなサーバーレス環境はプロジェクトのロケットスタートに最適です。

Firebase Authentication と Firestore を活用すると、"Sign In With Solana" (SIWS) で取得したPubkeyを軸としたユーザーデータの管理とセキュリティルールの設定が可能です。

これには、Firebase Authenticationのカスタムトークンを利用します。

https://firebase.google.com/docs/auth/admin/create-custom-tokens?hl=ja

実装はこのようになります。

verifySIWS.ts
...
import { getAuth } from 'firebase-admin/auth'
import bs58 from 'bs58'

export const verifySIWS = onRequest(
  publicHttpOption,
  async (req: TypedRequestBody<VerifySIWSParams>, res) => {
    try {
      ...

      if (!verifySignIn(backendInput, backendOutput)) {
        console.error('Sign In verification failed!')
        throw new Error('Sign In verification failed!')
      }

      // カスタムトークンを認証したpublicKeyをuidとして発行する
      const token = await getAuth().createCustomToken(
        bs58.encode(backendOutput.account.publicKey),
      )

      res.json({
        status: 'success',
        token,
      })
    } catch (error) {
      res.status(500).json({ status: 'error', message: String(error) })
    }
  },
)

フロントエンドにて、verifySIWSから受け取ったトークンでログインできます。

Sign In With Solana - Frontend
...
const verifyResponse = await fetchSkeetFunctions<VerifySIWSParams>(
  'skeet',
  'verifySIWS',
  { input, output },
)
const success = await verifyResponse?.json()

// Firebase Authentication カスタムトークンでサインイン
const userCredential = await signInWithCustomToken(auth, success?.token)

こうすることで、あとは普段のFirebase Authentication (EmailやGoogleログイン)と同じようにログイン状況の管理やFirestore セキュリティルールの設定を行うことができます。

下記はFirestoreルールで、ユーザー情報をログインしたユーザー以外から見ることはできず、本人以外からの書き込みを禁止する例です。

firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /User/{userId}/{document=**} {
      allow read: if request.auth != null;
      allow write: if request.auth.uid == userId;
    }
  }
}

下記のように開発を進めていくことができます。

と、ここまで長くなってしまいましたがお読みいただきありがとうございました!

Skeet ではこの"Sign In With Solana" (SIWS) を利用したWeb3アプリの開発をロケットスタートすることができます。

詳細は下記Webサイトか、GitHubリポジトリをご覧ください。

https://skeet.dev/ja/

https://github.com/elsoul/skeet-cli

ご感想やフィードバック等ございましたら大歓迎です!
ぜひ Skeet の開発者コミュニティ Discord にご参加ください!

https://discord.gg/H2HeqRq54J

今後ともよろしくお願いいたします。

Discussion