Release v0.0.1 - Initial OAuth2 server implementation
- Add Nostr OAuth2 server with NIP-98 authentication support - Implement OAuth2 authorization and token endpoints - Add .well-known/openid-configuration discovery endpoint - Include Dockerfile for containerized deployment - Add Claude Code release command for version management - Create example configuration file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
181
internal/handler/token.go
Normal file
181
internal/handler/token.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mleku.dev/mleku/gitea-nostr-auth/internal/nostr"
|
||||
)
|
||||
|
||||
type TokenRequest struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
Code string `json:"code"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorDesc string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) Token(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Parse form data
|
||||
if err := r.ParseForm(); err != nil {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "invalid_request",
|
||||
ErrorDesc: "failed to parse form",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract credentials (support both Basic auth and form params)
|
||||
clientID, clientSecret := extractClientCredentials(r)
|
||||
|
||||
grantType := r.FormValue("grant_type")
|
||||
code := r.FormValue("code")
|
||||
redirectURI := r.FormValue("redirect_uri")
|
||||
|
||||
// Override client credentials from form if provided
|
||||
if formClientID := r.FormValue("client_id"); formClientID != "" {
|
||||
clientID = formClientID
|
||||
}
|
||||
if formClientSecret := r.FormValue("client_secret"); formClientSecret != "" {
|
||||
clientSecret = formClientSecret
|
||||
}
|
||||
|
||||
// Validate grant type
|
||||
if grantType != "authorization_code" {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "unsupported_grant_type",
|
||||
ErrorDesc: "only authorization_code is supported",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate client
|
||||
client := h.cfg.GetClient(clientID)
|
||||
if client == nil {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "invalid_client",
|
||||
ErrorDesc: "unknown client_id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify client secret
|
||||
if subtle.ConstantTimeCompare([]byte(client.ClientSecret), []byte(clientSecret)) != 1 {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "invalid_client",
|
||||
ErrorDesc: "invalid client_secret",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Look up authorization code
|
||||
authCode, err := h.store.GetAuthCode(code)
|
||||
if err != nil || authCode == nil {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "invalid_grant",
|
||||
ErrorDesc: "invalid or expired authorization code",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify code belongs to this client
|
||||
if authCode.ClientID != clientID {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "invalid_grant",
|
||||
ErrorDesc: "code was not issued to this client",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify redirect URI matches
|
||||
if authCode.RedirectURI != redirectURI {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "invalid_grant",
|
||||
ErrorDesc: "redirect_uri mismatch",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete auth code (single use)
|
||||
h.store.DeleteAuthCode(code)
|
||||
|
||||
// Create access token
|
||||
accessToken, err := h.store.CreateAccessToken(authCode.Pubkey, clientID)
|
||||
if err != nil {
|
||||
log.Printf("failed to create access token: %v", err)
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "server_error",
|
||||
ErrorDesc: "failed to create token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate ID token (simple JWT-like structure for OIDC compatibility)
|
||||
idToken := generateIDToken(h.cfg.Server.BaseURL, clientID, authCode.Pubkey)
|
||||
|
||||
expiresIn := int(time.Until(accessToken.ExpiresAt).Seconds())
|
||||
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
AccessToken: accessToken.Token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: expiresIn,
|
||||
IDToken: idToken,
|
||||
})
|
||||
}
|
||||
|
||||
func extractClientCredentials(r *http.Request) (clientID, clientSecret string) {
|
||||
// Try Basic auth header first
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Basic ") {
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic "))
|
||||
if err == nil {
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// generateIDToken creates a simple ID token
|
||||
// In production, this should be a properly signed JWT
|
||||
func generateIDToken(issuer, audience, subject string) string {
|
||||
// Create a simple base64-encoded JSON token
|
||||
// In production, use proper JWT signing
|
||||
header := `{"alg":"none","typ":"JWT"}`
|
||||
|
||||
now := time.Now()
|
||||
payload := map[string]interface{}{
|
||||
"iss": issuer,
|
||||
"sub": subject,
|
||||
"aud": audience,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
"preferred_username": nostr.PubkeyToNpub(subject),
|
||||
}
|
||||
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString([]byte(header))
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
||||
|
||||
// Unsigned token (alg: none)
|
||||
return headerB64 + "." + payloadB64 + "."
|
||||
}
|
||||
Reference in New Issue
Block a user