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:
146
internal/config/config.go
Normal file
146
internal/config/config.go
Normal 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
|
||||
}
|
||||
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(),
|
||||
})
|
||||
}
|
||||
323
internal/nostr/fetcher.go
Normal file
323
internal/nostr/fetcher.go
Normal 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
55
internal/nostr/pubkey.go
Normal 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
51
internal/nostr/relays.go
Normal 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
218
internal/oauth2/store.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user