token.go raw

   1  package handler
   2  
   3  import (
   4  	"crypto/subtle"
   5  	"encoding/base64"
   6  	"encoding/json"
   7  	"log"
   8  	"net/http"
   9  	"strings"
  10  	"time"
  11  
  12  	"github.com/mlekudev/gitea-nostr-auth/internal/nostr"
  13  )
  14  
  15  type TokenRequest struct {
  16  	GrantType    string `json:"grant_type"`
  17  	Code         string `json:"code"`
  18  	RedirectURI  string `json:"redirect_uri"`
  19  	ClientID     string `json:"client_id"`
  20  	ClientSecret string `json:"client_secret"`
  21  }
  22  
  23  type TokenResponse struct {
  24  	AccessToken  string `json:"access_token"`
  25  	TokenType    string `json:"token_type"`
  26  	ExpiresIn    int    `json:"expires_in"`
  27  	IDToken      string `json:"id_token,omitempty"`
  28  	Error        string `json:"error,omitempty"`
  29  	ErrorDesc    string `json:"error_description,omitempty"`
  30  }
  31  
  32  func (h *Handler) Token(w http.ResponseWriter, r *http.Request) {
  33  	w.Header().Set("Content-Type", "application/json")
  34  
  35  	// Parse form data
  36  	if err := r.ParseForm(); err != nil {
  37  		json.NewEncoder(w).Encode(TokenResponse{
  38  			Error:     "invalid_request",
  39  			ErrorDesc: "failed to parse form",
  40  		})
  41  		return
  42  	}
  43  
  44  	// Extract credentials (support both Basic auth and form params)
  45  	clientID, clientSecret := extractClientCredentials(r)
  46  
  47  	grantType := r.FormValue("grant_type")
  48  	code := r.FormValue("code")
  49  	redirectURI := r.FormValue("redirect_uri")
  50  
  51  	// Override client credentials from form if provided
  52  	if formClientID := r.FormValue("client_id"); formClientID != "" {
  53  		clientID = formClientID
  54  	}
  55  	if formClientSecret := r.FormValue("client_secret"); formClientSecret != "" {
  56  		clientSecret = formClientSecret
  57  	}
  58  
  59  	// Validate grant type
  60  	if grantType != "authorization_code" {
  61  		json.NewEncoder(w).Encode(TokenResponse{
  62  			Error:     "unsupported_grant_type",
  63  			ErrorDesc: "only authorization_code is supported",
  64  		})
  65  		return
  66  	}
  67  
  68  	// Validate client
  69  	client := h.cfg.GetClient(clientID)
  70  	if client == nil {
  71  		json.NewEncoder(w).Encode(TokenResponse{
  72  			Error:     "invalid_client",
  73  			ErrorDesc: "unknown client_id",
  74  		})
  75  		return
  76  	}
  77  
  78  	// Verify client secret
  79  	if subtle.ConstantTimeCompare([]byte(client.ClientSecret), []byte(clientSecret)) != 1 {
  80  		json.NewEncoder(w).Encode(TokenResponse{
  81  			Error:     "invalid_client",
  82  			ErrorDesc: "invalid client_secret",
  83  		})
  84  		return
  85  	}
  86  
  87  	// Look up authorization code
  88  	authCode, err := h.store.GetAuthCode(code)
  89  	if err != nil || authCode == nil {
  90  		json.NewEncoder(w).Encode(TokenResponse{
  91  			Error:     "invalid_grant",
  92  			ErrorDesc: "invalid or expired authorization code",
  93  		})
  94  		return
  95  	}
  96  
  97  	// Verify code belongs to this client
  98  	if authCode.ClientID != clientID {
  99  		json.NewEncoder(w).Encode(TokenResponse{
 100  			Error:     "invalid_grant",
 101  			ErrorDesc: "code was not issued to this client",
 102  		})
 103  		return
 104  	}
 105  
 106  	// Verify redirect URI matches
 107  	if authCode.RedirectURI != redirectURI {
 108  		json.NewEncoder(w).Encode(TokenResponse{
 109  			Error:     "invalid_grant",
 110  			ErrorDesc: "redirect_uri mismatch",
 111  		})
 112  		return
 113  	}
 114  
 115  	// Delete auth code (single use)
 116  	h.store.DeleteAuthCode(code)
 117  
 118  	// Create access token
 119  	accessToken, err := h.store.CreateAccessToken(authCode.Pubkey, clientID)
 120  	if err != nil {
 121  		log.Printf("failed to create access token: %v", err)
 122  		json.NewEncoder(w).Encode(TokenResponse{
 123  			Error:     "server_error",
 124  			ErrorDesc: "failed to create token",
 125  		})
 126  		return
 127  	}
 128  
 129  	// Generate ID token (simple JWT-like structure for OIDC compatibility)
 130  	idToken := generateIDToken(h.cfg.Server.BaseURL, clientID, authCode.Pubkey)
 131  
 132  	expiresIn := int(time.Until(accessToken.ExpiresAt).Seconds())
 133  
 134  	json.NewEncoder(w).Encode(TokenResponse{
 135  		AccessToken: accessToken.Token,
 136  		TokenType:   "Bearer",
 137  		ExpiresIn:   expiresIn,
 138  		IDToken:     idToken,
 139  	})
 140  }
 141  
 142  func extractClientCredentials(r *http.Request) (clientID, clientSecret string) {
 143  	// Try Basic auth header first
 144  	authHeader := r.Header.Get("Authorization")
 145  	if strings.HasPrefix(authHeader, "Basic ") {
 146  		decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic "))
 147  		if err == nil {
 148  			parts := strings.SplitN(string(decoded), ":", 2)
 149  			if len(parts) == 2 {
 150  				return parts[0], parts[1]
 151  			}
 152  		}
 153  	}
 154  	return "", ""
 155  }
 156  
 157  // generateIDToken creates a simple ID token
 158  // In production, this should be a properly signed JWT
 159  func generateIDToken(issuer, audience, subject string) string {
 160  	// Create a simple base64-encoded JSON token
 161  	// In production, use proper JWT signing
 162  	header := `{"alg":"none","typ":"JWT"}`
 163  
 164  	now := time.Now()
 165  	payload := map[string]interface{}{
 166  		"iss": issuer,
 167  		"sub": subject,
 168  		"aud": audience,
 169  		"iat": now.Unix(),
 170  		"exp": now.Add(time.Hour).Unix(),
 171  		"preferred_username": nostr.PubkeyToNpub(subject),
 172  	}
 173  
 174  	payloadBytes, _ := json.Marshal(payload)
 175  
 176  	headerB64 := base64.RawURLEncoding.EncodeToString([]byte(header))
 177  	payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes)
 178  
 179  	// Unsigned token (alg: none)
 180  	return headerB64 + "." + payloadB64 + "."
 181  }
 182