From 896a7599a0516d6cecfd22bee505d50d057832ebc07e7a66c410297fc2a85908 Mon Sep 17 00:00:00 2001 From: mleku Date: Fri, 19 Dec 2025 09:37:26 +0100 Subject: [PATCH] Release v0.0.1 - Initial OAuth2 server implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/commands/release.md | 51 +++++ .gitignore | 6 + Dockerfile | 37 ++++ README.md | 133 ++++++++++++- cmd/nostr-oauth2-server/main.go | 41 ++++ config.example.yaml | 33 ++++ go.mod | 29 +++ go.sum | 142 ++++++++++++++ internal/config/config.go | 146 +++++++++++++++ internal/handler/authorize.go | 296 +++++++++++++++++++++++++++++ internal/handler/discovery.go | 76 ++++++++ internal/handler/routes.go | 55 ++++++ internal/handler/token.go | 181 ++++++++++++++++++ internal/handler/userinfo.go | 92 +++++++++ internal/handler/verify.go | 134 +++++++++++++ internal/nostr/fetcher.go | 323 ++++++++++++++++++++++++++++++++ internal/nostr/pubkey.go | 55 ++++++ internal/nostr/relays.go | 51 +++++ internal/oauth2/store.go | 218 +++++++++++++++++++++ pkg/version/version | 1 + 20 files changed, 2099 insertions(+), 1 deletion(-) create mode 100644 .claude/commands/release.md create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 cmd/nostr-oauth2-server/main.go create mode 100644 config.example.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/handler/authorize.go create mode 100644 internal/handler/discovery.go create mode 100644 internal/handler/routes.go create mode 100644 internal/handler/token.go create mode 100644 internal/handler/userinfo.go create mode 100644 internal/handler/verify.go create mode 100644 internal/nostr/fetcher.go create mode 100644 internal/nostr/pubkey.go create mode 100644 internal/nostr/relays.go create mode 100644 internal/oauth2/store.go create mode 100644 pkg/version/version 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

+ + + +
+ + +
+
+ + + +` + +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