🌟
SvelteKitでTwitterログイン(3-legged OAuth)を作ってみる
ユーザーの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
これはTwitterのバグなんじゃないかとずっと思ってるんですが、この流れの中でリクエストトークンシークレットの出番がありません。
ここでクエリから取得した
oauth_token
,oauth_verifier
のみでアクセストークン / アクセストークンシークレットを取得できるってことはoauth_token
,oauth_verifier
を用いてアクセストークン / アクセストークンシークレットを取得してDBに保持する = 第3者に悪意のあるユーザーのトークンが紐付けられるというCSRFが成り立ってしまいそうです。
本来は
getAccessToken()
という関数がoAuthRequestToken.oauth_token_secret
の値も必要とするべきなんだと思いますが、Twitterがそれなしでのリクエストを許可していることは変えられないので、CSRF対策としてコールバック時に
oAuthRequestToken.oauth_token
の値をセッションに紐づけておくoauth_token
の値が セッションと紐づけられている値と一致することを確認するという、OAuth 2.0における state パラメータと同様の実装をすることをお勧めします。
おっしゃっていることは理解できます。oauth_token が同一かどうかぐらいしかチェックする方法がないなという部分をその後の実装をしていて気にしていました。