1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LINEログインを Cloudflare Workers で実装する

1
Posted at

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 に置きます。

env
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 IDAPP_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 が取れればよいので scopeopenid だけにしています。
表示名やアイコンも使うなら profile openid にします。

returnTo はログイン後に戻るパスです。
そのまま使うと open redirect になり得るので、アプリ内の相対パスだけ許可します。

function sanitizeReturnTo(value: string | undefined) {
  return value?.startsWith('/') && !value.startsWith('//') ? value : '/'
}

callback で ID token を検証する

LINEログインが終わると、callback URL に codestate が返ってきます。

callback では以下を行います。

  1. Cookie に保存した state と一致するか確認
  2. codecode_verifier を token endpoint に送る
  3. 返ってきた id_token を verify endpoint で検証する
  4. sub を LINE の user ID として保存する
  5. アプリ側の 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/tokencodecode_verifier を送ります。

verifyLineIdToken() では https://api.line.me/oauth2/v2.1/verifyid_tokenclient_idnonce を送ります。
どちらも失敗したら 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 で statenonce を検証できます。

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 の実装そのものより、statenonce、PKCE、Cookie、CSRF、open redirect 対策を忘れないことが大事でした。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?