Skip to content

カスタム Claims

内蔵の Claims 構造体は一般的なシーンをカバーしていますが、ビジネスシステムには通常追加フィールドが必要です。CustomClaims インターフェースを実装することで独自の Claims 構造体を定義できます。

CustomClaims インターフェース

go
type CustomClaims interface {
    GetRegisteredClaims() *RegisteredClaims
    Validate() error
}

2 つのメソッドを実装するだけです:

メソッド説明
GetRegisteredClaims()標準 JWT フィールド(iss、sub、aud など)を返す
Validate()カスタム検証ロジック

Extra フィールド vs カスタム型

ビジネスフィールドの保存には 2 つの方法があります:内蔵 Claims.Extra フィールドを使用するか、カスタム Claims 型を定義するかです。それぞれ一長一短があります。

比較

次元Claims.Extraカスタム Claims 型
型安全性なし、値は any、型アサーションが必要あり、コンパイル時の型チェック
IDE 補完なし、map キーに補完なしあり、フィールド自動補完
カスタム検証なし、ライブラリ内蔵の深層検証のみあり、Validate() で自由に実装
深層検証あり、長さ/インジェクション/制御文字なし、登録クレームのサニタイズのみ
ネスト構造なし、ネスト map 非対応あり、任意の構造体
適用シーン少数のオプション追加フィールドコアビジネスフィールド、カスタム検証が必要

Extra フィールドの制限

内蔵 Claims.Extramap[string]any で、Processor はトークン作成時に深層検証を実行します:

制限項目制約
最大キー数50 個
許可される値の型string[]string のみ
ネスト map拒否(ValidationError を返す)
文字列値の長さ≤ 256 文字
インジェクションパターン検出他の文字列フィールドと同様
go
// ✅ 合法 — string と []string の値のみ
claims := &jwt.Claims{
    UserID: "user1",
    Extra: map[string]any{
        "team_id": "team-abc",            // string
        "tags":    []string{"vip", "qa"}, // []string
    },
}

// ❌ 不正 — ネスト map は深層検証で拒否される
claims = &jwt.Claims{
    Extra: map[string]any{
        "profile": map[string]any{"age": 30}, // ValidationError: nested maps not allowed
    },
}

選び方

  • 少数、オプション、フラットな追加情報(例:team_idtags)→ Extra を使用し、ライブラリ内蔵の深層検証を享受、自前の検証は不要。
  • コアビジネスフィールド列挙/クロスフィールド制約/型安全性が必要 → カスタム Claims 型を定義し、Validate() でビジネスルールを実装。注意:カスタム構造体フィールドは深層検証されないため、長さとインジェクションチェックを自前で補う必要があります(下記セキュリティ影響と検証テンプレートを参照)。

カスタム Claims の定義

go
type MyClaims struct {
    UserID string `json:"user_id"`
    Email  string `json:"email"`
    Role   string `json:"role"`
    jwt.RegisteredClaims
}

func (c *MyClaims) GetRegisteredClaims() *jwt.RegisteredClaims {
    return &c.RegisteredClaims
}

func (c *MyClaims) Validate() error {
    if c.UserID == "" {
        return errors.New("user_id is required")
    }
    if c.Email == "" {
        return errors.New("email is required")
    }
    return nil
}

ポイント

  • jwt.RegisteredClaims の埋め込みは必須
  • GetRegisteredClaims() は埋め込まれたフィールドのポインタを返す
  • Validate() はトークンの作成時と検証時の両方で呼び出される

カスタム Claims の使用

トークンの作成

go
claims := &MyClaims{
    UserID: "user123",
    Email:  "[email protected]",
    Role:   "admin",
}
token, err := processor.Create(claims)

カスタム構造体に検証

ValidateInto を使用してトークンをカスタム構造体にパースします:

go
myClaims := &MyClaims{}
result, valid, err := processor.ValidateInto(token, myClaims)
if err != nil {
    panic(err)
}
if valid {
    parsed := result.(*MyClaims)
    fmt.Println("UserID:", parsed.UserID)
    fmt.Println("Email:", parsed.Email)
}

カスタム構造体にリフレッシュ

RefreshInto を使用してトークンをリフレッシュし、カスタムフィールドを維持します:

go
newToken, err := processor.RefreshInto(refreshToken, &MyClaims{})
if err != nil {
    panic(err)
}

時系列フィールドの保護

RefreshInto は Claims の時系列フィールド(IssuedAtExpiresAtID)を自動的に復元します。操作が失敗しても復元が保証されます。

複雑な検証例

カスタム Claims の真の価値は Validate() でビジネスルールを実装できる点にあります。以下の例は必須チェック、列挙値制約、クロスフィールド制約を示します:

go
package main

import (
    "errors"
    "fmt"

    "github.com/cybergodev/jwt"
)

// AccountClaims はアカウント階層とデバイスクォータのビジネスクレームを保持
type AccountClaims struct {
    UserID    string   `json:"user_id"`
    Tier      string   `json:"tier"`       // free | pro | enterprise
    Region    string   `json:"region"`     // cn | us | eu
    DeviceIDs []string `json:"device_ids"`
    jwt.RegisteredClaims
}

// 各階層の最大デバイス数
var tierMaxDevices = map[string]int{
    "free":       2,
    "pro":        10,
    "enterprise": 100,
}

var allowedRegions = map[string]bool{"cn": true, "us": true, "eu": true}

