go-oauth2

認証ライブラリGoOAuth2認可サーバーJWTセキュリティAPI認証

認証ライブラリ

go-oauth2

概要

go-oauth2は、Go言語で本格的なOAuth 2.0認可サーバーを構築するための包括的なライブラリです。RFC 6749に完全準拠したOAuth 2.0の実装を提供し、認可コードグラント、クライアント認証、リフレッシュトークンなどの主要な機能をサポートしています。シンプルで直感的なAPIでありながら、エンタープライズレベルのOAuth 2.0プロバイダーを構築できる柔軟性と拡張性を備えており、カスタムトークンストア、認証ストア、クライアントストアの実装が可能です。JWT、カスタムトークン形式、複数のグラントタイプに対応し、2025年現在も活発に開発が続けられているGo言語のOAuth 2.0エコシステムにおける主要なライブラリの一つです。

詳細

go-oauth2は、OAuth 2.0認可サーバーを実装するためのGo言語ライブラリで、OAuth 2.0仕様の完全な実装を提供します。以下の主な特徴があります:

  • OAuth 2.0準拠: RFC 6749、RFC 6750(Bearer Token)に完全準拠した実装
  • グラントタイプサポート: Authorization Code、Client Credentials、Password、Refresh Token、Implicit
  • 柔軟なストレージ: メモリ、Redis、MongoDB、PostgreSQLなど多様なストレージバックエンド対応
  • JWT対応: JWTアクセストークンの生成・検証機能を標準サポート
  • カスタマイズ性: トークン生成、クライアント認証、ユーザー認証の完全なカスタマイズが可能
  • セキュリティ機能: PKCE、State、Scope、Token Introspectionなどの高度なセキュリティ機能

メリット・デメリット

メリット

  • OAuth 2.0標準への完全準拠により高い互換性と信頼性を実現
  • 豊富なグラントタイプとセキュリティ機能で幅広いユースケースに対応
  • 柔軟なストレージ抽象化により様々なデータベースとキャッシュシステムを利用可能
  • JWT標準対応でステートレスなトークン管理が可能
  • シンプルなAPIでありながら高度なカスタマイズ性を提供
  • 活発な開発コミュニティと豊富なドキュメント・サンプルコード

デメリット

  • OAuth 2.0の複雑性により初期学習コストが高い
  • 本格的な認可サーバー構築には追加的なセキュリティ実装が必要
  • Go以外の言語環境では使用できない
  • 単純な認証要件には過剰な機能セットとなる可能性
  • エンタープライズレベルの運用には詳細な設定と監視が必要

参考ページ

書き方の例

基本的なOAuth 2.0サーバーのセットアップ

package main

import (
	"log"
	"net/http"
	"time"

	"github.com/go-oauth2/oauth2/v4/errors"
	"github.com/go-oauth2/oauth2/v4/manage"
	"github.com/go-oauth2/oauth2/v4/models"
	"github.com/go-oauth2/oauth2/v4/server"
	"github.com/go-oauth2/oauth2/v4/store"
	"github.com/go-oauth2/oauth2/v4/generate"
)

