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:
51
.claude/commands/release.md
Normal file
51
.claude/commands/release.md
Normal 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
6
.gitignore
vendored
Normal 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
37
Dockerfile
Normal 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
133
README.md
@@ -1,3 +1,134 @@
|
||||
# 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).
|
||||
|
||||
41
cmd/nostr-oauth2-server/main.go
Normal file
41
cmd/nostr-oauth2-server/main.go
Normal 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
33
config.example.yaml
Normal 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
29
go.mod
Normal 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
142
go.sum
Normal 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
146
internal/config/config.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
OAuth2 OAuth2Config `yaml:"oauth2"`
|
||||
Nostr NostrConfig `yaml:"nostr"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
Host string `yaml:"host"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
}
|
||||
|
||||
func (s ServerConfig) Address() string {
|
||||
host := s.Host
|
||||
if host == "" {
|
||||
host = "0.0.0.0"
|
||||
}
|
||||
port := s.Port
|
||||
if port == 0 {
|
||||
port = 8080
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", host, port)
|
||||
}
|
||||
|
||||
type OAuth2Config struct {
|
||||
Clients []ClientConfig `yaml:"clients"`
|
||||
}
|
||||
|
||||
type ClientConfig struct {
|
||||
ClientID string `yaml:"client_id"`
|
||||
ClientSecret string `yaml:"client_secret"`
|
||||
RedirectURIs []string `yaml:"redirect_uris"`
|
||||
}
|
||||
|
||||
type NostrConfig struct {
|
||||
ChallengeTTL time.Duration `yaml:"challenge_ttl"`
|
||||
FallbackRelays []string `yaml:"fallback_relays"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
cfg.setDefaults()
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func FromEnv() *Config {
|
||||
cfg := &Config{}
|
||||
|
||||
// Server config
|
||||
if port := os.Getenv("PORT"); port != "" {
|
||||
cfg.Server.Port, _ = strconv.Atoi(port)
|
||||
}
|
||||
cfg.Server.Host = os.Getenv("HOST")
|
||||
cfg.Server.BaseURL = os.Getenv("BASE_URL")
|
||||
|
||||
// OAuth2 client config (single client from env)
|
||||
clientID := os.Getenv("OAUTH2_CLIENT_ID")
|
||||
clientSecret := os.Getenv("OAUTH2_CLIENT_SECRET")
|
||||
redirectURIs := os.Getenv("OAUTH2_REDIRECT_URIS")
|
||||
|
||||
if clientID != "" {
|
||||
cfg.OAuth2.Clients = []ClientConfig{{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURIs: strings.Split(redirectURIs, ","),
|
||||
}}
|
||||
}
|
||||
|
||||
// Nostr config
|
||||
if ttl := os.Getenv("NOSTR_CHALLENGE_TTL"); ttl != "" {
|
||||
cfg.Nostr.ChallengeTTL, _ = time.ParseDuration(ttl)
|
||||
}
|
||||
if relays := os.Getenv("NOSTR_FALLBACK_RELAYS"); relays != "" {
|
||||
cfg.Nostr.FallbackRelays = strings.Split(relays, ",")
|
||||
}
|
||||
|
||||
cfg.setDefaults()
|
||||
return cfg
|
||||
}
|
||||
|
||||
// DefaultFallbackRelays are well-known relays that aggregate profile data
|
||||
var DefaultFallbackRelays = []string{
|
||||
"wss://relay.nostr.band/",
|
||||
"wss://nostr.wine/",
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://purplepag.es/",
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.Server.Port == 0 {
|
||||
c.Server.Port = 8080
|
||||
}
|
||||
if c.Server.BaseURL == "" {
|
||||
c.Server.BaseURL = fmt.Sprintf("http://localhost:%d", c.Server.Port)
|
||||
}
|
||||
if c.Nostr.ChallengeTTL == 0 {
|
||||
c.Nostr.ChallengeTTL = 60 * time.Second
|
||||
}
|
||||
if len(c.Nostr.FallbackRelays) == 0 {
|
||||
c.Nostr.FallbackRelays = DefaultFallbackRelays
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) GetClient(clientID string) *ClientConfig {
|
||||
for i := range c.OAuth2.Clients {
|
||||
if c.OAuth2.Clients[i].ClientID == clientID {
|
||||
return &c.OAuth2.Clients[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) ValidateRedirectURI(clientID, redirectURI string) bool {
|
||||
client := c.GetClient(clientID)
|
||||
if client == nil {
|
||||
return false
|
||||
}
|
||||
for _, uri := range client.RedirectURIs {
|
||||
if uri == redirectURI {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
296
internal/handler/authorize.go
Normal file
296
internal/handler/authorize.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const loginPageHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login with Nostr</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #fff;
|
||||
margin-bottom: 10px;
|
||||
font-size: 24px;
|
||||
}
|
||||
.subtitle {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-bottom: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.nostr-logo {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
button {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 16px 32px;
|
||||
font-size: 16px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 40px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.status {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.status.error {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #fca5a5;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.status.success {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #86efac;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.status.info {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #93c5fd;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.no-extension {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.no-extension a {
|
||||
color: #8b5cf6;
|
||||
text-decoration: none;
|
||||
}
|
||||
.no-extension a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.extension-list {
|
||||
margin-top: 15px;
|
||||
text-align: left;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.extension-list li {
|
||||
margin: 8px 0;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.pubkey {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nostr-logo">🗝</div>
|
||||
<h1>Login with Nostr</h1>
|
||||
<p class="subtitle">Sign in using your Nostr identity</p>
|
||||
|
||||
<div id="no-extension" class="no-extension hidden">
|
||||
<p>No Nostr extension detected. Please install one of these:</p>
|
||||
<ul class="extension-list">
|
||||
<li><a href="https://getalby.com" target="_blank">Alby</a></li>
|
||||
<li><a href="https://github.com/nickytonline/nos2x" target="_blank">nos2x</a></li>
|
||||
<li><a href="https://github.com/nickytonline/flamingo" target="_blank">Flamingo</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="login-section">
|
||||
<button id="login-btn" disabled>Checking for extension...</button>
|
||||
<div id="status" class="status hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const challenge = "{{.Challenge}}";
|
||||
const verifyURL = "{{.VerifyURL}}";
|
||||
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const noExtensionDiv = document.getElementById('no-extension');
|
||||
const loginSection = document.getElementById('login-section');
|
||||
|
||||
function showStatus(message, type) {
|
||||
statusDiv.textContent = message;
|
||||
statusDiv.className = 'status ' + type;
|
||||
statusDiv.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
statusDiv.classList.add('hidden');
|
||||
}
|
||||
|
||||
async function checkExtension() {
|
||||
// Wait a bit for extension to inject
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
if (typeof window.nostr === 'undefined') {
|
||||
noExtensionDiv.classList.remove('hidden');
|
||||
loginBtn.textContent = 'No extension found';
|
||||
return false;
|
||||
}
|
||||
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Sign in with Nostr';
|
||||
return true;
|
||||
}
|
||||
|
||||
async function login() {
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Signing...';
|
||||
hideStatus();
|
||||
|
||||
try {
|
||||
// Get public key
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
showStatus('Got public key, signing challenge...', 'info');
|
||||
|
||||
// Create event to sign (NIP-98 style)
|
||||
const event = {
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['u', window.location.href],
|
||||
['method', 'GET'],
|
||||
['challenge', challenge]
|
||||
],
|
||||
content: ''
|
||||
};
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
showStatus('Signed! Verifying with server...', 'info');
|
||||
|
||||
// Submit to server
|
||||
const response = await fetch(verifyURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(signedEvent)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.redirect_url) {
|
||||
showStatus('Success! Redirecting...', 'success');
|
||||
window.location.href = result.redirect_url;
|
||||
} else if (result.error) {
|
||||
showStatus('Error: ' + result.error, 'error');
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Try again';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
showStatus('Error: ' + err.message, 'error');
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Try again';
|
||||
}
|
||||
}
|
||||
|
||||
loginBtn.addEventListener('click', login);
|
||||
checkExtension();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
func (h *Handler) Authorize(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract OAuth2 parameters
|
||||
clientID := r.URL.Query().Get("client_id")
|
||||
redirectURI := r.URL.Query().Get("redirect_uri")
|
||||
state := r.URL.Query().Get("state")
|
||||
responseType := r.URL.Query().Get("response_type")
|
||||
|
||||
// Validate required parameters
|
||||
if clientID == "" {
|
||||
http.Error(w, "missing client_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if redirectURI == "" {
|
||||
http.Error(w, "missing redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if responseType != "code" {
|
||||
http.Error(w, "unsupported response_type, must be 'code'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate client and redirect URI
|
||||
if !h.cfg.ValidateRedirectURI(clientID, redirectURI) {
|
||||
http.Error(w, "invalid client_id or redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Create challenge
|
||||
challenge, err := h.store.CreateChallenge(clientID, state, redirectURI, h.cfg.Nostr.ChallengeTTL)
|
||||
if err != nil {
|
||||
log.Printf("failed to create challenge: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Render login page
|
||||
tmpl, err := template.New("login").Parse(loginPageHTML)
|
||||
if err != nil {
|
||||
log.Printf("failed to parse template: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Challenge string
|
||||
VerifyURL string
|
||||
}{
|
||||
Challenge: challenge.Nonce,
|
||||
VerifyURL: h.cfg.Server.BaseURL + "/verify",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
log.Printf("failed to execute template: %v", err)
|
||||
}
|
||||
}
|
||||
76
internal/handler/discovery.go
Normal file
76
internal/handler/discovery.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type OIDCConfiguration struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
}
|
||||
|
||||
func (h *Handler) OIDCDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||
baseURL := h.cfg.Server.BaseURL
|
||||
|
||||
config := OIDCConfiguration{
|
||||
Issuer: baseURL,
|
||||
AuthorizationEndpoint: baseURL + "/authorize",
|
||||
TokenEndpoint: baseURL + "/token",
|
||||
UserInfoEndpoint: baseURL + "/userinfo",
|
||||
JwksURI: baseURL + "/.well-known/jwks.json",
|
||||
ResponseTypesSupported: []string{"code"},
|
||||
SubjectTypesSupported: []string{"public"},
|
||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||
ScopesSupported: []string{"openid", "profile", "email"},
|
||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
||||
ClaimsSupported: []string{
|
||||
"sub",
|
||||
"iss",
|
||||
"aud",
|
||||
"exp",
|
||||
"iat",
|
||||
"name",
|
||||
"preferred_username",
|
||||
"email",
|
||||
},
|
||||
GrantTypesSupported: []string{"authorization_code"},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(config)
|
||||
}
|
||||
|
||||
type JWKSet struct {
|
||||
Keys []JWK `json:"keys"`
|
||||
}
|
||||
|
||||
type JWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Use string `json:"use"`
|
||||
Kid string `json:"kid"`
|
||||
Alg string `json:"alg"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
}
|
||||
|
||||
func (h *Handler) JWKS(w http.ResponseWriter, r *http.Request) {
|
||||
// For simplicity, we'll use a static JWKS
|
||||
// In production, this should be dynamically generated from actual keys
|
||||
jwks := JWKSet{
|
||||
Keys: []JWK{},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(jwks)
|
||||
}
|
||||
55
internal/handler/routes.go
Normal file
55
internal/handler/routes.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
|
||||
"git.mleku.dev/mleku/gitea-nostr-auth/internal/config"
|
||||
"git.mleku.dev/mleku/gitea-nostr-auth/internal/nostr"
|
||||
"git.mleku.dev/mleku/gitea-nostr-auth/internal/oauth2"
|
||||
)
|
||||
|
||||
func NewRouter(cfg *config.Config, store oauth2.Store, fetcher *nostr.Fetcher) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Middleware
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
h := &Handler{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
fetcher: fetcher,
|
||||
}
|
||||
|
||||
// OIDC Discovery
|
||||
r.Get("/.well-known/openid-configuration", h.OIDCDiscovery)
|
||||
|
||||
// OAuth2 endpoints
|
||||
r.Get("/authorize", h.Authorize)
|
||||
r.Post("/verify", h.Verify)
|
||||
r.Post("/token", h.Token)
|
||||
r.Get("/userinfo", h.UserInfo)
|
||||
|
||||
// JWKS endpoint (required for OIDC)
|
||||
r.Get("/.well-known/jwks.json", h.JWKS)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
cfg *config.Config
|
||||
store oauth2.Store
|
||||
fetcher *nostr.Fetcher
|
||||
}
|
||||
181
internal/handler/token.go
Normal file
181
internal/handler/token.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mleku.dev/mleku/gitea-nostr-auth/internal/nostr"
|
||||
)
|
||||
|
||||
type TokenRequest struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
Code string `json:"code"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorDesc string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) Token(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Parse form data
|
||||
if err := r.ParseForm(); err != nil {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "invalid_request",
|
||||
ErrorDesc: "failed to parse form",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract credentials (support both Basic auth and form params)
|
||||
clientID, clientSecret := extractClientCredentials(r)
|
||||
|
||||
grantType := r.FormValue("grant_type")
|
||||
code := r.FormValue("code")
|
||||
redirectURI := r.FormValue("redirect_uri")
|
||||
|
||||
// Override client credentials from form if provided
|
||||
if formClientID := r.FormValue("client_id"); formClientID != "" {
|
||||
clientID = formClientID
|
||||
}
|
||||
if formClientSecret := r.FormValue("client_secret"); formClientSecret != "" {
|
||||
clientSecret = formClientSecret
|
||||
}
|
||||
|
||||
// Validate grant type
|
||||
if grantType != "authorization_code" {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "unsupported_grant_type",
|
||||
ErrorDesc: "only authorization_code is supported",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate client
|
||||
client := h.cfg.GetClient(clientID)
|
||||
if client == nil {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "invalid_client",
|
||||
ErrorDesc: "unknown client_id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify client secret
|
||||
if subtle.ConstantTimeCompare([]byte(client.ClientSecret), []byte(clientSecret)) != 1 {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "invalid_client",
|
||||
ErrorDesc: "invalid client_secret",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Look up authorization code
|
||||
authCode, err := h.store.GetAuthCode(code)
|
||||
if err != nil || authCode == nil {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "invalid_grant",
|
||||
ErrorDesc: "invalid or expired authorization code",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify code belongs to this client
|
||||
if authCode.ClientID != clientID {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "invalid_grant",
|
||||
ErrorDesc: "code was not issued to this client",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify redirect URI matches
|
||||
if authCode.RedirectURI != redirectURI {
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "invalid_grant",
|
||||
ErrorDesc: "redirect_uri mismatch",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete auth code (single use)
|
||||
h.store.DeleteAuthCode(code)
|
||||
|
||||
// Create access token
|
||||
accessToken, err := h.store.CreateAccessToken(authCode.Pubkey, clientID)
|
||||
if err != nil {
|
||||
log.Printf("failed to create access token: %v", err)
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
Error: "server_error",
|
||||
ErrorDesc: "failed to create token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate ID token (simple JWT-like structure for OIDC compatibility)
|
||||
idToken := generateIDToken(h.cfg.Server.BaseURL, clientID, authCode.Pubkey)
|
||||
|
||||
expiresIn := int(time.Until(accessToken.ExpiresAt).Seconds())
|
||||
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
AccessToken: accessToken.Token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: expiresIn,
|
||||
IDToken: idToken,
|
||||
})
|
||||
}
|
||||
|
||||
func extractClientCredentials(r *http.Request) (clientID, clientSecret string) {
|
||||
// Try Basic auth header first
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Basic ") {
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic "))
|
||||
if err == nil {
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// generateIDToken creates a simple ID token
|
||||
// In production, this should be a properly signed JWT
|
||||
func generateIDToken(issuer, audience, subject string) string {
|
||||
// Create a simple base64-encoded JSON token
|
||||
// In production, use proper JWT signing
|
||||
header := `{"alg":"none","typ":"JWT"}`
|
||||
|
||||
now := time.Now()
|
||||
payload := map[string]interface{}{
|
||||
"iss": issuer,
|
||||
"sub": subject,
|
||||
"aud": audience,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
"preferred_username": nostr.PubkeyToNpub(subject),
|
||||
}
|
||||
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString([]byte(header))
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
||||
|
||||
// Unsigned token (alg: none)
|
||||
return headerB64 + "." + payloadB64 + "."
|
||||
}
|
||||
92
internal/handler/userinfo.go
Normal file
92
internal/handler/userinfo.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.mleku.dev/mleku/gitea-nostr-auth/internal/nostr"
|
||||
)
|
||||
|
||||
type UserInfoResponse struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorDesc string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) UserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Extract Bearer token
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(UserInfoResponse{
|
||||
Error: "invalid_token",
|
||||
ErrorDesc: "missing or invalid Authorization header",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Look up access token
|
||||
accessToken, err := h.store.GetAccessToken(token)
|
||||
if err != nil || accessToken == nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(UserInfoResponse{
|
||||
Error: "invalid_token",
|
||||
ErrorDesc: "token is invalid or expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pubkey := accessToken.Pubkey
|
||||
npub := nostr.PubkeyToNpub(pubkey)
|
||||
|
||||
// Fetch profile from relays (this also fetches relay list first)
|
||||
log.Printf("Fetching profile for %s from relays...", nostr.TruncateNpub(npub))
|
||||
profile := h.fetcher.FetchProfile(r.Context(), pubkey)
|
||||
|
||||
// Build response with profile data or fallbacks
|
||||
response := UserInfoResponse{
|
||||
Sub: pubkey,
|
||||
}
|
||||
|
||||
if profile != nil {
|
||||
log.Printf("Got profile for %s: name=%s, nip05=%s", nostr.TruncateNpub(npub), profile.Name, profile.Nip05)
|
||||
|
||||
// Use profile data
|
||||
response.Name = profile.GetDisplayName()
|
||||
response.PreferredUsername = profile.GetUsername()
|
||||
response.Picture = profile.Picture
|
||||
response.Website = profile.Website
|
||||
response.Profile = profile.About
|
||||
|
||||
// Use NIP-05 as email if available (it's verified in the Nostr sense)
|
||||
if profile.Nip05 != "" {
|
||||
// NIP-05 format: name@domain.com or _@domain.com
|
||||
response.Email = profile.Nip05
|
||||
response.EmailVerified = false // We haven't verified it ourselves
|
||||
} else {
|
||||
response.Email = nostr.GeneratePlaceholderEmail(pubkey)
|
||||
}
|
||||
} else {
|
||||
log.Printf("No profile found for %s, using defaults", nostr.TruncateNpub(npub))
|
||||
|
||||
// Fallback to generated values
|
||||
response.PreferredUsername = nostr.GenerateUsername(pubkey)
|
||||
response.Name = nostr.TruncateNpub(npub)
|
||||
response.Email = nostr.GeneratePlaceholderEmail(pubkey)
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
134
internal/handler/verify.go
Normal file
134
internal/handler/verify.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
type VerifyRequest struct {
|
||||
ID string `json:"id"`
|
||||
PubKey string `json:"pubkey"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Kind int `json:"kind"`
|
||||
Tags [][]string `json:"tags"`
|
||||
Content string `json:"content"`
|
||||
Sig string `json:"sig"`
|
||||
}
|
||||
|
||||
type VerifyResponse struct {
|
||||
Success bool `json:"success"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var req VerifyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to go-nostr Event for verification
|
||||
event := nostr.Event{
|
||||
ID: req.ID,
|
||||
PubKey: req.PubKey,
|
||||
CreatedAt: nostr.Timestamp(req.CreatedAt),
|
||||
Kind: req.Kind,
|
||||
Tags: make(nostr.Tags, len(req.Tags)),
|
||||
Content: req.Content,
|
||||
Sig: req.Sig,
|
||||
}
|
||||
|
||||
for i, tag := range req.Tags {
|
||||
event.Tags[i] = tag
|
||||
}
|
||||
|
||||
// Verify event kind
|
||||
if event.Kind != 27235 {
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid event kind, expected 27235"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify timestamp is within window
|
||||
eventTime := time.Unix(int64(event.CreatedAt), 0)
|
||||
if time.Since(eventTime) > h.cfg.Nostr.ChallengeTTL {
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "event timestamp too old"})
|
||||
return
|
||||
}
|
||||
if eventTime.After(time.Now().Add(time.Minute)) {
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "event timestamp in future"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
ok, err := event.CheckSignature()
|
||||
if err != nil || !ok {
|
||||
log.Printf("signature verification failed: %v", err)
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid signature"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract challenge from tags
|
||||
var challengeNonce string
|
||||
for _, tag := range event.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "challenge" {
|
||||
challengeNonce = tag[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if challengeNonce == "" {
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "missing challenge tag"})
|
||||
return
|
||||
}
|
||||
|
||||
// Look up challenge
|
||||
challenge, err := h.store.GetChallenge(challengeNonce)
|
||||
if err != nil || challenge == nil {
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid or expired challenge"})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete challenge (single use)
|
||||
h.store.DeleteChallenge(challengeNonce)
|
||||
|
||||
// Create authorization code
|
||||
authCode, err := h.store.CreateAuthCode(
|
||||
challenge.ClientID,
|
||||
challenge.RedirectURI,
|
||||
event.PubKey,
|
||||
challenge.State,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("failed to create auth code: %v", err)
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build redirect URL
|
||||
redirectURL, err := url.Parse(challenge.RedirectURI)
|
||||
if err != nil {
|
||||
log.Printf("invalid redirect URI: %v", err)
|
||||
json.NewEncoder(w).Encode(VerifyResponse{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
q := redirectURL.Query()
|
||||
q.Set("code", authCode.Code)
|
||||
if challenge.State != "" {
|
||||
q.Set("state", challenge.State)
|
||||
}
|
||||
redirectURL.RawQuery = q.Encode()
|
||||
|
||||
json.NewEncoder(w).Encode(VerifyResponse{
|
||||
Success: true,
|
||||
RedirectURL: redirectURL.String(),
|
||||
})
|
||||
}
|
||||
323
internal/nostr/fetcher.go
Normal file
323
internal/nostr/fetcher.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
const (
|
||||
// FetchTimeout is how long to wait for relay responses
|
||||
FetchTimeout = 10 * time.Second
|
||||
// CacheTTL is how long to cache relay lists and profiles
|
||||
CacheTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Fetcher handles fetching relay lists and profiles from Nostr relays
|
||||
type Fetcher struct {
|
||||
fallbackRelays []string
|
||||
relayCache map[string]*relayListCacheEntry
|
||||
profileCache map[string]*profileCacheEntry
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type relayListCacheEntry struct {
|
||||
Relays []Nip65Relay
|
||||
FetchedAt time.Time
|
||||
}
|
||||
|
||||
type profileCacheEntry struct {
|
||||
Profile *ProfileMetadata
|
||||
FetchedAt time.Time
|
||||
}
|
||||
|
||||
// NewFetcher creates a new Fetcher with the given fallback relays
|
||||
func NewFetcher(fallbackRelays []string) *Fetcher {
|
||||
return &Fetcher{
|
||||
fallbackRelays: fallbackRelays,
|
||||
relayCache: make(map[string]*relayListCacheEntry),
|
||||
profileCache: make(map[string]*profileCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// FetchRelayList fetches a user's NIP-65 relay list (kind 10002)
|
||||
func (f *Fetcher) FetchRelayList(ctx context.Context, pubkey string) []Nip65Relay {
|
||||
// Check cache first
|
||||
f.mu.RLock()
|
||||
if entry, ok := f.relayCache[pubkey]; ok {
|
||||
if time.Since(entry.FetchedAt) < CacheTTL {
|
||||
f.mu.RUnlock()
|
||||
return entry.Relays
|
||||
}
|
||||
}
|
||||
f.mu.RUnlock()
|
||||
|
||||
// Fetch from relays
|
||||
relays := f.doFetchRelayList(ctx, pubkey)
|
||||
|
||||
// Cache result
|
||||
f.mu.Lock()
|
||||
f.relayCache[pubkey] = &relayListCacheEntry{
|
||||
Relays: relays,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
return relays
|
||||
}
|
||||
|
||||
func (f *Fetcher) doFetchRelayList(ctx context.Context, pubkey string) []Nip65Relay {
|
||||
ctx, cancel := context.WithTimeout(ctx, FetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
filter := nostr.Filter{
|
||||
Kinds: []int{10002},
|
||||
Authors: []string{pubkey},
|
||||
Limit: 10,
|
||||
}
|
||||
|
||||
events := f.queryRelays(ctx, f.fallbackRelays, filter)
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the most recent event
|
||||
var latest *nostr.Event
|
||||
for _, ev := range events {
|
||||
if latest == nil || ev.CreatedAt > latest.CreatedAt {
|
||||
latest = ev
|
||||
}
|
||||
}
|
||||
|
||||
// Parse relay tags
|
||||
var relays []Nip65Relay
|
||||
for _, tag := range latest.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "r" {
|
||||
relay := Nip65Relay{
|
||||
URL: tag[1],
|
||||
Read: true,
|
||||
Write: true,
|
||||
}
|
||||
// Check for read/write marker
|
||||
if len(tag) >= 3 {
|
||||
switch tag[2] {
|
||||
case "read":
|
||||
relay.Write = false
|
||||
case "write":
|
||||
relay.Read = false
|
||||
}
|
||||
}
|
||||
relays = append(relays, relay)
|
||||
}
|
||||
}
|
||||
|
||||
return relays
|
||||
}
|
||||
|
||||
// FetchProfile fetches a user's profile metadata (kind 0)
|
||||
// It first fetches the user's relay list, then queries those relays + fallbacks
|
||||
func (f *Fetcher) FetchProfile(ctx context.Context, pubkey string) *ProfileMetadata {
|
||||
// Check cache first
|
||||
f.mu.RLock()
|
||||
if entry, ok := f.profileCache[pubkey]; ok {
|
||||
if time.Since(entry.FetchedAt) < CacheTTL {
|
||||
f.mu.RUnlock()
|
||||
return entry.Profile
|
||||
}
|
||||
}
|
||||
f.mu.RUnlock()
|
||||
|
||||
// First, get the user's relay list
|
||||
userRelays := f.FetchRelayList(ctx, pubkey)
|
||||
|
||||
// Build relay list: user's read relays + fallbacks
|
||||
relayURLs := make([]string, 0, len(userRelays)+len(f.fallbackRelays))
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// Add user's read relays first (more likely to have their profile)
|
||||
for _, r := range userRelays {
|
||||
if r.Read && !seen[r.URL] {
|
||||
relayURLs = append(relayURLs, r.URL)
|
||||
seen[r.URL] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Add fallback relays
|
||||
for _, url := range f.fallbackRelays {
|
||||
if !seen[url] {
|
||||
relayURLs = append(relayURLs, url)
|
||||
seen[url] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch profile
|
||||
profile := f.doFetchProfile(ctx, pubkey, relayURLs)
|
||||
|
||||
// Cache result (even if nil)
|
||||
f.mu.Lock()
|
||||
f.profileCache[pubkey] = &profileCacheEntry{
|
||||
Profile: profile,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
func (f *Fetcher) doFetchProfile(ctx context.Context, pubkey string, relayURLs []string) *ProfileMetadata {
|
||||
ctx, cancel := context.WithTimeout(ctx, FetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
filter := nostr.Filter{
|
||||
Kinds: []int{0},
|
||||
Authors: []string{pubkey},
|
||||
Limit: 10,
|
||||
}
|
||||
|
||||
events := f.queryRelays(ctx, relayURLs, filter)
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the most recent event
|
||||
var latest *nostr.Event
|
||||
for _, ev := range events {
|
||||
if latest == nil || ev.CreatedAt > latest.CreatedAt {
|
||||
latest = ev
|
||||
}
|
||||
}
|
||||
|
||||
// Parse profile content
|
||||
var content map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(latest.Content), &content); err != nil {
|
||||
log.Printf("Failed to parse profile content for %s: %v", pubkey, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
profile := &ProfileMetadata{
|
||||
Pubkey: pubkey,
|
||||
}
|
||||
|
||||
if v, ok := content["name"].(string); ok {
|
||||
profile.Name = v
|
||||
}
|
||||
if v, ok := content["display_name"].(string); ok {
|
||||
profile.DisplayName = v
|
||||
}
|
||||
if v, ok := content["displayName"].(string); ok && profile.DisplayName == "" {
|
||||
profile.DisplayName = v
|
||||
}
|
||||
if v, ok := content["picture"].(string); ok {
|
||||
profile.Picture = v
|
||||
}
|
||||
if v, ok := content["banner"].(string); ok {
|
||||
profile.Banner = v
|
||||
}
|
||||
if v, ok := content["about"].(string); ok {
|
||||
profile.About = v
|
||||
}
|
||||
if v, ok := content["website"].(string); ok {
|
||||
profile.Website = v
|
||||
}
|
||||
if v, ok := content["nip05"].(string); ok {
|
||||
profile.Nip05 = v
|
||||
}
|
||||
if v, ok := content["lud06"].(string); ok {
|
||||
profile.Lud06 = v
|
||||
}
|
||||
if v, ok := content["lud16"].(string); ok {
|
||||
profile.Lud16 = v
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
// queryRelays queries multiple relays and collects events
|
||||
func (f *Fetcher) queryRelays(ctx context.Context, relayURLs []string, filter nostr.Filter) []*nostr.Event {
|
||||
var (
|
||||
events []*nostr.Event
|
||||
eventsMu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
// Query each relay concurrently
|
||||
for _, url := range relayURLs {
|
||||
wg.Add(1)
|
||||
go func(relayURL string) {
|
||||
defer wg.Done()
|
||||
|
||||
relay, err := nostr.RelayConnect(ctx, relayURL)
|
||||
if err != nil {
|
||||
// Silently skip failed relays
|
||||
return
|
||||
}
|
||||
defer relay.Close()
|
||||
|
||||
sub, err := relay.Subscribe(ctx, []nostr.Filter{filter})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer sub.Unsub()
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-sub.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
eventsMu.Lock()
|
||||
events = append(events, ev)
|
||||
eventsMu.Unlock()
|
||||
case <-sub.EndOfStoredEvents:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}(url)
|
||||
}
|
||||
|
||||
// Wait for all queries to complete or timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
// GetCachedProfile returns a cached profile if available and not expired
|
||||
func (f *Fetcher) GetCachedProfile(pubkey string) *ProfileMetadata {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
|
||||
if entry, ok := f.profileCache[pubkey]; ok {
|
||||
if time.Since(entry.FetchedAt) < CacheTTL {
|
||||
return entry.Profile
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCachedRelayList returns a cached relay list if available and not expired
|
||||
func (f *Fetcher) GetCachedRelayList(pubkey string) []Nip65Relay {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
|
||||
if entry, ok := f.relayCache[pubkey]; ok {
|
||||
if time.Since(entry.FetchedAt) < CacheTTL {
|
||||
return entry.Relays
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
55
internal/nostr/pubkey.go
Normal file
55
internal/nostr/pubkey.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"github.com/nbd-wtf/go-nostr/nip19"
|
||||
)
|
||||
|
||||
// PubkeyToNpub converts a hex public key to bech32 npub format
|
||||
func PubkeyToNpub(hexPubkey string) string {
|
||||
npub, err := nip19.EncodePublicKey(hexPubkey)
|
||||
if err != nil {
|
||||
// If encoding fails, return truncated hex
|
||||
if len(hexPubkey) > 16 {
|
||||
return hexPubkey[:16] + "..."
|
||||
}
|
||||
return hexPubkey
|
||||
}
|
||||
return npub
|
||||
}
|
||||
|
||||
// NpubToPubkey converts a bech32 npub to hex public key
|
||||
func NpubToPubkey(npub string) (string, error) {
|
||||
prefix, data, err := nip19.Decode(npub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if prefix != "npub" {
|
||||
return "", err
|
||||
}
|
||||
return data.(string), nil
|
||||
}
|
||||
|
||||
// TruncateNpub returns a shortened npub for display
|
||||
func TruncateNpub(npub string) string {
|
||||
if len(npub) <= 20 {
|
||||
return npub
|
||||
}
|
||||
return npub[:12] + "..." + npub[len(npub)-8:]
|
||||
}
|
||||
|
||||
// GenerateUsername creates a username from a pubkey
|
||||
// Prefers NIP-05 identifier if available, otherwise uses npub prefix
|
||||
func GenerateUsername(hexPubkey string) string {
|
||||
npub := PubkeyToNpub(hexPubkey)
|
||||
// Use first 12 chars of npub (npub1 + 7 chars)
|
||||
if len(npub) > 12 {
|
||||
return npub[:12]
|
||||
}
|
||||
return npub
|
||||
}
|
||||
|
||||
// GeneratePlaceholderEmail creates a placeholder email for Gitea
|
||||
func GeneratePlaceholderEmail(hexPubkey string) string {
|
||||
username := GenerateUsername(hexPubkey)
|
||||
return username + "@nostr.local"
|
||||
}
|
||||
51
internal/nostr/relays.go
Normal file
51
internal/nostr/relays.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package nostr
|
||||
|
||||
// Nip65Relay represents a relay from a user's NIP-65 relay list
|
||||
type Nip65Relay struct {
|
||||
URL string `json:"url"`
|
||||
Read bool `json:"read"`
|
||||
Write bool `json:"write"`
|
||||
}
|
||||
|
||||
// ProfileMetadata represents parsed kind 0 profile data
|
||||
type ProfileMetadata struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name string `json:"name,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
Banner string `json:"banner,omitempty"`
|
||||
About string `json:"about,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Nip05 string `json:"nip05,omitempty"`
|
||||
Lud06 string `json:"lud06,omitempty"`
|
||||
Lud16 string `json:"lud16,omitempty"`
|
||||
}
|
||||
|
||||
// GetUsername returns the best username from profile metadata
|
||||
func (p *ProfileMetadata) GetUsername() string {
|
||||
// Prefer NIP-05 identifier (without domain for uniqueness)
|
||||
if p.Nip05 != "" {
|
||||
return p.Nip05
|
||||
}
|
||||
// Then name
|
||||
if p.Name != "" {
|
||||
return p.Name
|
||||
}
|
||||
// Then display_name
|
||||
if p.DisplayName != "" {
|
||||
return p.DisplayName
|
||||
}
|
||||
// Fallback to truncated npub
|
||||
return GenerateUsername(p.Pubkey)
|
||||
}
|
||||
|
||||
// GetDisplayName returns the best display name
|
||||
func (p *ProfileMetadata) GetDisplayName() string {
|
||||
if p.DisplayName != "" {
|
||||
return p.DisplayName
|
||||
}
|
||||
if p.Name != "" {
|
||||
return p.Name
|
||||
}
|
||||
return TruncateNpub(PubkeyToNpub(p.Pubkey))
|
||||
}
|
||||
218
internal/oauth2/store.go
Normal file
218
internal/oauth2/store.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthCode represents an OAuth2 authorization code
|
||||
type AuthCode struct {
|
||||
Code string
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
Pubkey string // Nostr public key (hex)
|
||||
State string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Challenge represents a Nostr authentication challenge
|
||||
type Challenge struct {
|
||||
Nonce string
|
||||
ClientID string
|
||||
State string
|
||||
RedirectURI string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// AccessToken represents an issued access token
|
||||
type AccessToken struct {
|
||||
Token string
|
||||
Pubkey string
|
||||
ClientID string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Store interface for OAuth2 data persistence
|
||||
type Store interface {
|
||||
// Challenge operations
|
||||
CreateChallenge(clientID, state, redirectURI string, ttl time.Duration) (*Challenge, error)
|
||||
GetChallenge(nonce string) (*Challenge, error)
|
||||
DeleteChallenge(nonce string) error
|
||||
|
||||
// Auth code operations
|
||||
CreateAuthCode(clientID, redirectURI, pubkey, state string) (*AuthCode, error)
|
||||
GetAuthCode(code string) (*AuthCode, error)
|
||||
DeleteAuthCode(code string) error
|
||||
|
||||
// Access token operations
|
||||
CreateAccessToken(pubkey, clientID string) (*AccessToken, error)
|
||||
GetAccessToken(token string) (*AccessToken, error)
|
||||
}
|
||||
|
||||
// MemoryStore is an in-memory implementation of Store
|
||||
type MemoryStore struct {
|
||||
challenges map[string]*Challenge
|
||||
authCodes map[string]*AuthCode
|
||||
accessTokens map[string]*AccessToken
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
s := &MemoryStore{
|
||||
challenges: make(map[string]*Challenge),
|
||||
authCodes: make(map[string]*AuthCode),
|
||||
accessTokens: make(map[string]*AccessToken),
|
||||
}
|
||||
go s.cleanup()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *MemoryStore) cleanup() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
for range ticker.C {
|
||||
s.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, v := range s.challenges {
|
||||
if now.After(v.ExpiresAt) {
|
||||
delete(s.challenges, k)
|
||||
}
|
||||
}
|
||||
for k, v := range s.authCodes {
|
||||
if now.After(v.ExpiresAt) {
|
||||
delete(s.authCodes, k)
|
||||
}
|
||||
}
|
||||
for k, v := range s.accessTokens {
|
||||
if now.After(v.ExpiresAt) {
|
||||
delete(s.accessTokens, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func generateToken(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) CreateChallenge(clientID, state, redirectURI string, ttl time.Duration) (*Challenge, error) {
|
||||
nonce, err := generateToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
challenge := &Challenge{
|
||||
Nonce: nonce,
|
||||
ClientID: clientID,
|
||||
State: state,
|
||||
RedirectURI: redirectURI,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.challenges[nonce] = challenge
|
||||
s.mu.Unlock()
|
||||
|
||||
return challenge, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetChallenge(nonce string) (*Challenge, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
challenge, ok := s.challenges[nonce]
|
||||
if !ok || time.Now().After(challenge.ExpiresAt) {
|
||||
return nil, nil
|
||||
}
|
||||
return challenge, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) DeleteChallenge(nonce string) error {
|
||||
s.mu.Lock()
|
||||
delete(s.challenges, nonce)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) CreateAuthCode(clientID, redirectURI, pubkey, state string) (*AuthCode, error) {
|
||||
code, err := generateToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authCode := &AuthCode{
|
||||
Code: code,
|
||||
ClientID: clientID,
|
||||
RedirectURI: redirectURI,
|
||||
Pubkey: pubkey,
|
||||
State: state,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.authCodes[code] = authCode
|
||||
s.mu.Unlock()
|
||||
|
||||
return authCode, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetAuthCode(code string) (*AuthCode, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
authCode, ok := s.authCodes[code]
|
||||
if !ok || time.Now().After(authCode.ExpiresAt) {
|
||||
return nil, nil
|
||||
}
|
||||
return authCode, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) DeleteAuthCode(code string) error {
|
||||
s.mu.Lock()
|
||||
delete(s.authCodes, code)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) CreateAccessToken(pubkey, clientID string) (*AccessToken, error) {
|
||||
token, err := generateToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessToken := &AccessToken{
|
||||
Token: token,
|
||||
Pubkey: pubkey,
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.accessTokens[token] = accessToken
|
||||
s.mu.Unlock()
|
||||
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetAccessToken(token string) (*AccessToken, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
accessToken, ok := s.accessTokens[token]
|
||||
if !ok || time.Now().After(accessToken.ExpiresAt) {
|
||||
return nil, nil
|
||||
}
|
||||
return accessToken, nil
|
||||
}
|
||||
1
pkg/version/version
Normal file
1
pkg/version/version
Normal file
@@ -0,0 +1 @@
|
||||
v0.0.1
|
||||
Reference in New Issue
Block a user