APIトークンガイド
Guren はAPIリクエストを認証するためのセキュアなAPIトークンシステムを提供します。トークンは保存前にハッシュ化され、abilities(スコープ)をサポートし、有効期限を設定できます。
APIトークンガイド
Guren はAPIリクエストを認証するためのセキュアなAPIトークンシステムを提供します。トークンは保存前にハッシュ化され、abilities(スコープ)をサポートし、有効期限を設定できます。
コアコンセプト
- ApiToken – データベースに保存されるトークンデータ(ハッシュ化され、プレーンテキストは保存されない)。
- ApiTokenStore – トークンストレージのインターフェース(メモリまたはデータベース)。
- Bearerトークンミドルウェア – Authorizationヘッダーを使用してリクエストを認証。
- Abilities – トークンが実行できるアクションを定義するスコープ。
基本的な使い方
トークンの作成
import { createApiToken, MemoryApiTokenStore } from '@guren/core'
const store = new MemoryApiTokenStore() // 本番環境ではデータベースを使用
// ユーザー用のトークンを作成
const { plainTextToken, token } = await createApiToken(store, {
name: 'モバイルアプリトークン',
userId: user.id,
abilities: ['posts:read', 'posts:write'],
expiresIn: 30 * 24 * 60 * 60 * 1000, // 30日
})
// plainTextTokenをユーザーに返す - これが利用可能な唯一の機会!
return ctx.json({ token: plainTextToken })
トークンフォーマット
トークンは{id}|{token}形式で返されます。
abc123def456...|xyz789ghi012...
トークン部分は保存前にハッシュ化されます。プレーンテキストのトークンは復元できません。
トークンの検証
import { verifyApiToken } from '@guren/core'
const result = await verifyApiToken(plainTextToken, store)
if (!result) {
return ctx.json({ error: '無効なトークン' }, 401)
}
console.log(result.userId) // ユーザーID
console.log(result.abilities) // ['posts:read', 'posts:write']
console.log(result.token) // トークンメタデータ(プレーンテキストなし)
トークンAbilities
Abilitiesの確認
import { tokenCan, tokenCanAll, tokenCanAny } from '@guren/core'
const token = { abilities: ['posts:read', 'posts:write'] }
// 単一のabilityを確認
tokenCan(token, 'posts:read') // true
tokenCan(token, 'posts:delete') // false
// すべてのabilitiesを確認
tokenCanAll(token, ['posts:read', 'posts:write']) // true
tokenCanAll(token, ['posts:read', 'posts:delete']) // false
// いずれかのabilityを確認
tokenCanAny(token, ['posts:read', 'posts:delete']) // true
tokenCanAny(token, ['users:read', 'users:write']) // false
ワイルドカードAbility
*を使用してすべてのabilitiesを付与できます。
const { plainTextToken } = await createApiToken(store, {
name: '管理者トークン',
userId: user.id,
abilities: ['*'], // すべての操作が可能
})
tokenCan({ abilities: ['*'] }, 'anything') // true
Bearerトークンミドルウェア
基本セットアップ
import { createBearerTokenMiddleware } from '@guren/core'
// すべてのAPIルートを保護
app.use('/api/*', createBearerTokenMiddleware({ store }))
Ability要件付き
import { Router } from '@guren/core'
// ルートに特定のabilitiesを要求
export function registerApiRoutes(router: Router): void {
router.delete('/api/posts/:id', [PostController, 'destroy']).middleware(
createBearerTokenMiddleware({
store,
abilities: ['posts:delete'],
}),
)
}
ユーザー読み込み付き
app.use('/api/*', createBearerTokenMiddleware({
store,
loadUser: async (userId) => {
return User.find(userId)
},
}))
// コンテキストでユーザーが利用可能に
router.get('/api/me', (ctx) => {
const user = ctx.get('guren:user')
return ctx.json(user)
})
カスタムエラーハンドラー
app.use('/api/*', createBearerTokenMiddleware({
store,
onUnauthorized: (ctx) => {
return ctx.json({ error: '有効なAPIトークンを提供してください' }, 401)
},
onForbidden: (ctx, requiredAbilities) => {
return ctx.json({
error: '権限が不足しています',
required: requiredAbilities,
}, 403)
},
}))
ルートでトークンにアクセス
import { getApiToken } from '@guren/core'
router.get('/api/token-info', (ctx) => {
const tokenInfo = getApiToken(ctx)
if (!tokenInfo) {
return ctx.json({ error: '認証されていません' }, 401)
}
return ctx.json({
userId: tokenInfo.userId,
tokenName: tokenInfo.token.name,
abilities: tokenInfo.abilities,
lastUsedAt: tokenInfo.token.lastUsedAt,
})
})
トークン管理
ユーザーのトークン一覧
import { getUserApiTokens } from '@guren/core'
router.get('/api/tokens', async (ctx) => {
const user = ctx.get('guren:user')
const tokens = await getUserApiTokens(user.id, store)
return ctx.json({
tokens: tokens.map(t => ({
id: t.id,
name: t.name,
abilities: t.abilities,
lastUsedAt: t.lastUsedAt,
createdAt: t.createdAt,
expiresAt: t.expiresAt,
})),
})
})
トークンの無効化
import { revokeApiToken, revokeAllApiTokens } from '@guren/core'
// 特定のトークンを無効化
router.delete('/api/tokens/:id', async (ctx) => {
const tokenId = ctx.req.param('id')
await revokeApiToken(tokenId, store)
return ctx.json({ message: 'トークンが無効化されました' })
})
// すべてのトークンを無効化(パスワード変更時など)
router.post('/api/tokens/revoke-all', async (ctx) => {
const user = ctx.get('guren:user')
await revokeAllApiTokens(user.id, store)
return ctx.json({ message: 'すべてのトークンが無効化されました' })
})
データベースストレージ
組み込みの DatabaseApiTokenStore
本番環境では組み込みの DatabaseApiTokenStore を使います。api_tokens スキーマの Drizzle テーブルを渡すだけで、カスタムストアの実装は不要です。
import { DatabaseApiTokenStore } from '@guren/core'
import { apiTokens } from '@/db/schema'
const store = new DatabaseApiTokenStore(apiTokens)
// すべてのトークンヘルパーで利用可能
const { plainTextToken } = await createApiToken(store, {
name: 'Mobile App Token',
userId: user.id,
})
ストアはアプリで設定済みの ORM 接続(標準の DatabaseProvider セットアップ)を利用するため、追加の配線は不要です。期限切れトークンは verifyApiToken が拒否します。テーブルから削除するには、スケジュールジョブから store.deleteExpired() を呼んでください。
データベーススキーマ
カラムのプロパティ名は ApiToken のフィールドと一致させます。
// db/schema.ts
import { pgTable, text, timestamp, jsonb } from 'drizzle-orm/pg-core'
export const apiTokens = pgTable('api_tokens', {
id: text('id').primaryKey(),
name: text('name').notNull(),
hashedToken: text('hashed_token').notNull().unique(),
userId: text('user_id').notNull(),
abilities: jsonb('abilities').$type<string[]>().notNull(),
lastUsedAt: timestamp('last_used_at'),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
abilities カラムが jsonb ではなく JSON 文字列を保持する text カラムの場合は、{ abilitiesMode: 'text' } を渡します。
const store = new DatabaseApiTokenStore(apiTokens, { abilitiesMode: 'text' })
カスタムストア
ApiTokenStore インターフェースを実装したオブジェクトであれば何でも利用できます。トークンを外部システムに保存する場合は自前で実装してください。
import type { ApiTokenStore, ApiToken } from '@guren/core'
export class ExternalApiTokenStore implements ApiTokenStore {
async store(token: ApiToken): Promise<void> { /* ... */ }
async findByHashedToken(hashedToken: string): Promise<ApiToken | null> { /* ... */ }
async findByUserId(userId: string | number): Promise<ApiToken[]> { /* ... */ }
async delete(id: string): Promise<void> { /* ... */ }
async deleteForUser(userId: string | number): Promise<void> { /* ... */ }
async updateLastUsed(id: string, timestamp: Date): Promise<void> { /* ... */ }
}
設定オプション
トークン作成オプション
interface CreateApiTokenOptions {
name: string // 人間が読めるトークン名
userId: string | number // 所有者のユーザーID
abilities?: string[] // トークンスコープ(デフォルト: ['*'])
expiresIn?: number | null // 有効期限までのミリ秒
tokenLength?: number // トークンバイト数(デフォルト: 32)
}
ミドルウェアオプション
interface BearerTokenMiddlewareOptions {
store: ApiTokenStore // トークンストレージ
loadUser?: (userId: string | number) => Promise<unknown> // ユーザーローダー
abilities?: string[] // 必要なabilities
onUnauthorized?: (ctx: Context) => Response // 401ハンドラー
onForbidden?: (ctx: Context, required: string[]) => Response // 403ハンドラー
headerName?: string // ヘッダー名(デフォルト: 'Authorization')
updateLastUsed?: boolean // 使用状況追跡(デフォルト: true)
}
テスト
import { describe, test, expect, beforeEach } from 'bun:test'
import {
createApiToken,
verifyApiToken,
MemoryApiTokenStore,
createBearerTokenMiddleware,
} from '@guren/core'
import { Hono } from 'hono'
describe('APIトークン', () => {
let store: MemoryApiTokenStore
beforeEach(() => {
store = new MemoryApiTokenStore()
})
test('トークンを作成し検証する', async () => {
const { plainTextToken, token } = await createApiToken(store, {
name: 'テストトークン',
userId: 1,
abilities: ['read'],
})
expect(plainTextToken).toMatch(/^[a-f0-9]+\|[a-f0-9]+$/)
const result = await verifyApiToken(plainTextToken, store)
expect(result?.userId).toBe(1)
expect(result?.abilities).toEqual(['read'])
})
test('期限切れトークンを拒否する', async () => {
const { plainTextToken } = await createApiToken(store, {
name: 'テストトークン',
userId: 1,
expiresIn: -1000, // すでに期限切れ
})
const result = await verifyApiToken(plainTextToken, store)
expect(result).toBeNull()
})
test('ミドルウェアがリクエストを認証する', async () => {
const { plainTextToken } = await createApiToken(store, {
name: 'テストトークン',
userId: 1,
})
const app = new Hono()
app.use('*', createBearerTokenMiddleware({ store }))
app.get('/', (c) => c.text('OK'))
const res = await app.request('/', {
headers: { Authorization: `Bearer ${plainTextToken}` },
})
expect(res.status).toBe(200)
})
test('ミドルウェアがabilitiesを確認する', async () => {
const { plainTextToken } = await createApiToken(store, {
name: 'テストトークン',
userId: 1,
abilities: ['read'],
})
const app = new Hono()
app.use('*', createBearerTokenMiddleware({
store,
abilities: ['write'],
}))
app.get('/', (c) => c.text('OK'))
const res = await app.request('/', {
headers: { Authorization: `Bearer ${plainTextToken}` },
})
expect(res.status).toBe(403)
})
})
ベストプラクティス
プレーントークンを保存しない: ハッシュ化されたトークンのみが保存される。プレーンテキストは作成時に一度だけ表示。
具体的なabilitiesを使用: セキュリティ向上のため、
['*']よりも['posts:read', 'posts:write']を推奨。有効期限を設定: セキュリティのためトークンには有効期限を設定。30〜90日が一般的。
パスワード変更時に無効化: ユーザーがパスワードを変更したら、すべてのトークンを無効化。
本番環境ではデータベースストレージを使用:
MemoryApiTokenStoreはテスト用のみ。最終使用日時を追跡:
lastUsedAtフィールドで未使用トークンを特定。トークンに意味のある名前を付ける: 識別しやすいよう「モバイルアプリ」「CI/CDパイプライン」などの名前を使用。
トークンローテーションを実装: ユーザーが定期的にトークンを再生成できるようにする。