func main() {
	// マネージャーの作成
	manager := manage.NewDefaultManager()

	// トークン設定
	manager.SetAuthorizeCodeTokenCfg(&manage.Config{
		AccessTokenExp:    time.Hour * 2,
		RefreshTokenExp:   time.Hour * 24 * 7,
		IsGenerateRefresh: true,
	})

	// JWTアクセストークンジェネレーターの設定
	manager.MapAccessGenerate(generate.NewJWTAccessGenerate("", []byte("your-secret-key"), jwt.SigningMethodHS256))

	// クライアントストアの設定
	clientStore := store.NewClientStore()
	clientStore.Set("client-id-123", &models.Client{
		ID:     "client-id-123",
		Secret: "client-secret-456",
		Domain: "http://localhost:3000",
		UserID: "app-user",
	})
	manager.MapClientStorage(clientStore)

	// トークンストアの設定(メモリストア)
	manager.MapTokenStorage(store.NewMemoryTokenStore())

	// OAuth2サーバーの作成
	srv := server.NewDefaultServer(manager)
	srv.SetAllowGetAccessRequest(true)
	srv.SetClientInfoHandler(server.ClientFormHandler)

	// ユーザー認証ハンドラー
	srv.SetUserAuthorizationHandler(userAuthorizeHandler)

	// 認証スコープ処理
	srv.SetAuthorizeScopeHandler(func(rw http.ResponseWriter, r *http.Request) (scope string, err error) {
		// リクエストされたスコープを検証
		requestScope := r.FormValue("scope")
		validScopes := []string{"read", "write", "admin"}
		
		for _, validScope := range validScopes {
			if requestScope == validScope {
				return requestScope, nil
			}
		}
		
		return "read", nil // デフォルトスコープ
	})

	// エラーハンドラー
	srv.SetInternalErrorHandler(func(err error) (re *errors.Response) {
		log.Println("OAuth2 Internal Error:", err.Error())
		return
	})

	// ルート設定
	http.HandleFunc("/oauth/authorize", func(w http.ResponseWriter, r *http.Request) {
		err := srv.HandleAuthorizeRequest(w, r)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
		}
	})

	http.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
		err := srv.HandleTokenRequest(w, r)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
	})

	// 保護されたリソースエンドポイント
	http.HandleFunc("/api/profile", func(w http.ResponseWriter, r *http.Request) {
		token, err := srv.ValidationBearerToken(r)
		if err != nil {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}

		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(fmt.Sprintf(`{
			"user_id": "%s",
			"client_id": "%s",
			"scope": "%s",
			"expires_at": "%s"
		}`, token.GetUserID(), token.GetClientID(), token.GetScope(), token.GetAccessCreateAt().Add(token.GetAccessExpiresIn()))))
	})

	log.Println("OAuth2 Server starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

// ユーザー認証ハンドラー
func userAuthorizeHandler(w http.ResponseWriter, r *http.Request) (userID string, err error) {
	// セッションからユーザーIDを取得(簡単な例)
	username := r.FormValue("username")
	password := r.FormValue("password")

	// 実際の実装では、データベースやLDAPでユーザー認証
	if username == "testuser" && password == "testpass" {
		return "user-123", nil
	}

	// 認証失敗時はログインページにリダイレクト
	w.Header().Set("Location", "/login?redirect="+r.URL.RequestURI())
	w.WriteHeader(http.StatusFound)
	return "", errors.ErrAccessDenied
}

Redis ストレージバックエンドの実装

package main

import (
	"encoding/json"
	"time"

	"github.com/go-redis/redis/v8"
	"github.com/go-oauth2/oauth2/v4"
	"github.com/go-oauth2/oauth2/v4/models"
)

// Redis トークンストア
type RedisTokenStore struct {
	client *redis.Client
	prefix string
}

func NewRedisTokenStore(client *redis.Client, prefix string) *RedisTokenStore {
	return &RedisTokenStore{
		client: client,
		prefix: prefix,
	}
}

// oauth2.TokenStore インターフェースの実装
func (rs *RedisTokenStore) Create(ctx context.Context, info oauth2.TokenInfo) error {
	ct := time.Now()
	code := info.GetCode()
	access := info.GetAccess()
	refresh := info.GetRefresh()

	data, err := json.Marshal(info)
	if err != nil {
		return err
	}

	// 認可コードの保存
	if code != "" {
		key := rs.prefix + "code:" + code
		expiration := info.GetCodeCreateAt().Add(info.GetCodeExpiresIn()).Sub(ct)
		err = rs.client.Set(ctx, key, data, expiration).Err()
		if err != nil {
			return err
		}
	}

	// アクセストークンの保存
	if access != "" {
		key := rs.prefix + "access:" + access
		expiration := info.GetAccessCreateAt().Add(info.GetAccessExpiresIn()).Sub(ct)
		err = rs.client.Set(ctx, key, data, expiration).Err()
		if err != nil {
			return err
		}
	}

	// リフレッシュトークンの保存
	if refresh != "" {
		key := rs.prefix + "refresh:" + refresh
		expiration := info.GetRefreshCreateAt().Add(info.GetRefreshExpiresIn()).Sub(ct)
		err = rs.client.Set(ctx, key, data, expiration).Err()
		if err != nil {
			return err
		}
	}

	return nil
}

func (rs *RedisTokenStore) GetByCode(ctx context.Context, code string) (oauth2.TokenInfo, error) {
	key := rs.prefix + "code:" + code
	data, err := rs.client.Get(ctx, key).Result()
	if err != nil {
		return nil, err
	}

	var token models.Token
	err = json.Unmarshal([]byte(data), &token)
	return &token, err
}

func (rs *RedisTokenStore) GetByAccess(ctx context.Context, access string) (oauth2.TokenInfo, error) {
	key := rs.prefix + "access:" + access
	data, err := rs.client.Get(ctx, key).Result()
	if err != nil {
		return nil, err
	}

	var token models.Token
	err = json.Unmarshal([]byte(data), &token)
	return &token, err
}

func (rs *RedisTokenStore) GetByRefresh(ctx context.Context, refresh string) (oauth2.TokenInfo, error) {
	key := rs.prefix + "refresh:" + refresh
	data, err := rs.client.Get(ctx, key).Result()
	if err != nil {
		return nil, err
	}

	var token models.Token
	err = json.Unmarshal([]byte(data), &token)
	return &token, err
}

func (rs *RedisTokenStore) RemoveByCode(ctx context.Context, code string) error {
	key := rs.prefix + "code:" + code
	return rs.client.Del(ctx, key).Err()
}

func (rs *RedisTokenStore) RemoveByAccess(ctx context.Context, access string) error {
	key := rs.prefix + "access:" + access
	return rs.client.Del(ctx, key).Err()
}

func (rs *RedisTokenStore) RemoveByRefresh(ctx context.Context, refresh string) error {
	key := rs.prefix + "refresh:" + refresh
	return rs.client.Del(ctx, key).Err()
}

// Redis ストアの使用例
func setupWithRedis() {
	// Redis クライアントの設定
	rdb := redis.NewClient(&redis.Options{
		Addr:     "localhost:6379",
		Password: "",
		DB:       0,
	})

	manager := manage.NewDefaultManager()

	// Redis トークンストアを設定
	tokenStore := NewRedisTokenStore(rdb, "oauth2:")
	manager.MapTokenStorage(tokenStore)

	// 他の設定...
}

JWT アクセストークンとカスタムクレーム

package main

import (
	"context"
	"encoding/json"
	"time"

	"github.com/dgrijalva/jwt-go"
	"github.com/go-oauth2/oauth2/v4"
	"github.com/go-oauth2/oauth2/v4/generate"
)

// カスタムクレーム構造体
type CustomClaims struct {
	UserID      string   `json:"user_id"`
	ClientID    string   `json:"client_id"`
	Scope       string   `json:"scope"`
	Permissions []string `json:"permissions"`
	Role        string   `json:"role"`
	jwt.StandardClaims
}

// カスタムJWTジェネレーター
type CustomJWTGenerator struct {
	signingKey    []byte
	signingMethod jwt.SigningMethod
}

func NewCustomJWTGenerator(key []byte, method jwt.SigningMethod) *CustomJWTGenerator {
	return &CustomJWTGenerator{
		signingKey:    key,
		signingMethod: method,
	}
}

// oauth2.AccessGenerate インターフェースの実装
func (cjg *CustomJWTGenerator) Token(ctx context.Context, data *oauth2.GenerateBasic, isGenRefresh bool) (string, string, error) {
	// カスタムクレームの構築
	claims := CustomClaims{
		UserID:   data.UserID,
		ClientID: data.Client.GetID(),
		Scope:    data.Request.Scope,
		StandardClaims: jwt.StandardClaims{
			Audience:  data.Client.GetID(),
			Subject:   data.UserID,
			Issuer:    "oauth2-server",
			ExpiresAt: time.Now().Add(data.TokenInfo.GetAccessExpiresIn()).Unix(),
			IssuedAt:  time.Now().Unix(),
			NotBefore: time.Now().Unix(),
		},
	}

	// ユーザー情報に基づく権限設定
	claims.Permissions = getUserPermissions(data.UserID)
	claims.Role = getUserRole(data.UserID)

	// JWTトークン生成
	token := jwt.NewWithClaims(cjg.signingMethod, claims)
	access, err := token.SignedString(cjg.signingKey)
	if err != nil {
		return "", "", err
	}

	var refresh string
	if isGenRefresh {
		// リフレッシュトークンの生成
		refreshClaims := jwt.StandardClaims{
			Subject:   data.UserID,
			Issuer:    "oauth2-server",
			ExpiresAt: time.Now().Add(data.TokenInfo.GetRefreshExpiresIn()).Unix(),
			IssuedAt:  time.Now().Unix(),
		}
		refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
		refresh, err = refreshToken.SignedString(cjg.signingKey)
		if err != nil {
			return "", "", err
		}
	}

	return access, refresh, nil
}

// ユーザー権限取得(データベースから実際に取得)
func getUserPermissions(userID string) []string {
	// データベースやキャッシュからユーザー権限を取得
	userPerms := map[string][]string{
		"user-123": {"read:profile", "write:profile"},
		"admin-456": {"read:profile", "write:profile", "admin:users", "admin:system"},
	}

	if perms, exists := userPerms[userID]; exists {
		return perms
	}
	return []string{"read:profile"}
}

func getUserRole(userID string) string {
	userRoles := map[string]string{
		"user-123":  "user",
		"admin-456": "admin",
	}

	if role, exists := userRoles[userID]; exists {
		return role
	}
	return "user"
}

// JWT 検証ミドルウェア
func jwtValidationMiddleware(signingKey []byte) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			tokenString := extractTokenFromHeader(r)
			if tokenString == "" {
				http.Error(w, "Missing authorization token", http.StatusUnauthorized)
				return
			}

			token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
				return signingKey, nil
			})

			if err != nil || !token.Valid {
				http.Error(w, "Invalid token", http.StatusUnauthorized)
				return
			}

			claims := token.Claims.(*CustomClaims)
			
			// コンテキストにクレーム情報を設定
			ctx := context.WithValue(r.Context(), "user_claims", claims)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func extractTokenFromHeader(r *http.Request) string {
	authHeader := r.Header.Get("Authorization")
	if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
		return authHeader[7:]
	}
	return ""
}

