Skip to content

✨ Discord ディープリンクログインとLINE外部ブラウザ対応を実装#29

Open
Shion1305 wants to merge 2 commits into
mainfrom
shion/feat-discord-deeplink-line-browser
Open

✨ Discord ディープリンクログインとLINE外部ブラウザ対応を実装#29
Shion1305 wants to merge 2 commits into
mainfrom
shion/feat-discord-deeplink-line-browser

Conversation

@Shion1305
Copy link
Copy Markdown
Member

Summary

  • Discord ディープリンク (discord:///) による直接ログイン機能を追加
  • カスタムOAuth2フローでNextAuthセッションを作成
  • LINEアプリ内ブラウザ検出時に外部ブラウザへリダイレクト
  • /submit への遷移で完全なページリロードを強制してミドルウェアを実行

主な変更内容

新規ファイル

  • app/api/auth-discord/route.ts: ディープリンク用カスタムOAuth2コールバックエンドポイント

    • Discordからの認証コードを受け取り、アクセストークンと交換
    • ユーザー情報取得とギルドメンバーシップ確認
    • セッション作成エンドポイントへリダイレクト
  • app/api/auth/create-session/route.ts: NextAuth互換セッション作成エンドポイント

    • Discord ユーザーデータからJWTトークンを生成
    • Admin権限チェック
    • NextAuth形式のセッションクッキーを設定
  • components/DiscordLoginButton.tsx: 2つのログイン方法を提供

    • 📱 Discordアプリでログイン(ディープリンク使用)
    • 🌐 ブラウザでログイン(NextAuth標準フロー)
  • proxy.ts: LINEアプリ内ブラウザ検出ミドルウェア

    • /submit ページのみに適用
    • LINEブラウザ検出時に ?openExternalBrowser=1 パラメータを付与してリダイレクト

修正ファイル

  • app/page.tsx: /submitへのリンクを<Link>から<a>タグに変更(3箇所)
  • app/admin/page.tsx: 同上(1箇所)
  • app/submit/page.tsx: DiscordLoginButtonコンポーネントを使用
  • .env.example: NEXT_PUBLIC_DISCORD_CLIENT_ID環境変数を追加

動作フロー

ディープリンクログイン

  1. ユーザーが「📱 Discordアプリでログイン」をクリック
  2. discord:///oauth2/authorize?... が開く
  3. Discordアプリ(またはブラウザ)でOAuth認証
  4. /api/auth-discord にコールバック
  5. バックエンドでトークン交換、ユーザー情報取得
  6. /api/auth/create-session でNextAuthセッション作成
  7. /submit ページにリダイレクト(ログイン済み)

LINEブラウザリダイレクト

  1. LINEアプリ内ブラウザで /submit にアクセス
  2. proxy.ts がUser-Agentから Line/ を検出
  3. /submit?openExternalBrowser=1 にリダイレクト
  4. LINEが外部ブラウザで開く

Test plan

  • ディープリンクログインが正常に動作することを確認
  • 標準ブラウザログインが正常に動作することを確認
  • LINEアプリ内ブラウザから /submit にアクセスし、外部ブラウザで開くことを確認
  • トップページから /submit に遷移した際にミドルウェアが実行されることを確認
  • Discord Developer Portalに /api/auth-discord をリダイレクトURLとして追加

注意事項

本番環境にデプロイする前に、Discord Developer Portalで以下のリダイレクトURLを追加する必要があります:

  • 開発環境: http://localhost:3000/api/auth-discord
  • 本番環境: https://your-domain.com/api/auth-discord

また、環境変数 NEXT_PUBLIC_DISCORD_CLIENT_ID を設定してください(AUTH_DISCORD_ID と同じ値)。

- 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環境変数を追加
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread proxy.ts

return NextResponse.next()
}

Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
export function middleware(request: NextRequest) {
return proxy(request)
}

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +17
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',
})
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread app/api/auth-discord/route.ts Outdated
'avatar',
`https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png`
)
sessionUrl.searchParams.set('accessToken', accessToken)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
sessionUrl.searchParams.set('accessToken', accessToken)

Copilot uses AI. Check for mistakes.
Comment thread app/api/auth-discord/route.ts Outdated
Comment on lines +88 to +91
sessionUrl.searchParams.set(
'avatar',
`https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png`
)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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`
)
}

Copilot uses AI. Check for mistakes.
Comment thread app/admin/page.tsx
Comment on lines +34 to +36
<a href="/submit">
<Button>ログインページへ</Button>
</Link>
</a>
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread proxy.ts
Comment on lines +21 to +26
const alreadyExternal = url.searchParams.get('openExternalBrowser') === '1'

if (isLineInAppBrowser && !alreadyExternal) {
url.searchParams.set('openExternalBrowser', '1')
return NextResponse.redirect(url, 302)
}
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread components/DiscordLoginButton.tsx Outdated
Comment on lines +11 to +16
// 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',
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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,

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +16
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))
}
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/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.

Copilot uses AI. Check for mistakes.
Comment thread app/api/auth/create-session/route.ts Outdated
Comment on lines +7 to +16
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 {
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/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.

Suggested change
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')
}

Copilot uses AI. Check for mistakes.
Comment thread app/api/auth/create-session/route.ts Outdated
Comment on lines +5 to +11
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')

Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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 uses AI. Check for mistakes.
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: 削除(セキュリティ上の理由)
@Shion1305
Copy link
Copy Markdown
Member Author

Caution

NEXT_PUBLIC_DISCORD_CLIENT_ID must be added to configuration before deployment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants