Release v0.0.1 - Initial OAuth2 server implementation

- Add Nostr OAuth2 server with NIP-98 authentication support
- Implement OAuth2 authorization and token endpoints
- Add .well-known/openid-configuration discovery endpoint
- Include Dockerfile for containerized deployment
- Add Claude Code release command for version management
- Create example configuration file

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-19 09:37:26 +01:00
parent 52e486a948
commit 896a7599a0
20 changed files with 2099 additions and 1 deletions

View File

@@ -0,0 +1,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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# Binaries (at root only)
/nostr-oauth2-server
/nostr-oauth2-server-*
# Config with secrets
config.production.yaml

37
Dockerfile Normal file
View File

@@ -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"]

133
README.md
View File

@@ -1,3 +1,134 @@
# gitea-nostr-auth # gitea-nostr-auth
nostr nip-07 based extension signer auth with automatic account creation 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).

View File

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

33
config.example.yaml Normal file
View File

@@ -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/"

29
go.mod Normal file
View File

@@ -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
)

142
go.sum Normal file
View File

@@ -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=

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

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

View File

@@ -0,0 +1,296 @@
package handler
import (
"html/template"
"log"
"net/http"
)
const loginPageHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login with Nostr</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 20px;
}
.container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 40px;
max-width: 400px;
width: 100%;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.1);
}
h1 {
color: #fff;
margin-bottom: 10px;
font-size: 24px;
}
.subtitle {
color: rgba(255, 255, 255, 0.6);
margin-bottom: 30px;
font-size: 14px;
}
.nostr-logo {
font-size: 64px;
margin-bottom: 20px;
}
button {
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
color: white;
border: none;
padding: 16px 32px;
font-size: 16px;
border-radius: 12px;
cursor: pointer;
width: 100%;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 40px rgba(139, 92, 246, 0.3);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status {
margin-top: 20px;
padding: 12px;
border-radius: 8px;
font-size: 14px;
}
.status.error {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.status.success {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.status.info {
background: rgba(59, 130, 246, 0.2);
color: #93c5fd;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.hidden {
display: none;
}
.no-extension {
color: rgba(255, 255, 255, 0.8);
}
.no-extension a {
color: #8b5cf6;
text-decoration: none;
}
.no-extension a:hover {
text-decoration: underline;
}
.extension-list {
margin-top: 15px;
text-align: left;
padding-left: 20px;
}
.extension-list li {
margin: 8px 0;
color: rgba(255, 255, 255, 0.7);
}
.pubkey {
font-family: monospace;
font-size: 12px;
word-break: break-all;
background: rgba(0, 0, 0, 0.3);
padding: 8px;
border-radius: 6px;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="nostr-logo">&#x1F5DD;</div>
<h1>Login with Nostr</h1>
<p class="subtitle">Sign in using your Nostr identity</p>
<div id="no-extension" class="no-extension hidden">
<p>No Nostr extension detected. Please install one of these:</p>
<ul class="extension-list">
<li><a href="https://getalby.com" target="_blank">Alby</a></li>
<li><a href="https://github.com/nickytonline/nos2x" target="_blank">nos2x</a></li>
<li><a href="https://github.com/nickytonline/flamingo" target="_blank">Flamingo</a></li>
</ul>
</div>
<div id="login-section">
<button id="login-btn" disabled>Checking for extension...</button>
<div id="status" class="status hidden"></div>
</div>
</div>
<script>
const challenge = "{{.Challenge}}";
const verifyURL = "{{.VerifyURL}}";
const loginBtn = document.getElementById('login-btn');
const statusDiv = document.getElementById('status');
const noExtensionDiv = document.getElementById('no-extension');
const loginSection = document.getElementById('login-section');
function showStatus(message, type) {
statusDiv.textContent = message;
statusDiv.className = 'status ' + type;
statusDiv.classList.remove('hidden');
}
function hideStatus() {
statusDiv.classList.add('hidden');
}
async function checkExtension() {
// Wait a bit for extension to inject
await new Promise(resolve => setTimeout(resolve, 100));
if (typeof window.nostr === 'undefined') {
noExtensionDiv.classList.remove('hidden');
loginBtn.textContent = 'No extension found';
return false;
}
loginBtn.disabled = false;
loginBtn.textContent = 'Sign in with Nostr';
return true;
}
async function login() {
loginBtn.disabled = true;
loginBtn.textContent = 'Signing...';
hideStatus();
try {
// Get public key
const pubkey = await window.nostr.getPublicKey();
showStatus('Got public key, signing challenge...', 'info');
// Create event to sign (NIP-98 style)
const event = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', window.location.href],
['method', 'GET'],
['challenge', challenge]
],
content: ''
};
// Sign the event
const signedEvent = await window.nostr.signEvent(event);
showStatus('Signed! Verifying with server...', 'info');
// Submit to server
const response = await fetch(verifyURL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
});
const result = await response.json();
if (result.redirect_url) {
showStatus('Success! Redirecting...', 'success');
window.location.href = result.redirect_url;
} else if (result.error) {
showStatus('Error: ' + result.error, 'error');
loginBtn.disabled = false;
loginBtn.textContent = 'Try again';
}
} catch (err) {
console.error('Login error:', err);
showStatus('Error: ' + err.message, 'error');
loginBtn.disabled = false;
loginBtn.textContent = 'Try again';
}
}
loginBtn.addEventListener('click', login);
checkExtension();
</script>
</body>
</html>`
func (h *Handler) Authorize(w http.ResponseWriter, r *http.Request) {
// Extract OAuth2 parameters
clientID := r.URL.Query().Get("client_id")
redirectURI := r.URL.Query().Get("redirect_uri")
state := r.URL.Query().Get("state")
responseType := r.URL.Query().Get("response_type")
// Validate required parameters
if clientID == "" {
http.Error(w, "missing client_id", http.StatusBadRequest)
return
}
if redirectURI == "" {
http.Error(w, "missing redirect_uri", http.StatusBadRequest)
return
}
if responseType != "code" {
http.Error(w, "unsupported response_type, must be 'code'", http.StatusBadRequest)
return
}
// Validate client and redirect URI
if !h.cfg.ValidateRedirectURI(clientID, redirectURI) {
http.Error(w, "invalid client_id or redirect_uri", http.StatusBadRequest)
return
}
// Create challenge
challenge, err := h.store.CreateChallenge(clientID, state, redirectURI, h.cfg.Nostr.ChallengeTTL)
if err != nil {
log.Printf("failed to create challenge: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Render login page
tmpl, err := template.New("login").Parse(loginPageHTML)
if err != nil {
log.Printf("failed to parse template: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := struct {
Challenge string
VerifyURL string
}{
Challenge: challenge.Nonce,
VerifyURL: h.cfg.Server.BaseURL + "/verify",
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("failed to execute template: %v", err)
}
}

View File

@@ -0,0 +1,76 @@
package handler
import (
"encoding/json"
"net/http"
)
type OIDCConfiguration struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
JwksURI string `json:"jwks_uri"`
ResponseTypesSupported []string `json:"response_types_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
ScopesSupported []string `json:"scopes_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
ClaimsSupported []string `json:"claims_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
}
func (h *Handler) OIDCDiscovery(w http.ResponseWriter, r *http.Request) {
baseURL := h.cfg.Server.BaseURL
config := OIDCConfiguration{
Issuer: baseURL,
AuthorizationEndpoint: baseURL + "/authorize",
TokenEndpoint: baseURL + "/token",
UserInfoEndpoint: baseURL + "/userinfo",
JwksURI: baseURL + "/.well-known/jwks.json",
ResponseTypesSupported: []string{"code"},
SubjectTypesSupported: []string{"public"},
IDTokenSigningAlgValuesSupported: []string{"RS256"},
ScopesSupported: []string{"openid", "profile", "email"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
ClaimsSupported: []string{
"sub",
"iss",
"aud",
"exp",
"iat",
"name",
"preferred_username",
"email",
},
GrantTypesSupported: []string{"authorization_code"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(config)
}
type JWKSet struct {
Keys []JWK `json:"keys"`
}
type JWK struct {
Kty string `json:"kty"`
Use string `json:"use"`
Kid string `json:"kid"`
Alg string `json:"alg"`
N string `json:"n"`
E string `json:"e"`
}
func (h *Handler) JWKS(w http.ResponseWriter, r *http.Request) {
// For simplicity, we'll use a static JWKS
// In production, this should be dynamically generated from actual keys
jwks := JWKSet{
Keys: []JWK{},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(jwks)
}

View File

@@ -0,0 +1,55 @@
package handler
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"git.mleku.dev/mleku/gitea-nostr-auth/internal/config"
"git.mleku.dev/mleku/gitea-nostr-auth/internal/nostr"
"git.mleku.dev/mleku/gitea-nostr-auth/internal/oauth2"
)
func NewRouter(cfg *config.Config, store oauth2.Store, fetcher *nostr.Fetcher) http.Handler {
r := chi.NewRouter()
// Middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RealIP)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: true,
MaxAge: 300,
}))
h := &Handler{
cfg: cfg,
store: store,
fetcher: fetcher,
}
// OIDC Discovery
r.Get("/.well-known/openid-configuration", h.OIDCDiscovery)
// OAuth2 endpoints
r.Get("/authorize", h.Authorize)
r.Post("/verify", h.Verify)
r.Post("/token", h.Token)
r.Get("/userinfo", h.UserInfo)
// JWKS endpoint (required for OIDC)
r.Get("/.well-known/jwks.json", h.JWKS)
return r
}
type Handler struct {
cfg *config.Config
store oauth2.Store
fetcher *nostr.Fetcher
}

181
internal/handler/token.go Normal file
View File

@@ -0,0 +1,181 @@
package handler
import (
"crypto/subtle"
"encoding/base64"
"encoding/json"
"log"
"net/http"
"strings"
"time"
"git.mleku.dev/mleku/gitea-nostr-auth/internal/nostr"
)
type TokenRequest struct {
GrantType string `json:"grant_type"`
Code string `json:"code"`
RedirectURI string `json:"redirect_uri"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token,omitempty"`
Error string `json:"error,omitempty"`
ErrorDesc string `json:"error_description,omitempty"`
}
func (h *Handler) Token(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Parse form data
if err := r.ParseForm(); err != nil {
json.NewEncoder(w).Encode(TokenResponse{
Error: "invalid_request",
ErrorDesc: "failed to parse form",
})
return
}
// Extract credentials (support both Basic auth and form params)
clientID, clientSecret := extractClientCredentials(r)
grantType := r.FormValue("grant_type")
code := r.FormValue("code")
redirectURI := r.FormValue("redirect_uri")
// Override client credentials from form if provided
if formClientID := r.FormValue("client_id"); formClientID != "" {
clientID = formClientID
}
if formClientSecret := r.FormValue("client_secret"); formClientSecret != "" {
clientSecret = formClientSecret
}
// Validate grant type
if grantType != "authorization_code" {
json.NewEncoder(w).Encode(TokenResponse{
Error: "unsupported_grant_type",
ErrorDesc: "only authorization_code is supported",
})
return
}
// Validate client
client := h.cfg.GetClient(clientID)
if client == nil {
json.NewEncoder(w).Encode(TokenResponse{
Error: "invalid_client",
ErrorDesc: "unknown client_id",
})
return
}
// Verify client secret
if subtle.ConstantTimeCompare([]byte(client.ClientSecret), []byte(clientSecret)) != 1 {
json.NewEncoder(w).Encode(TokenResponse{
Error: "invalid_client",
ErrorDesc: "invalid client_secret",
})
return
}
// Look up authorization code
authCode, err := h.store.GetAuthCode(code)
if err != nil || authCode == nil {
json.NewEncoder(w).Encode(TokenResponse{
Error: "invalid_grant",
ErrorDesc: "invalid or expired authorization code",
})
return
}
// Verify code belongs to this client
if authCode.ClientID != clientID {
json.NewEncoder(w).Encode(TokenResponse{
Error: "invalid_grant",
ErrorDesc: "code was not issued to this client",
})
return
}
// Verify redirect URI matches
if authCode.RedirectURI != redirectURI {
json.NewEncoder(w).Encode(TokenResponse{
Error: "invalid_grant",
ErrorDesc: "redirect_uri mismatch",
})
return
}
// Delete auth code (single use)
h.store.DeleteAuthCode(code)
// Create access token
accessToken, err := h.store.CreateAccessToken(authCode.Pubkey, clientID)
if err != nil {
log.Printf("failed to create access token: %v", err)
json.NewEncoder(w).Encode(TokenResponse{
Error: "server_error",
ErrorDesc: "failed to create token",
})
return
}
// Generate ID token (simple JWT-like structure for OIDC compatibility)
idToken := generateIDToken(h.cfg.Server.BaseURL, clientID, authCode.Pubkey)
expiresIn := int(time.Until(accessToken.ExpiresAt).Seconds())
json.NewEncoder(w).Encode(TokenResponse{
AccessToken: accessToken.Token,
TokenType: "Bearer",
ExpiresIn: expiresIn,
IDToken: idToken,
})
}
func extractClientCredentials(r *http.Request) (clientID, clientSecret string) {
// Try Basic auth header first
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Basic ") {
decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic "))
if err == nil {
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) == 2 {
return parts[0], parts[1]
}
}
}
return "", ""
}
// generateIDToken creates a simple ID token
// In production, this should be a properly signed JWT
func generateIDToken(issuer, audience, subject string) string {
// Create a simple base64-encoded JSON token
// In production, use proper JWT signing
header := `{"alg":"none","typ":"JWT"}`
now := time.Now()
payload := map[string]interface{}{
"iss": issuer,
"sub": subject,
"aud": audience,
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
"preferred_username": nostr.PubkeyToNpub(subject),
}
payloadBytes, _ := json.Marshal(payload)
headerB64 := base64.RawURLEncoding.EncodeToString([]byte(header))
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes)
// Unsigned token (alg: none)
return headerB64 + "." + payloadB64 + "."
}