PKCE (Proof Key for Code Exchange) 対応

package main

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"net/http"

	"github.com/go-oauth2/oauth2/v4/server"
)

// PKCE チャレンジ生成
func generatePKCE() (verifier, challenge string, err error) {
	// Code Verifier 生成 (43-128文字のランダム文字列)
	verifierBytes := make([]byte, 32)
	_, err = rand.Read(verifierBytes)
	if err != nil {
		return
	}
	verifier = base64.RawURLEncoding.EncodeToString(verifierBytes)

	// Code Challenge 生成 (SHA256 ハッシュ)
	hash := sha256.Sum256([]byte(verifier))
	challenge = base64.RawURLEncoding.EncodeToString(hash[:])

	return
}

// PKCE 検証関数
func validatePKCE(codeVerifier, codeChallenge, codeChallengeMethod string) bool {
	if codeChallengeMethod == "S256" {
		hash := sha256.Sum256([]byte(codeVerifier))
		expected := base64.RawURLEncoding.EncodeToString(hash[:])
		return expected == codeChallenge
	} else if codeChallengeMethod == "plain" {
		return codeVerifier == codeChallenge
	}
	return false
}

// PKCE 対応OAuth2サーバー設定
func setupPKCEServer() {
	manager := manage.NewDefaultManager()
	srv := server.NewDefaultServer(manager)

	// PKCE 検証ハンドラー
	srv.SetExtensionFieldsHandler(func(ti oauth2.TokenInfo) map[string]interface{} {
		return map[string]interface{}{
			"pkce_supported": true,
		}
	})

	// 認可リクエスト処理でのPKCE検証
	srv.SetClientScopeHandler(func(tgr *oauth2.TokenGenerateRequest) (allowed bool, err error) {
		codeChallenge := tgr.Request.Form.Get("code_challenge")
		codeChallengeMethod := tgr.Request.Form.Get("code_challenge_method")

		// PKCE パラメータの検証
		if codeChallenge != "" {
			if codeChallengeMethod == "" {
				codeChallengeMethod = "plain"
			}
			
			if codeChallengeMethod != "S256" && codeChallengeMethod != "plain" {
				return false, errors.New("unsupported code challenge method")
			}
			
			// PKCE パラメータを保存(実際にはセッションやDBに保存)
			storePKCEChallenge(tgr.ClientID, codeChallenge, codeChallengeMethod)
		}

		return true, nil
	})

	// トークン交換時のPKCE検証
	srv.SetRefreshingScopeHandler(func(tgr *oauth2.TokenGenerateRequest, oldScope string) (allowed bool, err error) {
		codeVerifier := tgr.Request.Form.Get("code_verifier")
		
		if codeVerifier != "" {
			// 保存されたチャレンジを取得
			challenge, method := getPKCEChallenge(tgr.ClientID)
			
			if !validatePKCE(codeVerifier, challenge, method) {
				return false, errors.New("invalid code verifier")
			}
			
			// 検証成功後はチャレンジを削除
			removePKCEChallenge(tgr.ClientID)
		}

		return true, nil
	})
}

