Cloudflare Workers + Hono で作っているアプリに LINEログインを入れました。
この記事では、できるだけシンプルに以下を実装します。
- LINEログイン開始
- callback で認可コードを token に交換
- ID token を検証して LINE の user ID を取得
- アプリ側のセッションを
HttpOnly Cookieで持つ - Cookie セッション用に CSRF 対策も入れる
LINE の表示名やアイコンは使わず、「同じ LINE アカウントかどうか」だけ分かればよい前提で、ミニマルな構成です。
全体像
流れは普通の OAuth 2.0 Authorization Code Flow です。
ブラウザ
|
| /api/oauth/line/authorization
v
Cloudflare Workers
|
| LINE の認可画面へ redirect
v
LINE Platform
|
| /api/oauth/line/callback?code=...&state=...
v
Cloudflare Workers
|
| token endpoint / verify endpoint
v
LINE Platform
|
| session cookie を発行
v
ブラウザ
大事なのは、LINE の client_secret をブラウザに出さないことです。
OAuth の処理は Workers 側で完結させます。
LINE Developers 側の設定
LINE Developers Console で LINEログインのチャネルを作ります。
使う値は以下です。
- Channel ID
- Channel secret
- Callback URL
ローカルで Workers を http://localhost:8787 で動かすなら、callback URL は例えばこれです。
http://localhost:8787/api/oauth/line/callback
本番も使う場合は、本番 URL も登録します。
https://example.com/api/oauth/line/callback
Vite のフロントが 5173 で動いていても、LINE から戻ってくる先は Workers 側なので、callback URL は 8787 側になります。
Workers の環境変数
Workers 側では、LINE の設定値を環境変数で受け取ります。
type Bindings = {
DB: D1Database
APP_ORIGIN?: string
LINE_CHANNEL_ID?: string
LINE_CHANNEL_SECRET?: string
LINE_REDIRECT_URI?: string
}
ローカル開発では .dev.vars に置きます。
APP_ORIGIN=http://localhost:5173
LINE_CHANNEL_ID=xxxxxxxxxx
LINE_CHANNEL_SECRET=xxxxxxxxxx
LINE_REDIRECT_URI=http://localhost:8787/api/oauth/line/callback
本番では Secret として登録します。
npx wrangler secret put LINE_CHANNEL_SECRET
Channel ID や APP_ORIGIN は秘密情報ではないので、wrangler.toml の [vars] に置いてもよいです。
[vars]
APP_ORIGIN = "https://example.com"
LINE_CHANNEL_ID = "1234567890"
LINE_REDIRECT_URI = "https://example.com/api/oauth/line/callback"
D1 に保存するもの
アプリ側では、LINE のユーザーとアプリ内ユーザーを紐づけます。
最低限、以下のようなテーブルがあれば足ります。
users
id
auth_identities
user_id
provider -- "line"
provider_user_id -- LINE の sub
auth_sessions
user_id
session_token_hash
csrf_token_hash
expires_at
Cookie に入れるセッショントークンは、D1 には平文保存せず、ハッシュ化して保存します。
認証開始エンドポイント
ログイン開始用のエンドポイントを作ります。
ここでは、LINE の認可 URL を組み立ててリダイレクトします。
以下のコードは抜粋で、Cookie 操作や D1 操作はヘルパー関数に寄せています。
app.get('/api/oauth/line/authorization', async (c) => {
const config = getLineConfig(c)
const state = createRandomToken()
const nonce = createRandomToken()
const codeVerifier = createRandomToken()
const url = new URL('https://access.line.me/oauth2/v2.1/authorize')
url.search = new URLSearchParams({
response_type: 'code',
client_id: config.channelId,
redirect_uri: config.redirectUri,
scope: 'openid',
state,
nonce,
code_challenge: await createCodeChallenge(codeVerifier),
code_challenge_method: 'S256',
}).toString()
setOAuthCookies(c, {
state,
nonce,
codeVerifier,
returnTo: sanitizeReturnTo(c.req.query('returnTo')),
})
return c.redirect(url.toString())
})
今回は LINE の user ID が取れればよいので scope は openid だけにしています。
表示名やアイコンも使うなら profile openid にします。
returnTo はログイン後に戻るパスです。
そのまま使うと open redirect になり得るので、アプリ内の相対パスだけ許可します。
function sanitizeReturnTo(value: string | undefined) {
return value?.startsWith('/') && !value.startsWith('//') ? value : '/'
}
callback で ID token を検証する
LINEログインが終わると、callback URL に code と state が返ってきます。
callback では以下を行います。
- Cookie に保存した
stateと一致するか確認 -
codeとcode_verifierを token endpoint に送る - 返ってきた
id_tokenを verify endpoint で検証する -
subを LINE の user ID として保存する - アプリ側の session cookie を発行する
app.get('/api/oauth/line/callback', async (c) => {
const oauth = readAndClearOAuthCookies(c)
const code = c.req.query('code')
const state = c.req.query('state')
if (!code || state !== oauth.state) {
throw new HTTPException(400, { message: 'ログインをやり直してください' })
}
const token = await issueLineToken(c, code, oauth.codeVerifier)
const idToken = await verifyLineIdToken(c, token.id_token, oauth.nonce)
const user = await findOrCreateLineUser(c, idToken.sub)
await signIn(c, user.id)
return c.redirect(`${getAppOrigin(c)}${oauth.returnTo}`)
})
issueLineToken() では https://api.line.me/oauth2/v2.1/token に code と code_verifier を送ります。
verifyLineIdToken() では https://api.line.me/oauth2/v2.1/verify に id_token、client_id、nonce を送ります。
どちらも失敗したら 401 を返す想定です。
findOrCreateLineUser() と signIn() は D1 操作用の関数です。
やっていることは、auth_identities.provider_user_id に LINE の sub を保存し、auth_sessions.session_token_hash にセッショントークンのハッシュを保存するだけです。
Cookie は HttpOnly にする
セッション Cookie は JavaScript から読めないように HttpOnly にします。
OAuth 用の一時 Cookie も同じです。
function setAppCookie(c: AppContext, name: string, value: string, maxAge: number) {
setCookie(c, name, value, {
httpOnly: true,
secure: getAppOrigin(c).startsWith('https://'),
sameSite: 'Lax',
path: '/',
maxAge,
})
}
SameSite=Lax でも、LINE から callback URL へ戻るトップレベル GET では Cookie が送られます。
そのため、callback で state や nonce を検証できます。
token 生成と PKCE
ランダム値は crypto.getRandomValues() で作ります。
function createRandomToken() {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return base64UrlEncode(bytes)
}
async function createCodeChallenge(codeVerifier: string) {
const hash = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(codeVerifier),
)
return base64UrlEncode(new Uint8Array(hash))
}
書き込み API には CSRF 対策を入れる
Cookie セッションを使う場合、POST / PATCH / DELETE には CSRF 対策を入れます。
-
/api/session/csrf-tokenでランダムな CSRF token を発行 - D1 には token のハッシュだけ保存
- 書き込み API では
X-CSRF-Tokenを必須にする -
Originも確認する
app.post('/api/posts', async (c) => {
const session = await requireSession(c)
await verifyCsrf(c, session)
// create post
})
フロント側では、GET 以外のリクエストに X-CSRF-Token を付けます。
function shouldAttachCsrfToken(method: string) {
return !['GET', 'HEAD', 'OPTIONS'].includes(method)
}
state はログイン開始から callback までを守るための値です。
ログイン後の POST / PATCH / DELETE を守るには、別途 CSRF token を使います。
フロントのログインリンク
フロント側は、認証開始エンドポイントに飛ばすだけです。
const returnTo = '/posts/new'
const loginUrl = `/api/oauth/line/authorization?returnTo=${encodeURIComponent(returnTo)}`
return <a href={loginUrl}>LINEでログイン</a>
callback や token 交換は Workers 側で完結するので、フロントに LINE の secret は不要。
気をつけたこと
実装時はこのあたりを外さないようにしました。
- callback URL は LINE Developers Console に完全一致で登録する
-
stateは必ず検証する - ID token を使うなら
nonceも検証する - PKCE を入れる
-
returnToはアプリ内パスだけ許可する - セッション Cookie は
HttpOnly - D1 にセッショントークンを平文保存しない
- Cookie セッションの書き込み API には CSRF 対策を入れる
-
client_secretは Secret として扱い、ブラウザに出さない
まとめ
Cloudflare Workers でも LINEログインはシンプルに実装できます。
最小構成は以下です。
- Workers に
/authorizationと/callbackを作る - LINE の token endpoint と verify endpoint は Workers から呼ぶ
- LINE の
subをアプリ内ユーザーに紐づける - アプリのセッションは
HttpOnly Cookieで持つ
OAuth の実装そのものより、state、nonce、PKCE、Cookie、CSRF、open redirect 対策を忘れないことが大事でした。