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

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