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:
2025-12-19 09:37:26 +01:00
parent 52e486a948
commit 896a7599a0
20 changed files with 2099 additions and 1 deletions

146
internal/config/config.go Normal file
View File

@@ -0,0 +1,146 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
OAuth2 OAuth2Config `yaml:"oauth2"`
Nostr NostrConfig `yaml:"nostr"`
}
type ServerConfig struct {
Port int `yaml:"port"`
Host string `yaml:"host"`
BaseURL string `yaml:"base_url"`
}
func (s ServerConfig) Address() string {
host := s.Host
if host == "" {
host = "0.0.0.0"
}
port := s.Port
if port == 0 {
port = 8080
}
return fmt.Sprintf("%s:%d", host, port)
}
type OAuth2Config struct {
Clients []ClientConfig `yaml:"clients"`
}
type ClientConfig struct {
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
RedirectURIs []string `yaml:"redirect_uris"`
}
type NostrConfig struct {
ChallengeTTL time.Duration `yaml:"challenge_ttl"`
FallbackRelays []string `yaml:"fallback_relays"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
cfg.setDefaults()
return &cfg, nil
}
func FromEnv() *Config {
cfg := &Config{}
// Server config
if port := os.Getenv("PORT"); port != "" {
cfg.Server.Port, _ = strconv.Atoi(port)
}
cfg.Server.Host = os.Getenv("HOST")
cfg.Server.BaseURL = os.Getenv("BASE_URL")
// OAuth2 client config (single client from env)
clientID := os.Getenv("OAUTH2_CLIENT_ID")
clientSecret := os.Getenv("OAUTH2_CLIENT_SECRET")
redirectURIs := os.Getenv("OAUTH2_REDIRECT_URIS")
if clientID != "" {
cfg.OAuth2.Clients = []ClientConfig{{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURIs: strings.Split(redirectURIs, ","),
}}
}
// Nostr config
if ttl := os.Getenv("NOSTR_CHALLENGE_TTL"); ttl != "" {
cfg.Nostr.ChallengeTTL, _ = time.ParseDuration(ttl)
}
if relays := os.Getenv("NOSTR_FALLBACK_RELAYS"); relays != "" {
cfg.Nostr.FallbackRelays = strings.Split(relays, ",")
}
cfg.setDefaults()
return cfg
}
// DefaultFallbackRelays are well-known relays that aggregate profile data
var DefaultFallbackRelays = []string{
"wss://relay.nostr.band/",
"wss://nostr.wine/",
"wss://nos.lol/",
"wss://relay.primal.net/",
"wss://purplepag.es/",
}
func (c *Config) setDefaults() {
if c.Server.Port == 0 {
c.Server.Port = 8080
}
if c.Server.BaseURL == "" {
c.Server.BaseURL = fmt.Sprintf("http://localhost:%d", c.Server.Port)
}
if c.Nostr.ChallengeTTL == 0 {
c.Nostr.ChallengeTTL = 60 * time.Second
}
if len(c.Nostr.FallbackRelays) == 0 {
c.Nostr.FallbackRelays = DefaultFallbackRelays
}
}
func (c *Config) GetClient(clientID string) *ClientConfig {
for i := range c.OAuth2.Clients {
if c.OAuth2.Clients[i].ClientID == clientID {
return &c.OAuth2.Clients[i]
}
}
return nil
}
func (c *Config) ValidateRedirectURI(clientID, redirectURI string) bool {
client := c.GetClient(clientID)
if client == nil {
return false
}
for _, uri := range client.RedirectURIs {
if uri == redirectURI {
return true
}
}
return false
}

View 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">&#x1F5DD;</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)
}
}

View 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)
}

View 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
View 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 + "."
}

View 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
View 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(),
})
}

323
internal/nostr/fetcher.go Normal file
View File

@@ -0,0 +1,323 @@
package nostr
import (
"context"
"encoding/json"
"log"
"sync"
"time"
"github.com/nbd-wtf/go-nostr"
)
const (
// FetchTimeout is how long to wait for relay responses
FetchTimeout = 10 * time.Second
// CacheTTL is how long to cache relay lists and profiles
CacheTTL = 24 * time.Hour
)
// Fetcher handles fetching relay lists and profiles from Nostr relays
type Fetcher struct {
fallbackRelays []string
relayCache map[string]*relayListCacheEntry
profileCache map[string]*profileCacheEntry
mu sync.RWMutex
}
type relayListCacheEntry struct {
Relays []Nip65Relay
FetchedAt time.Time
}
type profileCacheEntry struct {
Profile *ProfileMetadata
FetchedAt time.Time
}
// NewFetcher creates a new Fetcher with the given fallback relays
func NewFetcher(fallbackRelays []string) *Fetcher {
return &Fetcher{
fallbackRelays: fallbackRelays,
relayCache: make(map[string]*relayListCacheEntry),
profileCache: make(map[string]*profileCacheEntry),
}
}
// FetchRelayList fetches a user's NIP-65 relay list (kind 10002)
func (f *Fetcher) FetchRelayList(ctx context.Context, pubkey string) []Nip65Relay {
// Check cache first
f.mu.RLock()
if entry, ok := f.relayCache[pubkey]; ok {
if time.Since(entry.FetchedAt) < CacheTTL {
f.mu.RUnlock()
return entry.Relays
}
}
f.mu.RUnlock()
// Fetch from relays
relays := f.doFetchRelayList(ctx, pubkey)
// Cache result
f.mu.Lock()
f.relayCache[pubkey] = &relayListCacheEntry{
Relays: relays,
FetchedAt: time.Now(),
}
f.mu.Unlock()
return relays
}
func (f *Fetcher) doFetchRelayList(ctx context.Context, pubkey string) []Nip65Relay {
ctx, cancel := context.WithTimeout(ctx, FetchTimeout)
defer cancel()
filter := nostr.Filter{
Kinds: []int{10002},
Authors: []string{pubkey},
Limit: 10,
}
events := f.queryRelays(ctx, f.fallbackRelays, filter)
if len(events) == 0 {
return nil
}
// Get the most recent event
var latest *nostr.Event
for _, ev := range events {
if latest == nil || ev.CreatedAt > latest.CreatedAt {
latest = ev
}
}
// Parse relay tags
var relays []Nip65Relay
for _, tag := range latest.Tags {
if len(tag) >= 2 && tag[0] == "r" {
relay := Nip65Relay{
URL: tag[1],
Read: true,
Write: true,
}
// Check for read/write marker
if len(tag) >= 3 {
switch tag[2] {
case "read":
relay.Write = false
case "write":
relay.Read = false
}
}
relays = append(relays, relay)
}
}
return relays
}
// FetchProfile fetches a user's profile metadata (kind 0)
// It first fetches the user's relay list, then queries those relays + fallbacks
func (f *Fetcher) FetchProfile(ctx context.Context, pubkey string) *ProfileMetadata {
// Check cache first
f.mu.RLock()
if entry, ok := f.profileCache[pubkey]; ok {
if time.Since(entry.FetchedAt) < CacheTTL {
f.mu.RUnlock()
return entry.Profile
}
}
f.mu.RUnlock()
// First, get the user's relay list
userRelays := f.FetchRelayList(ctx, pubkey)
// Build relay list: user's read relays + fallbacks
relayURLs := make([]string, 0, len(userRelays)+len(f.fallbackRelays))
seen := make(map[string]bool)
// Add user's read relays first (more likely to have their profile)
for _, r := range userRelays {
if r.Read && !seen[r.URL] {
relayURLs = append(relayURLs, r.URL)
seen[r.URL] = true
}
}
// Add fallback relays
for _, url := range f.fallbackRelays {
if !seen[url] {
relayURLs = append(relayURLs, url)
seen[url] = true
}
}
// Fetch profile
profile := f.doFetchProfile(ctx, pubkey, relayURLs)
// Cache result (even if nil)
f.mu.Lock()
f.profileCache[pubkey] = &profileCacheEntry{
Profile: profile,
FetchedAt: time.Now(),
}
f.mu.Unlock()
return profile
}
func (f *Fetcher) doFetchProfile(ctx context.Context, pubkey string, relayURLs []string) *ProfileMetadata {
ctx, cancel := context.WithTimeout(ctx, FetchTimeout)
defer cancel()
filter := nostr.Filter{
Kinds: []int{0},
Authors: []string{pubkey},
Limit: 10,
}
events := f.queryRelays(ctx, relayURLs, filter)
if len(events) == 0 {
return nil
}
// Get the most recent event
var latest *nostr.Event
for _, ev := range events {
if latest == nil || ev.CreatedAt > latest.CreatedAt {
latest = ev
}
}
// Parse profile content
var content map[string]interface{}
if err := json.Unmarshal([]byte(latest.Content), &content); err != nil {
log.Printf("Failed to parse profile content for %s: %v", pubkey, err)
return nil
}
profile := &ProfileMetadata{
Pubkey: pubkey,
}
if v, ok := content["name"].(string); ok {
profile.Name = v
}
if v, ok := content["display_name"].(string); ok {
profile.DisplayName = v
}
if v, ok := content["displayName"].(string); ok && profile.DisplayName == "" {
profile.DisplayName = v
}
if v, ok := content["picture"].(string); ok {
profile.Picture = v
}
if v, ok := content["banner"].(string); ok {
profile.Banner = v
}
if v, ok := content["about"].(string); ok {
profile.About = v
}
if v, ok := content["website"].(string); ok {
profile.Website = v
}
if v, ok := content["nip05"].(string); ok {
profile.Nip05 = v
}
if v, ok := content["lud06"].(string); ok {
profile.Lud06 = v
}
if v, ok := content["lud16"].(string); ok {
profile.Lud16 = v
}
return profile
}
// queryRelays queries multiple relays and collects events
func (f *Fetcher) queryRelays(ctx context.Context, relayURLs []string, filter nostr.Filter) []*nostr.Event {
var (
events []*nostr.Event
eventsMu sync.Mutex
wg sync.WaitGroup
)
// Query each relay concurrently
for _, url := range relayURLs {
wg.Add(1)
go func(relayURL string) {
defer wg.Done()
relay, err := nostr.RelayConnect(ctx, relayURL)
if err != nil {
// Silently skip failed relays
return
}
defer relay.Close()
sub, err := relay.Subscribe(ctx, []nostr.Filter{filter})
if err != nil {
return
}
defer sub.Unsub()
for {
select {
case ev, ok := <-sub.Events:
if !ok {
return
}
eventsMu.Lock()
events = append(events, ev)
eventsMu.Unlock()
case <-sub.EndOfStoredEvents:
return
case <-ctx.Done():
return
}
}
}(url)
}
// Wait for all queries to complete or timeout
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
}
return events
}
// GetCachedProfile returns a cached profile if available and not expired
func (f *Fetcher) GetCachedProfile(pubkey string) *ProfileMetadata {
f.mu.RLock()
defer f.mu.RUnlock()
if entry, ok := f.profileCache[pubkey]; ok {
if time.Since(entry.FetchedAt) < CacheTTL {
return entry.Profile
}
}
return nil
}
// GetCachedRelayList returns a cached relay list if available and not expired
func (f *Fetcher) GetCachedRelayList(pubkey string) []Nip65Relay {
f.mu.RLock()
defer f.mu.RUnlock()
if entry, ok := f.relayCache[pubkey]; ok {
if time.Since(entry.FetchedAt) < CacheTTL {
return entry.Relays
}
}
return nil
}

55
internal/nostr/pubkey.go Normal file
View File

@@ -0,0 +1,55 @@
package nostr
import (
"github.com/nbd-wtf/go-nostr/nip19"
)
// PubkeyToNpub converts a hex public key to bech32 npub format
func PubkeyToNpub(hexPubkey string) string {
npub, err := nip19.EncodePublicKey(hexPubkey)
if err != nil {
// If encoding fails, return truncated hex
if len(hexPubkey) > 16 {
return hexPubkey[:16] + "..."
}
return hexPubkey
}
return npub
}
// NpubToPubkey converts a bech32 npub to hex public key
func NpubToPubkey(npub string) (string, error) {
prefix, data, err := nip19.Decode(npub)
if err != nil {
return "", err
}
if prefix != "npub" {
return "", err
}
return data.(string), nil
}
// TruncateNpub returns a shortened npub for display
func TruncateNpub(npub string) string {
if len(npub) <= 20 {
return npub
}
return npub[:12] + "..." + npub[len(npub)-8:]
}
// GenerateUsername creates a username from a pubkey
// Prefers NIP-05 identifier if available, otherwise uses npub prefix
func GenerateUsername(hexPubkey string) string {
npub := PubkeyToNpub(hexPubkey)
// Use first 12 chars of npub (npub1 + 7 chars)
if len(npub) > 12 {
return npub[:12]
}
return npub
}
// GeneratePlaceholderEmail creates a placeholder email for Gitea
func GeneratePlaceholderEmail(hexPubkey string) string {
username := GenerateUsername(hexPubkey)
return username + "@nostr.local"
}

51
internal/nostr/relays.go Normal file
View File

@@ -0,0 +1,51 @@
package nostr
// Nip65Relay represents a relay from a user's NIP-65 relay list
type Nip65Relay struct {
URL string `json:"url"`
Read bool `json:"read"`
Write bool `json:"write"`
}
// ProfileMetadata represents parsed kind 0 profile data
type ProfileMetadata struct {
Pubkey string `json:"pubkey"`
Name string `json:"name,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Picture string `json:"picture,omitempty"`
Banner string `json:"banner,omitempty"`
About string `json:"about,omitempty"`
Website string `json:"website,omitempty"`
Nip05 string `json:"nip05,omitempty"`
Lud06 string `json:"lud06,omitempty"`
Lud16 string `json:"lud16,omitempty"`
}
// GetUsername returns the best username from profile metadata
func (p *ProfileMetadata) GetUsername() string {
// Prefer NIP-05 identifier (without domain for uniqueness)
if p.Nip05 != "" {
return p.Nip05
}
// Then name
if p.Name != "" {
return p.Name
}
// Then display_name
if p.DisplayName != "" {
return p.DisplayName
}
// Fallback to truncated npub
return GenerateUsername(p.Pubkey)
}
// GetDisplayName returns the best display name
func (p *ProfileMetadata) GetDisplayName() string {
if p.DisplayName != "" {
return p.DisplayName
}
if p.Name != "" {
return p.Name
}
return TruncateNpub(PubkeyToNpub(p.Pubkey))
}

