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:
296
internal/handler/authorize.go
Normal file
296
internal/handler/authorize.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const loginPageHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login with Nostr</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #fff;
|
||||
margin-bottom: 10px;
|
||||
font-size: 24px;
|
||||
}
|
||||
.subtitle {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-bottom: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.nostr-logo {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
button {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 16px 32px;
|
||||
font-size: 16px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 40px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.status {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.status.error {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #fca5a5;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.status.success {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #86efac;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.status.info {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #93c5fd;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.no-extension {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.no-extension a {
|
||||
color: #8b5cf6;
|
||||
text-decoration: none;
|
||||
}
|
||||
.no-extension a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.extension-list {
|
||||
margin-top: 15px;
|
||||
text-align: left;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.extension-list li {
|
||||
margin: 8px 0;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.pubkey {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nostr-logo">🗝</div>
|
||||
<h1>Login with Nostr</h1>
|
||||
<p class="subtitle">Sign in using your Nostr identity</p>
|
||||
|
||||
<div id="no-extension" class="no-extension hidden">
|
||||
<p>No Nostr extension detected. Please install one of these:</p>
|
||||
<ul class="extension-list">
|
||||
<li><a href="https://getalby.com" target="_blank">Alby</a></li>
|
||||
<li><a href="https://github.com/nickytonline/nos2x" target="_blank">nos2x</a></li>
|
||||
<li><a href="https://github.com/nickytonline/flamingo" target="_blank">Flamingo</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="login-section">
|
||||
<button id="login-btn" disabled>Checking for extension...</button>
|
||||
<div id="status" class="status hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const challenge = "{{.Challenge}}";
|
||||
const verifyURL = "{{.VerifyURL}}";
|
||||
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const noExtensionDiv = document.getElementById('no-extension');
|
||||
const loginSection = document.getElementById('login-section');
|
||||
|
||||
function showStatus(message, type) {
|
||||
statusDiv.textContent = message;
|
||||
statusDiv.className = 'status ' + type;
|
||||
statusDiv.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
statusDiv.classList.add('hidden');
|
||||
}
|
||||
|
||||
async function checkExtension() {
|
||||
// Wait a bit for extension to inject
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
if (typeof window.nostr === 'undefined') {
|
||||
noExtensionDiv.classList.remove('hidden');
|
||||
loginBtn.textContent = 'No extension found';
|
||||
return false;
|
||||
}
|
||||
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Sign in with Nostr';
|
||||
return true;
|
||||
}
|
||||
|
||||
async function login() {
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Signing...';
|
||||
hideStatus();
|
||||
|
||||
try {
|
||||
// Get public key
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
showStatus('Got public key, signing challenge...', 'info');
|
||||
|
||||
// Create event to sign (NIP-98 style)
|
||||
const event = {
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['u', window.location.href],
|
||||
['method', 'GET'],
|
||||
['challenge', challenge]
|
||||
],
|
||||
content: ''
|
||||
};
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
showStatus('Signed! Verifying with server...', 'info');
|
||||
|
||||
// Submit to server
|
||||
const response = await fetch(verifyURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(signedEvent)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.redirect_url) {
|
||||
showStatus('Success! Redirecting...', 'success');
|
||||
window.location.href = result.redirect_url;
|
||||
} else if (result.error) {
|
||||
showStatus('Error: ' + result.error, 'error');
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Try again';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
showStatus('Error: ' + err.message, 'error');
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Try again';
|
||||
}
|
||||
}
|
||||
|
||||
loginBtn.addEventListener('click', login);
|
||||
checkExtension();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
func (h *Handler) Authorize(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract OAuth2 parameters
|
||||
clientID := r.URL.Query().Get("client_id")
|
||||
redirectURI := r.URL.Query().Get("redirect_uri")
|
||||
state := r.URL.Query().Get("state")
|
||||
responseType := r.URL.Query().Get("response_type")
|
||||
|
||||
// Validate required parameters
|
||||
if clientID == "" {
|
||||
http.Error(w, "missing client_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if redirectURI == "" {
|
||||
http.Error(w, "missing redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if responseType != "code" {
|
||||
http.Error(w, "unsupported response_type, must be 'code'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate client and redirect URI
|
||||
if !h.cfg.ValidateRedirectURI(clientID, redirectURI) {
|
||||
http.Error(w, "invalid client_id or redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Create challenge
|
||||
challenge, err := h.store.CreateChallenge(clientID, state, redirectURI, h.cfg.Nostr.ChallengeTTL)
|
||||
if err != nil {
|
||||
log.Printf("failed to create challenge: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Render login page
|
||||
tmpl, err := template.New("login").Parse(loginPageHTML)
|
||||
if err != nil {
|
||||
log.Printf("failed to parse template: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Challenge string
|
||||
VerifyURL string
|
||||
}{
|
||||
Challenge: challenge.Nonce,
|
||||
VerifyURL: h.cfg.Server.BaseURL + "/verify",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
log.Printf("failed to execute template: %v", err)
|
||||
}
|
||||
}
|
||||
76
internal/handler/discovery.go
Normal file
76
internal/handler/discovery.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type OIDCConfiguration struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
}
|
||||
|
||||
func (h *Handler) OIDCDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||
baseURL := h.cfg.Server.BaseURL
|
||||
|
||||
config := OIDCConfiguration{
|
||||
Issuer: baseURL,
|
||||
AuthorizationEndpoint: baseURL + "/authorize",
|
||||
TokenEndpoint: baseURL + "/token",
|
||||
UserInfoEndpoint: baseURL + "/userinfo",
|
||||
JwksURI: baseURL + "/.well-known/jwks.json",
|
||||
ResponseTypesSupported: []string{"code"},
|
||||
SubjectTypesSupported: []string{"public"},
|
||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||
ScopesSupported: []string{"openid", "profile", "email"},
|
||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
||||
ClaimsSupported: []string{
|
||||
"sub",
|
||||
"iss",
|
||||
"aud",
|
||||
"exp",
|
||||
"iat",
|
||||
"name",
|
||||
"preferred_username",
|
||||
"email",
|
||||
},
|
||||
GrantTypesSupported: []string{"authorization_code"},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(config)
|
||||
}
|
||||
|
||||
type JWKSet struct {
|
||||
Keys []JWK `json:"keys"`
|
||||
}
|
||||
|
||||
type JWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Use string `json:"use"`
|
||||
Kid string `json:"kid"`
|
||||
Alg string `json:"alg"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
}
|
||||
|
||||
func (h *Handler) JWKS(w http.ResponseWriter, r *http.Request) {
|
||||
// For simplicity, we'll use a static JWKS
|
||||
// In production, this should be dynamically generated from actual keys
|
||||
jwks := JWKSet{
|
||||
Keys: []JWK{},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(jwks)
|
||||
}
|
||||
55
internal/handler/routes.go
Normal file
55
internal/handler/routes.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
|
||||
"git.mleku.dev/mleku/gitea-nostr-auth/internal/config"
|
||||
"git.mleku.dev/mleku/gitea-nostr-auth/internal/nostr"
|
||||
"git.mleku.dev/mleku/gitea-nostr-auth/internal/oauth2"
|
||||
)
|
||||
|
||||
func NewRouter(cfg *config.Config, store oauth2.Store, fetcher *nostr.Fetcher) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Middleware
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
h := &Handler{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
fetcher: fetcher,
|
||||
}
|
||||
|
||||
// OIDC Discovery
|
||||
r.Get("/.well-known/openid-configuration", h.OIDCDiscovery)
|
||||
|
||||
// OAuth2 endpoints
|
||||
r.Get("/authorize", h.Authorize)
|
||||
r.Post("/verify", h.Verify)
|
||||
r.Post("/token", h.Token)
|
||||
r.Get("/userinfo", h.UserInfo)
|
||||
|
||||
// JWKS endpoint (required for OIDC)
|
||||
r.Get("/.well-known/jwks.json", h.JWKS)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
cfg *config.Config
|
||||
store oauth2.Store
|
||||
fetcher *nostr.Fetcher
|
||||
}
|
||||
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 + "."
|
||||
}
|
||||
92
internal/handler/userinfo.go
Normal file
92
internal/handler/userinfo.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.mleku.dev/mleku/gitea-nostr-auth/internal/nostr"
|
||||
)
|
||||
|
||||
type UserInfoResponse struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorDesc string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) UserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Extract Bearer token
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(UserInfoResponse{
|
||||
Error: "invalid_token",
|
||||
ErrorDesc: "missing or invalid Authorization header",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Look up access token
|
||||
accessToken, err := h.store.GetAccessToken(token)
|
||||
if err != nil || accessToken == nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(UserInfoResponse{
|
||||
Error: "invalid_token",
|
||||
ErrorDesc: "token is invalid or expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pubkey := accessToken.Pubkey
|
||||
npub := nostr.PubkeyToNpub(pubkey)
|
||||
|
||||
// Fetch profile from relays (this also fetches relay list first)
|
||||
log.Printf("Fetching profile for %s from relays...", nostr.TruncateNpub(npub))
|
||||
profile := h.fetcher.FetchProfile(r.Context(), pubkey)
|
||||
|
||||
// Build response with profile data or fallbacks
|
||||
response := UserInfoResponse{
|
||||
Sub: pubkey,
|
||||
}
|
||||
|
||||
if profile != nil {
|
||||
log.Printf("Got profile for %s: name=%s, nip05=%s", nostr.TruncateNpub(npub), profile.Name, profile.Nip05)
|
||||
|
||||
// Use profile data
|
||||
response.Name = profile.GetDisplayName()
|
||||
response.PreferredUsername = profile.GetUsername()
|
||||
response.Picture = profile.Picture
|
||||
response.Website = profile.Website
|
||||
response.Profile = profile.About
|
||||
|
||||
// Use NIP-05 as email if available (it's verified in the Nostr sense)
|
||||
if profile.Nip05 != "" {
|
||||
// NIP-05 format: name@domain.com or _@domain.com
|
||||
response.Email = profile.Nip05
|
||||
response.EmailVerified = false // We haven't verified it ourselves
|
||||
} else {
|
||||
response.Email = nostr.GeneratePlaceholderEmail(pubkey)
|
||||
}
|
||||
} else {
|
||||
log.Printf("No profile found for %s, using defaults", nostr.TruncateNpub(npub))
|
||||
|
||||
// Fallback to generated values
|
||||
response.PreferredUsername = nostr.GenerateUsername(pubkey)
|
||||
response.Name = nostr.TruncateNpub(npub)
|
||||
response.Email = nostr.GeneratePlaceholderEmail(pubkey)
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
134
internal/handler/verify.go
Normal file
134
internal/handler/verify.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
type VerifyRequest struct {
|
||||
ID string `json:"id"`
|
||||
PubKey string `json:"pubkey"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Kind int `json:"kind"`
|
||||
Tags [][]string `json:"tags"`
|
||||
Content string `json:"content"`
|
||||
Sig string `json:"sig"`
|
||||
}
|
||||
|
||||
type VerifyResponse struct {
|
||||
Success bool `json:"success"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var req VerifyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to go-nostr Event for verification
|
||||
event := nostr.Event{
|
||||
ID: req.ID,
|
||||
PubKey: req.PubKey,
|
||||
CreatedAt: nostr.Timestamp(req.CreatedAt),
|
||||
Kind: req.Kind,
|
||||
Tags: make(nostr.Tags, len(req.Tags)),
|
||||
Content: req.Content,
|
||||
Sig: req.Sig,
|
||||
}
|
||||
|
||||
for i, tag := range req.Tags {
|
||||
event.Tags[i] = tag
|
||||
}
|
||||
|
||||
// Verify event kind
|
||||
if event.Kind != 27235 {
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid event kind, expected 27235"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify timestamp is within window
|
||||
eventTime := time.Unix(int64(event.CreatedAt), 0)
|
||||
if time.Since(eventTime) > h.cfg.Nostr.ChallengeTTL {
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "event timestamp too old"})
|
||||
return
|
||||
}
|
||||
if eventTime.After(time.Now().Add(time.Minute)) {
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "event timestamp in future"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
ok, err := event.CheckSignature()
|
||||
if err != nil || !ok {
|
||||
log.Printf("signature verification failed: %v", err)
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid signature"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract challenge from tags
|
||||
var challengeNonce string
|
||||
for _, tag := range event.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "challenge" {
|
||||
challengeNonce = tag[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if challengeNonce == "" {
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "missing challenge tag"})
|
||||
return
|
||||
}
|
||||
|
||||
// Look up challenge
|
||||
challenge, err := h.store.GetChallenge(challengeNonce)
|
||||
if err != nil || challenge == nil {
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid or expired challenge"})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete challenge (single use)
|
||||
h.store.DeleteChallenge(challengeNonce)
|
||||
|
||||
// Create authorization code
|
||||
authCode, err := h.store.CreateAuthCode(
|
||||
challenge.ClientID,
|
||||
challenge.RedirectURI,
|
||||
event.PubKey,
|
||||
challenge.State,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("failed to create auth code: %v", err)
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build redirect URL
|
||||
redirectURL, err := url.Parse(challenge.RedirectURI)
|
||||
if err != nil {
|
||||
log.Printf("invalid redirect URI: %v", err)
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
q := redirectURL.Query()
|
||||
q.Set("code", authCode.Code)
|
||||
if challenge.State != "" {
|
||||
q.Set("state", challenge.State)
|
||||
}
|
||||
redirectURL.RawQuery = q.Encode()
|
||||
|
||||
json.NewEncoder(w).Encode(VerifyResponse{
|
||||
Success: true,
|
||||
RedirectURL: redirectURL.String(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user