func (c *AccountClaims) GetRegisteredClaims() *jwt.RegisteredClaims {
    return &c.RegisteredClaims
}

func (c *AccountClaims) Validate() error {
    // 1. 必須フィールドのチェック
    if c.UserID == "" {
        return errors.New("user_id is required")
    }

    // 2. 列挙値のチェック
    if _, ok := tierMaxDevices[c.Tier]; !ok {
        return fmt.Errorf("invalid tier %q: must be free, pro or enterprise", c.Tier)
    }
    if !allowedRegions[c.Region] {
        return fmt.Errorf("invalid region %q: must be cn, us or eu", c.Region)
    }

    // 3. クロスフィールド制約:デバイス数が階層クォータを超えないこと
    if max := tierMaxDevices[c.Tier]; len(c.DeviceIDs) > max {
        return fmt.Errorf("tier %q allows at most %d devices, got %d",
            c.Tier, max, len(c.DeviceIDs))
    }

    return nil
}

func main() {
    cfg := jwt.DefaultConfig()
    cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
    processor, err := jwt.New(cfg)
    if err != nil {
        panic(err)
    }
    defer processor.Close()

    // 合法トークン:pro 階層、3 デバイス(≤ 10)
    valid := &AccountClaims{
        UserID:    "user123",
        Tier:      "pro",
        Region:    "cn",
        DeviceIDs: []string{"dev-1", "dev-2", "dev-3"},
    }
    _, err = processor.Create(valid)
    if err != nil {
        panic(err)
    }
    fmt.Println("Token created successfully")

    // 違反トークン:free 階層に 5 デバイス(> 2)→ Validate() が拒否
    _, err = processor.Create(&AccountClaims{
        UserID:    "user456",
        Tier:      "free",
        Region:    "us",
        DeviceIDs: []string{"d1", "d2", "d3", "d4", "d5"},
    })
    fmt.Println("Over-quota error:", err)
    // 出力: Over-quota error: invalid claims: tier "free" allows at most 2 devices, got 5
}

エラーラッピング

Validate() が返す記述的エラーは ErrInvalidClaims でラップされます。呼び出し側は errors.Is(err, jwt.ErrInvalidClaims) でカテゴリを判定することも、エラー文字列を直接読んでビジネス詳細を取得することもできます。詳しくはエラー処理を参照してください。

検証の違い

内蔵 *Claims とカスタム型では異なる検証パスを通ります:

検証項目*Claimsカスタム型
Validate() メソッド
文字列長制限(256 文字)
配列サイズ制限(100 項目)
注入パターン検出
制御文字フィルタリング
Extra フィールド制限該当なし
登録クレームの文字列サニタイズ

セキュリティ影響

カスタム Claims のビジネスフィールドは深層検証の対象になりません。これは、悪意のある入力が署名チェックを通過してカスタム構造体にパースされた場合、<script> タグ、SQL フラグメント、超長文字列などの危険なコンテンツがそのままトークンに格納されることを意味します——内蔵 *Claims の 46 種のインジェクションパターン検出、256 文字長制限、制御文字フィルタリングはすべて適用されません。

Validate() メソッドですべての必要な検証を自身で実装してください。そうでないとトークンが XSS/SQL インジェクションの媒体になる可能性があります。

セキュリティ影響と検証テンプレート

以下のヘルパー関数は内蔵深層検証のコアルジック(長さ上限、制御文字、インジェクション部分文字列)を再現したもので、カスタム Claims の Validate() でそのまま呼び出せます:

go
package main

import (
    "errors"
    "fmt"
    "strings"
)

const maxClaimLength = 256

// dangerousSubstrings はライブラリ内蔵検出と重複する高危険部分文字列を列挙、ビジネスに応じて追加・削除可。
var dangerousSubstrings = []string{
    "<script", "javascript:", "onerror=", "onload=",
    "drop table", "union select", "../", "/etc/passwd",
}

// validateField はカスタムフィールドの長さ、制御文字、一般的なインジェクションパターンを検証。
func validateField(name, value string) error {
    if len(value) > maxClaimLength {
        return fmt.Errorf("%s exceeds maximum length of %d", name, maxClaimLength)
    }
    for i := 0; i < len(value); i++ {
        c := value[i]
        if c < 32 && c != '\t' && c != '\n' && c != '\r' {
            return fmt.Errorf("%s contains invalid control character", name)
        }
    }
    lower := strings.ToLower(value)
    for _, pattern := range dangerousSubstrings {
        if strings.Contains(lower, pattern) {
            return fmt.Errorf("%s contains suspicious pattern", name)
        }
    }
    return nil
}

type MyClaims struct {
    UserID     string `json:"user_id"`
    Department string `json:"department"`
}

func (c *MyClaims) Validate() error {
    if c.UserID == "" {
        return errors.New("user_id is required")
    }
    // カスタムフィールドは内蔵深層検証の対象外、長さとインジェクションチェックを手動で補う
    if err := validateField("user_id", c.UserID); err != nil {
        return err
    }
    if err := validateField("department", c.Department); err != nil {
        return err
    }
    return nil
}

func main() {}

オプションインターフェース:RateLimitKeyer

カスタム Claims は RateLimitKeyer インターフェースを実装してレート制限キーを提供できます:

go
func (c *MyClaims) RateLimitKey() string {
    return c.Email // Email をレート制限キーとして使用
}

レート制限キーの検索優先順位:Subject*Claims.UserIDRateLimitKey()

次のステップ