218
internal/oauth2/store.go Normal file
View File

@@ -0,0 +1,218 @@
package oauth2
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
// AuthCode represents an OAuth2 authorization code
type AuthCode struct {
Code string
ClientID string
RedirectURI string
Pubkey string // Nostr public key (hex)
State string
CreatedAt time.Time
ExpiresAt time.Time
}
// Challenge represents a Nostr authentication challenge
type Challenge struct {
Nonce string
ClientID string
State string
RedirectURI string
CreatedAt time.Time
ExpiresAt time.Time
}
// AccessToken represents an issued access token
type AccessToken struct {
Token string
Pubkey string
ClientID string
CreatedAt time.Time
ExpiresAt time.Time
}
// Store interface for OAuth2 data persistence
type Store interface {
// Challenge operations
CreateChallenge(clientID, state, redirectURI string, ttl time.Duration) (*Challenge, error)
GetChallenge(nonce string) (*Challenge, error)
DeleteChallenge(nonce string) error
// Auth code operations
CreateAuthCode(clientID, redirectURI, pubkey, state string) (*AuthCode, error)
GetAuthCode(code string) (*AuthCode, error)
DeleteAuthCode(code string) error
// Access token operations
CreateAccessToken(pubkey, clientID string) (*AccessToken, error)
GetAccessToken(token string) (*AccessToken, error)
}
// MemoryStore is an in-memory implementation of Store
type MemoryStore struct {
challenges map[string]*Challenge
authCodes map[string]*AuthCode
accessTokens map[string]*AccessToken
mu sync.RWMutex
}
func NewMemoryStore() *MemoryStore {
s := &MemoryStore{
challenges: make(map[string]*Challenge),
authCodes: make(map[string]*AuthCode),
accessTokens: make(map[string]*AccessToken),
}
go s.cleanup()
return s
}
func (s *MemoryStore) cleanup() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
s.mu.Lock()
now := time.Now()
for k, v := range s.challenges {
if now.After(v.ExpiresAt) {
delete(s.challenges, k)
}
}
for k, v := range s.authCodes {
if now.After(v.ExpiresAt) {
delete(s.authCodes, k)
}
}
for k, v := range s.accessTokens {
if now.After(v.ExpiresAt) {
delete(s.accessTokens, k)
}
}
s.mu.Unlock()
}
}
func generateToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func (s *MemoryStore) CreateChallenge(clientID, state, redirectURI string, ttl time.Duration) (*Challenge, error) {
nonce, err := generateToken(32)
if err != nil {
return nil, err
}
challenge := &Challenge{
Nonce: nonce,
ClientID: clientID,
State: state,
RedirectURI: redirectURI,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(ttl),
}
s.mu.Lock()
s.challenges[nonce] = challenge
s.mu.Unlock()
return challenge, nil
}
func (s *MemoryStore) GetChallenge(nonce string) (*Challenge, error) {
s.mu.RLock()
defer s.mu.RUnlock()
challenge, ok := s.challenges[nonce]
if !ok || time.Now().After(challenge.ExpiresAt) {
return nil, nil
}
return challenge, nil
}
func (s *MemoryStore) DeleteChallenge(nonce string) error {
s.mu.Lock()
delete(s.challenges, nonce)
s.mu.Unlock()
return nil
}
func (s *MemoryStore) CreateAuthCode(clientID, redirectURI, pubkey, state string) (*AuthCode, error) {
code, err := generateToken(32)
if err != nil {
return nil, err
}
authCode := &AuthCode{
Code: code,
ClientID: clientID,
RedirectURI: redirectURI,
Pubkey: pubkey,
State: state,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(10 * time.Minute),
}
s.mu.Lock()
s.authCodes[code] = authCode
s.mu.Unlock()
return authCode, nil
}
func (s *MemoryStore) GetAuthCode(code string) (*AuthCode, error) {
s.mu.RLock()
defer s.mu.RUnlock()
authCode, ok := s.authCodes[code]
if !ok || time.Now().After(authCode.ExpiresAt) {
return nil, nil
}
return authCode, nil
}
func (s *MemoryStore) DeleteAuthCode(code string) error {
s.mu.Lock()
delete(s.authCodes, code)
s.mu.Unlock()
return nil
}
func (s *MemoryStore) CreateAccessToken(pubkey, clientID string) (*AccessToken, error) {
token, err := generateToken(32)
if err != nil {
return nil, err
}
accessToken := &AccessToken{
Token: token,
Pubkey: pubkey,
ClientID: clientID,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
}
s.mu.Lock()
s.accessTokens[token] = accessToken
s.mu.Unlock()
return accessToken, nil
}
func (s *MemoryStore) GetAccessToken(token string) (*AccessToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
accessToken, ok := s.accessTokens[token]
if !ok || time.Now().After(accessToken.ExpiresAt) {
return nil, nil
}
return accessToken, nil
}