🌟

SvelteKitでTwitterログイン(3-legged OAuth)を作ってみる

2021/08/12に公開2

ユーザーのTwitterアクセストークンを預って代理でAPIを叩くような機能を開発していて、アクセストークン取得部分に3-legged OAuthフローが必要になる。

ディレクトリ構成

routes/auth 以下でログイン処理を行うようにした

src/routes/auth/
├── callback.js
├── index.svelte
└── signin.js

0 directories, 3 files

/auth/

ユーザーが /auth/ にアクセスするとサインインボタンを表示する

index.svelte
<a href="/auth/signin">
    <button>
        SIGN IN
    </button>
</a>

/auth/signin

Twitterサーバーの認証にリダイレクトする。

svelteのページコンポーネンス向けにredirectのAPIはあるけどend pointをユーザーに直接踏ませてリダイレクトする用途ではなさそうだったので、HTTPヘッダでリダイレクトすることにした。

Twitterサーバーとのコネクションハンドリングに https://github.com/twitterdev/Twitter-API-v2-sample-code/https://github.com/twitterdev/twauth-web/ を参考にした。

<ins>
コメントをもらってoauth_tokenの検証フローを追加した。callbackで返ってきた人が別人になっていないかと確認する

SvelteKitの$app/storesはエンドポイントのコンテキストでは動かないのでセッションを継続する仕組みを自分で作る想定。
</ins>

signin.js
import ky from 'ky'
import crypto from 'crypto'
import OAuth from 'oauth-1.0a'
import qs from 'querystring'

const requestTokenURL = 'https://api.twitter.com/oauth/request_token';

const oauth = OAuth({
    consumer: {
        key: process.env.CONSUMER_KEY,
        secret: process.env.CONSUMER_SECRET,
    },
    signature_method: 'HMAC-SHA1',
    hash_function: (baseString, key) => crypto.createHmac('sha1', key).update(baseString).digest('base64')
});

async function requestToken() {
    const authHeader = oauth.toHeader(oauth.authorize({
        url: requestTokenURL,
        method: 'POST',
    }));

    const formData = new URLSearchParams();
    formData.set('oauth_callback', 'http://localhost:3000/auth/callback');
    
    const res = await ky.post(requestTokenURL, {
        body: formData.toString(),
        headers: {
            Authorization: authHeader["Authorization"]
        }
    });

    if (res.ok) {
        const body = await res.text()
        return qs.parse(body);
    } else {
        throw new Error('Cannot get an OAuth request token');
    }
}

export async function get(request) {
    const oAuthRequestToken = await requestToken()
    const authorizeURL = new URL('https://api.twitter.com/oauth/authorize');
    authorizeURL.searchParams.append('oauth_token', oAuthRequestToken.oauth_token)
    
    session(request).set('oauth_token', oAuthRequestToken.oauth_token)
    
    return {
        status: 302,
        headers: {
            'Location': authorizeURL.href
        }
    }
}

/auth/callback

Twitterサーバーからリダイレクトする先。

callback.js
const accessTokenURL = 'https://api.twitter.com/oauth/access_token';

async function getAccessToken(oauthToken, verifier) {
    const authHeader = oauth.toHeader(oauth.authorize({
        url: accessTokenURL,
        method: 'POST'
    }));

    const url = `${accessTokenURL}?oauth_verifier=${verifier}&oauth_token=${oauthToken}`
    const res = await ky.post(url, {
        headers: {
            Authorization: authHeader["Authorization"]
        }
    });

    if (res.ok) {
        const body = await res.text()
        return qs.parse(body);
    } else {
        throw new Error('Cannot get an OAuth request token');
    }
}

export async function get(request) {
    const oauthToken = request.query.get('oauth_token')
    if (session(request).get('oauth_token') !== oauthToken) {
        return {
            status: 403,
            body: 'token missmatch',
        }    
    }
    
    const verifier = request.query.get('oauth_verifier')
    const accessToken = await getAccessToken(oauthToken, verifier)
    
    // 例: ここで自分のサービスのDBに保存する
    // const user = getUser(request)
    // await user.token.update({
    //     data: accessToken,
    // })
    
    return {
        status: 302,
        headers: {
            'Location': '/',
        }
    }
}

accessTokenオブジェクトの中身

accessToken
{
    oauth_token:'XXXXXXXXXXXXXXXXXX'
    oauth_token_secret:'YYYYYYYYYYYYYYYYYYY'
    screen_name:'asdfghjkl'
    user_id:'1234567890'
}

Discussion

ritouritou

これはTwitterのバグなんじゃないかとずっと思ってるんですが、この流れの中でリクエストトークンシークレットの出番がありません。

export async function get(request) {
    const oauthToken = request.query.get('oauth_token')
    const verifier = request.query.get('oauth_verifier')
    const accessToken = await getAccessToken(oauthToken, verifier)

ここでクエリから取得した oauth_token, oauth_verifier のみでアクセストークン / アクセストークンシークレットを取得できるってことは

  1. 悪意のあるユーザーが自分が同意した後のコールバックURLを第3者に送る
  2. 第3者のブラウザ上で、悪意のあるユーザーの oauth_token, oauth_verifierを用いてアクセストークン / アクセストークンシークレットを取得してDBに保持する = 第3者に悪意のあるユーザーのトークンが紐付けられる

というCSRFが成り立ってしまいそうです。

本来は getAccessToken() という関数が oAuthRequestToken.oauth_token_secret の値も必要とするべきなんだと思いますが、Twitterがそれなしでのリクエストを許可していることは変えられないので、CSRF対策として

コールバック時に

  1. authorizeURL にリダイレクトする前に、oAuthRequestToken.oauth_token の値をセッションに紐づけておく
  2. クエリから取得した oauth_token の値が セッションと紐づけられている値と一致することを確認する

という、OAuth 2.0における state パラメータと同様の実装をすることをお勧めします。

laisolaiso

おっしゃっていることは理解できます。oauth_token が同一かどうかぐらいしかチェックする方法がないなという部分をその後の実装をしていて気にしていました。