// PKCE チャレンジ保存・取得・削除(実際にはRedisやDBを使用)
var pkceStore = make(map[string]map[string]string)

func storePKCEChallenge(clientID, challenge, method string) {
	if pkceStore[clientID] == nil {
		pkceStore[clientID] = make(map[string]string)
	}
	pkceStore[clientID]["challenge"] = challenge
	pkceStore[clientID]["method"] = method
}

func getPKCEChallenge(clientID string) (challenge, method string) {
	if client, exists := pkceStore[clientID]; exists {
		return client["challenge"], client["method"]
	}
	return "", ""
}

func removePKCEChallenge(clientID string) {
	delete(pkceStore, clientID)
}

OAuth2 クライアント実装

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"strings"
)

// OAuth2 クライアント
type OAuth2Client struct {
	ClientID     string
	ClientSecret string
	RedirectURI  string
	AuthURL      string
	TokenURL     string
	Scope        string
}

// 認可URL生成
func (c *OAuth2Client) GetAuthorizationURL(state string) string {
	params := url.Values{}
	params.Add("client_id", c.ClientID)
	params.Add("redirect_uri", c.RedirectURI)
	params.Add("response_type", "code")
	params.Add("scope", c.Scope)
	params.Add("state", state)

	return c.AuthURL + "?" + params.Encode()
}

