🔐 OAuth 2.0とは?
📖 定義
OAuth 2.0は、ユーザーがパスワードを共有せずに、他のWebサイトやアプリケーションに自分の情報へのアクセス権限を付与できるオープン標準プロトコルです。「Googleでログイン」「GitHubでログイン」などのソーシャルログインが代表的な例です。認証(Authentication)ではなく認可(Authorization)に焦点を当て、安全で便利なユーザー体験を提 供します。
🎯 例えで理解する
ホテルのキーカード
従来の方法 = ホテルのマスターキー
ホテルオーナー: "マスターキーをお渡しします"
あなた: "これは全ての部屋を開けられるじゃないですか! 😱"
"私のパスワードを教えるのと同じです"
OAuth 2.0 = ホテルのキーカード
ホテルオーナー: "このキーカードは..."
✅ あなたの部屋だけ開けられます
✅ チェックアウト時に自動的に失効します
✅ 紛失してもマスターキーは安全です
✅ 必要ならいつでも無効化できます
あなた: "完璧です!"
委任状
シナリオ: 銀行業務を代理人に任せる
❌ パスワード共有方式
あなた: "私の銀行のパスワードは1234です"
代理人: "銀行で全てができ ますね!"
危険: 預金引き出し、口座削除など全ての権限
✅ OAuth方式
あなた: "銀行に行って委任状を作成します"
銀行: "どのような権限を付与しますか?"
あなた: "残高照会のみ可能にしてください"
銀行: "期間は?"
あなた: "1週間だけです"
代理人は:
✅ 残高のみ照会可能
✅ 1週間後に自動失効
✅ あなたのパスワードを知らない
✅ いつでも権限取り消し可能
⚙️ 動作原理
1. OAuth 2.0の主要概念
┌─────────────────────────────────────────┐
│ OAuth 2.0の4つの役割 │
└─────────────────────────────────────────┘
1. Resource Owner (リソース所有者)
└─ ユーザー (あなた)
2. Client (クライアント)
└─ サービスを使用しようとするアプリケーション
└─ 例: あなたが作ったWebサイト
3. Authorization Server (認可サーバー)
└─ 権限を付与するサーバー
└─ 例: Google, GitHub, Naver
4. Resource Server (リソースサーバー)
└─ ユーザーの情報を持っているサーバー
└─ 例: Google API, GitHub API
2. OAuth 2.0認証フロー (Authorization Code)
┌──────────┐ ┌──────────┐
│ ユーザー │ │クライアント│
│(あなた) │ │(Webアプリ)│
└─────┬────┘ └────┬─────┘
│ │
│ 1. "Googleでログイン"をクリック │
│──────────────────────────────────────────>│
│ │
│ 2. Googleログインページへリダイレクト │
│<──────────────────────────────────────────│
│ │
│ ┌──────────────────┐ │
│ │ Google │ │
│ 3. ログイン│ (認可サーバー) │ │
│────────>│ │ │
│ │ │ │
│ 4. 権限リクエスト │
│ "このアプリがあなたのメールと │
│ プロフィールを見ようとしています" │
│<────────│ │ │
│ │ │ │
│ 5. 承認 │ │ │
│────────>│ │ │
│ │ │ │
│ │ 6. Authorization Code発行 │
│ │ (ワンタイムコード) │
│<────────│ │ │
│ └──────────────────┘ │
│ │
│ 7. Authorization Codeを渡す │
│──────────────────────────────────────────>│
│ │
│ ┌──────────────────┐ │
│ │ Google │ │
│ │ (認可サーバー) │ 8. Codeと │
│ │ │<─── Client │
│ │ │ Secret │
│ │ │ を交換 │
│ │ │ │
│ │ 9. Access Token │ ────────────>│
│ │ 発行 │ │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ Google API │ │
│ │ (リソースサーバー)│ 10. Tokenで │
│ │ │<──── ユーザー │
│ │ │ 情報要求 │
│ │ │ │
│ │ 11. ユーザー情報 │ ────────────>│
│ │ レスポンス │ │
│ └──────────────────┘ │
│ │
│ 12. ログイン完了! │
│<──────────────────────────────────────────│
│ │
ポイント:
- ユーザーパスワードはGoogleにのみ入力
- WebアプリはパスワードをЗ知らない
- Access Tokenでのみ権限のある情報にアクセス
3. OAuth 2.0 Grant Types
========== 1. Authorization Code (最も安全) ==========
使用: サーバーサイドWebアプリケーション
利点: 最も安全 (Client Secret使用)
例: "Googleでログイン"
フロー:
1. ユーザー → Googleログイン
2. Google → Authorization Code発行
3. サーバー → Code + SecretでAccess Tokenを交換
4. サーバー → TokenでAPI呼び出し
========== 2. Implicit (シンプルだが安 全性は低い) ==========
使用: ブラウザ専用アプリ (SPA)
利点: シンプル (サーバー不要)
欠点: セキュリティ脆弱 (TokenがURLに露出)
例: 古いSPA
⚠️ 現在は推奨されない
========== 3. Resource Owner Password (レガシー) ==========
使用: 信頼できるアプリのみ
利点: シンプル
欠点: ユーザーパスワードをアプリが知ることになる
例: 自社モバイルアプリ
⚠️ OAuthの思想に反する、非推奨
========== 4. Client Credentials ==========
使用: サーバー間通信 (M2M)
利点: ユーザー認証不要
例: バックエンドサービス間通信
フロー:
1. サーバー → Client ID + Secretを送信
2. Authorization Server → Access Token発行
3. サーバー → TokenでAPI呼び出し
========== 5. PKCE (Proof Key for Code Exchange) ==========
使用: モバイルアプリ、SPA (現在推奨)
利点: Authorization Code + 追加セキュリティ
例: 現代的なモバイル/SPAアプリ
フロー:
1. アプリ → code_verifier生成 (ランダム文字列)
2. アプリ → code_challenge生成 (verifierハッシュ)
3. アプリ → challengeと共にAuthorization Codeをリクエスト
4. Google → Code発行
5. アプリ → Code + verifierでToken交換
6. Google → verifier検証後Token発行
💡 実際の例
Google OAuth 2.0ログイン (Node.js)
// ========== 1. Google Cloud Console設定 ==========
/*
1. https://console.cloud.google.com にアクセス
2. プロジェクト作成
3. "APIとサービス" → "認証情報"
4. "OAuth 2.0クライアントID"作成
5. 承認済みのリダイレクトURIを追加:
http://localhost:3000/auth/google/callback
6. Client IDとClient Secretを取得
*/
// ========== 2. サーバー実装 ==========
const express = require('express');
const axios = require('axios');
const session = require('express-session');
const app = express();
// セッション設定
app.use(session({
secret: 'your-session-secret',
resave: false,
saveUninitialized: true
}));
// Google OAuth設定
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const REDIRECT_URI = 'http://localhost:3000/auth/google/callback';
// ========== Step 1: ログイン開始 ==========
app.get('/auth/google', (req, res) => {
// Google認証URL生成
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code', // Authorization Codeをリクエスト
scope: 'openid email profile', // リクエストする権限
access_type: 'offline', // Refresh Tokenを取得
prompt: 'consent' // 常に同意画面を表示
});
// Googleログインページへリダイレクト
res.redirect(`${authUrl}?${params}`);
});
// ========== Step 2: コールバック処理 ==========
app.get('/auth/google/callback', async (req, res) => {
const { code, error } = req.query;
// ユーザーが拒否した場合
if (error) {
return res.status(400).send(`認証失敗: ${error}`);
}
try {
// Authorization CodeをAccess Tokenに交換
const tokenResponse = await axios.post(
'https://oauth2.googleapis.com/token',
{
code,
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code'
}
);
const {
access_token,
refresh_token,
expires_in,
token_type
} = tokenResponse.data;
console.log('Access Token:', access_token);
console.log('Expires in:', expires_in, 'seconds');
// Access Tokenでユーザー情報を取得
const userResponse = await axios.get(
'https://www.googleapis.com/oauth2/v2/userinfo',
{
headers: {
Authorization: `Bearer ${access_token}`
}
}
);
const user = userResponse.data;
/*
{
id: "123456789",
email: "user@gmail.com",
verified_email: true,
name: "山田太郎",
picture: "https://lh3.googleusercontent.com/...",
locale: "ja"
}
*/
// セッションにユーザー情報を保存
req.session.user = {
id: user.id,
email: user.email,
name: user.name,
picture: user.picture,
accessToken: access_token,
refreshToken: refresh_token
};
// データベースにユーザーを保存
await saveOrUpdateUser(user);
// ホームページへリダイレクト
res.redirect('/dashboard');
} catch (error) {
console.error('OAuthエラー:', error.response?.data || error.message);
res.status(500).send('認証処理中にエラーが発生しました');
}
});
// ========== ユーザー情報確認 ==========
app.get('/dashboard', (req, res) => {
if (!req.session.user) {
return res.redirect('/');
}
res.send(`
<h1>こんにちは、${req.session.user.name}さん!</h1>
<img src="${req.session.user.picture}" alt="プロフィール" />
<p>メール: ${req.session.user.email}</p>
<a href="/logout">ログアウト</a>
`);
});
// ========== ログアウト ==========
app.get('/logout', async (req, res) => {
// Google Access Tokenを取り消し (オプション)
const accessToken = req.session.user?.accessToken;
if (accessToken) {
try {
await axios.post(
`https://oauth2.googleapis.com/revoke?token=${accessToken}`
);
} catch (error) {
console.error('Token取り消し失敗:', error.message);
}
}
// セッション削除
req.session.destroy();
res.redirect('/');
});
// ========== ホームページ ==========
app.get('/', (req, res) => {
res.send(`
<h1>OAuth 2.0 デモ</h1>
<a href="/auth/google">
<button>Googleでログイン</button>
</a>
`);
});
app.listen(3000, () => {
console.log('サーバー実行中: http://localhost:3000');
});
Passport.jsで簡単に実装
// ========== Passport.js使用 ==========
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
// Passport設定
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: 'http://localhost:3000/auth/google/callback'
},
async (accessToken, refreshToken, profile, done) => {
// ユーザー情報処理
try {
// データベースでユーザーを検索または作成
let user = await User.findOne({ googleId: profile.id });
if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
picture: profile.photos[0].value
});
}
done(null, user);
} catch (error) {
done(error, null);
}
}
));
// セッションシリアライゼーション
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
const user = await User.findById(id);
done(null, user);
});
// Express設定
app.use(passport.initialize());
app.use(passport.session());
// ========== ルート ==========
// Googleログイン開始
app.get('/auth/google',
passport.authenticate('google', {
scope: ['profile', 'email']
})
);
// Googleコールバック
app.get('/auth/google/callback',
passport.authenticate('google', {
failureRedirect: '/login',
successRedirect: '/dashboard'
})
);
// ログアウト
app.get('/logout', (req, res) => {
req.logout((err) => {
if (err) console.error(err);
res.redirect('/');
});
});
// 認証ミドルウェア
function isAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.redirect('/login');
}
// 保護されたルート
app.get('/dashboard', isAuthenticated, (req, res) => {
res.send(`こんにちは、${req.user.name}さん!`);
});
GitHub OAuth 2.0
// ========== GitHub OAuth設定 ==========
/*
1. https://github.com/settings/developers にアクセス
2. "New OAuth App"をクリック
3. 情報を入力:
- Application name: My App
- Homepage URL: http://localhost:3000
- Authorization callback URL: http://localhost:3000/auth/github/callback
4. Client IDとClient Secretを取得
*/
const express = require('express');
const axios = require('axios');
const app = express();
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID;
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET;
const REDIRECT_URI = 'http://localhost:3000/auth/github/callback';
// ========== GitHubログイン ==========
app.get('/auth/github', (req, res) => {
const authUrl = 'https://github.com/login/oauth/authorize';
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'user:email', // リクエストする権限
state: Math.random().toString(36) // CSRF防止
});
res.redirect(`${authUrl}?${params}`);
});
// ========== コールバック処理 ==========
app.get('/auth/github/callback', async (req, res) => {
const { code, state } = req.query;
try {
// 1. Access Tokenを取得
const tokenResponse = await axios.post(
'https://github.com/login/oauth/access_token',
{
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
redirect_uri: REDIRECT_URI
},
{
headers: { Accept: 'application/json' }
}
);
const { access_token } = tokenResponse.data;
// 2. ユーザー情報を取得
const userResponse = await axios.get('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${access_token}`
}
});
const user = userResponse.data;
/*
{
login: "username",
id: 12345,
avatar_url: "https://avatars.githubusercontent.com/...",
name: "山田太郎",
email: "user@example.com",
bio: "開発者です",
public_repos: 50
}
*/
// 3. メールを取得 (別のリクエスト)
const emailResponse = await axios.get('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${access_token}`
}
});
const primaryEmail = emailResponse.data.find(email => email.primary);
res.json({
user,
email: primaryEmail.email
});
} catch (error) {
console.error('GitHub OAuthエラー:', error.message);
res.status(500).send('認証失敗');
}
});
app.listen(3000);
ReactでOAuthを使用
// ========== Reactクライアント ==========
import React, { useEffect, useState } from 'react';
import axios from 'axios';
function App() {
const [user, setUser] = useState(null);
// ページロード時にユーザー情報を確認
useEffect(() => {
checkAuth();
}, []);
async function checkAuth() {
try {
const response = await axios.get('/api/me', {
withCredentials: true // クッキーを含む
});
setUser(response.data);
} catch (error) {
console.log('ログインしていません');
}
}
function handleGoogleLogin() {
// サーバーのOAuthエンドポイントに移動
window.location.href = 'http://localhost:3000/auth/google';
}
function handleGitHubLogin() {
window.location.href = 'http://localhost:3000/auth/github';
}
function handleLogout() {
window.location.href = 'http://localhost:3000/logout';
}
if (user) {
return (
<div>
<h1>こんにちは、{user.name}さん!</h1>
<img src={user.picture} alt="プロフィール" />
<p>{user.email}</p>
<button onClick={handleLogout}>ログアウト</button>
</div>
);
}
return (
<div>
<h1>ログイン</h1>
<button onClick={handleGoogleLogin}>
🔵 Googleでログイン
</button>
<button onClick={handleGitHubLogin}>
⚫ GitHubでログイン
</button>
</div>
);
}
export default App;
PKCEでセキュリティ強化 (SPA/モバイル)
// ========== PKCE (Proof Key for Code Exchange) ==========
// モバイルアプリやSPAで使用
const crypto = require('crypto');
// ========== 1. Code Verifier生成 ==========
function generateCodeVerifier() {
return base64URLEncode(crypto.randomBytes(32));
}
// ========== 2. Code Challenge生成 ==========
function generateCodeChallenge(verifier) {
const hash = crypto.createHash('sha256')
.update(verifier)
.digest();
return base64URLEncode(hash);
}
function base64URLEncode(buffer) {
return buffer.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// ========== 3. ログイン開始 (クライアント) ==========
async function startPKCELogin() {
// Verifierを生成して保存
const codeVerifier = generateCodeVerifier();
sessionStorage.setItem('code_verifier', codeVerifier);
// Challengeを生成
const codeChallenge = generateCodeChallenge(codeVerifier);
// Google認証URL
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'openid email profile',
code_challenge: codeChallenge, // PKCE
code_challenge_method: 'S256' // SHA-256
});
window.location.href = `${authUrl}?${params}`;
}
// ========== 4. コールバック処理 ==========
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
// 保存されたVerifierを取得
const codeVerifier = sessionStorage.getItem('code_verifier');
// Token交換
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
code_verifier: codeVerifier // PKCE Verifier
})
});
const { access_token } = await response.json();
// Verifierを削除
sessionStorage.removeItem('code_verifier');
return access_token;
}
🤔 よくある質問
Q1. OAuth vs JWT vs セッション?
A:
// ========== 1. セッション (Session) ==========
// 伝統的な方法
// ログイン
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 認証確認
if (isValidUser(username, password)) {
// セッションに保存
req.session.userId = user.id;
res.json({ message: 'ログイン成功' });
}
});
// 認証確認
app.get('/profile', (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'ログインが必要です' });
}
const user = getUserById(req.session.userId);
res.json(user);
});
利点: シンプル、サーバーで完全制御
欠点: サーバーメモリ/ストレージが必要、拡張性が低い
// ========== 2. JWT (JSON Web Token) ==========
// ステートレス認証
// ログイン
app.post('/login', (req, res) => {
const { username, password } = req.body;
if (isValidUser(username, password)) {
// JWT生成
const token = jwt.sign(
{ userId: user.id, username: user.username },
SECRET_KEY,
{ expiresIn: '1h' }
);
res.json({ token });
}
});
// 認証確認
app.get('/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
try {
const decoded = jwt.verify(token, SECRET_KEY);
const user = getUserById(decoded.userId);
res.json(user);
} catch (error) {
res.status(401).json({ error: '無効なトークン' });
}
});
利点: ステートレス、拡張性が良い
欠点: 無効化が難しい、サイズが大きい
// ========== 3. OAuth 2.0 ==========
// サードパーティ認証と認可
// ユーザーがGoogleでログイン
// → GoogleがAccess Tokenを発行
// → 私たちのアプリはTokenでGoogle APIを呼び出す
利点:
- パスワードを共有しない
- 権限を細かく制御
- 標準プロトコル
- ソーシャルログインが可能
欠点:
- 複雑
- 外部サービスに依存
// ========== 比較 ==========
特性 | セッション | JWT | OAuth
-------------|-----------|----------|-------
保存場所 | サーバー | クライアント| サーバー
拡張性 | 低い | 高い | 高い
セキュリティ | 高い | 普通 | 高い
複雑度 | 低い | 普通 | 高い
サードパーティ認証| 不可 | 不可 | 可能
// ========== 組み合わせ使用 ==========
// OAuthでログイン後JWTを発行
app.get('/auth/google/callback', async (req, res) => {
// 1. Google OAuthでユーザー認証
const googleUser = await getGoogleUser(req.query.code);
// 2. 私たちのデータベースにユーザーを保存
const user = await saveOrUpdateUser(googleUser);
// 3. JWTを生成して返す
const token = jwt.sign(
{ userId: user.id, email: user.email },
SECRET_KEY,
{ expiresIn: '7d' }
);
res.json({ token });
});
// その後のリクエストはJWTで認証
app.get('/api/profile', authenticateJWT, (req, res) => {
res.json(req.user);
});
Q2. Access TokenとRefresh Tokenの違いは?
A:
// ========== Access Token ==========
// 短い有効期限 (15分~1時間)
// APIリクエスト時に使用
// ========== Refresh Token ==========
// 長い有効期限 (7日~30日)
// Access Token更新時に使用
// ========== 実装例 ==========
// ログイン時に両方発行
app.post('/login', async (req, res) => {
const user = await authenticateUser(req.body);
// Access Token (15分)
const accessToken = jwt.sign(
{ userId: user.id, type: 'access' },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
// Refresh Token (7日)
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Refresh TokenをDBに保存 (無効化のため)
await saveRefreshToken(user.id, refreshToken);
res.json({ accessToken, refreshToken });
});
// ========== APIリクエスト (Access Token) ==========
app.get('/api/data', authenticateAccess, (req, res) => {
res.json({ data: '重要なデータ' });
});
function authenticateAccess(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
try {
const decoded = jwt.verify(token, ACCESS_SECRET);
if (decoded.type !== 'access') {
throw new Error('無効なトークンタイプ');
}
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'access_token_expired',
message: 'Refresh Tokenで更新してください'
});
}
res.status(401).json({ error: '無効なトークン' });
}
}
// ========== Access Token更新 ==========
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh Tokenが必要です' });
}
try {
// 1. Refresh Token検証
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
if (decoded.type !== 'refresh') {
throw new Error('無効なトークンタイプ');
}
// 2. DBで確認 (無効化されているか)
const isValid = await isRefreshTokenValid(decoded.userId, refreshToken);
if (!isValid) {
throw new Error('無効化されたRefresh Token');
}
// 3. 新しいAccess Tokenを発行
const newAccessToken = jwt.sign(
{ userId: decoded.userId, type: 'access' },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (error) {
res.status(401).json({ error: '無効なRefresh Token' });
}
});
// ========== フロントエンド自動更新 ==========
import axios from 'axios';
let accessToken = localStorage.getItem('accessToken');
let refreshToken = localStorage.getItem('refreshToken');
// Axiosインターセプター
axios.interceptors.request.use(
config => {
config.headers.Authorization = `Bearer ${accessToken}`;
return config;
}
);
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
// Access Token期限切れ
if (error.response?.data?.error === 'access_token_expired' &&
!originalRequest._retry) {
originalRequest._retry = true;
try {
// Refresh Tokenで更新
const response = await axios.post('/auth/refresh', {
refreshToken
});
accessToken = response.data.accessToken;
localStorage.setItem('accessToken', accessToken);
// 元のリクエストを再試行
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return axios(originalRequest);
} catch (refreshError) {
// Refresh Tokenも期限切れ → ログアウト
localStorage.clear();
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
// ========== セキュリティヒント ==========
// ✅ 良い例
1. Access Token: 短い有効期限 (15分)
2. Refresh Token: DBに保存して無効化可能に
3. Refresh TokenはHttpOnlyクッキーに保存
4. HTTPS必須
5. Refresh Token Rotation (再発行時に新しいトークンを発行)
// ❌ 悪い例
1. Access Tokenの有効期限を長くしすぎる
2. Refresh TokenをlocalStorageに保存
3. Refresh Tokenを無効化できない
4. HTTPを使用
Q3. Scope(権限範囲)とは?
A:
// ========== Scope概念 ==========
// アプリケーションがリクエストする権限の範囲
// ========== Google OAuth Scopes ==========
const scopes = [
'openid', // 基本情報
'email', // メール
'profile', // プロフィール (名前、写真)
'https://www.googleapis.com/auth/drive.readonly', // Drive読み取り
'https://www.googleapis.com/auth/gmail.send' // Gmail送信
];
// ログイン時に権限をリクエスト
app.get('/auth/google', (req, res) => {
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: scopes.join(' ') // スペースで区切る
});
res.redirect(`${authUrl}?${params}`);
});
// ユーザーに表示される画面:
/*
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
My Appがリクエストしています:
✓ メールアドレスの確認
✓ 基本プロフィール情報の確認
✓ Google Driveファイルの表示
✓ Gmailメールの送信
[許可] [拒否]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
*/
// ========== GitHub OAuth Scopes ==========
const githubScopes = [
'user', // ユーザー情報
'user:email', // メール (非公開を含む)
'repo', // リポジトリ全体アクセス
'public_repo', // 公開リポジトリのみ
'read:org', // 組織情報の読み取り
'write:org' // 組織情報の書き込み
];
app.get('/auth/github', (req, res) => {
const authUrl = 'https://github.com/login/oauth/authorize';
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'user:email public_repo' // 必要な権限のみ
});
res.redirect(`${authUrl}?${params}`);
});
// ========== 最小権限の原則 ==========
// ❌ 悪い例: 必要以上の権限をリクエスト
scope: 'user repo admin:org' // 権限が多すぎる!
// ✅ 良い例: 必要な権限のみ
scope: 'user:email' // メールのみ必要
// ========== 動的Scopeリクエスト ==========
// 初期ログイン: 基本情報のみ
app.get('/auth/google', (req, res) => {
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'email profile' // 基本情報のみ
});
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
});
// 後で追加の権限が必要な場合
app.get('/auth/google/drive', (req, res) => {
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'https://www.googleapis.com/auth/drive.readonly',
prompt: 'consent' // 再同意をリクエスト
});
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
});
// ========== 段階的な承認 ==========
// 必要に応じて段階 的に権限を追加
// ステップ1: ログイン (基本情報)
scope: 'email profile'
// ステップ2: Google Drive連携 (ユーザーが希望する場合)
scope: 'https://www.googleapis.com/auth/drive'
// ステップ3: Gmail連携 (ユーザーが希望する場合)
scope: 'https://www.googleapis.com/auth/gmail.send'
// ========== Scope検証 ==========
// Access Tokenに含まれるscopeを確認
async function checkScope(accessToken, requiredScope) {
const response = await axios.get(
'https://www.googleapis.com/oauth2/v1/tokeninfo',
{ params: { access_token: accessToken } }
);
const grantedScopes = response.data.scope.split(' ');
if (!grantedScopes.includes(requiredScope)) {
throw new Error('権限不足');
}
}
// 使用例
app.get('/api/drive/files', async (req, res) => {
const accessToken = req.headers.authorization?.split(' ')[1];
try {
// Drive権限を確認
await checkScope(accessToken, 'https://www.googleapis.com/auth/drive.readonly');
// Drive APIを呼び出し
const files = await listDriveFiles(accessToken);
res.json(files);
} catch (error) {
res.status(403).json({
error: '権限不足',
message: 'Google Driveアクセス権限が必要です'
});
}
});
Q4. OAuthのセキュリティベストプラクティスは?
A:
// ========== 1. HTTPS必須 ==========
// ✅ 常にHTTPSを使用
const REDIRECT_URI = 'https://myapp.com/callback';
// ❌ HTTPは絶対禁止
const REDIRECT_URI = 'http://myapp.com/callback'; // Token盗難のリスク!
// ========== 2. Stateパラメータ (CSRF防止) ==========
// ログイン開始
app.get('/auth/google', (req, res) => {
// ランダムなstateを生成して保存
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'email profile',
state // CSRF防止
});
res.redirect(`${authUrl}?${params}`);
});
// コールバックで検証
app.get('/auth/google/callback', (req, res) => {
const { state, code } = req.query;
// stateを確認
if (state !== req.session.oauthState) {
return res.status(403).send('CSRF攻撃を検出');
}
// stateを削除
delete req.session.oauthState;
// ... 処理を続ける
});
// ========== 3. Client Secretの保護 ==========
// ✅ 環境変数を使用
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
// ✅ サーバーでのみ使用
// フロントエンドには絶対に公開しない!
// ❌ コードにハードコード禁止
const CLIENT_SECRET = 'abc123xyz'; // 絶対禁止!
// ========== 4. Redirect URI検証 ==========
// Google Cloud Consoleに登録されたURIのみ許可
// ✅ 正確に一致するURI
https://myapp.com/auth/callback
// ❌ ワイルドカード使用禁止
https://myapp.com/*
// ❌ オープンリダイレクター脆弱性
https://myapp.com/callback?redirect=evil.com
// ========== 5. Token保存 ==========
// ✅ サーバー: 環境変数または暗号化されたDB
await saveToken(userId, encryptToken(accessToken));
// ✅ クライアント: HttpOnlyクッキー (XSS防止)
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 3600000 // 1時間
});
// ❌ localStorage (XSS脆弱)
localStorage.setItem('token', accessToken); // 危険!
// ========== 6. Token有効期限 ==========
// ✅ 短い有効期限
const accessToken = jwt.sign(payload, secret, {
expiresIn: '15m' // 15分
});
// ✅ Refresh Token使用
const refreshToken = jwt.sign(payload, secret, {
expiresIn: '7d' // 7日
});
// ❌ 有効期限なし
const token = jwt.sign(payload, secret); // 危険!
// ========== 7. Scopeの最小化 ==========
// ✅ 必要な権限のみ
scope: 'email profile'
// ❌ すべての権限をリクエスト
scope: 'https://www.googleapis.com/auth/drive' // 不要
// ========== 8. Token取り消しの実装 ==========
// ログアウト時にTokenを取り消し
app.get('/logout', async (req, res) => {
const accessToken = req.cookies.access_token;
// Google Tokenを取り消し
await axios.post(
`https://oauth2.googleapis.com/revoke?token=${accessToken}`
);
// DBからRefresh Tokenを削除
await deleteRefreshToken(req.user.id);
// クッキーを削除
res.clearCookie('access_token');
res.redirect('/');
});
// ========== 9. PKCE使用 (モバイル/SPA) ==========
// Client Secretなしでセキュリティ強化
const codeVerifier = generateRandomString(128);
const codeChallenge = base64URLEncode(sha256(codeVerifier));
// 認証リクエスト時にchallengeを送信
const params = {
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
code_challenge: codeChallenge,
code_challenge_method: 'S256'
};
// Token交換時にverifierを送信
const tokenParams = {
code,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
code_verifier: codeVerifier // Secretの代わり
};
// ========== 10. 定期的なToken更新 ==========
// バックグラウンドで自動更新
setInterval(async () => {
try {
const newAccessToken = await refreshAccessToken(refreshToken);
updateToken(newAccessToken);
} catch (error) {
console.error('Token更新失敗:', error);
redirectToLogin();
}
}, 14 * 60 * 1000); // 14分ごと (15分の有効期限前に)