View File

@@ -0,0 +1,92 @@
package handler
import (
"encoding/json"
"log"
"net/http"
"strings"
"git.mleku.dev/mleku/gitea-nostr-auth/internal/nostr"
)
type UserInfoResponse struct {
Sub string `json:"sub"`
Name string `json:"name,omitempty"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
Picture string `json:"picture,omitempty"`
Profile string `json:"profile,omitempty"`
Website string `json:"website,omitempty"`
Error string `json:"error,omitempty"`
ErrorDesc string `json:"error_description,omitempty"`
}
func (h *Handler) UserInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Extract Bearer token
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(UserInfoResponse{
Error: "invalid_token",
ErrorDesc: "missing or invalid Authorization header",
})
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
// Look up access token
accessToken, err := h.store.GetAccessToken(token)
if err != nil || accessToken == nil {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(UserInfoResponse{
Error: "invalid_token",
ErrorDesc: "token is invalid or expired",
})
return
}
pubkey := accessToken.Pubkey
npub := nostr.PubkeyToNpub(pubkey)
// Fetch profile from relays (this also fetches relay list first)
log.Printf("Fetching profile for %s from relays...", nostr.TruncateNpub(npub))
profile := h.fetcher.FetchProfile(r.Context(), pubkey)
// Build response with profile data or fallbacks
response := UserInfoResponse{
Sub: pubkey,
}
if profile != nil {
log.Printf("Got profile for %s: name=%s, nip05=%s", nostr.TruncateNpub(npub), profile.Name, profile.Nip05)
// Use profile data
response.Name = profile.GetDisplayName()
response.PreferredUsername = profile.GetUsername()
response.Picture = profile.Picture
response.Website = profile.Website
response.Profile = profile.About
// Use NIP-05 as email if available (it's verified in the Nostr sense)
if profile.Nip05 != "" {
// NIP-05 format: name@domain.com or _@domain.com
response.Email = profile.Nip05
response.EmailVerified = false // We haven't verified it ourselves
} else {
response.Email = nostr.GeneratePlaceholderEmail(pubkey)
}
} else {
log.Printf("No profile found for %s, using defaults", nostr.TruncateNpub(npub))
// Fallback to generated values
response.PreferredUsername = nostr.GenerateUsername(pubkey)
response.Name = nostr.TruncateNpub(npub)
response.Email = nostr.GeneratePlaceholderEmail(pubkey)
}
json.NewEncoder(w).Encode(response)
}

134
internal/handler/verify.go Normal file
View File

@@ -0,0 +1,134 @@
package handler
import (
"encoding/json"
"log"
"net/http"
"net/url"
"time"
"github.com/nbd-wtf/go-nostr"
)
type VerifyRequest struct {
ID string `json:"id"`
PubKey string `json:"pubkey"`
CreatedAt int64 `json:"created_at"`
Kind int `json:"kind"`
Tags [][]string `json:"tags"`
Content string `json:"content"`
Sig string `json:"sig"`
}
type VerifyResponse struct {
Success bool `json:"success"`
RedirectURL string `json:"redirect_url,omitempty"`
Error string `json:"error,omitempty"`
}
func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var req VerifyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid request body"})
return
}
// Convert to go-nostr Event for verification
event := nostr.Event{
ID: req.ID,
PubKey: req.PubKey,
CreatedAt: nostr.Timestamp(req.CreatedAt),
Kind: req.Kind,
Tags: make(nostr.Tags, len(req.Tags)),
Content: req.Content,
Sig: req.Sig,
}
for i, tag := range req.Tags {
event.Tags[i] = tag
}
// Verify event kind
if event.Kind != 27235 {
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid event kind, expected 27235"})
return
}
// Verify timestamp is within window
eventTime := time.Unix(int64(event.CreatedAt), 0)
if time.Since(eventTime) > h.cfg.Nostr.ChallengeTTL {
json.NewEncoder(w).Encode(VerifyResponse{Error: "event timestamp too old"})
return
}
if eventTime.After(time.Now().Add(time.Minute)) {
json.NewEncoder(w).Encode(VerifyResponse{Error: "event timestamp in future"})
return
}
// Verify signature
ok, err := event.CheckSignature()
if err != nil || !ok {
log.Printf("signature verification failed: %v", err)
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid signature"})
return
}
// Extract challenge from tags
var challengeNonce string
for _, tag := range event.Tags {
if len(tag) >= 2 && tag[0] == "challenge" {
challengeNonce = tag[1]
break
}
}
if challengeNonce == "" {
json.NewEncoder(w).Encode(VerifyResponse{Error: "missing challenge tag"})
return
}
// Look up challenge
challenge, err := h.store.GetChallenge(challengeNonce)
if err != nil || challenge == nil {
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid or expired challenge"})
return
}
// Delete challenge (single use)
h.store.DeleteChallenge(challengeNonce)
// Create authorization code
authCode, err := h.store.CreateAuthCode(
challenge.ClientID,
challenge.RedirectURI,
event.PubKey,
challenge.State,
)
if err != nil {
log.Printf("failed to create auth code: %v", err)
json.NewEncoder(w).Encode(VerifyResponse{Error: "internal error"})
return
}
// Build redirect URL
redirectURL, err := url.Parse(challenge.RedirectURI)
if err != nil {
log.Printf("invalid redirect URI: %v", err)
json.NewEncoder(w).Encode(VerifyResponse{Error: "internal error"})
return
}
q := redirectURL.Query()
q.Set("code", authCode.Code)
if challenge.State != "" {
q.Set("state", challenge.State)
}
redirectURL.RawQuery = q.Encode()
json.NewEncoder(w).Encode(VerifyResponse{
Success: true,
RedirectURL: redirectURL.String(),
})
}

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

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

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

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

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

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

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

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

1
pkg/version/version Normal file
View File

@@ -0,0 +1 @@
v0.0.1