// PKCEサポート付き認可URL生成
func (c *OAuth2Client) GetAuthorizationURLWithPKCE(state string) (string, string, error) {
	verifier, challenge, err := generatePKCE()
	if err != nil {
		return "", "", err
	}

	params := url.Values{}
	params.Add("client_id", c.ClientID)
	params.Add("redirect_uri", c.RedirectURI)
	params.Add("response_type", "code")
	params.Add("scope", c.Scope)
	params.Add("state", state)
	params.Add("code_challenge", challenge)
	params.Add("code_challenge_method", "S256")

	authURL := c.AuthURL + "?" + params.Encode()
	return authURL, verifier, nil
}

// トークン交換
type TokenResponse struct {
	AccessToken  string `json:"access_token"`
	TokenType    string `json:"token_type"`
	ExpiresIn    int    `json:"expires_in"`
	RefreshToken string `json:"refresh_token"`
	Scope        string `json:"scope"`
}

func (c *OAuth2Client) ExchangeCodeForToken(code, codeVerifier string) (*TokenResponse, error) {
	data := url.Values{}
	data.Set("grant_type", "authorization_code")
	data.Set("client_id", c.ClientID)
	data.Set("client_secret", c.ClientSecret)
	data.Set("code", code)
	data.Set("redirect_uri", c.RedirectURI)
	
	if codeVerifier != "" {
		data.Set("code_verifier", codeVerifier)
	}

	resp, err := http.Post(c.TokenURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("token exchange failed: %s", resp.Status)
	}

	var tokenResp TokenResponse
	err = json.NewDecoder(resp.Body).Decode(&tokenResp)
	return &tokenResp, err
}

// リフレッシュトークンでアクセストークン更新
func (c *OAuth2Client) RefreshAccessToken(refreshToken string) (*TokenResponse, error) {
	data := url.Values{}
	data.Set("grant_type", "refresh_token")
	data.Set("client_id", c.ClientID)
	data.Set("client_secret", c.ClientSecret)
	data.Set("refresh_token", refreshToken)

	resp, err := http.Post(c.TokenURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("token refresh failed: %s", resp.Status)
	}

	var tokenResp TokenResponse
	err = json.NewDecoder(resp.Body).Decode(&tokenResp)
	return &tokenResp, err
}

// 使用例
func clientExample() {
	client := &OAuth2Client{
		ClientID:     "client-id-123",
		ClientSecret: "client-secret-456",
		RedirectURI:  "http://localhost:3000/callback",
		AuthURL:      "http://localhost:8080/oauth/authorize",
		TokenURL:     "http://localhost:8080/oauth/token",
		Scope:        "read write",
	}

	// PKCEサポートの認可フロー
	authURL, verifier, err := client.GetAuthorizationURLWithPKCE("random-state-123")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("認可URL: %s\n", authURL)
	fmt.Printf("Code Verifier (保存してください): %s\n", verifier)

	// コールバック後、認可コードとverifierでトークン交換
	// tokenResp, err := client.ExchangeCodeForToken("authorization-code", verifier)
}