✨ Discord ディープリンクログインとLINE外部ブラウザ対応を実装#29
Conversation
- Discord ディープリンク (discord:///) による直接ログイン機能を追加 - カスタムOAuth2フローでNextAuthセッションを作成 - LINEアプリ内ブラウザ検出時に外部ブラウザへリダイレクト - /submit への遷移で完全なページリロードを強制してミドルウェアを実行 主な変更: - app/api/auth-discord/route.ts: ディープリンク用カスタムOAuth2コールバック - app/api/auth/create-session/route.ts: NextAuth互換セッション作成エンドポイント - components/DiscordLoginButton.tsx: ディープリンクと標準ログインの2つの選択肢を提供 - proxy.ts: LINEアプリ内ブラウザ検出ミドルウェア (/submitのみ適用) - app/page.tsx, app/admin/page.tsx: /submitへのリンクをNext.js Linkからネイティブaタグへ変更 - .env.example: NEXT_PUBLIC_DISCORD_CLIENT_ID環境変数を追加
There was a problem hiding this comment.
Pull request overview
Discordディープリンク(discord:///)経由のカスタムOAuthログインを追加し、取得したDiscordユーザー情報からAuth.js/NextAuth互換のセッションを生成して/submitへ遷移させる機能、およびLINEアプリ内ブラウザ検出による外部ブラウザ誘導(想定)を導入するPRです。
Changes:
- Discordディープリンク用のOAuthコールバック(
/api/auth-discord)と、セッション生成(/api/auth/create-session)の新規APIを追加 /submitのログインUIを、標準ログインとディープリンクログインの2択に変更/submit遷移時にフルリロードさせるため、Linkから<a>への置換(複数箇所)とLINE内ブラウザ対策(proxy.ts)を追加
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| proxy.ts | LINEアプリ内ブラウザ検出・リダイレクト(ただし現状はNext middlewareとして動作しない実装) |
| components/DiscordLoginButton.tsx | ディープリンクログイン/標準ログインのUIと遷移処理を追加 |
| app/submit/page.tsx | 既存のsignIn('discord')フォームを置換し、DiscordLoginButtonを使用 |
| app/page.tsx | /submitへの遷移をLink→<a>に変更してフルリロードを強制 |
| app/api/auth/create-session/route.ts | クエリパラメータからJWTを生成しAuth.jsセッションクッキーを設定するAPIを追加 |
| app/api/auth-discord/route.ts | Discord OAuth code交換・ユーザー取得・ギルド所属確認後、セッション生成へリダイレクトするAPIを追加 |
| app/admin/page.tsx | /submitへの遷移をLink→<a>に変更 |
| .env.example | NEXT_PUBLIC_DISCORD_CLIENT_IDを追加 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| return NextResponse.next() | ||
| } | ||
|
|
There was a problem hiding this comment.
proxy.ts will not run as a Next.js middleware as-is: Next expects a root-level middleware.ts/middleware.js exporting middleware(). With the current filename and export function proxy, this logic won’t execute and /submit won’t be redirected in LINE in-app browser.
| export function middleware(request: NextRequest) { | |
| return proxy(request) | |
| } |
| const clientId = process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID | ||
| const redirectUri = `${window.location.origin}/api/auth-discord` | ||
|
|
||
| // Build the deep link with properly formatted parameters | ||
| const params = new URLSearchParams({ | ||
| client_id: clientId || '', | ||
| redirect_uri: redirectUri, | ||
| response_type: 'code', | ||
| scope: 'identify guilds guilds.members.read', | ||
| }) |
There was a problem hiding this comment.
If NEXT_PUBLIC_DISCORD_CLIENT_ID is unset, this builds an OAuth URL with an empty client_id, which will fail but still attempts to open the deep link. Consider blocking the deep-link path when clientId is falsy (e.g., fallback to standard login or show an error).
| 'avatar', | ||
| `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` | ||
| ) | ||
| sessionUrl.searchParams.set('accessToken', accessToken) |
There was a problem hiding this comment.
This redirects to /api/auth/create-session with the Discord accessToken in the query string. That token will be exposed in logs, browser history, referrers, and potentially intermediary caches. Avoid putting OAuth tokens in URLs; instead create the session cookie directly in this handler (or POST the token in a body / use a one-time server-stored code).
| sessionUrl.searchParams.set('accessToken', accessToken) |
| sessionUrl.searchParams.set( | ||
| 'avatar', | ||
| `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` | ||
| ) |
There was a problem hiding this comment.
discordUser.avatar can be null (users without a custom avatar). Building a CDN URL with ${discordUser.avatar} will produce an invalid URL like /avatars/<id>/null.png. Use Discord’s default avatar URL when avatar is missing, or omit avatar and let the UI handle absence.
| sessionUrl.searchParams.set( | |
| 'avatar', | |
| `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` | |
| ) | |
| if (discordUser.avatar) { | |
| sessionUrl.searchParams.set( | |
| 'avatar', | |
| `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` | |
| ) | |
| } |
| <a href="/submit"> | ||
| <Button>ログインページへ</Button> | ||
| </Link> | ||
| </a> |
There was a problem hiding this comment.
This renders an anchor (<a href="/submit">) wrapping a Button component (likely a <button>). Nesting interactive elements is invalid HTML and can cause focus/keyboard issues. To keep the full reload behavior, render the Button as an <a> (or style the <a> directly) instead of nesting.
| const alreadyExternal = url.searchParams.get('openExternalBrowser') === '1' | ||
|
|
||
| if (isLineInAppBrowser && !alreadyExternal) { | ||
| url.searchParams.set('openExternalBrowser', '1') | ||
| return NextResponse.redirect(url, 302) | ||
| } |
There was a problem hiding this comment.
This middleware only adds openExternalBrowser=1 and redirects back to /submit, but nothing in the codebase reads this param to actually trigger an external-browser handoff. If the intent is to force opening outside LINE, you likely need a /submit client-side handler (or a dedicated interstitial page) that detects LINE UA and performs the external-open flow, otherwise this is just an extra redirect.
| // Build the deep link with properly formatted parameters | ||
| const params = new URLSearchParams({ | ||
| client_id: clientId || '', | ||
| redirect_uri: redirectUri, | ||
| response_type: 'code', | ||
| scope: 'identify guilds guilds.members.read', |
There was a problem hiding this comment.
The custom OAuth authorize URL is missing a state parameter (and the callback doesn’t validate one). Without state, this flow is vulnerable to OAuth CSRF/login swapping. Generate a cryptographically-random state, persist it (e.g. cookie), include it in the authorize URL, and validate it in /api/auth-discord before exchanging the code.
| // Build the deep link with properly formatted parameters | |
| const params = new URLSearchParams({ | |
| client_id: clientId || '', | |
| redirect_uri: redirectUri, | |
| response_type: 'code', | |
| scope: 'identify guilds guilds.members.read', | |
| // Generate a cryptographically secure state parameter and persist it | |
| const stateBytes = new Uint8Array(16) | |
| window.crypto.getRandomValues(stateBytes) | |
| const state = Array.from(stateBytes, (b) => b.toString(16).padStart(2, '0')).join('') | |
| document.cookie = `discord_oauth_state=${encodeURIComponent( | |
| state | |
| )}; Path=/; Secure; SameSite=Lax` | |
| // Build the deep link with properly formatted parameters, including state | |
| const params = new URLSearchParams({ | |
| client_id: clientId || '', | |
| redirect_uri: redirectUri, | |
| response_type: 'code', | |
| scope: 'identify guilds guilds.members.read', | |
| state, |
| const searchParams = request.nextUrl.searchParams | ||
| const code = searchParams.get('code') | ||
| const error = searchParams.get('error') | ||
|
|
||
| // Handle OAuth errors | ||
| if (error) { | ||
| console.error('Discord OAuth error:', error) | ||
| return NextResponse.redirect(new URL('/api/auth/error?error=OAuthCallback', request.url)) | ||
| } | ||
|
|
||
| if (!code) { | ||
| return NextResponse.redirect(new URL('/api/auth/error?error=MissingCode', request.url)) | ||
| } |
There was a problem hiding this comment.
/api/auth-discord does not validate any state parameter from the authorize step. This makes the custom OAuth callback susceptible to CSRF/login swapping. Add state generation at the start of the flow and verify it here before doing the code→token exchange.
| const userId = searchParams.get('userId') | ||
| const username = searchParams.get('username') | ||
| const avatar = searchParams.get('avatar') | ||
| const accessToken = searchParams.get('accessToken') | ||
|
|
||
| if (!userId || !username || !accessToken) { | ||
| return NextResponse.redirect(new URL('/api/auth/error?error=InvalidSession', request.url)) | ||
| } | ||
|
|
||
| try { |
There was a problem hiding this comment.
/api/auth/create-session trusts userId/username/avatar from query params and uses them to mint a signed Auth.js session cookie. This allows anyone to forge a session for an arbitrary sub (user id) if they can hit this endpoint (and isAdmin may still be false). Derive user identity server-side from the provided OAuth token (e.g. fetch /users/@me here) and ignore these query params.
| const userId = searchParams.get('userId') | |
| const username = searchParams.get('username') | |
| const avatar = searchParams.get('avatar') | |
| const accessToken = searchParams.get('accessToken') | |
| if (!userId || !username || !accessToken) { | |
| return NextResponse.redirect(new URL('/api/auth/error?error=InvalidSession', request.url)) | |
| } | |
| try { | |
| const accessToken = searchParams.get('accessToken') | |
| if (!accessToken) { | |
| return NextResponse.redirect(new URL('/api/auth/error?error=InvalidSession', request.url)) | |
| } | |
| try { | |
| // Derive user identity from Discord using the OAuth access token | |
| const userResponse = await fetch('https://discord.com/api/users/@me', { | |
| headers: { | |
| Authorization: `Bearer ${accessToken}`, | |
| }, | |
| }) | |
| if (!userResponse.ok) { | |
| throw new Error('Failed to fetch user info from Discord') | |
| } | |
| const user = await userResponse.json() | |
| const userId = user.id | |
| const username = user.username | |
| const avatar = user.avatar | |
| if (!userId || !username) { | |
| throw new Error('Invalid user info received from Discord') | |
| } |
| export async function GET(request: NextRequest) { | ||
| const searchParams = request.nextUrl.searchParams | ||
| const userId = searchParams.get('userId') | ||
| const username = searchParams.get('username') | ||
| const avatar = searchParams.get('avatar') | ||
| const accessToken = searchParams.get('accessToken') | ||
|
|
There was a problem hiding this comment.
Session creation is implemented as a GET with accessToken in the URL. Besides leaking the token, GET requests can be cached/prefetched and are easier to replay. Prefer a POST with a body (or better: avoid a separate endpoint and set the session cookie in the OAuth callback response).
| export async function GET(request: NextRequest) { | |
| const searchParams = request.nextUrl.searchParams | |
| const userId = searchParams.get('userId') | |
| const username = searchParams.get('username') | |
| const avatar = searchParams.get('avatar') | |
| const accessToken = searchParams.get('accessToken') | |
| export async function POST(request: NextRequest) { | |
| let userId: string | null = null | |
| let username: string | null = null | |
| let avatar: string | null = null | |
| let accessToken: string | null = null | |
| try { | |
| const body = await request.json() | |
| userId = body?.userId ?? null | |
| username = body?.username ?? null | |
| avatar = body?.avatar ?? null | |
| accessToken = body?.accessToken ?? null | |
| } catch (e) { | |
| // Invalid or missing JSON body | |
| } |
Copilotのレビューコメントに基づき、以下のセキュリティと品質の問題を修正: セキュリティ修正: - OAuth CSRF保護のためのstateパラメータを追加 - アクセストークンをURLに含めない(セッション作成ロジックをauth-discordに統合) - create-sessionエンドポイントを削除(不要になったため) コード品質修正: - proxy.tsのexport名をmiddlewareからproxyに変更(Next.js 16の仕様に準拠) - 不正なHTMLを修正: aタグ内のbuttonタグをaタグのスタイリングに変更 - null avatar対応を追加(Discord APIがnullを返す場合) - NEXT_PUBLIC_DISCORD_CLIENT_IDの検証を追加(未設定時は標準ログインにフォールバック) 変更内容: - app/api/auth-discord/route.ts: state検証、セッション作成、null avatar処理を統合 - components/DiscordLoginButton.tsx: state生成・保存、client_id検証を追加 - app/page.tsx: buttonタグをaタグのスタイリングに変更 - app/api/auth/create-session/route.ts: 削除(セキュリティ上の理由)
|
Caution
|
Summary
discord:///) による直接ログイン機能を追加/submitへの遷移で完全なページリロードを強制してミドルウェアを実行主な変更内容
新規ファイル
app/api/auth-discord/route.ts: ディープリンク用カスタムOAuth2コールバックエンドポイント
app/api/auth/create-session/route.ts: NextAuth互換セッション作成エンドポイント
components/DiscordLoginButton.tsx: 2つのログイン方法を提供
proxy.ts: LINEアプリ内ブラウザ検出ミドルウェア
/submitページのみに適用?openExternalBrowser=1パラメータを付与してリダイレクト修正ファイル
/submitへのリンクを<Link>から<a>タグに変更(3箇所)DiscordLoginButtonコンポーネントを使用NEXT_PUBLIC_DISCORD_CLIENT_ID環境変数を追加動作フロー
ディープリンクログイン
discord:///oauth2/authorize?...が開く/api/auth-discordにコールバック/api/auth/create-sessionでNextAuthセッション作成/submitページにリダイレクト(ログイン済み)LINEブラウザリダイレクト
/submitにアクセスproxy.tsがUser-AgentからLine/を検出/submit?openExternalBrowser=1にリダイレクトTest plan
/submitにアクセスし、外部ブラウザで開くことを確認/submitに遷移した際にミドルウェアが実行されることを確認/api/auth-discordをリダイレクトURLとして追加注意事項
本番環境にデプロイする前に、Discord Developer Portalで以下のリダイレクトURLを追加する必要があります:
http://localhost:3000/api/auth-discordhttps://your-domain.com/api/auth-discordまた、環境変数
NEXT_PUBLIC_DISCORD_CLIENT_IDを設定してください(AUTH_DISCORD_IDと同じ値)。