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