diff --git a/.claude/commands/release.md b/.claude/commands/release.md
new file mode 100644
index 0000000..4ef2881
--- /dev/null
+++ b/.claude/commands/release.md
@@ -0,0 +1,51 @@
+# Release Command
+
+Review all changes in the repository and create a release with proper commit message, version tag, and push to remote.
+
+## Argument: $ARGUMENTS
+
+The argument should be one of:
+- `patch` - Bump the patch version (e.g., v0.0.1 -> v0.0.2)
+- `minor` - Bump the minor version and reset patch to 0 (e.g., v0.0.2 -> v0.1.0)
+- `major` - Bump the major version and reset minor and patch to 0 (e.g., v0.1.0 -> v1.0.0)
+
+If no argument provided, default to `patch`.
+
+## Steps to perform:
+
+1. **Read the current version** from `pkg/version/version`
+
+2. **Calculate the new version** based on the argument:
+ - Parse the current version (format: vMAJOR.MINOR.PATCH)
+ - If `patch`: increment PATCH by 1
+ - If `minor`: increment MINOR by 1, set PATCH to 0
+ - If `major`: increment MAJOR by 1, set MINOR and PATCH to 0
+
+3. **Update the version file** (`pkg/version/version`) with the new version
+
+4. **Verify the build compiles** before proceeding:
+ ```
+ CGO_ENABLED=0 go build -o /dev/null ./...
+ ```
+ If build fails, fix issues before proceeding.
+
+5. **Review changes** using `git status` and `git diff --stat HEAD`
+
+6. **Compose a commit message** following this format:
+ - First line: 72 chars max, imperative mood summary (e.g., "Release v0.0.1")
+ - Blank line
+ - Bullet points describing each significant change since last release
+ - Footer with Claude Code attribution
+
+7. **Stage all changes** with `git add -A`
+
+8. **Create the commit** with the composed message
+
+9. **Create a git tag** with the new version (e.g., `v0.0.1`)
+
+10. **Push to remote** with tags:
+ ```
+ git push origin main --tags
+ ```
+
+11. **Report completion** with the new version and commit hash
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a5b35f6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+# Binaries (at root only)
+/nostr-oauth2-server
+/nostr-oauth2-server-*
+
+# Config with secrets
+config.production.yaml
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..d7ef48a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,37 @@
+# Build stage
+FROM golang:1.22-alpine AS builder
+
+WORKDIR /app
+
+# Install git for go modules
+RUN apk add --no-cache git
+
+# Copy go mod files
+COPY go.mod go.sum ./
+RUN go mod download
+
+# Copy source code
+COPY . .
+
+# Build the binary
+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o nostr-oauth2-server ./cmd/nostr-oauth2-server
+
+# Runtime stage
+FROM alpine:3.19
+
+RUN apk --no-cache add ca-certificates
+
+WORKDIR /app
+
+# Copy binary from builder
+COPY --from=builder /app/nostr-oauth2-server .
+
+# Copy default config
+COPY config.example.yaml /app/config.yaml
+
+# Expose port
+EXPOSE 8080
+
+# Run the server
+ENTRYPOINT ["./nostr-oauth2-server"]
+CMD ["-config", "/app/config.yaml"]
diff --git a/README.md b/README.md
index b9140b8..1f50816 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,134 @@
# gitea-nostr-auth
-nostr nip-07 based extension signer auth with automatic account creation
\ No newline at end of file
+An OAuth2/OIDC provider that enables Nostr NIP-07 browser extension authentication for Gitea. Allows users to sign in to Gitea using their Nostr identity.
+
+## Features
+
+- **NIP-07 Authentication**: Uses browser extensions (Alby, nos2x, etc.) for signing
+- **OAuth2/OIDC Compatible**: Works as a standard OpenID Connect provider for Gitea
+- **Auto-Registration**: Automatically creates Gitea accounts for new Nostr users
+- **Profile Fetching**: Fetches user profiles (name, picture, NIP-05) from Nostr relays
+- **Smart Relay Discovery**: First fetches user's NIP-65 relay list, then queries those relays for profile
+- **Caching**: 24-hour cache for relay lists and profiles to minimize relay queries
+
+## Quick Start
+
+### 1. Build
+
+```bash
+go build ./cmd/nostr-oauth2-server
+```
+
+### 2. Configure
+
+Copy `config.example.yaml` to `config.yaml` and update:
+
+```yaml
+server:
+ port: 8080
+ base_url: "https://nostr-auth.example.com"
+
+oauth2:
+ clients:
+ - client_id: "gitea"
+ client_secret: "your-secure-secret" # Generate with: openssl rand -hex 32
+ redirect_uris:
+ - "https://gitea.example.com/user/oauth2/nostr/callback"
+```
+
+### 3. Run
+
+```bash
+./nostr-oauth2-server -config config.yaml
+```
+
+Or with environment variables:
+
+```bash
+PORT=8080 \
+BASE_URL=https://nostr-auth.example.com \
+OAUTH2_CLIENT_ID=gitea \
+OAUTH2_CLIENT_SECRET=your-secure-secret \
+OAUTH2_REDIRECT_URIS=https://gitea.example.com/user/oauth2/nostr/callback \
+./nostr-oauth2-server
+```
+
+### 4. Configure Gitea
+
+Add the OAuth2 authentication source:
+
+```bash
+gitea admin auth add-oauth \
+ --name "Nostr" \
+ --provider openidConnect \
+ --key "gitea" \
+ --secret "your-secure-secret" \
+ --auto-discover-url "https://nostr-auth.example.com/.well-known/openid-configuration"
+```
+
+Enable auto-registration in Gitea's `app.ini`:
+
+```ini
+[service]
+DISABLE_REGISTRATION = false
+ALLOW_ONLY_EXTERNAL_REGISTRATION = true
+```
+
+## Docker
+
+```bash
+docker build -t nostr-oauth2-server .
+docker run -p 8080:8080 -v $(pwd)/config.yaml:/app/config.yaml nostr-oauth2-server
+```
+
+## How It Works
+
+### Authentication Flow
+
+1. User clicks "Login with Nostr" on Gitea
+2. Gitea redirects to nostr-oauth2-server's `/authorize` endpoint
+3. Login page uses `window.nostr` (NIP-07) to get pubkey and sign a challenge
+4. Server verifies signature and issues an OAuth2 authorization code
+5. Gitea exchanges code for access token and fetches user info
+6. User is logged in (or account is created if new)
+
+### Profile Fetching
+
+When Gitea requests user info, the server:
+
+1. **Fetches NIP-65 relay list (kind 10002)** from fallback relays to find user's preferred relays
+2. **Queries user's read relays + fallbacks** for their profile (kind 0)
+3. **Extracts profile data**: name, display_name, picture, NIP-05, website, etc.
+4. **Caches results** for 24 hours to minimize relay queries
+
+This ensures profiles are found even if only stored on the user's preferred relays.
+
+### Fallback Relays
+
+Default relays used for initial queries (configurable):
+- `wss://relay.nostr.band/`
+- `wss://nostr.wine/`
+- `wss://nos.lol/`
+- `wss://relay.primal.net/`
+- `wss://purplepag.es/`
+
+## API Endpoints
+
+| Endpoint | Description |
+|----------|-------------|
+| `/.well-known/openid-configuration` | OIDC discovery document |
+| `/authorize` | OAuth2 authorization (shows login page) |
+| `/verify` | Verify signed Nostr event |
+| `/token` | Exchange auth code for access token |
+| `/userinfo` | Get user profile (npub, username, email) |
+
+## Security
+
+- Challenges are single-use and expire after 60 seconds
+- Event signatures are verified using secp256k1
+- Timestamps must be within a 60-second window
+- HTTPS required in production
+
+## License
+
+This is free and unencumbered software released into the public domain (Unlicense).
diff --git a/cmd/nostr-oauth2-server/main.go b/cmd/nostr-oauth2-server/main.go
new file mode 100644
index 0000000..a15a9a3
--- /dev/null
+++ b/cmd/nostr-oauth2-server/main.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+ "flag"
+ "log"
+ "net/http"
+ "os"
+
+ "git.mleku.dev/mleku/gitea-nostr-auth/internal/config"
+ "git.mleku.dev/mleku/gitea-nostr-auth/internal/handler"
+ "git.mleku.dev/mleku/gitea-nostr-auth/internal/nostr"
+ "git.mleku.dev/mleku/gitea-nostr-auth/internal/oauth2"
+)
+
+func main() {
+ configPath := flag.String("config", "config.yaml", "path to config file")
+ flag.Parse()
+
+ cfg, err := config.Load(*configPath)
+ if err != nil {
+ // Try environment variables if config file not found
+ if os.IsNotExist(err) {
+ cfg = config.FromEnv()
+ } else {
+ log.Fatalf("failed to load config: %v", err)
+ }
+ }
+
+ store := oauth2.NewMemoryStore()
+ fetcher := nostr.NewFetcher(cfg.Nostr.FallbackRelays)
+ router := handler.NewRouter(cfg, store, fetcher)
+
+ addr := cfg.Server.Address()
+ log.Printf("Starting nostr-oauth2-server on %s", addr)
+ log.Printf("Base URL: %s", cfg.Server.BaseURL)
+ log.Printf("Fallback relays: %v", cfg.Nostr.FallbackRelays)
+
+ if err := http.ListenAndServe(addr, router); err != nil {
+ log.Fatalf("server error: %v", err)
+ }
+}
diff --git a/config.example.yaml b/config.example.yaml
new file mode 100644
index 0000000..86f7fc8
--- /dev/null
+++ b/config.example.yaml
@@ -0,0 +1,33 @@
+# Nostr OAuth2 Server Configuration
+
+server:
+ # Port to listen on
+ port: 8080
+ # Host to bind to (empty or 0.0.0.0 for all interfaces)
+ host: ""
+ # Public base URL (used for generating redirect URLs)
+ base_url: "http://localhost:8080"
+
+oauth2:
+ clients:
+ # Gitea client configuration
+ - client_id: "gitea"
+ # Generate a secure secret: openssl rand -hex 32
+ client_secret: "change-me-to-a-secure-secret"
+ redirect_uris:
+ - "http://localhost:3000/user/oauth2/nostr/callback"
+ # Add your production Gitea URL here
+ # - "https://gitea.example.com/user/oauth2/nostr/callback"
+
+nostr:
+ # How long a challenge is valid
+ challenge_ttl: 60s
+ # Fallback relays for fetching relay lists and profiles
+ # These are used when looking up NIP-65 relay lists and kind 0 profiles
+ # Leave empty to use defaults: relay.nostr.band, nostr.wine, nos.lol, relay.primal.net, purplepag.es
+ fallback_relays:
+ - "wss://relay.nostr.band/"
+ - "wss://nostr.wine/"
+ - "wss://nos.lol/"
+ - "wss://relay.primal.net/"
+ - "wss://purplepag.es/"
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..946e811
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,29 @@
+module git.mleku.dev/mleku/gitea-nostr-auth
+
+go 1.23.1
+
+require (
+ github.com/go-chi/chi/v5 v5.1.0
+ github.com/go-chi/cors v1.2.1
+ github.com/nbd-wtf/go-nostr v0.42.1
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
+ github.com/btcsuite/btcd/btcutil v1.1.3 // indirect
+ github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
+ github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
+ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
+ github.com/gobwas/httphead v0.1.0 // indirect
+ github.com/gobwas/pool v0.2.1 // indirect
+ github.com/gobwas/ws v1.4.0 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect
+ github.com/tidwall/gjson v1.17.3 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
+ golang.org/x/sys v0.26.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..8d3da75
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,142 @@
+github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
+github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
+github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
+github.com/btcsuite/btcd v0.23.0/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
+github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
+github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
+github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
+github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
+github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
+github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
+github.com/btcsuite/btcd/btcutil v1.1.3 h1:xfbtw8lwpp0G6NwSHb+UE67ryTFHJAiNuipusjXSohQ=
+github.com/btcsuite/btcd/btcutil v1.1.3/go.mod h1:UR7dsSJzJUfMmFiiLlIrMq1lS9jh9EdCV7FStZSnpi0=
+github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
+github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
+github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
+github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
+github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
+github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
+github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
+github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
+github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
+github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
+github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
+github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
+github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
+github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
+github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
+github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
+github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
+github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
+github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
+github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/nbd-wtf/go-nostr v0.42.1 h1:zQksbvj+EsLgZBuKugvMzMpzXZuIp1WSx3BvgZ5ZY6A=
+github.com/nbd-wtf/go-nostr v0.42.1/go.mod h1:FBa4FBJO7NuANvkeKSlrf0BIyxGufmrUbuelr6Q4Ick=
+github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
+github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
+github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
+github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
+github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
+github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
+golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
+golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
+golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..e3f7edc
--- /dev/null
+++ b/internal/config/config.go
@@ -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
+}
diff --git a/internal/handler/authorize.go b/internal/handler/authorize.go
new file mode 100644
index 0000000..da78122
--- /dev/null
+++ b/internal/handler/authorize.go
@@ -0,0 +1,296 @@
+package handler
+
+import (
+ "html/template"
+ "log"
+ "net/http"
+)
+
+const loginPageHTML = `
+
+
+
+
+ Login with Nostr
+
+
+
+
+
🗝
+
Login with Nostr
+
Sign in using your Nostr identity
+
+
+
No Nostr extension detected. Please install one of these:
+
+
+
+
+
+
+
+
+
+
+
+`
+
+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)
+ }
+}
diff --git a/internal/handler/discovery.go b/internal/handler/discovery.go
new file mode 100644
index 0000000..49d8692
--- /dev/null
+++ b/internal/handler/discovery.go
@@ -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)
+}
diff --git a/internal/handler/routes.go b/internal/handler/routes.go
new file mode 100644
index 0000000..d238213
--- /dev/null
+++ b/internal/handler/routes.go
@@ -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
+}
diff --git a/internal/handler/token.go b/internal/handler/token.go
new file mode 100644
index 0000000..0be993a
--- /dev/null
+++ b/internal/handler/token.go
@@ -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 + "."
+}
diff --git a/internal/handler/userinfo.go b/internal/handler/userinfo.go
new file mode 100644
index 0000000..30e44e2
--- /dev/null
+++ b/internal/handler/userinfo.go
@@ -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)
+}
diff --git a/internal/handler/verify.go b/internal/handler/verify.go
new file mode 100644
index 0000000..df912a1
--- /dev/null
+++ b/internal/handler/verify.go
@@ -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(),
+ })
+}
diff --git a/internal/nostr/fetcher.go b/internal/nostr/fetcher.go
new file mode 100644
index 0000000..4125015
--- /dev/null
+++ b/internal/nostr/fetcher.go
@@ -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
+}
diff --git a/internal/nostr/pubkey.go b/internal/nostr/pubkey.go
new file mode 100644
index 0000000..96b0116
--- /dev/null
+++ b/internal/nostr/pubkey.go
@@ -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"
+}
diff --git a/internal/nostr/relays.go b/internal/nostr/relays.go
new file mode 100644
index 0000000..416c871
--- /dev/null
+++ b/internal/nostr/relays.go
@@ -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))
+}
diff --git a/internal/oauth2/store.go b/internal/oauth2/store.go
new file mode 100644
index 0000000..cbcc51c
--- /dev/null
+++ b/internal/oauth2/store.go
@@ -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
+}
diff --git a/pkg/version/version b/pkg/version/version
new file mode 100644
index 0000000..a17a148
--- /dev/null
+++ b/pkg/version/version
@@ -0,0 +1 @@
+v0.0.1