mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-02 17:31:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de934e9384 | ||
|
|
6c6cec6326 | ||
|
|
f4c7b72b3e | ||
|
|
5dbcc455e8 | ||
|
|
181b10b6b2 | ||
|
|
4ddcd54558 | ||
|
|
f71193674b | ||
|
|
ad69f0ac80 | ||
|
|
28eba5a3f3 | ||
|
|
99167120dc | ||
|
|
93a5d8826d | ||
|
|
ae16bee0c1 | ||
|
|
6f93b63e4b | ||
|
|
3c682390a6 | ||
|
|
85304fd8b4 | ||
|
|
be4e4b8367 | ||
|
|
88ca486108 | ||
|
|
30635ba397 | ||
|
|
9db77a2bf4 | ||
|
|
5fbcd85420 | ||
|
|
9438cb2fff | ||
|
|
ab44cf15eb | ||
|
|
3769d11f9f | ||
|
|
c49fead1d8 | ||
|
|
2241ac470b | ||
|
|
cf1712c2d8 | ||
|
|
53a34171e8 | ||
|
|
e3b492f3b9 | ||
|
|
e3e6b4c918 | ||
|
|
782a93bae0 | ||
|
|
1c908fca36 | ||
|
|
30f6902e5f | ||
|
|
d4b45bb9d7 | ||
|
|
3442afd407 | ||
|
|
5e113a8066 | ||
|
|
81a33f4541 | ||
|
|
6bd50128fb | ||
|
|
6b4b1c42ed | ||
|
|
ebfa2b062a | ||
|
|
40eadc995e | ||
|
|
7570d67787 | ||
|
|
d19e0b676b | ||
|
|
08d4af2dc0 | ||
|
|
7f8034ae44 | ||
|
|
19ae52dd12 |
+11
-209
@@ -106,10 +106,8 @@ make testnet-stop
|
||||
### Building from Source
|
||||
|
||||
```bash
|
||||
# Build all binaries
|
||||
make build # snrd blockchain node
|
||||
make build-hway # Highway service
|
||||
make build-vault # Vault WASM plugin
|
||||
# Build snrd blockchain node
|
||||
make build
|
||||
|
||||
# Build Docker image
|
||||
make docker
|
||||
@@ -162,38 +160,11 @@ make ipfs-status # Check IPFS connectivity
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
Sonr consists of three primary components and three custom modules:
|
||||
|
||||
### Service Architecture
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Caddy │────▶│ snrd │ │ IPFS │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ ┌─────────┐ ┌─────────┐
|
||||
└─────────▶│ Highway │────▶│ Redis │
|
||||
└─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
- **Caddy**: Reverse proxy with gRPC-Web support (port 80)
|
||||
- **Redis**: Task queue backend for Highway (port 6379)
|
||||
- **Highway**: UCAN-based task processor (port 8090)
|
||||
- **IPFS**: Distributed storage (API: 5001, Gateway: 8080)
|
||||
- **snrd**: Blockchain node (gRPC: 9090, REST: 1317)
|
||||
|
||||
### Cross-Platform Support
|
||||
|
||||
The `localnet` target now automatically detects and uses the best available method:
|
||||
1. Checks for local binary (built with `make install`)
|
||||
2. Falls back to Docker if available
|
||||
3. Handles permission issues on systems like Arch Linux
|
||||
4. Supports systemd service installation (see `etc/systemd/`)
|
||||
Sonr is a Cosmos SDK-based blockchain with integrated IPFS storage and three custom modules:
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. **Blockchain Node (`snrd`)**
|
||||
#### **Blockchain Node (`snrd`)**
|
||||
|
||||
The main blockchain daemon built with Cosmos SDK v0.50.14, providing:
|
||||
|
||||
@@ -201,22 +172,7 @@ The main blockchain daemon built with Cosmos SDK v0.50.14, providing:
|
||||
- EVM compatibility via Evmos integration
|
||||
- IBC for cross-chain communication
|
||||
- CosmWasm smart contract support
|
||||
|
||||
#### 2. **Highway Service (`hway`)**
|
||||
|
||||
An Asynq-based task processing service for vault operations:
|
||||
|
||||
- Redis-backed job queue with priority levels
|
||||
- Actor-based concurrency using Proto.Actor framework
|
||||
- Processes cryptographic operations through WebAssembly enclaves
|
||||
|
||||
#### 3. **Motor Plugin (`motr`)**
|
||||
|
||||
WebAssembly-based vault system providing:
|
||||
|
||||
- Secure execution environment for sensitive operations
|
||||
- Hardware-backed key management
|
||||
- Multi-party computation capabilities
|
||||
- IPFS integration for decentralized storage
|
||||
|
||||
## 📖 Module Documentation
|
||||
|
||||
@@ -291,7 +247,7 @@ snrd tx svc register-service my-service example.com \
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Environment variables can be configured via Docker Compose:
|
||||
Environment variables can be configured for local development:
|
||||
|
||||
```bash
|
||||
# Chain configuration
|
||||
@@ -301,45 +257,12 @@ export BLOCK_TIME="1000ms"
|
||||
# Network selection for Starship
|
||||
export NETWORK="devnet" # or "testnet"
|
||||
|
||||
# Redis configuration
|
||||
REDIS_URL=redis://redis:6379
|
||||
REDIS_DB=0
|
||||
|
||||
# Highway service
|
||||
HIGHWAY_PORT=8090
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# IPFS configuration
|
||||
IPFS_API_URL=http://ipfs:5001
|
||||
|
||||
# Caddy configuration
|
||||
CADDY_DOMAIN=localhost
|
||||
IPFS_API_URL=http://127.0.0.1:5001
|
||||
```
|
||||
|
||||
Environment variables can be set directly or via a `.env` file in the project root.
|
||||
|
||||
### Docker Compose Services
|
||||
|
||||
The project includes a comprehensive Docker Compose setup for running all backend services:
|
||||
|
||||
```bash
|
||||
# Start all services (Redis, Highway, Caddy, IPFS)
|
||||
make docker-up
|
||||
|
||||
# Stop all services
|
||||
make docker-down
|
||||
|
||||
# View service logs
|
||||
make docker-logs # All services
|
||||
make docker-logs-redis # Specific service
|
||||
|
||||
# Check service health
|
||||
make docker-status
|
||||
|
||||
# Clean up Docker resources
|
||||
make docker-clean
|
||||
```
|
||||
|
||||
### Starship Configuration
|
||||
|
||||
Edit `starship.yml` to configure multi-node testnets:
|
||||
@@ -353,147 +276,26 @@ chains:
|
||||
# ... additional configuration
|
||||
```
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
**Services not starting:**
|
||||
```bash
|
||||
# Check service status
|
||||
make docker-status
|
||||
|
||||
# View detailed logs
|
||||
make docker-logs
|
||||
|
||||
# Ensure Docker network exists
|
||||
docker network ls | grep sonr-network
|
||||
```
|
||||
|
||||
**Redis connection issues:**
|
||||
```bash
|
||||
# Test Redis connectivity
|
||||
docker exec redis redis-cli ping
|
||||
|
||||
# Check Redis logs
|
||||
make docker-logs-redis
|
||||
```
|
||||
|
||||
**IPFS not accessible:**
|
||||
```bash
|
||||
# Verify IPFS is running
|
||||
curl http://127.0.0.1:5001/api/v0/version
|
||||
|
||||
# Check IPFS logs
|
||||
docker compose -f etc/stack/compose.yml logs ipfs
|
||||
```
|
||||
|
||||
**Port conflicts:**
|
||||
- Caddy: 80 (HTTP)
|
||||
- Redis: 6379
|
||||
- Highway: 8090
|
||||
- IPFS API: 5001
|
||||
- IPFS Gateway: 8080
|
||||
|
||||
Stop conflicting services or modify ports in `etc/stack/compose.yml`.
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
Sonr uses a modern monorepo architecture with pnpm workspaces for JavaScript/TypeScript packages alongside the Go blockchain implementation:
|
||||
|
||||
### Workspace Organization
|
||||
|
||||
```
|
||||
sonr/
|
||||
├── app/ # Application setup and module wiring
|
||||
├── cmd/ # Binary entry points
|
||||
│ ├── snrd/ # Blockchain node
|
||||
│ ├── hway/ # Highway service
|
||||
│ └── motr/ # Motor WASM plugin
|
||||
│ └── snrd/ # Blockchain node
|
||||
├── x/ # Custom chain modules
|
||||
│ ├── did/ # W3C DID implementation
|
||||
│ ├── dwn/ # Decentralized Web Nodes
|
||||
│ └── svc/ # Service management
|
||||
├── types/ # Internal packages
|
||||
│ ├── coins/ # Task processing
|
||||
│ └── ipfs/ # Authorization networks
|
||||
│ ├── coins/ # Coin utilities
|
||||
│ └── ipfs/ # IPFS integration
|
||||
├── proto/ # Protobuf definitions
|
||||
├── scripts/ # Utility scripts
|
||||
├── test/ # Integration tests
|
||||
├── docs/ # Documentation site
|
||||
│
|
||||
# Monorepo Packages (pnpm workspaces)
|
||||
├── packages/ # Core JavaScript/TypeScript packages
|
||||
│ ├── es/ # @sonr.io/es - ES client library
|
||||
│ ├── sdk/ # @sonr.io/sdk - SDK package
|
||||
│ └── ui/ # @sonr.io/ui - Shared UI components
|
||||
├── cli/ # CLI tools
|
||||
│ ├── install/ # @sonr.io/install - Installation CLI
|
||||
│ └── join-testnet/# @sonr.io/join-testnet - Testnet CLI
|
||||
└── web/ # Web applications
|
||||
├── auth/ # Authentication app (Next.js)
|
||||
└── dash/ # Dashboard app (Next.js)
|
||||
└── docs/ # Documentation site
|
||||
```
|
||||
|
||||
### Working with the Monorepo
|
||||
|
||||
#### Installation
|
||||
|
||||
```bash
|
||||
# Install all dependencies for the monorepo
|
||||
pnpm install
|
||||
|
||||
# Build all packages
|
||||
pnpm build
|
||||
|
||||
# Run development mode for all packages
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
#### Package Management
|
||||
|
||||
```bash
|
||||
# Run commands in specific packages
|
||||
pnpm --filter @sonr.io/es build
|
||||
pnpm --filter @sonr.io/sdk test
|
||||
|
||||
# Add dependencies to specific packages
|
||||
pnpm --filter @sonr.io/ui add react
|
||||
|
||||
# Run commands in all packages
|
||||
pnpm -r build # Build all packages
|
||||
pnpm -r test # Test all packages
|
||||
```
|
||||
|
||||
#### Versioning & Publishing
|
||||
|
||||
The monorepo uses [Changesets](https://github.com/changesets/changesets) for package versioning:
|
||||
|
||||
```bash
|
||||
# Add a changeset for your changes
|
||||
pnpm changeset
|
||||
|
||||
# Version packages based on changesets
|
||||
pnpm changeset version
|
||||
|
||||
# Publish packages to npm
|
||||
pnpm changeset publish
|
||||
```
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **pnpm workspaces**: Efficient dependency management with single lockfile
|
||||
- **TypeScript project references**: Incremental builds and better IDE performance
|
||||
- **Turbo**: Build orchestration and caching for faster builds
|
||||
- **Changesets**: Automated versioning and changelog generation
|
||||
- **Biome**: Fast, unified linting and formatting (replaces ESLint/Prettier)
|
||||
|
||||
### Package Dependencies
|
||||
|
||||
- `@sonr.io/es`: Core ES client library with protobuf types
|
||||
- `@sonr.io/sdk`: High-level SDK (depends on @sonr.io/es)
|
||||
- `@sonr.io/ui`: Shared UI components for web applications
|
||||
- `@sonr.io/install`: CLI tool for installing Sonr
|
||||
- `@sonr.io/join-testnet`: CLI tool for joining testnet
|
||||
- Web apps use all three core packages (@sonr.io/es, @sonr.io/sdk, @sonr.io/ui)
|
||||
|
||||
## 🤝 Community & Support
|
||||
|
||||
- [GitHub Discussions](https://github.com/sonr-io/sonr/discussions) - Community forum
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[scopes]
|
||||
ante = ["app/ante"]
|
||||
api = ["api/dex", "api/did", "api/dwn", "api/svc", "api"]
|
||||
app = ["app/context", "app/decorators", "app/params", "app/upgrades", "app"]
|
||||
chains = ["chains"]
|
||||
ci = ["Makefile", ".github"]
|
||||
cli = ["app/commands"]
|
||||
cmd = ["cmd/snrd", "cmd"]
|
||||
config = ["networks/localnet", "networks/testnet", "networks"]
|
||||
deps = ["go.sum", "go.mod"]
|
||||
dex = ["proto/dex", "x/dex"]
|
||||
did = ["proto/did", "x/did"]
|
||||
docs = ["docs"]
|
||||
dwn = ["proto/dwn", "x/dwn"]
|
||||
proto = ["proto"]
|
||||
scripts = ["scripts"]
|
||||
svc = ["proto/svc", "x/svc"]
|
||||
test = ["test/e2e", "test/integration", "test/oauth", "test"]
|
||||
+3
-78
@@ -1,6 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Go modules
|
||||
# Go modules (main)
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
@@ -14,88 +14,13 @@ updates:
|
||||
prefix: chore
|
||||
include: scope
|
||||
|
||||
# Go modules in subdirectories
|
||||
- package-ecosystem: gomod
|
||||
directory: "/cmd/hway"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "02:00"
|
||||
labels:
|
||||
- dependencies
|
||||
- hway
|
||||
commit-message:
|
||||
prefix: "chore(hway)"
|
||||
|
||||
- package-ecosystem: gomod
|
||||
directory: "/cmd/motr"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "02:00"
|
||||
labels:
|
||||
- dependencies
|
||||
- motr
|
||||
commit-message:
|
||||
prefix: "chore(motr)"
|
||||
|
||||
- package-ecosystem: gomod
|
||||
directory: "/cmd/vault"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "02:00"
|
||||
labels:
|
||||
- dependencies
|
||||
- vault
|
||||
commit-message:
|
||||
prefix: "chore(vault)"
|
||||
|
||||
- package-ecosystem: gomod
|
||||
directory: "/client"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "02:00"
|
||||
labels:
|
||||
- dependencies
|
||||
- client
|
||||
commit-message:
|
||||
prefix: "chore(client)"
|
||||
|
||||
- package-ecosystem: gomod
|
||||
directory: "/crypto"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "02:00"
|
||||
labels:
|
||||
- dependencies
|
||||
- crypto
|
||||
commit-message:
|
||||
prefix: "chore(crypto)"
|
||||
|
||||
# JavaScript/TypeScript packages
|
||||
- package-ecosystem: npm
|
||||
# GitHub Actions
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "03:00"
|
||||
labels:
|
||||
- dependencies
|
||||
- javascript
|
||||
commit-message:
|
||||
prefix: chore
|
||||
include: scope
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "04:00"
|
||||
labels:
|
||||
- dependencies
|
||||
- ci
|
||||
|
||||
@@ -1,583 +0,0 @@
|
||||
default-api: groq
|
||||
default-model: qwen3
|
||||
format-text:
|
||||
markdown: "Format the response as markdown without enclosing backticks."
|
||||
json: "Format the response as json without enclosing backticks."
|
||||
mcp-servers:
|
||||
github:
|
||||
command: docker
|
||||
args:
|
||||
- run
|
||||
- "-i"
|
||||
- "--rm"
|
||||
- "-e"
|
||||
- GITHUB_PERSONAL_ACCESS_TOKEN
|
||||
- "ghcr.io/github/github-mcp-server"
|
||||
ref:
|
||||
command: npx
|
||||
args:
|
||||
- ref-tools-mcp@latest
|
||||
mcp-timeout: 15s
|
||||
roles:
|
||||
"default": []
|
||||
"pr-writer":
|
||||
- You are a PR description writer
|
||||
- Create clear, structured PR descriptions
|
||||
- Include all relevant context from commits and changes
|
||||
- Format using markdown with proper sections
|
||||
- Reference issues and milestones appropriately
|
||||
- Be concise but comprehensive
|
||||
- Include a testing checklist if relevant
|
||||
"commit-writer":
|
||||
- You are a conventional commit message generator following commitizen format
|
||||
- Analyze the provided git diff and scope information to create a proper commit
|
||||
- Use the format type(scope) description
|
||||
- Valid types feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
||||
- The scope should be one of the affected scopes provided
|
||||
- Description should be concise, present tense, no period at the end
|
||||
- If there are breaking changes, add BREAKING CHANGE in the footer
|
||||
- Focus on WHAT changed and WHY, not HOW (the diff shows how)
|
||||
- Group related changes logically
|
||||
- Maximum 72 characters for the header line
|
||||
- Output ONLY the commit message, no explanations
|
||||
"commit-analyzer":
|
||||
- Analyze git diffs to determine the primary type of change
|
||||
- Identify if changes are features, fixes, refactoring, etc
|
||||
- Summarize the key changes in a structured format
|
||||
- Output a JSON object with type, scope, description, and body fields
|
||||
"branch-namer":
|
||||
- you are a branch namer
|
||||
- you do not explain anything
|
||||
- you read the provided issue and body then create a title for the branch
|
||||
- you do not provide any explanation whatsoever, ONLY the title
|
||||
- titles must be no more than 3 words (example = feature/add-new-feature)
|
||||
- ONLY use the following format - feat/<feature-name>
|
||||
"issue-creator":
|
||||
- you are an advanced issue creator for github in the sonr-io org
|
||||
- you are provided an input in the format of - <repo-name>:<issue-context>
|
||||
- you do not explain anything
|
||||
- you read the short provided context for a new issue and then create an appropriate issue body
|
||||
- ALWAYS find relevant context by using the `mcp::ref::ref_search_documentation` tool
|
||||
- ALWAYS link relevant issues by using the `mcp::github::search_issues` tool
|
||||
- ALWAYS create a new issue in the sonr-io/<repo-name> repo
|
||||
- ALWAYS assign the issue to @prnk28
|
||||
- |
|
||||
ONLY use the following format as this example:
|
||||
|
||||
Title: Implement x/dwn vaults plugin
|
||||
Labels: x/dwn, feature
|
||||
|
||||
## Requirements
|
||||
|
||||
- Must include DWN Bytes in Protobuf
|
||||
- Must Spawn DWN Plugins using Genesis Params
|
||||
- Must have Gas Free Query Methods for Spawn and Verify
|
||||
|
||||
## Context
|
||||
|
||||
### Affected Files
|
||||
|
||||
- @x/dwn/keeper/keeper.go
|
||||
- @x/dwn/client/wasm/main.go
|
||||
- @x/dwn/types/genesis.go
|
||||
|
||||
### Relevant Documentation
|
||||
|
||||
- [Example Doc 1](https://example.com)
|
||||
- [Example Doc 2](https://example.com)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Tests for Spawning Plugins from the x/dwn Genesis
|
||||
- [ ] Tests for Resolving Vault Secret Data from IPFS
|
||||
- [ ] Test for full-round Sign/Verify/Refresh on-chain
|
||||
- [ ] Grpc Gateway Compatibility for HTTP Requests
|
||||
- ALWAYS create issues using the `mcp::github::create_issue` tool
|
||||
- ALWAYS return the issue number after creating a new issue
|
||||
format: false
|
||||
role: "branch-namer"
|
||||
raw: true
|
||||
quiet: true
|
||||
temp: 1.0
|
||||
topp: 1.0
|
||||
topk: 50
|
||||
no-limit: true
|
||||
word-wrap: 80
|
||||
include-prompt-args: false
|
||||
include-prompt: 0
|
||||
max-retries: 5
|
||||
fanciness: 10
|
||||
status-text: Generating
|
||||
theme: base16
|
||||
max-input-chars: 12250
|
||||
max-completion-tokens: 100
|
||||
apis:
|
||||
openai:
|
||||
base-url: https://api.openai.com/v1
|
||||
api-key:
|
||||
api-key-env: OPENAI_API_KEY
|
||||
# api-key-cmd: rbw get -f OPENAI_API_KEY chat.openai.com
|
||||
models: # https://platform.openai.com/docs/models
|
||||
gpt-4.5-preview: #128k https://platform.openai.com/docs/models/gpt-4.5-preview
|
||||
aliases: ["gpt-4.5", "gpt4.5"]
|
||||
max-input-chars: 392000
|
||||
fallback: gpt-4
|
||||
gpt-4.5-preview-2025-02-27:
|
||||
max-input-chars: 392000
|
||||
fallback: gpt-4
|
||||
gpt-4o-mini:
|
||||
aliases: ["4o-mini"]
|
||||
max-input-chars: 392000
|
||||
fallback: gpt-4o
|
||||
gpt-4o:
|
||||
aliases: ["4o"]
|
||||
max-input-chars: 392000
|
||||
fallback: gpt-4
|
||||
gpt-4:
|
||||
aliases: ["4"]
|
||||
max-input-chars: 24500
|
||||
fallback: gpt-3.5-turbo
|
||||
gpt-4-1106-preview:
|
||||
aliases: ["128k"]
|
||||
max-input-chars: 392000
|
||||
fallback: gpt-4
|
||||
gpt-4-32k:
|
||||
aliases: ["32k"]
|
||||
max-input-chars: 98000
|
||||
fallback: gpt-4
|
||||
gpt-3.5-turbo:
|
||||
aliases: ["35t"]
|
||||
max-input-chars: 12250
|
||||
fallback: gpt-3.5
|
||||
gpt-3.5-turbo-1106:
|
||||
aliases: ["35t-1106"]
|
||||
max-input-chars: 12250
|
||||
fallback: gpt-3.5-turbo
|
||||
gpt-3.5-turbo-16k:
|
||||
aliases: ["35t16k"]
|
||||
max-input-chars: 44500
|
||||
fallback: gpt-3.5
|
||||
gpt-3.5:
|
||||
aliases: ["35"]
|
||||
max-input-chars: 12250
|
||||
fallback:
|
||||
o1:
|
||||
aliases: ["o1"]
|
||||
max-input-chars: 200000
|
||||
o1-preview:
|
||||
aliases: ["o1-preview"]
|
||||
max-input-chars: 128000
|
||||
o1-mini:
|
||||
aliases: ["o1-mini"]
|
||||
max-input-chars: 128000
|
||||
o3-mini:
|
||||
aliases: ["o3m", "o3-mini"]
|
||||
max-input-chars: 200000
|
||||
copilot:
|
||||
base-url: https://api.githubcopilot.com
|
||||
models:
|
||||
gpt-4o-2024-05-13:
|
||||
aliases: ["4o-2024", "4o", "gpt-4o"]
|
||||
max-input-chars: 392000
|
||||
gpt-4:
|
||||
aliases: ["4"]
|
||||
max-input-chars: 24500
|
||||
gpt-3.5-turbo:
|
||||
aliases: ["35t"]
|
||||
max-input-chars: 12250
|
||||
o1-preview-2024-09-12:
|
||||
aliases: ["o1-preview", "o1p"]
|
||||
max-input-chars: 128000
|
||||
claude-3.5-sonnet:
|
||||
aliases: ["claude3.5-sonnet", "sonnet-3.5", "claude-3-5-sonnet"]
|
||||
max-input-chars: 680000
|
||||
o1-preview:
|
||||
aliases: ["o1-preview"]
|
||||
max-input-chars: 128000
|
||||
o1-mini:
|
||||
aliases: ["o1-mini", "o1m", "o1-mini-2024-09-12"]
|
||||
max-input-chars: 128000
|
||||
o3-mini:
|
||||
aliases: ["o3m", "o3-mini"]
|
||||
max-input-chars: 128000
|
||||
gemini-2.0-flash-001:
|
||||
aliases: ["gm2f", "flash-2", "gemini-2-flash"]
|
||||
max-input-chars: 4194304
|
||||
anthropic:
|
||||
base-url: https://api.anthropic.com/v1
|
||||
api-key:
|
||||
api-key-env: ANTHROPIC_API_KEY
|
||||
models: # https://docs.anthropic.com/en/docs/about-claude/models
|
||||
claude-sonnet-4-20250514:
|
||||
aliases: ["claude-sonnet-4", "sonnet-4"]
|
||||
max-input-chars: 680000
|
||||
claude-3-7-sonnet-latest:
|
||||
aliases: ["claude3.7-sonnet", "claude-3-7-sonnet", "sonnet-3.7"]
|
||||
max-input-chars: 680000
|
||||
claude-3-7-sonnet-20250219:
|
||||
max-input-chars: 680000
|
||||
claude-3-5-sonnet-latest:
|
||||
aliases: ["claude3.5-sonnet", "claude-3-5-sonnet", "sonnet-3.5"]
|
||||
max-input-chars: 680000
|
||||
claude-3-5-sonnet-20241022:
|
||||
max-input-chars: 680000
|
||||
claude-3-5-sonnet-20240620:
|
||||
max-input-chars: 680000
|
||||
claude-3-opus-20240229:
|
||||
aliases: ["claude3-opus", "opus"]
|
||||
max-input-chars: 680000
|
||||
cohere:
|
||||
base-url: https://api.cohere.com/v1
|
||||
models:
|
||||
command-r-plus:
|
||||
max-input-chars: 128000
|
||||
command-r:
|
||||
max-input-chars: 128000
|
||||
google:
|
||||
models: # https://ai.google.dev/gemini-api/docs/models/gemini
|
||||
gemini-1.5-pro-latest:
|
||||
aliases: ["gmp", "gemini", "gemini-1.5-pro"]
|
||||
max-input-chars: 392000
|
||||
gemini-1.5-flash-latest:
|
||||
aliases: ["gmf", "flash", "gemini-1.5-flash"]
|
||||
max-input-chars: 392000
|
||||
gemini-2.0-flash-001:
|
||||
aliases: ["gm2f", "flash-2", "gemini-2-flash"]
|
||||
max-input-chars: 4194304
|
||||
gemini-2.0-flash-lite:
|
||||
aliases: ["gm2fl", "flash-2-lite", "gemini-2-flash-lite"]
|
||||
max-input-chars: 4194304
|
||||
ollama:
|
||||
base-url: http://localhost:11434
|
||||
models: # https://ollama.com/library
|
||||
"llama3.2:3b":
|
||||
aliases: ["llama3.2"]
|
||||
max-input-chars: 650000
|
||||
"llama3.2:1b":
|
||||
aliases: ["llama3.2_1b"]
|
||||
max-input-chars: 650000
|
||||
"llama3:70b":
|
||||
aliases: ["llama3"]
|
||||
max-input-chars: 650000
|
||||
perplexity:
|
||||
base-url: https://api.perplexity.ai
|
||||
api-key:
|
||||
api-key-env: PERPLEXITY_API_KEY
|
||||
models: # https://docs.perplexity.ai/guides/model-cards
|
||||
llama-3.1-sonar-small-128k-online:
|
||||
aliases: ["llam31-small"]
|
||||
max-input-chars: 127072
|
||||
llama-3.1-sonar-large-128k-online:
|
||||
aliases: ["llam31-large"]
|
||||
max-input-chars: 127072
|
||||
llama-3.1-sonar-huge-128k-online:
|
||||
aliases: ["llam31-huge"]
|
||||
max-input-chars: 127072
|
||||
groq:
|
||||
base-url: https://api.groq.com/openai/v1
|
||||
api-key:
|
||||
api-key-env: GROQ_API_KEY
|
||||
models: # https://console.groq.com/docs/models
|
||||
# Production models
|
||||
gemma2-9b-it:
|
||||
aliases: ["gemma2", "gemma"]
|
||||
max-input-chars: 24500 # 8,192
|
||||
llama-3.3-70b-versatile:
|
||||
aliases: ["llama3.3", "llama3.3-70b", "llama3.3-versatile"]
|
||||
max-input-chars: 392000 # 128K
|
||||
max-completion-tokens: 98000 # 32,768
|
||||
llama-3.1-8b-instant:
|
||||
aliases: ["llama3.1-8b", "llama3.1-instant"]
|
||||
max-input-chars: 392000 # 128K
|
||||
max-completion-tokens: 24500 # 8,192
|
||||
llama-guard-3-8b:
|
||||
aliases: ["llama-guard"]
|
||||
max-input-chars: 24500 # 8,192
|
||||
llama3-70b-8192:
|
||||
aliases: ["llama3", "llama3-70b"]
|
||||
max-input-chars: 24500 # 8,192
|
||||
fallback: llama3-8b-8192
|
||||
llama3-8b-8192:
|
||||
aliases: ["llama3-8b"]
|
||||
max-input-chars: 24500 # 8,192
|
||||
mixtral-8x7b-32768:
|
||||
aliases: ["mixtral"]
|
||||
max-input-chars: 98000 # 32,768
|
||||
meta-llama/llama-4-scout-17b-16e-instruct:
|
||||
aliases: ["llama4-scout"]
|
||||
max-input-chars: 392000 # 128K
|
||||
meta-llama/llama-4-maverick-17b-128e-instruct:
|
||||
aliases: ["llama4", "llama4-maverick"]
|
||||
max-input-chars: 392000 # 128K
|
||||
# Preview models
|
||||
mistral-saba-24b:
|
||||
aliases: ["saba", "mistral-saba", "saba-24b"]
|
||||
max-input-chars: 98000 # 32K
|
||||
qwen-2.5-coder-32b:
|
||||
aliases: ["qwen-coder", "qwen2.5-coder", "qwen-2.5-coder"]
|
||||
max-input-chars: 392000 # 128K
|
||||
qwen/qwen3-32b:
|
||||
aliases: ["qwen3", "qwen3-32b"]
|
||||
max-input-chars: 392000 # 128K
|
||||
deepseek-r1-distill-qwen-32b:
|
||||
aliases: ["deepseek-r1", "r1-qwen", "deepseek-qwen"]
|
||||
max-input-chars: 392000 # 128K
|
||||
max-completion-tokens: 49152 # 16,384
|
||||
deepseek-r1-distill-llama-70b-specdec:
|
||||
aliases: ["deepseek-r1-specdec", "r1-llama-specdec"]
|
||||
max-input-chars: 392000 # 128K
|
||||
max-completion-tokens: 49152 # 16,384
|
||||
deepseek-r1-distill-llama-70b:
|
||||
aliases: ["deepseek-r1-llama", "r1-llama"]
|
||||
max-input-chars: 392000 # 128K
|
||||
llama-3.3-70b-specdec:
|
||||
aliases: ["llama3.3-specdec"]
|
||||
max-input-chars: 24500 # 8,192
|
||||
llama-3.2-1b-preview:
|
||||
aliases: ["llama3.2-1b"]
|
||||
max-input-chars: 392000 # 128K
|
||||
max-completion-tokens: 24500 # 8,192
|
||||
llama-3.2-3b-preview:
|
||||
aliases: ["llama3.2-3b"]
|
||||
max-input-chars: 392000 # 128K
|
||||
max-completion-tokens: 24500 # 8,192
|
||||
llama-3.2-11b-vision-preview:
|
||||
aliases: ["llama3.2-vision", "llama3.2-11b-vision"]
|
||||
max-input-chars: 392000 # 128K
|
||||
max-completion-tokens: 24500 # 8,192
|
||||
llama-3.2-90b-vision-preview:
|
||||
aliases: ["llama3.2-90b-vision"]
|
||||
max-input-chars: 392000 # 128K
|
||||
max-completion-tokens: 24500 # 8,192
|
||||
cerebras:
|
||||
base-url: https://api.cerebras.ai/v1
|
||||
api-key:
|
||||
api-key-env: CEREBRAS_API_KEY
|
||||
models: # https://inference-docs.cerebras.ai/introduction
|
||||
llama3.1-8b:
|
||||
aliases: ["llama3.1-8b-cerebras"]
|
||||
max-input-chars: 24500
|
||||
llama3.1-70b:
|
||||
aliases: ["llama3.1-cerebras", "llama3.1-70b-cerebras"]
|
||||
max-input-chars: 24500
|
||||
sambanova:
|
||||
base-url: https://api.sambanova.ai/v1
|
||||
api-key:
|
||||
api-key-env: SAMBANOVA_API_KEY
|
||||
models: # https://docs.sambanova.ai/cloud/docs/get-started/supported-models
|
||||
# Preview models
|
||||
DeepSeek-R1:
|
||||
aliases: ["deepseek-r1-sambanova", "deepseek-r1-preview"]
|
||||
max-input-chars: 24500 # 8k tokens
|
||||
# Production models
|
||||
DeepSeek-R1-Distill-Llama-70B:
|
||||
aliases: ["deepseek-r1-llama-sambanova", "deepseek-r1-distill"]
|
||||
max-input-chars: 98000 # 32k tokens
|
||||
Llama-3.1-Tulu-3-405B:
|
||||
aliases: ["llama3.1-tulu", "tulu-405b"]
|
||||
max-input-chars: 49000 # 16k tokens
|
||||
Meta-Llama-3.3-70B-Instruct:
|
||||
aliases: ["llama3.3-sambanova", "llama3.3-70b-sambanova"]
|
||||
max-input-chars: 392000 # 128k tokens
|
||||
Meta-Llama-3.2-3B-Instruct:
|
||||
aliases: ["llama3.2-3b-sambanova"]
|
||||
max-input-chars: 24500 # 8k tokens
|
||||
Meta-Llama-3.2-1B-Instruct:
|
||||
aliases: ["llama3.2-1b-sambanova"]
|
||||
max-input-chars: 49000 # 16k tokens
|
||||
Meta-Llama-3.1-405B-Instruct:
|
||||
aliases: ["llama3.1-405b-sambanova"]
|
||||
max-input-chars: 49000 # 16k tokens
|
||||
Meta-Llama-3.1-70B-Instruct:
|
||||
aliases: ["llama3.1-70b-sambanova"]
|
||||
max-input-chars: 392000 # 128k tokens
|
||||
Meta-Llama-3.1-8B-Instruct:
|
||||
aliases: ["llama3.1-8b-sambanova"]
|
||||
max-input-chars: 49000 # 16k tokens
|
||||
Meta-Llama-Guard-3-8B:
|
||||
aliases: ["llama-guard-sambanova"]
|
||||
max-input-chars: 24500 # 8k tokens
|
||||
Llama-3.2-90B-Vision-Instruct:
|
||||
aliases: ["llama3.2-vision-90b", "llama3.2-90b-vision-sambanova"]
|
||||
max-input-chars: 12250 # 4k tokens
|
||||
Llama-3.2-11B-Vision-Instruct:
|
||||
aliases: ["llama3.2-vision-11b", "llama3.2-11b-vision-sambanova"]
|
||||
max-input-chars: 12250 # 4k tokens
|
||||
Qwen2.5-72B-Instruct:
|
||||
aliases: ["qwen2.5-sambanova", "qwen2.5-72b"]
|
||||
max-input-chars: 49000 # 16k tokens
|
||||
Qwen2.5-Coder-32B-Instruct:
|
||||
aliases: ["qwen2.5-coder-sambanova", "qwen-coder-sambanova"]
|
||||
max-input-chars: 49000 # 16k tokens
|
||||
QwQ-32B-Preview:
|
||||
aliases: ["qwq-sambanova", "qwq-32b"]
|
||||
max-input-chars: 49000 # 16k tokens
|
||||
localai:
|
||||
# LocalAI setup instructions: https://github.com/go-skynet/LocalAI#example-use-gpt4all-j-model
|
||||
base-url: http://localhost:8080
|
||||
models:
|
||||
ggml-gpt4all-j:
|
||||
aliases: ["local", "4all"]
|
||||
max-input-chars: 12250
|
||||
fallback:
|
||||
azure:
|
||||
# Set to 'azure-ad' to use Active Directory
|
||||
# Azure OpenAI setup: https://learn.microsoft.com/en-us/azure/cognitive-services/openai/how-to/create-resource
|
||||
base-url: https://YOUR_RESOURCE_NAME.openai.azure.com
|
||||
api-key:
|
||||
api-key-env: AZURE_OPENAI_KEY
|
||||
models:
|
||||
gpt-4:
|
||||
aliases: ["az4"]
|
||||
max-input-chars: 24500
|
||||
fallback: gpt-35-turbo
|
||||
gpt-35-turbo:
|
||||
aliases: ["az35t"]
|
||||
max-input-chars: 12250
|
||||
fallback: gpt-35
|
||||
gpt-35:
|
||||
aliases: ["az35"]
|
||||
max-input-chars: 12250
|
||||
fallback:
|
||||
o1-preview:
|
||||
aliases: ["o1-preview"]
|
||||
max-input-chars: 128000
|
||||
o1-mini:
|
||||
aliases: ["o1-mini"]
|
||||
max-input-chars: 128000
|
||||
runpod:
|
||||
# https://docs.runpod.io/serverless/workers/vllm/openai-compatibility
|
||||
base-url: https://api.runpod.ai/v2/${YOUR_ENDPOINT}/openai/v1
|
||||
api-key:
|
||||
api-key-env: RUNPOD_API_KEY
|
||||
models:
|
||||
openchat/openchat-3.5-1210:
|
||||
aliases: ["openchat"]
|
||||
max-input-chars: 8192
|
||||
mistral:
|
||||
base-url: https://api.mistral.ai/v1
|
||||
api-key:
|
||||
api-key-env: MISTRAL_API_KEY
|
||||
models: # https://docs.mistral.ai/getting-started/models/
|
||||
mistral-large-latest:
|
||||
aliases: ["mistral-large"]
|
||||
max-input-chars: 384000
|
||||
open-mistral-nemo:
|
||||
aliases: ["mistral-nemo"]
|
||||
max-input-chars: 384000
|
||||
deepseek:
|
||||
base-url: https://api.deepseek.com/
|
||||
api-key:
|
||||
api-key-env: DEEPSEEK_API_KEY
|
||||
models:
|
||||
deepseek-chat:
|
||||
aliases: ["chat"]
|
||||
max-input-chars: 384000
|
||||
deepseek-reasoner:
|
||||
aliases: ["r1"]
|
||||
max-input-chars: 384000
|
||||
github-models:
|
||||
base-url: https://models.github.ai/inference
|
||||
api-key:
|
||||
api-key-env: GITHUB_PERSONAL_ACCESS_TOKEN
|
||||
models:
|
||||
openai/gpt-4.1:
|
||||
max-input-chars: 392000
|
||||
openai/o3-mini:
|
||||
max-input-chars: 392000
|
||||
openai/o4-mini:
|
||||
max-input-chars: 392000
|
||||
openai/text-embedding-3-large:
|
||||
max-input-chars: 392000
|
||||
openai/text-embedding-3-small:
|
||||
max-input-chars: 392000
|
||||
ai21-labs/AI21-Jamba-1.5-Large:
|
||||
max-input-chars: 392000
|
||||
ai21-labs/AI21-Jamba-1.5-Mini:
|
||||
max-input-chars: 392000
|
||||
cohere/cohere-command-a:
|
||||
max-input-chars: 392000
|
||||
cohere/Cohere-command-r:
|
||||
max-input-chars: 392000
|
||||
cohere/Cohere-command-r-08-2024:
|
||||
max-input-chars: 392000
|
||||
cohere/Cohere-command-r-plus:
|
||||
max-input-chars: 392000
|
||||
cohere/Cohere-command-r-plus-08-2024:
|
||||
max-input-chars: 392000
|
||||
cohere/Cohere-embed-v3-english:
|
||||
max-input-chars: 392000
|
||||
cohere/Cohere-embed-v3-multilingual:
|
||||
max-input-chars: 392000
|
||||
core42/jais-30b-chat:
|
||||
max-input-chars: 392000
|
||||
deepseek/DeepSeek-R1:
|
||||
max-input-chars: 392000
|
||||
deepseek/DeepSeek-V3-0324:
|
||||
max-input-chars: 392000
|
||||
meta/Llama-3.2-11B-Vision-Instruct:
|
||||
max-input-chars: 392000
|
||||
meta/Llama-3.2-90B-Vision-Instruct:
|
||||
max-input-chars: 392000
|
||||
meta/Llama-3.3-70B-Instruct:
|
||||
max-input-chars: 392000
|
||||
meta/Llama-4-Maverick-17B-128E-Instruct-FP8:
|
||||
max-input-chars: 392000
|
||||
meta/Llama-4-Scout-17B-16E-Instruct:
|
||||
max-input-chars: 392000
|
||||
meta/Meta-Llama-3.1-405B-Instruct:
|
||||
max-input-chars: 392000
|
||||
meta/Meta-Llama-3.1-70B-Instruct:
|
||||
max-input-chars: 392000
|
||||
meta/Meta-Llama-3.1-8B-Instruct:
|
||||
max-input-chars: 392000
|
||||
meta/Meta-Llama-3-70B-Instruct:
|
||||
max-input-chars: 392000
|
||||
meta/Meta-Llama-3-8B-Instruct:
|
||||
max-input-chars: 392000
|
||||
mistral-ai/Codestral-2501:
|
||||
max-input-chars: 392000
|
||||
mistral-ai/Ministral-3B:
|
||||
max-input-chars: 392000
|
||||
mistral-ai/Mistral-Large-2411:
|
||||
max-input-chars: 392000
|
||||
mistral-ai/mistral-medium-2505:
|
||||
max-input-chars: 392000
|
||||
mistral-ai/Mistral-Nemo:
|
||||
max-input-chars: 392000
|
||||
mistral-ai/mistral-small-2503:
|
||||
max-input-chars: 392000
|
||||
xai/grok-3:
|
||||
max-input-chars: 392000
|
||||
xai/grok-3-mini:
|
||||
max-input-chars: 392000
|
||||
microsoft/MAI-DS-R1:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-3.5-mini-instruct:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-3.5-MoE-instruct:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-3.5-vision-instruct:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-3-medium-128k-instruct:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-3-medium-4k-instruct:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-3-mini-128k-instruct:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-3-mini-4k-instruct:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-3-small-128k-instruct:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-3-small-8k-instruct:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-4:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-4-mini-instruct:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-4-mini-reasoning:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-4-multimodal-instruct:
|
||||
max-input-chars: 392000
|
||||
microsoft/Phi-4-reasoning:
|
||||
max-input-chars: 392000
|
||||
@@ -0,0 +1,58 @@
|
||||
[
|
||||
{
|
||||
"scope": "deps",
|
||||
"path": "go.sum"
|
||||
},
|
||||
{
|
||||
"scope": "deps",
|
||||
"path": "go.mod"
|
||||
},
|
||||
{
|
||||
"scope": "mdx",
|
||||
"path": "docs"
|
||||
},
|
||||
{
|
||||
"scope": "ci",
|
||||
"path": "Makefile"
|
||||
},
|
||||
{
|
||||
"scope": "ci",
|
||||
"path": ".github"
|
||||
},
|
||||
{
|
||||
"scope": "dex",
|
||||
"path": "x/dex"
|
||||
},
|
||||
{
|
||||
"scope": "dex",
|
||||
"path": "proto/dex"
|
||||
},
|
||||
{
|
||||
"scope": "did",
|
||||
"path": "x/did"
|
||||
},
|
||||
{
|
||||
"scope": "did",
|
||||
"path": "proto/did"
|
||||
},
|
||||
{
|
||||
"scope": "dwn",
|
||||
"path": "x/dwn"
|
||||
},
|
||||
{
|
||||
"scope": "dwn",
|
||||
"path": "proto/dwn"
|
||||
},
|
||||
{
|
||||
"scope": "svc",
|
||||
"path": "x/svc"
|
||||
},
|
||||
{
|
||||
"scope": "svc",
|
||||
"path": "proto/svc"
|
||||
},
|
||||
{
|
||||
"scope": "config",
|
||||
"path": "networks"
|
||||
}
|
||||
]
|
||||
@@ -1,73 +0,0 @@
|
||||
- name: client
|
||||
include:
|
||||
- client/**
|
||||
- name: snrd
|
||||
include:
|
||||
- cmd/snrd/**
|
||||
- app/**
|
||||
- go.mod
|
||||
- go.sum
|
||||
- name: crypto
|
||||
include:
|
||||
- crypto/**
|
||||
- name: devops
|
||||
include:
|
||||
- .github/**
|
||||
- .devcontainer/**
|
||||
- scripts/**
|
||||
- etc/**
|
||||
- Dockerfile
|
||||
- "*-compose.yml"
|
||||
- README.md
|
||||
- name: docs
|
||||
include:
|
||||
- docs/**
|
||||
- name: hway
|
||||
include:
|
||||
- cmd/hway/**
|
||||
- bridge/**
|
||||
- types/**
|
||||
- name: dex
|
||||
include:
|
||||
- proto/dex/**
|
||||
- x/dex/**
|
||||
- name: did
|
||||
include:
|
||||
- proto/dex/**
|
||||
- x/did/**
|
||||
- name: dwn
|
||||
include:
|
||||
- proto/dex/**
|
||||
- x/dwn/**
|
||||
- name: svc
|
||||
include:
|
||||
- proto/dex/**
|
||||
- x/svc/**
|
||||
- name: motr
|
||||
include:
|
||||
- cmd/motr/**
|
||||
- name: vault
|
||||
include:
|
||||
- cmd/vault/**
|
||||
- name: com
|
||||
include:
|
||||
- packages/com/**
|
||||
- name: es
|
||||
include:
|
||||
- packages/es/**
|
||||
- name: pkl
|
||||
include:
|
||||
- packages/pkl/**
|
||||
- name: sdk
|
||||
include:
|
||||
- packages/sdk/**
|
||||
- name: ui
|
||||
include:
|
||||
- packages/ui/**
|
||||
- name: auth
|
||||
include:
|
||||
- web/auth/**
|
||||
- name: dash
|
||||
include:
|
||||
- web/dash/**
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
jobs:
|
||||
report:
|
||||
name: Report CI Status
|
||||
runs-on: builder
|
||||
runs-on: tenki-standard-autoscale
|
||||
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
|
||||
steps:
|
||||
- name: Report Failure
|
||||
|
||||
@@ -13,12 +13,12 @@ env:
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate & Test
|
||||
runs-on: builder # Self-hosted runner
|
||||
runs-on: tenki-standard-autoscale
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Required for github-env.sh comparisons
|
||||
fetch-depth: 0 # Required for github-env.sh comparisons
|
||||
# Cache can be local on self-hosted runner for better performance
|
||||
- name: Cache Dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -26,9 +26,8 @@ jobs:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
~/.local/share/pnpm/store
|
||||
~/.devbox
|
||||
key: deps-builder-${{ hashFiles('go.sum', 'pnpm-lock.yaml', 'devbox.json') }}
|
||||
key: deps-builder-${{ hashFiles('go.sum', 'devbox.json') }}
|
||||
restore-keys: deps-builder-
|
||||
|
||||
- name: Install Dependencies
|
||||
@@ -58,4 +57,4 @@ jobs:
|
||||
# Clean build artifacts to prevent disk fill
|
||||
make clean || true
|
||||
# Keep caches but remove build outputs
|
||||
rm -rf dist/ build/ || true
|
||||
rm -rf dist/ build/ || true
|
||||
+22
-37
@@ -11,6 +11,9 @@ Thumbs.db
|
||||
.logs/
|
||||
.venv/
|
||||
services/
|
||||
Taskfile*
|
||||
mprocs*
|
||||
.claude*
|
||||
|
||||
# ===== IDE & Editor Files =====
|
||||
.vscode/
|
||||
@@ -31,30 +34,22 @@ out/
|
||||
|
||||
# Go binaries (but allow docs references)
|
||||
snrd
|
||||
hway
|
||||
motr
|
||||
!cmd/motr/
|
||||
!cmd/hway/
|
||||
!cmd/snrd/
|
||||
!docs/**/motr
|
||||
!docs/**/hway
|
||||
|
||||
# Allow specific CLI binaries
|
||||
!cli/join-testnet/bin/
|
||||
!cli/install/bin/
|
||||
|
||||
# ===== Node.js & Frontend =====
|
||||
node_modules/
|
||||
.pnpm-debug.log*
|
||||
pnpm-debug.log*
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
*.tsbuildinfo
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
.vercel/
|
||||
**/.open-next
|
||||
# ===== Removed: Node.js & Frontend (no longer needed for blockchain-only repo) =====
|
||||
# node_modules/
|
||||
# .pnpm-debug.log*
|
||||
# pnpm-debug.log*
|
||||
# package-lock.json
|
||||
# yarn.lock
|
||||
# *.tsbuildinfo
|
||||
# .next/
|
||||
# .vercel/
|
||||
# **/.open-next
|
||||
|
||||
# ===== Go Development =====
|
||||
pkg/mod/
|
||||
@@ -73,30 +68,13 @@ artifacts/
|
||||
**/target/
|
||||
**/Cargo.lock
|
||||
|
||||
# Contract-specific
|
||||
contracts/DAO/deployment_ids.env
|
||||
contracts/DAO/deployment_report_*.md
|
||||
contracts/DAO/relayer.pid
|
||||
contracts/DAO/*.mnemonic
|
||||
contracts/DAO/tx_*.json
|
||||
contracts/DAO/signed_*.json
|
||||
contracts/DAO/init_*.json
|
||||
contracts/DAO/MAINNET_DEPLOYMENT_INSTRUCTIONS.md
|
||||
contracts/DAO/backups/
|
||||
contracts/wSNR/remappings.txt
|
||||
|
||||
# ===== Blockchain & Cosmos SDK =====
|
||||
.snrd/
|
||||
.hway/
|
||||
keyring-test/
|
||||
mytestnet/
|
||||
docker/testnet/testnet-data
|
||||
|
||||
# ===== Monorepo & Build Tools =====
|
||||
.turbo/
|
||||
.gitwork/
|
||||
.changeset/.cli-state
|
||||
.biome/
|
||||
# ===== Build Tools =====
|
||||
.devbox/
|
||||
.task*
|
||||
Taskfile.yml
|
||||
@@ -111,6 +89,7 @@ logs/
|
||||
configs/logs.json
|
||||
tmp/
|
||||
tmp*
|
||||
tmp-openapi-gen/
|
||||
|
||||
# ===== Environment & Secrets =====
|
||||
.env
|
||||
@@ -137,4 +116,10 @@ COMPLETION_SUMMARY.md
|
||||
data/
|
||||
compose/
|
||||
heighliner*
|
||||
**/*.swagger.yaml
|
||||
# Ignore individual module OpenAPI files (generated in tmp directory)
|
||||
tmp-openapi-gen/**/*.json
|
||||
# Allow the final combined openapi.yml
|
||||
!docs/static/openapi.yml
|
||||
.mcp.json
|
||||
node_modules
|
||||
.claude
|
||||
|
||||
+326
-2
@@ -1,5 +1,329 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
|
||||
---
|
||||
version: 2
|
||||
monorepo:
|
||||
tag_prefix: v
|
||||
dist: dist/snrd
|
||||
|
||||
project_name: snrd
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
|
||||
builds:
|
||||
# Darwin AMD64 Build
|
||||
- id: snrd-darwin-amd64
|
||||
main: ./cmd/snrd
|
||||
binary: snrd
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=o64-clang
|
||||
- CXX=o64-clang++
|
||||
- CGO_LDFLAGS=-lm
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
flags:
|
||||
- -mod=readonly
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -X github.com/cosmos/cosmos-sdk/version.Name=sonr
|
||||
- -X github.com/cosmos/cosmos-sdk/version.AppName=snrd
|
||||
- -X github.com/cosmos/cosmos-sdk/version.Version={{.Version}}
|
||||
- -X github.com/cosmos/cosmos-sdk/version.Commit={{.Commit}}
|
||||
- -X "github.com/cosmos/cosmos-sdk/version.BuildTags=netgo,ledger"
|
||||
- -s -w
|
||||
tags:
|
||||
- netgo
|
||||
- ledger
|
||||
|
||||
# Darwin ARM64 Build
|
||||
- id: snrd-darwin-arm64
|
||||
main: ./cmd/snrd
|
||||
binary: snrd
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=oa64-clang
|
||||
- CXX=oa64-clang++
|
||||
- CGO_LDFLAGS=-lm
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- arm64
|
||||
flags:
|
||||
- -mod=readonly
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -X github.com/cosmos/cosmos-sdk/version.Name=sonr
|
||||
- -X github.com/cosmos/cosmos-sdk/version.AppName=snrd
|
||||
- -X github.com/cosmos/cosmos-sdk/version.Version={{.Version}}
|
||||
- -X github.com/cosmos/cosmos-sdk/version.Commit={{.Commit}}
|
||||
- -X "github.com/cosmos/cosmos-sdk/version.BuildTags=netgo,ledger"
|
||||
- -s -w
|
||||
tags:
|
||||
- netgo
|
||||
- ledger
|
||||
|
||||
# Linux AMD64 Build
|
||||
- id: snrd-linux-amd64
|
||||
main: ./cmd/snrd
|
||||
binary: snrd
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=x86_64-linux-gnu-gcc
|
||||
- CXX=x86_64-linux-gnu-g++
|
||||
- CGO_LDFLAGS=-lm
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
goamd64:
|
||||
- v1
|
||||
flags:
|
||||
- -mod=readonly
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -X github.com/cosmos/cosmos-sdk/version.Name=sonr
|
||||
- -X github.com/cosmos/cosmos-sdk/version.AppName=snrd
|
||||
- -X github.com/cosmos/cosmos-sdk/version.Version={{.Version}}
|
||||
- -X github.com/cosmos/cosmos-sdk/version.Commit={{.Commit}}
|
||||
- -X "github.com/cosmos/cosmos-sdk/version.BuildTags=netgo,ledger"
|
||||
- -s -w
|
||||
tags:
|
||||
- netgo
|
||||
- ledger
|
||||
|
||||
# Linux ARM64 Build
|
||||
- id: snrd-linux-arm64
|
||||
main: ./cmd/snrd
|
||||
binary: snrd
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=aarch64-linux-gnu-gcc
|
||||
- CXX=aarch64-linux-gnu-g++
|
||||
- CGO_LDFLAGS=-lm
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm64
|
||||
flags:
|
||||
- -mod=readonly
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -X github.com/cosmos/cosmos-sdk/version.Name=sonr
|
||||
- -X github.com/cosmos/cosmos-sdk/version.AppName=snrd
|
||||
- -X github.com/cosmos/cosmos-sdk/version.Version={{.Version}}
|
||||
- -X github.com/cosmos/cosmos-sdk/version.Commit={{.Commit}}
|
||||
- -X "github.com/cosmos/cosmos-sdk/version.BuildTags=netgo,ledger"
|
||||
- -s -w
|
||||
tags:
|
||||
- netgo
|
||||
- ledger
|
||||
|
||||
aur_sources:
|
||||
- name: snrd
|
||||
disable: true
|
||||
homepage: "https://sonr.io"
|
||||
description: "Sonr blockchain daemon - decentralized identity and data storage network"
|
||||
maintainers:
|
||||
- "Sonr <support@sonr.io>"
|
||||
license: "GPL-3.0"
|
||||
private_key: "{{ .Env.AUR_KEY }}"
|
||||
git_url: "ssh://[email protected]/snrd.git"
|
||||
skip_upload: auto
|
||||
provides:
|
||||
- snrd
|
||||
conflicts:
|
||||
- snrd-bin
|
||||
depends:
|
||||
- glibc
|
||||
makedepends:
|
||||
- go
|
||||
- git
|
||||
- make
|
||||
commit_msg_template: "Update to {{ .Tag }}"
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: "prad@sonr.io"
|
||||
prepare: |-
|
||||
cd "${pkgname}_${pkgver}"
|
||||
go mod download
|
||||
build: |-
|
||||
cd "${pkgname}_${pkgver}"
|
||||
export CGO_ENABLED=1
|
||||
export CGO_CPPFLAGS="${CPPFLAGS}"
|
||||
export CGO_CFLAGS="${CFLAGS}"
|
||||
export CGO_CXXFLAGS="${CXXFLAGS}"
|
||||
export CGO_LDFLAGS="${LDFLAGS}"
|
||||
export GOFLAGS="-buildmode=pie -trimpath -mod=readonly -modcacherw"
|
||||
go build \
|
||||
-ldflags="-w -s -buildid='' -linkmode=external \
|
||||
-X github.com/cosmos/cosmos-sdk/version.Name=sonr \
|
||||
-X github.com/cosmos/cosmos-sdk/version.AppName=snrd \
|
||||
-X github.com/cosmos/cosmos-sdk/version.Version=${pkgver} \
|
||||
-X 'github.com/cosmos/cosmos-sdk/version.BuildTags=netgo,ledger'" \
|
||||
-tags "netgo,ledger" \
|
||||
-o snrd ./cmd/snrd
|
||||
chmod +x ./snrd
|
||||
package: |-
|
||||
cd "${pkgname}_${pkgver}"
|
||||
|
||||
# bin
|
||||
install -Dm755 "./snrd" "${pkgdir}/usr/bin/snrd"
|
||||
|
||||
# license
|
||||
if [ -f "./LICENSE" ]; then
|
||||
install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/snrd/LICENSE"
|
||||
fi
|
||||
|
||||
# readme
|
||||
if [ -f "./README.md" ]; then
|
||||
install -Dm644 "./README.md" "${pkgdir}/usr/share/doc/snrd/README.md"
|
||||
fi
|
||||
|
||||
# config directory
|
||||
install -dm755 "${pkgdir}/etc/snrd"
|
||||
install -dm755 "${pkgdir}/var/lib/snrd"
|
||||
backup:
|
||||
- /etc/snrd/config.toml
|
||||
- /etc/snrd/app.toml
|
||||
|
||||
nix:
|
||||
- name: snrd
|
||||
ids:
|
||||
- snrd
|
||||
homepage: "https://sonr.io"
|
||||
description: "Sonr blockchain daemon - decentralized identity network"
|
||||
license: "gpl3"
|
||||
path: pkgs/snrd/default.nix
|
||||
commit_msg_template: "snrd: {{ .Tag }}"
|
||||
dependencies:
|
||||
- stdenv
|
||||
- glibc
|
||||
extra_install: |-
|
||||
wrapProgram $out/bin/snrd --prefix PATH : ${lib.makeBinPath [ glibc stdenv.cc.cc.lib ]}
|
||||
repository:
|
||||
owner: sonr-io
|
||||
name: nur
|
||||
branch: main
|
||||
token: "{{ .Env.GITHUB_TOKEN }}"
|
||||
|
||||
archives:
|
||||
- id: snrd
|
||||
ids:
|
||||
- snrd-linux-amd64
|
||||
- snrd-linux-arm64
|
||||
- snrd-darwin-amd64
|
||||
- snrd-darwin-arm64
|
||||
name_template: >-
|
||||
snrd_{{ .Os }}_{{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }}
|
||||
formats: ["tar.gz"]
|
||||
files:
|
||||
- src: README*
|
||||
wrap_in_directory: false
|
||||
|
||||
homebrew_casks:
|
||||
- name: snrd
|
||||
ids:
|
||||
- snrd
|
||||
homepage: "https://sonr.io"
|
||||
description: "Sonr blockchain daemon - decentralized identity and data storage network"
|
||||
commit_msg_template: "Brew cask update for {{ .ProjectName }} version {{ .Tag }}"
|
||||
directory: Casks
|
||||
repository:
|
||||
owner: sonr-io
|
||||
name: homebrew-tap
|
||||
branch: main
|
||||
token: "{{ .Env.GITHUB_TOKEN }}"
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: "prad@sonr.io"
|
||||
hooks:
|
||||
post:
|
||||
install: |
|
||||
if OS.mac?
|
||||
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/snrd"]
|
||||
end
|
||||
|
||||
nfpms:
|
||||
- id: snrd
|
||||
package_name: snrd
|
||||
ids:
|
||||
- snrd-linux-amd64
|
||||
- snrd-linux-arm64
|
||||
file_name_template: "snrd_{{ .Os }}_{{ .Arch }}{{ .ConventionalExtension }}"
|
||||
vendor: Sonr
|
||||
homepage: "https://sonr.io"
|
||||
maintainer: "Sonr <support@sonr.io>"
|
||||
description: "Sonr is a decentralized, permissionless, and censorship-resistant identity network."
|
||||
license: "GPL-3.0"
|
||||
formats:
|
||||
- rpm
|
||||
- deb
|
||||
- apk
|
||||
- archlinux
|
||||
contents:
|
||||
- src: README*
|
||||
dst: /usr/share/doc/snrd
|
||||
bindir: /usr/bin
|
||||
section: net
|
||||
priority: optional
|
||||
|
||||
blobs:
|
||||
- provider: s3
|
||||
endpoint: https://eb37925850388bca807b7fab964c12bb.r2.cloudflarestorage.com
|
||||
bucket: releases
|
||||
region: auto
|
||||
directory: "snrd/{{ .Tag }}"
|
||||
ids:
|
||||
- snrd
|
||||
|
||||
release:
|
||||
disable: false
|
||||
github:
|
||||
owner: sonr-io
|
||||
name: sonr
|
||||
name_template: "{{ .Tag }}"
|
||||
draft: false
|
||||
replace_existing_draft: false # Don't replace drafts
|
||||
replace_existing_artifacts: false # Append, don't replace
|
||||
mode: append # Explicitly set to append mode
|
||||
|
||||
checksum:
|
||||
name_template: "snrd_checksums.txt"
|
||||
|
||||
npms:
|
||||
- name: "@sonr.io/snrd"
|
||||
ids:
|
||||
- snrd
|
||||
description: "Sonr blockchain daemon - decentralized identity and data storage network"
|
||||
homepage: "https://sonr.io"
|
||||
license: "GPL-3.0"
|
||||
author: "Sonr <support@sonr.io>"
|
||||
repository: "https://github.com/sonr-io/sonr"
|
||||
bugs: "https://github.com/sonr-io/sonr/issues"
|
||||
keywords:
|
||||
- blockchain
|
||||
- cosmos
|
||||
- did
|
||||
- identity
|
||||
- web3
|
||||
- sonr
|
||||
access: public
|
||||
format: tar.gz
|
||||
|
||||
snapshot:
|
||||
version_template: "{{ .Branch }}-{{ .ShortCommit }}-dev"
|
||||
|
||||
# Changelog configuration
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^chore:"
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
.dockerignore
|
||||
.goreleaser.yml
|
||||
api
|
||||
contracts
|
||||
chains
|
||||
crypto
|
||||
proto
|
||||
test
|
||||
**/_test.go
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# Caddy reverse proxy configuration for Sonr services
|
||||
{
|
||||
# Global options
|
||||
admin off
|
||||
auto_https off
|
||||
# Enable debug mode for development
|
||||
debug
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
:80/health {
|
||||
respond "OK" 200
|
||||
}
|
||||
|
||||
# REST API proxy (Cosmos SDK)
|
||||
:80/api/* {
|
||||
reverse_proxy snrd:1317
|
||||
header Access-Control-Allow-Origin *
|
||||
header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
header Access-Control-Allow-Headers "Content-Type, Authorization"
|
||||
}
|
||||
|
||||
# gRPC-Web proxy
|
||||
:80/grpc/* {
|
||||
reverse_proxy h2c://snrd:9090
|
||||
header Access-Control-Allow-Origin *
|
||||
header Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
||||
header Access-Control-Allow-Headers "Content-Type, x-grpc-web, x-user-agent"
|
||||
}
|
||||
|
||||
# JSON-RPC proxy (EVM)
|
||||
:80/rpc {
|
||||
reverse_proxy snrd:8545
|
||||
header Access-Control-Allow-Origin *
|
||||
header Access-Control-Allow-Methods "POST, OPTIONS"
|
||||
header Access-Control-Allow-Headers "Content-Type"
|
||||
}
|
||||
|
||||
# WebSocket JSON-RPC proxy (EVM)
|
||||
:80/ws {
|
||||
reverse_proxy snrd:8546
|
||||
header Access-Control-Allow-Origin *
|
||||
}
|
||||
|
||||
# Highway service proxy
|
||||
:80/highway/* {
|
||||
reverse_proxy highway:8090
|
||||
header Access-Control-Allow-Origin *
|
||||
header Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
||||
header Access-Control-Allow-Headers "Content-Type, Authorization"
|
||||
}
|
||||
|
||||
# Production configuration with TLS (when CADDY_DOMAIN is set)
|
||||
{$CADDY_DOMAIN:localhost} {
|
||||
# TLS configuration for production
|
||||
@production {
|
||||
not host localhost
|
||||
}
|
||||
|
||||
tls @production {
|
||||
# Automatic HTTPS with Let's Encrypt
|
||||
}
|
||||
|
||||
# Same proxy rules as above
|
||||
handle /health {
|
||||
respond "OK" 200
|
||||
}
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy snrd:1317
|
||||
}
|
||||
|
||||
handle /grpc/* {
|
||||
reverse_proxy h2c://snrd:9090
|
||||
}
|
||||
|
||||
handle /rpc {
|
||||
reverse_proxy snrd:8545
|
||||
}
|
||||
|
||||
handle /ws {
|
||||
reverse_proxy snrd:8546
|
||||
}
|
||||
|
||||
handle /highway/* {
|
||||
reverse_proxy highway:8090
|
||||
}
|
||||
|
||||
# Default handler
|
||||
handle {
|
||||
respond "Sonr Network Gateway" 200
|
||||
}
|
||||
}
|
||||
+10
-86
@@ -1,5 +1,5 @@
|
||||
# Use build argument for base image to allow using pre-cached base
|
||||
ARG BASE_IMAGE=golang:1.24.7-alpine3.22
|
||||
ARG BASE_IMAGE=golang:1.25-alpine3.22
|
||||
|
||||
FROM ${BASE_IMAGE} AS builder
|
||||
SHELL ["/bin/sh", "-ecuxo", "pipefail"]
|
||||
@@ -16,7 +16,7 @@ RUN apk add --no-cache \
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
# Copy entire source code (including crypto module)
|
||||
# Copy entire source code
|
||||
COPY . .
|
||||
|
||||
# Fix git ownership issue
|
||||
@@ -26,7 +26,7 @@ RUN git config --global --add safe.directory /code
|
||||
RUN --mount=type=cache,target=/tmp/wasmvm \
|
||||
set -eux; \
|
||||
export ARCH=$(uname -m); \
|
||||
WASM_VERSION=$(go list -m all | grep github.com/CosmWasm/wasmvm | head -1 || echo ""); \
|
||||
WASM_VERSION=$(GOTOOLCHAIN=auto go list -m all | grep github.com/CosmWasm/wasmvm | head -1 || echo ""); \
|
||||
if [ ! -z "${WASM_VERSION}" ]; then \
|
||||
WASMVM_REPO=$(echo $WASM_VERSION | awk '{print $1}'); \
|
||||
WASMVM_VERS=$(echo $WASM_VERSION | awk '{print $2}'); \
|
||||
@@ -40,7 +40,7 @@ RUN --mount=type=cache,target=/tmp/wasmvm \
|
||||
# Download Go modules
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
go mod download
|
||||
GOTOOLCHAIN=auto go mod download
|
||||
|
||||
# Build binary with optimizations
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
@@ -50,7 +50,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
COMMIT=$(git log -1 --format='%H' 2>/dev/null || echo "unknown"); \
|
||||
LEDGER_ENABLED=false BUILD_TAGS=muslc LINK_STATICALLY=true \
|
||||
CGO_ENABLED=1 GOOS=linux \
|
||||
go build \
|
||||
GOTOOLCHAIN=auto go build \
|
||||
-mod=readonly \
|
||||
-tags "netgo,ledger,muslc" \
|
||||
-ldflags "-X github.com/cosmos/cosmos-sdk/version.Name=sonr \
|
||||
@@ -67,85 +67,6 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
echo "Ensuring binary is statically linked ..."; \
|
||||
(file /code/build/snrd | grep "statically linked")
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Highway service build stage
|
||||
FROM ${BASE_IMAGE} AS highway-builder
|
||||
SHELL ["/bin/sh", "-ecuxo", "pipefail"]
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates \
|
||||
build-base \
|
||||
git \
|
||||
linux-headers \
|
||||
bash
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
# Copy entire source code
|
||||
COPY . .
|
||||
|
||||
# Fix git ownership issue
|
||||
RUN git config --global --add safe.directory /code
|
||||
|
||||
# Download Go modules
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
go mod download
|
||||
|
||||
# Build Highway binary with optimizations
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
set -eux; \
|
||||
VERSION=$(git describe --tags --always 2>/dev/null || echo "dev"); \
|
||||
COMMIT=$(git log -1 --format='%H' 2>/dev/null || echo "unknown"); \
|
||||
CGO_ENABLED=1 GOOS=linux \
|
||||
go build \
|
||||
-mod=readonly \
|
||||
-tags "netgo" \
|
||||
-ldflags "-X main.Version=${VERSION} \
|
||||
-X main.Commit=${COMMIT} \
|
||||
-w -s -linkmode=external -extldflags '-static'" \
|
||||
-buildvcs=false \
|
||||
-trimpath \
|
||||
-o /code/build/hway \
|
||||
./cmd/hway; \
|
||||
file /code/build/hway; \
|
||||
echo "Ensuring binary is statically linked ..."; \
|
||||
(file /code/build/hway | grep "statically linked")
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Highway runtime image
|
||||
FROM alpine:3.17 AS highway
|
||||
|
||||
LABEL org.opencontainers.image.title="Sonr Highway Service"
|
||||
LABEL org.opencontainers.image.source="https://github.com/sonr-io/sonr"
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=highway-builder /code/build/hway /usr/bin/hway
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates wget
|
||||
|
||||
# Create non-root user
|
||||
RUN adduser -D -u 1000 highway
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /home/highway
|
||||
|
||||
# Switch to non-root user
|
||||
USER highway
|
||||
|
||||
# Health check endpoint
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --spider -q http://localhost:8090/health || exit 1
|
||||
|
||||
# Expose Highway port
|
||||
EXPOSE 8090
|
||||
|
||||
# Set default command
|
||||
ENTRYPOINT ["/usr/bin/hway"]
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Final minimal runtime image (default target for snrd)
|
||||
FROM alpine:3.17
|
||||
@@ -156,8 +77,11 @@ LABEL org.opencontainers.image.source="https://github.com/sonr-io/sonr"
|
||||
# Copy binary from build stage
|
||||
COPY --from=builder /code/build/snrd /usr/bin
|
||||
COPY --from=builder /lib/libwasmvm_muslc.a /lib/libwasmvm_muslc.a
|
||||
COPY --from=builder /code/scripts/test_node.sh /usr/bin/testnet
|
||||
RUN chmod +x /usr/bin/testnet
|
||||
|
||||
# Copy runtime scripts and make them executable
|
||||
COPY --from=builder /code/scripts/test_node.sh /usr/bin/devnet
|
||||
COPY --from=builder /code/scripts/testnet-setup.sh /usr/bin/testnet
|
||||
COPY --from=builder /code/scripts/lib/ /usr/local/lib/sonr-scripts/
|
||||
|
||||
# Set up dependencies
|
||||
ENV PACKAGES="curl make bash jq sed"
|
||||
|
||||
@@ -104,7 +104,7 @@ stop:
|
||||
########################################
|
||||
### Tools & dependencies
|
||||
########################################
|
||||
format: go-format ts-format
|
||||
format: go-format
|
||||
go-format:
|
||||
@gum log --level info "Formatting Go code with gofumpt and goimports..."
|
||||
@if command -v gofumpt > /dev/null; then \
|
||||
@@ -119,12 +119,7 @@ go-format:
|
||||
fi
|
||||
@gum log --level info "✅ Code formatted"
|
||||
|
||||
ts-format:
|
||||
@gum log --level info "Formatting all web applications..."
|
||||
@pnpm biome format --config-path=$(PWD)/biome.json .
|
||||
|
||||
|
||||
lint: go-lint ts-lint
|
||||
lint: go-lint
|
||||
|
||||
go-lint:
|
||||
@gum log --level info "Running golangci-lint..."
|
||||
@@ -137,11 +132,7 @@ go-lint:
|
||||
golangci-lint run --timeout=10m; \
|
||||
fi
|
||||
|
||||
ts-lint:
|
||||
@gum log --level info "Running Biome linter with auto-fix for TypeScript projects..."
|
||||
@pnpm biome lint --config-path=./biome.json --write .
|
||||
|
||||
.PHONY: lint go-lint ts-lint format
|
||||
.PHONY: lint go-lint format
|
||||
|
||||
go-mod-cache: go.sum
|
||||
@gum log --level info "Download go modules to local cache"
|
||||
@@ -152,10 +143,6 @@ go.sum: go.mod
|
||||
@go mod tidy
|
||||
@go mod verify
|
||||
|
||||
pnpm-install:
|
||||
@gum log --level info "Installing pnpm dependencies"
|
||||
@pnpm install
|
||||
|
||||
draw-deps:
|
||||
@# requires brew install graphviz or apt-get install graphviz
|
||||
go install github.com/RobotsAndPencils/goviz@latest
|
||||
@@ -165,18 +152,11 @@ draw-deps:
|
||||
tidy:
|
||||
@go mod tidy
|
||||
@make -C client tidy
|
||||
@make -C crypto tidy
|
||||
@make -C cmd/hway tidy
|
||||
@make -C cmd/motr tidy
|
||||
@make -C cmd/vault tidy
|
||||
|
||||
clean: tidy
|
||||
@gum log --level info "Cleaning build artifacts..."
|
||||
rm -rf snapcraft-local.yaml build/ dist/
|
||||
@$(MAKE) -C cmd/snrd clean
|
||||
@$(MAKE) -C cmd/hway clean
|
||||
@$(MAKE) -C cmd/vault clean
|
||||
@$(MAKE) -C cmd/motr clean
|
||||
|
||||
clean-docker:
|
||||
@gum log --level info "Removing all Docker volumes and networks..."
|
||||
@@ -197,28 +177,19 @@ build: go.sum
|
||||
|
||||
build-snrd: build
|
||||
|
||||
build-client: go.sum build-vault build-motr
|
||||
build-client: go.sum
|
||||
@$(MAKE) -C client build
|
||||
@cd /tmp && go mod init test || true
|
||||
@cd /tmp && go get github.com/sonr-io/sonr/client@main || true
|
||||
@cd /tmp && gum log --level info "Client SDK import successful"
|
||||
|
||||
build-hway: go.sum build-vault build-motr
|
||||
@$(MAKE) -C cmd/hway build
|
||||
|
||||
build-vault:
|
||||
@$(MAKE) -C cmd/vault build
|
||||
|
||||
build-motr: ## Build Motor WASM service worker
|
||||
@$(MAKE) -C cmd/motr build
|
||||
|
||||
# Build all components in parallel
|
||||
build-all: go.sum
|
||||
@gum log --level info "Building all components in parallel..."
|
||||
@$(MAKE) -j5 build build-hway build-client build-motr build-vault
|
||||
@$(MAKE) -j2 build build-client
|
||||
@gum log --level info "✅ All components built successfully"
|
||||
|
||||
.PHONY: install build build-client build-hway build-snrd build-vault build-motr build-all
|
||||
.PHONY: install build build-client build-snrd build-all
|
||||
|
||||
########################################
|
||||
### Docker & Services
|
||||
@@ -226,17 +197,20 @@ build-all: go.sum
|
||||
|
||||
docker:
|
||||
@gum log --level info "Building Docker images..."
|
||||
@bash scripts/containers.sh build-all
|
||||
@docker buildx build -t onsonr/snrd:latest -f Dockerfile .
|
||||
|
||||
localnet: ## Cross-platform localnet (auto-detects best method for your system)
|
||||
@bash scripts/cross_platform_localnet.sh
|
||||
localnet: ## Start single-node localnet with Docker (cleans HOME_DIR first)
|
||||
@gum log --level info "Cleaning HOME_DIR (~/.sonr)..."
|
||||
@chmod -R u+rwX $(HOME)/.sonr 2>/dev/null || true
|
||||
@rm -rf $(HOME)/.sonr 2>/dev/null || true
|
||||
@$(MAKE) dockernet
|
||||
|
||||
dockernet:
|
||||
@gum log --level info "Starting network with Docker in detached mode..."
|
||||
@docker stop sonr-testnode 2>/dev/null || true
|
||||
@docker rm sonr-testnode 2>/dev/null || true
|
||||
@sleep 3
|
||||
@CHAIN_ID="sonrtest_1-1" BLOCK_TIME="1000ms" CLEAN=true FORCE_DOCKER=true DOCKER_DETACHED=true bash scripts/test_node.sh
|
||||
@HOME_DIR=$(HOME)/.sonr CHAIN_ID="sonrtest_1-1" BLOCK_TIME="1000ms" CLEAN=true FORCE_DOCKER=true DOCKER_DETACHED=true bash scripts/test_node.sh
|
||||
|
||||
.PHONY: docker localnet dockernet
|
||||
|
||||
@@ -245,18 +219,12 @@ dockernet:
|
||||
########################################
|
||||
# Smart component release detection and automation
|
||||
release:
|
||||
@$(MAKE) -C cmd/hway release
|
||||
@$(MAKE) -C cmd/motr release
|
||||
@$(MAKE) -C cmd/snrd release
|
||||
@$(MAKE) -C cmd/vault release
|
||||
|
||||
# Snapshot builds for development
|
||||
snapshot:
|
||||
@gum log --level info "📦 Preparing component snapshot..."
|
||||
@$(MAKE) -C cmd/vault snapshot
|
||||
@$(MAKE) -C cmd/motr snapshot
|
||||
@$(MAKE) -C cmd/snrd snapshot
|
||||
@$(MAKE) -C cmd/hway snapshot
|
||||
|
||||
.PHONY: release snapshot
|
||||
|
||||
@@ -285,36 +253,40 @@ test-e2e-all:
|
||||
@gum log --level info "Running all e2e tests"
|
||||
@cd test/e2e && go test -race -v ./tests/...
|
||||
|
||||
e2e-usdc-swap-did:
|
||||
@gum log --level info "Running USDC Swap E2E tests with DID integration"
|
||||
@gum log --level warn "Note: These tests require manual setup. See test/e2e/usdc-swap-did/README.md"
|
||||
@cd test/e2e/usdc-swap-did && make test
|
||||
|
||||
e2e-usdc-swap-did-setup:
|
||||
@gum log --level info "Running USDC Swap E2E test setup..."
|
||||
@cd test/e2e/usdc-swap-did && bash setup.sh
|
||||
|
||||
e2e-usdc-swap-did-verbose:
|
||||
@gum log --level info "Running USDC Swap E2E tests (verbose mode)"
|
||||
@cd test/e2e/usdc-swap-did && make test-verbose
|
||||
|
||||
test-build-snrd: build
|
||||
@ls -la build/snrd
|
||||
@chmod +x build/snrd
|
||||
@./build/snrd version
|
||||
|
||||
test-build-hway: build-hway
|
||||
@ls -la build/hway
|
||||
|
||||
test-tdd:
|
||||
go test -json ./... 2>&1 | tdd-guard-go -project-root ${GIT_ROOT}
|
||||
|
||||
test-app:
|
||||
@VERSION=$(VERSION) go test -C . -mod=readonly -tags='ledger test_ledger_mock test' github.com/sonr-io/sonr/app/... github.com/sonr-io/sonr/x/... github.com/sonr-io/sonr/types/... github.com/sonr-io/sonr/internal/...
|
||||
@VERSION=$(VERSION) CGO_LDFLAGS="-lm" go test -C . -mod=readonly -tags='ledger test_ledger_mock test' github.com/sonr-io/sonr/app/... github.com/sonr-io/sonr/x/... github.com/sonr-io/common/... github.com/sonr-io/sonr/internal/...
|
||||
|
||||
test-devops:
|
||||
@echo "No devops tests"
|
||||
|
||||
test-client:
|
||||
@$(MAKE) -C cmd/vault build
|
||||
@$(MAKE) -C client test
|
||||
|
||||
test-crypto:
|
||||
@$(MAKE) -C crypto test
|
||||
|
||||
test-dwn-ci:
|
||||
@$(MAKE) -C cmd/vault build
|
||||
@go test -mod=readonly -tags='ledger test_ledger_mock test' -run='!IPFS' ./x/dwn/...
|
||||
|
||||
test-internal:
|
||||
@$(MAKE) -C cmd/vault build
|
||||
@VERSION=$(VERSION) go test -mod=readonly -tags='ledger test_ledger_mock test' ./internal/...
|
||||
|
||||
# Module testing - Simplified
|
||||
@@ -340,28 +312,14 @@ test-module:
|
||||
VERSION=$(VERSION) go test -mod=readonly -tags='ledger test_ledger_mock test' ./x/$(MODULE)/...; \
|
||||
fi \
|
||||
fi
|
||||
|
||||
# Specialized tests
|
||||
test-hway:
|
||||
@gum log --level info "Testing Highway service..."
|
||||
@VERSION=$(VERSION) go test -C . -mod=readonly -v github.com/sonr-io/sonr/bridge/...
|
||||
|
||||
test-proto:
|
||||
@$(MAKE) -C proto lint
|
||||
@$(MAKE) -C proto check-breaking
|
||||
|
||||
test-motr:
|
||||
@gum log --level info "Testing Motor WASM service worker..."
|
||||
@$(MAKE) -C cmd/motr test
|
||||
|
||||
test-vault:
|
||||
@gum log --level info "Testing Vault WASM plugin..."
|
||||
@$(MAKE) -C cmd/vault test
|
||||
|
||||
test-benchmark:
|
||||
@go test -mod=readonly -bench=. ./...
|
||||
|
||||
.PHONY: test test-all test-unit test-race test-cover test-tdd test-module test-hway test-motr test-vault test-benchmark
|
||||
.PHONY: test test-all test-unit test-race test-cover test-tdd test-module test-benchmark test-e2e test-e2e-all e2e-usdc-swap-did e2e-usdc-swap-did-setup e2e-usdc-swap-did-verbose
|
||||
|
||||
###############################################################################
|
||||
### Protobuf ###
|
||||
@@ -373,41 +331,20 @@ climd-gen:
|
||||
proto-gen:
|
||||
@gum log --level info "Generating Go protobuf files..."
|
||||
@$(MAKE) -C proto gen
|
||||
@gum log --level info "Generating TypeScript protobuf files..."
|
||||
@if [ -d "packages/es" ]; then \
|
||||
cd packages/es && pnpm gen:protobufs || { gum log --level error "TypeScript protobuf generation failed"; exit 1; }; \
|
||||
else \
|
||||
gum log --level warn "Skipping TypeScript generation: packages/es not found"; \
|
||||
fi
|
||||
@gum log --level info "Auto-formatting generated protobuf files..."
|
||||
@$(MAKE) format
|
||||
|
||||
swagger-gen:
|
||||
@$(MAKE) -C proto swagger-gen
|
||||
@gum log --level info "Moving and renaming generated files..."
|
||||
@find docs/static/openapi -type f \( -name "query.swagger.yaml" -o -name "tx.swagger.yaml" \) | while read -r filepath; do \
|
||||
\
|
||||
parent_dir=$$(dirname "$$filepath"); \
|
||||
grandparent_dir=$$(dirname "$$parent_dir"); \
|
||||
module=$$(basename "$$grandparent_dir"); \
|
||||
filename=$$(basename "$$filepath"); \
|
||||
\
|
||||
new_filename="$$module.$$filename"; \
|
||||
destination="docs/static/openapi/$$new_filename"; \
|
||||
\
|
||||
gum log --level debug "Moving $$filepath to $$destination"; \
|
||||
mv "$$filepath" "$$destination"; \
|
||||
done
|
||||
@gum log --level info "Cleaning up empty source directories..."
|
||||
@find docs/static/openapi -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} +
|
||||
@gum log --level info "Converting Swagger 2.0 to OpenAPI 3.0..."
|
||||
@pnpm run convert-swagger
|
||||
@gum log --level info "✅ API documentation processing complete."
|
||||
openapi-gen:
|
||||
@$(MAKE) -C proto openapi-gen
|
||||
@gum log --level info "✅ OpenAPI generation complete. Output: docs/static/openapi.yml"
|
||||
|
||||
# Backwards compatibility alias
|
||||
swagger-gen: openapi-gen
|
||||
|
||||
templ-gen:
|
||||
@docker run --rm -v `pwd`:/code -w=/code --user $(shell id -u):$(shell id -g) ghcr.io/a-h/templ:latest generate
|
||||
|
||||
.PHONY: proto-gen proto-swagger-gen swagger-gen proto-lint proto-check-breaking proto-publish
|
||||
.PHONY: proto-gen proto-openapi-gen openapi-gen swagger-gen proto-lint proto-check-breaking proto-publish
|
||||
|
||||
###############################################################################
|
||||
### Network Operations ###
|
||||
@@ -444,14 +381,15 @@ help:
|
||||
@gum log --level info ""
|
||||
@gum log --level info "🛠️ Build & Install:"
|
||||
@gum log --level info " install Install snrd binary"
|
||||
@gum log --level info " build Build snrd binary with vault WASM"
|
||||
@gum log --level info " build Build snrd binary"
|
||||
@gum log --level info " build-all Build all components in parallel"
|
||||
@gum log --level info " build-hway Build Highway service"
|
||||
@gum log --level info " build-vault Build vault WASM module"
|
||||
@gum log --level info " build-motr Build Motor WASM service worker"
|
||||
@gum log --level info " build-client Build client SDK"
|
||||
@gum log --level info " docker Build Docker images"
|
||||
@gum log --level info ""
|
||||
@gum log --level info "📦 Release & Distribution:"
|
||||
@gum log --level info " release Create production release with GoReleaser"
|
||||
@gum log --level info " snapshot Create development snapshot builds"
|
||||
@gum log --level info ""
|
||||
@gum log --level info "🚀 Local Development:"
|
||||
@gum log --level info " localnet Start single-node testnet"
|
||||
@gum log --level info " start Start backend services"
|
||||
@@ -461,7 +399,7 @@ help:
|
||||
@gum log --level info ""
|
||||
@gum log --level info "📦 Code Generation:"
|
||||
@gum log --level info " proto-gen Generate protobuf code"
|
||||
@gum log --level info " swagger-gen Generate OpenAPI docs"
|
||||
@gum log --level info " openapi-gen Generate OpenAPI documentation"
|
||||
@gum log --level info ""
|
||||
@gum log --level info "🔧 Development Tools:"
|
||||
@gum log --level info " format Format code (Go + TypeScript)"
|
||||
@@ -476,10 +414,11 @@ help:
|
||||
@gum log --level info " test-e2e Run e2e tests"
|
||||
@gum log --level info " test-e2e-all Run all e2e tests"
|
||||
@gum log --level info " test-module Test specific module (MODULE=did|dwn|svc)"
|
||||
@gum log --level info " test-packages Test all packages (lint + build)"
|
||||
@gum log --level info " test-ipfs Test vault with IPFS export/import"
|
||||
@gum log --level info " test-vault Test vault operations"
|
||||
@gum log --level info " test-web Test all web apps (lint + build)"
|
||||
@gum log --level info ""
|
||||
@gum log --level info "🔄 E2E Test Suites:"
|
||||
@gum log --level info " e2e-usdc-swap-did Run USDC swap E2E tests"
|
||||
@gum log --level info " e2e-usdc-swap-did-setup Interactive setup for USDC swap tests"
|
||||
@gum log --level info " e2e-usdc-swap-did-verbose Run USDC swap tests with verbose output"
|
||||
@gum log --level info ""
|
||||
@gum log --level info "📚 Module Testing Examples:"
|
||||
@gum log --level info " make test-module MODULE=did # Test DID module"
|
||||
|
||||
@@ -106,10 +106,8 @@ make testnet-stop
|
||||
### Building from Source
|
||||
|
||||
```bash
|
||||
# Build all binaries
|
||||
make build # snrd blockchain node
|
||||
make build-hway # Highway service
|
||||
make build-vault # Vault WASM plugin
|
||||
# Build blockchain node
|
||||
make build
|
||||
|
||||
# Build Docker image
|
||||
make docker
|
||||
@@ -162,38 +160,11 @@ make ipfs-status # Check IPFS connectivity
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
Sonr consists of three primary components and three custom modules:
|
||||
|
||||
### Service Architecture
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Caddy │────▶│ snrd │ │ IPFS │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ ┌─────────┐ ┌─────────┐
|
||||
└─────────▶│ Highway │────▶│ Redis │
|
||||
└─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
- **Caddy**: Reverse proxy with gRPC-Web support (port 80)
|
||||
- **Redis**: Task queue backend for Highway (port 6379)
|
||||
- **Highway**: UCAN-based task processor (port 8090)
|
||||
- **IPFS**: Distributed storage (API: 5001, Gateway: 8080)
|
||||
- **snrd**: Blockchain node (gRPC: 9090, REST: 1317)
|
||||
|
||||
### Cross-Platform Support
|
||||
|
||||
The `localnet` target now automatically detects and uses the best available method:
|
||||
1. Checks for local binary (built with `make install`)
|
||||
2. Falls back to Docker if available
|
||||
3. Handles permission issues on systems like Arch Linux
|
||||
4. Supports systemd service installation (see `etc/systemd/`)
|
||||
Sonr is a Cosmos SDK-based blockchain with integrated IPFS storage and three custom modules:
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. **Blockchain Node (`snrd`)**
|
||||
#### **Blockchain Node (`snrd`)**
|
||||
|
||||
The main blockchain daemon built with Cosmos SDK v0.50.14, providing:
|
||||
|
||||
@@ -201,22 +172,15 @@ The main blockchain daemon built with Cosmos SDK v0.50.14, providing:
|
||||
- EVM compatibility via Evmos integration
|
||||
- IBC for cross-chain communication
|
||||
- CosmWasm smart contract support
|
||||
- IPFS integration for decentralized storage
|
||||
|
||||
#### 2. **Highway Service (`hway`)**
|
||||
### Cross-Platform Support
|
||||
|
||||
An Asynq-based task processing service for vault operations:
|
||||
|
||||
- Redis-backed job queue with priority levels
|
||||
- Actor-based concurrency using Proto.Actor framework
|
||||
- Processes cryptographic operations through WebAssembly enclaves
|
||||
|
||||
#### 3. **Motor Plugin (`motr`)**
|
||||
|
||||
WebAssembly-based vault system providing:
|
||||
|
||||
- Secure execution environment for sensitive operations
|
||||
- Hardware-backed key management
|
||||
- Multi-party computation capabilities
|
||||
The `localnet` target automatically detects and uses the best available method:
|
||||
1. Checks for local binary (built with `make install`)
|
||||
2. Falls back to Docker if available
|
||||
3. Handles permission issues on systems like Arch Linux
|
||||
4. Supports systemd service installation (see `etc/systemd/`)
|
||||
|
||||
## 📖 Module Documentation
|
||||
|
||||
@@ -301,45 +265,12 @@ export BLOCK_TIME="1000ms"
|
||||
# Network selection for Starship
|
||||
export NETWORK="devnet" # or "testnet"
|
||||
|
||||
# Redis configuration
|
||||
REDIS_URL=redis://redis:6379
|
||||
REDIS_DB=0
|
||||
|
||||
# Highway service
|
||||
HIGHWAY_PORT=8090
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# IPFS configuration
|
||||
IPFS_API_URL=http://ipfs:5001
|
||||
|
||||
# Caddy configuration
|
||||
CADDY_DOMAIN=localhost
|
||||
```
|
||||
|
||||
Environment variables can be set directly or via a `.env` file in the project root.
|
||||
|
||||
### Docker Compose Services
|
||||
|
||||
The project includes a comprehensive Docker Compose setup for running all backend services:
|
||||
|
||||
```bash
|
||||
# Start all services (Redis, Highway, Caddy, IPFS)
|
||||
make docker-up
|
||||
|
||||
# Stop all services
|
||||
make docker-down
|
||||
|
||||
# View service logs
|
||||
make docker-logs # All services
|
||||
make docker-logs-redis # Specific service
|
||||
|
||||
# Check service health
|
||||
make docker-status
|
||||
|
||||
# Clean up Docker resources
|
||||
make docker-clean
|
||||
```
|
||||
|
||||
### Starship Configuration
|
||||
|
||||
Edit `starship.yml` to configure multi-node testnets:
|
||||
@@ -353,146 +284,53 @@ chains:
|
||||
# ... additional configuration
|
||||
```
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
**Services not starting:**
|
||||
```bash
|
||||
# Check service status
|
||||
make docker-status
|
||||
|
||||
# View detailed logs
|
||||
make docker-logs
|
||||
|
||||
# Ensure Docker network exists
|
||||
docker network ls | grep sonr-network
|
||||
```
|
||||
|
||||
**Redis connection issues:**
|
||||
```bash
|
||||
# Test Redis connectivity
|
||||
docker exec redis redis-cli ping
|
||||
|
||||
# Check Redis logs
|
||||
make docker-logs-redis
|
||||
```
|
||||
### Troubleshooting
|
||||
|
||||
**IPFS not accessible:**
|
||||
```bash
|
||||
# Verify IPFS is running
|
||||
curl http://127.0.0.1:5001/api/v0/version
|
||||
|
||||
# Check IPFS logs
|
||||
docker compose -f etc/stack/compose.yml logs ipfs
|
||||
# Check IPFS status
|
||||
make ipfs-status
|
||||
```
|
||||
|
||||
**Port conflicts:**
|
||||
- Caddy: 80 (HTTP)
|
||||
- Redis: 6379
|
||||
- Highway: 8090
|
||||
- IPFS API: 5001
|
||||
- IPFS Gateway: 8080
|
||||
- Node gRPC: 9090
|
||||
- Node REST API: 1317
|
||||
|
||||
Stop conflicting services or modify ports in `etc/stack/compose.yml`.
|
||||
Stop conflicting services or modify ports in configuration files.
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
Sonr uses a modern monorepo architecture with pnpm workspaces for JavaScript/TypeScript packages alongside the Go blockchain implementation:
|
||||
|
||||
### Workspace Organization
|
||||
Sonr is a focused Cosmos SDK blockchain implementation:
|
||||
|
||||
```
|
||||
sonr/
|
||||
├── app/ # Application setup and module wiring
|
||||
├── cmd/ # Binary entry points
|
||||
│ ├── snrd/ # Blockchain node
|
||||
│ ├── hway/ # Highway service
|
||||
│ └── motr/ # Motor WASM plugin
|
||||
├── x/ # Custom chain modules
|
||||
│ └── snrd/ # Blockchain node daemon
|
||||
├── x/ # Custom Cosmos SDK modules
|
||||
│ ├── did/ # W3C DID implementation
|
||||
│ ├── dwn/ # Decentralized Web Nodes
|
||||
│ └── svc/ # Service management
|
||||
├── types/ # Internal packages
|
||||
│ ├── coins/ # Task processing
|
||||
│ └── ipfs/ # Authorization networks
|
||||
├── types/ # Internal Go packages
|
||||
├── proto/ # Protobuf definitions
|
||||
├── scripts/ # Utility scripts
|
||||
├── test/ # Integration tests
|
||||
├── docs/ # Documentation site
|
||||
│
|
||||
# Monorepo Packages (pnpm workspaces)
|
||||
├── packages/ # Core JavaScript/TypeScript packages
|
||||
│ ├── es/ # @sonr.io/es - ES client library
|
||||
│ ├── sdk/ # @sonr.io/sdk - SDK package
|
||||
│ └── ui/ # @sonr.io/ui - Shared UI components
|
||||
├── cli/ # CLI tools
|
||||
│ ├── install/ # @sonr.io/install - Installation CLI
|
||||
│ └── join-testnet/# @sonr.io/join-testnet - Testnet CLI
|
||||
└── web/ # Web applications
|
||||
├── auth/ # Authentication app (Next.js)
|
||||
└── dash/ # Dashboard app (Next.js)
|
||||
```
|
||||
|
||||
### Working with the Monorepo
|
||||
|
||||
#### Installation
|
||||
|
||||
```bash
|
||||
# Install all dependencies for the monorepo
|
||||
pnpm install
|
||||
|
||||
# Build all packages
|
||||
pnpm build
|
||||
|
||||
# Run development mode for all packages
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
#### Package Management
|
||||
|
||||
```bash
|
||||
# Run commands in specific packages
|
||||
pnpm --filter @sonr.io/es build
|
||||
pnpm --filter @sonr.io/sdk test
|
||||
|
||||
# Add dependencies to specific packages
|
||||
pnpm --filter @sonr.io/ui add react
|
||||
|
||||
# Run commands in all packages
|
||||
pnpm -r build # Build all packages
|
||||
pnpm -r test # Test all packages
|
||||
```
|
||||
|
||||
#### Versioning & Publishing
|
||||
|
||||
The monorepo uses [Changesets](https://github.com/changesets/changesets) for package versioning:
|
||||
|
||||
```bash
|
||||
# Add a changeset for your changes
|
||||
pnpm changeset
|
||||
|
||||
# Version packages based on changesets
|
||||
pnpm changeset version
|
||||
|
||||
# Publish packages to npm
|
||||
pnpm changeset publish
|
||||
├── docs/ # Documentation
|
||||
└── client/ # Client libraries and tooling
|
||||
```
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **pnpm workspaces**: Efficient dependency management with single lockfile
|
||||
- **TypeScript project references**: Incremental builds and better IDE performance
|
||||
- **Turbo**: Build orchestration and caching for faster builds
|
||||
- **Changesets**: Automated versioning and changelog generation
|
||||
- **Biome**: Fast, unified linting and formatting (replaces ESLint/Prettier)
|
||||
|
||||
### Package Dependencies
|
||||
|
||||
- `@sonr.io/es`: Core ES client library with protobuf types
|
||||
- `@sonr.io/sdk`: High-level SDK (depends on @sonr.io/es)
|
||||
- `@sonr.io/ui`: Shared UI components for web applications
|
||||
- `@sonr.io/install`: CLI tool for installing Sonr
|
||||
- `@sonr.io/join-testnet`: CLI tool for joining testnet
|
||||
- Web apps use all three core packages (@sonr.io/es, @sonr.io/sdk, @sonr.io/ui)
|
||||
- **Cosmos SDK v0.50.14**: Blockchain framework
|
||||
- **CometBFT v0.38.17**: Byzantine fault-tolerant consensus
|
||||
- **IBC v8.7.0**: Inter-blockchain communication
|
||||
- **CosmWasm v1.5.8**: Smart contract support
|
||||
- **IPFS**: Decentralized storage integration
|
||||
|
||||
## 🤝 Community & Support
|
||||
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
package modulev1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/cosmos/app/v1alpha1"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
package dexv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
v1beta1 "cosmossdk.io/api/cosmos/base/v1beta1"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -3,15 +3,14 @@ package dexv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var _ protoreflect.List = (*_GenesisState_3_list)(nil)
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
package dexv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
v1beta1 "cosmossdk.io/api/cosmos/base/v1beta1"
|
||||
fmt "fmt"
|
||||
_ "github.com/cosmos/cosmos-proto"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
@@ -15,6 +11,9 @@ import (
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var _ protoreflect.List = (*_InterchainDEXAccount_7_list)(nil)
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
package dexv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
v1beta1 "cosmossdk.io/api/cosmos/base/query/v1beta1"
|
||||
v1beta11 "cosmossdk.io/api/cosmos/base/v1beta1"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
_ "google.golang.org/genproto/googleapis/api/annotations"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -8,7 +8,6 @@ package dexv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
|
||||
@@ -2,13 +2,9 @@
|
||||
package dexv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
v1beta1 "cosmossdk.io/api/cosmos/base/v1beta1"
|
||||
_ "cosmossdk.io/api/cosmos/msg/v1"
|
||||
fmt "fmt"
|
||||
_ "github.com/cosmos/cosmos-proto"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
@@ -16,6 +12,9 @@ import (
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var _ protoreflect.List = (*_MsgRegisterDEXAccount_3_list)(nil)
|
||||
|
||||
@@ -8,7 +8,6 @@ package dexv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
package modulev1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/cosmos/app/v1alpha1"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -3,16 +3,15 @@ package didv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var _ protoreflect.List = (*_EventDIDCreated_3_list)(nil)
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
package didv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/amino"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
package didv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sort "sort"
|
||||
sync "sync"
|
||||
|
||||
v1beta1 "cosmossdk.io/api/cosmos/base/query/v1beta1"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
_ "google.golang.org/genproto/googleapis/api/annotations"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sort "sort"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -8,7 +8,6 @@ package didv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
|
||||
@@ -4,7 +4,6 @@ package didv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
ormlist "cosmossdk.io/orm/model/ormlist"
|
||||
ormtable "cosmossdk.io/orm/model/ormtable"
|
||||
ormerrors "cosmossdk.io/orm/types/ormerrors"
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
package didv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/cosmos/orm/v1"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
package didv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/cosmos/msg/v1"
|
||||
fmt "fmt"
|
||||
_ "github.com/cosmos/cosmos-proto"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -8,7 +8,6 @@ package didv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
package didv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sort "sort"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/amino"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sort "sort"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
package modulev1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/cosmos/app/v1alpha1"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -3,16 +3,15 @@ package dwnv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
package dwnv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/amino"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var _ protoreflect.List = (*_GenesisState_2_list)(nil)
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
package dwnv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
v1beta1 "cosmossdk.io/api/cosmos/base/query/v1beta1"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
_ "google.golang.org/genproto/googleapis/api/annotations"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -8,7 +8,6 @@ package dwnv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
|
||||
@@ -4,7 +4,6 @@ package dwnv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
ormlist "cosmossdk.io/orm/model/ormlist"
|
||||
ormtable "cosmossdk.io/orm/model/ormtable"
|
||||
ormerrors "cosmossdk.io/orm/types/ormerrors"
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
package dwnv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/cosmos/orm/v1"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var _ protoreflect.List = (*_EncryptionMetadata_6_list)(nil)
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
package dwnv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/cosmos/msg/v1"
|
||||
fmt "fmt"
|
||||
_ "github.com/cosmos/cosmos-proto"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -8,7 +8,6 @@ package dwnv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
package modulev1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/cosmos/app/v1alpha1"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -3,16 +3,15 @@ package svcv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
package svcv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/amino"
|
||||
v1beta1 "cosmossdk.io/api/cosmos/base/v1beta1"
|
||||
fmt "fmt"
|
||||
_ "github.com/cosmos/cosmos-proto"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var _ protoreflect.List = (*_GenesisState_2_list)(nil)
|
||||
|
||||
@@ -3,16 +3,15 @@ package svcv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sort "sort"
|
||||
sync "sync"
|
||||
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "google.golang.org/genproto/googleapis/api/annotations"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sort "sort"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -8,7 +8,6 @@ package svcv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
|
||||
@@ -4,7 +4,6 @@ package svcv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
ormlist "cosmossdk.io/orm/model/ormlist"
|
||||
ormtable "cosmossdk.io/orm/model/ormtable"
|
||||
ormerrors "cosmossdk.io/orm/types/ormerrors"
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
package svcv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sort "sort"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/cosmos/orm/v1"
|
||||
fmt "fmt"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sort "sort"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var _ protoreflect.List = (*_Service_5_list)(nil)
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
package svcv1
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
|
||||
_ "cosmossdk.io/api/cosmos/msg/v1"
|
||||
fmt "fmt"
|
||||
_ "github.com/cosmos/cosmos-proto"
|
||||
runtime "github.com/cosmos/cosmos-proto/runtime"
|
||||
_ "github.com/cosmos/gogoproto/gogoproto"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoiface "google.golang.org/protobuf/runtime/protoiface"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -8,7 +8,6 @@ package svcv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
"github.com/sonr-io/sonr/crypto/keys"
|
||||
"github.com/sonr-io/sonr/crypto/ucan"
|
||||
"github.com/sonr-io/crypto/keys"
|
||||
"github.com/sonr-io/crypto/ucan"
|
||||
)
|
||||
|
||||
// UCANDecorator validates UCAN tokens in transactions
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
"github.com/sonr-io/sonr/crypto/ucan"
|
||||
"github.com/sonr-io/crypto/ucan"
|
||||
)
|
||||
|
||||
// TestUCANDecorator tests the UCAN decorator functionality
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
genutilcli "github.com/cosmos/cosmos-sdk/x/genutil/client/cli"
|
||||
genutiltypes "github.com/cosmos/cosmos-sdk/x/genutil/types"
|
||||
"github.com/sonr-io/sonr/app"
|
||||
"github.com/sonr-io/sonr/crypto/vrf"
|
||||
"github.com/sonr-io/crypto/vrf"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sonr-io/sonr/app"
|
||||
"github.com/sonr-io/sonr/crypto/vrf"
|
||||
"github.com/sonr-io/crypto/vrf"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"cosmossdk.io/log"
|
||||
"github.com/cosmos/cosmos-sdk/client"
|
||||
|
||||
"github.com/sonr-io/sonr/crypto/vrf"
|
||||
"github.com/sonr-io/crypto/vrf"
|
||||
)
|
||||
|
||||
// SonrContext manages node-specific state and configuration
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"cosmossdk.io/log"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sonr-io/sonr/crypto/vrf"
|
||||
"github.com/sonr-io/crypto/vrf"
|
||||
)
|
||||
|
||||
// TestSonrContextInitialization tests SonrContext initialization with VRF keys
|
||||
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"noUnusedVariables": "error",
|
||||
"useExhaustiveDependencies": "warn"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "warn",
|
||||
"useConst": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn",
|
||||
"noArrayIndexKey": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"trailingCommas": "es5",
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "warn"
|
||||
},
|
||||
"complexity": {
|
||||
"noStaticOnlyClass": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"useTemplate": "warn",
|
||||
"noUnusedTemplateLiteral": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// Package bridge provides the HTTP bridge server for the Highway service.
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/sonr-io/sonr/bridge/server"
|
||||
)
|
||||
|
||||
// HighwayService encapsulates the entire Highway service setup and lifecycle
|
||||
type HighwayService struct {
|
||||
config *Config
|
||||
client *asynq.Client
|
||||
httpServer *server.Server
|
||||
queueManager *QueueManager
|
||||
|
||||
// Internal channels for coordination
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
sigCh chan os.Signal
|
||||
}
|
||||
|
||||
// NewHighwayService creates a new Highway service with all components initialized
|
||||
func NewHighwayService() *HighwayService {
|
||||
// Setup graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Handle shutdown signals
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Initialize configuration
|
||||
config := NewConfig()
|
||||
|
||||
// Initialize Redis-based task queue client
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{Addr: config.RedisAddr})
|
||||
log.Printf("Asynq client connected to Redis at %s", config.RedisAddr)
|
||||
|
||||
// Create server configuration for bridge proxy
|
||||
serverConfig := &server.Config{
|
||||
HTTPAddr: fmt.Sprintf(":%d", config.HTTPPort),
|
||||
JWTSecret: config.JWTSecret,
|
||||
IPFSClient: config.IPFSClient,
|
||||
}
|
||||
|
||||
// Create HTTP bridge server
|
||||
httpServer := server.NewServer(serverConfig)
|
||||
|
||||
// Initialize UCAN task processing server with queue manager
|
||||
queueManager := NewQueueManager(config)
|
||||
|
||||
return &HighwayService{
|
||||
config: config,
|
||||
client: client,
|
||||
httpServer: httpServer,
|
||||
queueManager: queueManager,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
sigCh: sigCh,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the Highway service with HTTP server and queue processing
|
||||
func (hs *HighwayService) Start() error {
|
||||
log.Println("Starting Highway Service - UCAN-based MPC Task Processor")
|
||||
|
||||
// Create and start HTTP bridge server in a goroutine
|
||||
go func() {
|
||||
log.Printf("Starting HTTP bridge server on port %d", hs.config.HTTPPort)
|
||||
if err := hs.httpServer.Start(hs.client); err != nil {
|
||||
log.Fatalf("HTTP bridge server failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start queue server with graceful shutdown support
|
||||
go func() {
|
||||
if err := hs.queueManager.Run(); err != nil {
|
||||
log.Printf("Asynq server error: %v", err)
|
||||
hs.cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal
|
||||
select {
|
||||
case <-hs.sigCh:
|
||||
log.Println("Received shutdown signal, initiating graceful shutdown...")
|
||||
case <-hs.ctx.Done():
|
||||
log.Println("Context cancelled, shutting down...")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops all service components
|
||||
func (hs *HighwayService) Shutdown() {
|
||||
log.Println("Shutting down Highway service...")
|
||||
|
||||
// Close Asynq client
|
||||
if hs.client != nil {
|
||||
hs.client.Close()
|
||||
}
|
||||
|
||||
// Shutdown queue manager
|
||||
if hs.queueManager != nil {
|
||||
hs.queueManager.Shutdown()
|
||||
}
|
||||
|
||||
// Cancel context to signal shutdown to all components
|
||||
hs.cancel()
|
||||
|
||||
log.Println("Highway service stopped")
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewHighwayService(t *testing.T) {
|
||||
service := NewHighwayService()
|
||||
|
||||
require.NotNil(t, service)
|
||||
assert.NotNil(t, service.config)
|
||||
assert.NotNil(t, service.client)
|
||||
assert.NotNil(t, service.httpServer)
|
||||
assert.NotNil(t, service.queueManager)
|
||||
assert.NotNil(t, service.ctx)
|
||||
assert.NotNil(t, service.cancel)
|
||||
assert.NotNil(t, service.sigCh)
|
||||
}
|
||||
|
||||
func TestHighwayServiceComponents(t *testing.T) {
|
||||
service := NewHighwayService()
|
||||
defer service.Shutdown()
|
||||
|
||||
// Test that all components are properly initialized
|
||||
assert.NotNil(t, service.config.RedisAddr)
|
||||
assert.NotNil(t, service.config.JWTSecret)
|
||||
assert.NotNil(t, service.config.AsynqConfig)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/sonr-io/sonr/types/ipfs"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultRedisAddr = "127.0.0.1:6379"
|
||||
DefaultJWTSecret = "highway-ucan-secret-key"
|
||||
DefaultHTTPPort = 8090
|
||||
ShutdownTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// OIDCProviderConfig contains OIDC provider configuration
|
||||
type OIDCProviderConfig struct {
|
||||
Issuer string
|
||||
PublicURL string
|
||||
SigningKeyPath string
|
||||
EncryptionKeyPath string
|
||||
AuthorizationCodeTTL time.Duration
|
||||
AccessTokenTTL time.Duration
|
||||
RefreshTokenTTL time.Duration
|
||||
IDTokenTTL time.Duration
|
||||
EnablePKCE bool
|
||||
EnableRefreshTokens bool
|
||||
EnableSIOP bool
|
||||
SupportedScopes []string
|
||||
SupportedResponseTypes []string
|
||||
SupportedGrantTypes []string
|
||||
AllowedRedirectURIs []string
|
||||
WebAuthnRPID string
|
||||
WebAuthnRPName string
|
||||
WebAuthnTimeout int
|
||||
AutoCreateVault bool
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
RedisAddr string
|
||||
HTTPPort int
|
||||
JWTSecret []byte
|
||||
IPFSClient ipfs.IPFSClient
|
||||
ShutdownTimeout time.Duration
|
||||
AsynqConfig asynq.Config
|
||||
OIDC OIDCProviderConfig
|
||||
}
|
||||
|
||||
func NewConfig() *Config {
|
||||
jwtSecret := initializeJWTSecret()
|
||||
redisAddr := getRedisAddr()
|
||||
httpPort := getHTTPPort()
|
||||
ipfsClient := initializeIPFS()
|
||||
oidcConfig := initializeOIDCConfig()
|
||||
|
||||
return &Config{
|
||||
RedisAddr: redisAddr,
|
||||
HTTPPort: httpPort,
|
||||
JWTSecret: jwtSecret,
|
||||
IPFSClient: ipfsClient,
|
||||
ShutdownTimeout: ShutdownTimeout,
|
||||
AsynqConfig: asynq.Config{
|
||||
Concurrency: 10,
|
||||
Queues: map[string]int{
|
||||
"critical": 6,
|
||||
"default": 3,
|
||||
"low": 1,
|
||||
},
|
||||
ShutdownTimeout: ShutdownTimeout,
|
||||
// Enhanced error handling and retry configuration
|
||||
RetryDelayFunc: asynq.DefaultRetryDelayFunc,
|
||||
IsFailure: func(err error) bool {
|
||||
return err != nil
|
||||
},
|
||||
},
|
||||
OIDC: oidcConfig,
|
||||
}
|
||||
}
|
||||
|
||||
func initializeJWTSecret() []byte {
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
if secret == "" {
|
||||
secret = DefaultJWTSecret
|
||||
log.Printf("Warning: Using default JWT secret for UCAN operations")
|
||||
log.Printf("Set JWT_SECRET environment variable for production deployment")
|
||||
} else {
|
||||
log.Println("JWT secret loaded from environment")
|
||||
}
|
||||
return []byte(secret)
|
||||
}
|
||||
|
||||
func getRedisAddr() string {
|
||||
// Check for REDIS_URL first (Docker Compose style)
|
||||
if url := os.Getenv("REDIS_URL"); url != "" {
|
||||
// Parse redis://host:port format
|
||||
if len(url) > 8 && url[:8] == "redis://" {
|
||||
return url[8:]
|
||||
}
|
||||
return url
|
||||
}
|
||||
// Fall back to REDIS_ADDR
|
||||
if addr := os.Getenv("REDIS_ADDR"); addr != "" {
|
||||
return addr
|
||||
}
|
||||
log.Printf("Using default Redis address: %s", DefaultRedisAddr)
|
||||
return DefaultRedisAddr
|
||||
}
|
||||
|
||||
func initializeIPFS() ipfs.IPFSClient {
|
||||
ipfsClient, err := ipfs.GetClient()
|
||||
if err != nil {
|
||||
log.Printf("Warning: IPFS client initialization failed: %v", err)
|
||||
log.Println("Enclave data will be handled directly without IPFS storage")
|
||||
return nil
|
||||
}
|
||||
log.Println("IPFS client initialized successfully")
|
||||
return ipfsClient
|
||||
}
|
||||
|
||||
func initializeOIDCConfig() OIDCProviderConfig {
|
||||
issuer := os.Getenv("OIDC_ISSUER")
|
||||
if issuer == "" {
|
||||
issuer = "https://localhost:8080"
|
||||
}
|
||||
|
||||
publicURL := os.Getenv("OIDC_PUBLIC_URL")
|
||||
if publicURL == "" {
|
||||
publicURL = issuer
|
||||
}
|
||||
|
||||
rpID := os.Getenv("WEBAUTHN_RP_ID")
|
||||
if rpID == "" {
|
||||
rpID = "localhost"
|
||||
}
|
||||
|
||||
rpName := os.Getenv("WEBAUTHN_RP_NAME")
|
||||
if rpName == "" {
|
||||
rpName = "Sonr Identity Platform"
|
||||
}
|
||||
|
||||
return OIDCProviderConfig{
|
||||
Issuer: issuer,
|
||||
PublicURL: publicURL,
|
||||
SigningKeyPath: os.Getenv("OIDC_SIGNING_KEY_PATH"),
|
||||
EncryptionKeyPath: os.Getenv("OIDC_ENCRYPTION_KEY_PATH"),
|
||||
AuthorizationCodeTTL: 10 * time.Minute,
|
||||
AccessTokenTTL: 1 * time.Hour,
|
||||
RefreshTokenTTL: 7 * 24 * time.Hour,
|
||||
IDTokenTTL: 1 * time.Hour,
|
||||
EnablePKCE: true,
|
||||
EnableRefreshTokens: true,
|
||||
EnableSIOP: true,
|
||||
SupportedScopes: []string{
|
||||
"openid", "profile", "email", "did", "vault", "offline_access",
|
||||
},
|
||||
SupportedResponseTypes: []string{
|
||||
"code", "id_token", "code id_token",
|
||||
},
|
||||
SupportedGrantTypes: []string{
|
||||
"authorization_code", "refresh_token", "client_credentials",
|
||||
},
|
||||
AllowedRedirectURIs: getRedirectURIs(),
|
||||
WebAuthnRPID: rpID,
|
||||
WebAuthnRPName: rpName,
|
||||
WebAuthnTimeout: 60000,
|
||||
AutoCreateVault: true,
|
||||
}
|
||||
}
|
||||
|
||||
// getRedirectURIs returns the allowed redirect URIs for OIDC
|
||||
func getRedirectURIs() []string {
|
||||
// Default URIs for development
|
||||
uris := []string{
|
||||
"http://localhost:3000/callback",
|
||||
"http://localhost:3001/callback",
|
||||
"https://localhost:3000/callback",
|
||||
"https://localhost:3001/callback",
|
||||
}
|
||||
|
||||
// Add additional URIs from environment if specified
|
||||
if envURIs := os.Getenv("OIDC_ALLOWED_REDIRECT_URIS"); envURIs != "" {
|
||||
// Parse comma-separated URIs
|
||||
// This could be enhanced with proper validation
|
||||
log.Printf("Additional redirect URIs configured from environment")
|
||||
}
|
||||
|
||||
return uris
|
||||
}
|
||||
|
||||
// getHTTPPort returns the HTTP port for the Highway service
|
||||
func getHTTPPort() int {
|
||||
if port := os.Getenv("HIGHWAY_PORT"); port != "" {
|
||||
if p, err := strconv.Atoi(port); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return DefaultHTTPPort
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
// Package handlers provides HTTP handlers for the highway server
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CustomClaims defines custom JWT claims for vault operations
|
||||
type CustomClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Permissions []string `json:"permissions"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// LoginRequest represents the login request payload
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// LoginResponse represents the login response payload
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// LoginHandler generates JWT tokens for authentication
|
||||
// This now supports both traditional login and OIDC-based authentication
|
||||
func LoginHandler(jwtSecret []byte) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Check if this is an OIDC callback
|
||||
code := c.QueryParam("code")
|
||||
if code != "" {
|
||||
return handleOIDCCallback(c, code, jwtSecret)
|
||||
}
|
||||
|
||||
// Check if user has WebAuthn credentials
|
||||
authHeader := c.Request().Header.Get("X-WebAuthn-Assertion")
|
||||
if authHeader != "" {
|
||||
return handleWebAuthnLogin(c, authHeader, jwtSecret)
|
||||
}
|
||||
|
||||
// Traditional login flow
|
||||
var req LoginRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid JSON payload"})
|
||||
}
|
||||
|
||||
// Simple authentication - in production, validate against a proper user database
|
||||
if req.Username == "" || req.Password == "" {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Username and password are required"},
|
||||
)
|
||||
}
|
||||
|
||||
// For demo purposes, accept any non-empty credentials
|
||||
// In production, verify credentials against database/directory
|
||||
if req.Username == "vault-user" && req.Password == "vault-pass" {
|
||||
// Create custom claims
|
||||
claims := &CustomClaims{
|
||||
UserID: req.Username,
|
||||
Permissions: []string{
|
||||
"vault:generate",
|
||||
"vault:sign",
|
||||
"vault:verify",
|
||||
"vault:export",
|
||||
"vault:import",
|
||||
"vault:refresh",
|
||||
},
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)), // 24 hours
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "highway-vault",
|
||||
Subject: req.Username,
|
||||
},
|
||||
}
|
||||
|
||||
// Create token with claims
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Sign token with secret
|
||||
tokenString, err := token.SignedString(jwtSecret)
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to generate token"},
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, LoginResponse{Token: tokenString})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid credentials"})
|
||||
}
|
||||
}
|
||||
|
||||
// handleOIDCCallback processes OIDC authorization code callback
|
||||
func handleOIDCCallback(c echo.Context, code string, jwtSecret []byte) error {
|
||||
// Exchange code for tokens using OIDC token endpoint
|
||||
tokenReq := &OIDCTokenRequest{
|
||||
GrantType: "authorization_code",
|
||||
Code: code,
|
||||
RedirectURI: c.QueryParam("redirect_uri"),
|
||||
ClientID: c.QueryParam("client_id"),
|
||||
}
|
||||
|
||||
// Call internal OIDC token handler
|
||||
// In production, this would make an HTTP call to the OIDC provider
|
||||
c.Set("oidc_token_request", tokenReq)
|
||||
if err := handleAuthorizationCodeGrant(c, tokenReq); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the OIDC session from context
|
||||
session, ok := c.Get("oidc_session").(*OIDCSession)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to establish OIDC session",
|
||||
})
|
||||
}
|
||||
|
||||
// Create JWT token from OIDC session
|
||||
claims := &CustomClaims{
|
||||
UserID: session.UserDID,
|
||||
Permissions: []string{
|
||||
"vault:generate",
|
||||
"vault:sign",
|
||||
"vault:verify",
|
||||
"vault:export",
|
||||
"vault:import",
|
||||
"vault:refresh",
|
||||
},
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(session.ExpiresAt),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "highway-vault-oidc",
|
||||
Subject: session.UserDID,
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString(jwtSecret)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to generate token",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, LoginResponse{Token: tokenString})
|
||||
}
|
||||
|
||||
// handleWebAuthnLogin processes WebAuthn-based login
|
||||
func handleWebAuthnLogin(c echo.Context, assertion string, jwtSecret []byte) error {
|
||||
// Verify WebAuthn assertion
|
||||
// This would integrate with the WebAuthn handlers
|
||||
|
||||
// For now, extract username from assertion (simplified)
|
||||
username := c.Request().Header.Get("X-WebAuthn-Username")
|
||||
if username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "WebAuthn username required",
|
||||
})
|
||||
}
|
||||
|
||||
// Verify the assertion matches a stored credential
|
||||
webAuthnStore.mu.RLock()
|
||||
credentials, exists := webAuthnStore.credentials[username]
|
||||
webAuthnStore.mu.RUnlock()
|
||||
|
||||
if !exists || len(credentials) == 0 {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "No WebAuthn credentials found for user",
|
||||
})
|
||||
}
|
||||
|
||||
// Create JWT token for WebAuthn authenticated user
|
||||
claims := &CustomClaims{
|
||||
UserID: username,
|
||||
Permissions: []string{
|
||||
"vault:generate",
|
||||
"vault:sign",
|
||||
"vault:verify",
|
||||
"vault:export",
|
||||
"vault:import",
|
||||
"vault:refresh",
|
||||
},
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "highway-vault-webauthn",
|
||||
Subject: username,
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString(jwtSecret)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to generate token",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, LoginResponse{Token: tokenString})
|
||||
}
|
||||
|
||||
// OIDCLoginHandler initiates OIDC login flow
|
||||
func OIDCLoginHandler() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Build authorization URL
|
||||
authURL := buildOIDCAuthorizationURL(
|
||||
c.QueryParam("client_id"),
|
||||
c.QueryParam("redirect_uri"),
|
||||
c.QueryParam("scope"),
|
||||
c.QueryParam("state"),
|
||||
)
|
||||
|
||||
// Redirect to OIDC authorization endpoint
|
||||
return c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
}
|
||||
|
||||
// buildOIDCAuthorizationURL constructs the OIDC authorization URL
|
||||
func buildOIDCAuthorizationURL(clientID, redirectURI, scope, state string) string {
|
||||
// Default values if not provided
|
||||
if clientID == "" {
|
||||
clientID = "highway-vault-client"
|
||||
}
|
||||
if redirectURI == "" {
|
||||
redirectURI = "http://localhost:8080/auth/callback"
|
||||
}
|
||||
if scope == "" {
|
||||
scope = "openid profile did vault"
|
||||
}
|
||||
if state == "" {
|
||||
state = generateState()
|
||||
}
|
||||
|
||||
// Build authorization URL
|
||||
return fmt.Sprintf(
|
||||
"https://localhost:8080/oidc/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&state=%s",
|
||||
clientID,
|
||||
redirectURI,
|
||||
scope,
|
||||
state,
|
||||
)
|
||||
}
|
||||
|
||||
// generateState generates a random state parameter for OIDC
|
||||
func generateState() string {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "default-state"
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/sonr-io/sonr/crypto/mpc"
|
||||
)
|
||||
|
||||
// getQueueFromPriority returns the queue name based on priority
|
||||
func getQueueFromPriority(priority string) string {
|
||||
return GetQueueFromPriority(priority)
|
||||
}
|
||||
|
||||
// BenchmarkJSONMarshaling measures JSON encoding/decoding performance
|
||||
func BenchmarkJSONMarshaling(b *testing.B) {
|
||||
payload := map[string]any{
|
||||
"message": []byte("benchmark test message for JSON marshaling performance"),
|
||||
"enclave": &mpc.EnclaveData{},
|
||||
"priority": "critical",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
// Marshal
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
b.Error(err)
|
||||
}
|
||||
|
||||
// Unmarshal
|
||||
var decoded map[string]any
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
if err != nil {
|
||||
b.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkQueuePrioritySelection measures queue selection performance
|
||||
func BenchmarkQueuePrioritySelection(b *testing.B) {
|
||||
priorities := []string{"critical", "high", "default", "low", "", "unknown"}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
priority := priorities[i%len(priorities)]
|
||||
i++
|
||||
queue := getQueueFromPriority(priority)
|
||||
_ = queue // Avoid compiler optimization
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// BroadcastService handles blockchain broadcasting operations
|
||||
type BroadcastService struct {
|
||||
// TODO: Add cosmos SDK client for actual broadcasting
|
||||
}
|
||||
|
||||
var broadcastService = &BroadcastService{}
|
||||
|
||||
// HandleBroadcast handles generic message broadcasting to blockchain
|
||||
func HandleBroadcast(c echo.Context) error {
|
||||
var req BroadcastRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid broadcast request",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if req.Message == nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Message is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Process based on gasless flag
|
||||
if req.Gasless {
|
||||
return handleGaslessBroadcast(c, &req)
|
||||
}
|
||||
|
||||
return handleStandardBroadcast(c, &req)
|
||||
}
|
||||
|
||||
// handleGaslessBroadcast handles gasless transaction broadcasting
|
||||
func handleGaslessBroadcast(c echo.Context, req *BroadcastRequest) error {
|
||||
// Validate gasless eligibility
|
||||
if !isGaslessEligible(req.Message) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Message not eligible for gasless broadcast",
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Implement actual blockchain broadcast
|
||||
// For now, simulate successful broadcast
|
||||
response := &BroadcastResponse{
|
||||
TxHash: generateTxHash(),
|
||||
Height: 12345,
|
||||
Code: 0,
|
||||
RawLog: "Transaction broadcast successfully",
|
||||
Success: true,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleStandardBroadcast handles regular transaction broadcasting
|
||||
func handleStandardBroadcast(c echo.Context, req *BroadcastRequest) error {
|
||||
// Get sender address
|
||||
fromAddress := req.FromAddress
|
||||
if fromAddress == "" {
|
||||
// Try to get from context
|
||||
userDID := c.Get("user_did")
|
||||
if userDID != nil {
|
||||
fromAddress = deriveAddressFromDID(userDID.(string))
|
||||
}
|
||||
}
|
||||
|
||||
if fromAddress == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "From address is required for standard broadcast",
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Implement actual blockchain broadcast
|
||||
// For now, simulate successful broadcast
|
||||
response := &BroadcastResponse{
|
||||
TxHash: generateTxHash(),
|
||||
Height: 12346,
|
||||
Code: 0,
|
||||
RawLog: "Transaction broadcast successfully",
|
||||
Success: true,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// BroadcastWebAuthnRegistration broadcasts WebAuthn registration to blockchain
|
||||
func BroadcastWebAuthnRegistration(
|
||||
credential *WebAuthnCredential,
|
||||
gasless bool,
|
||||
) (*BroadcastResponse, error) {
|
||||
// Create MsgRegisterWebAuthnCredential
|
||||
msg := map[string]any{
|
||||
"@type": "/sonr.did.v1.MsgRegisterWebAuthnCredential",
|
||||
"username": credential.Username,
|
||||
"credential_id": credential.CredentialID,
|
||||
"public_key": credential.PublicKey,
|
||||
"attestation_object": credential.AttestationObject,
|
||||
"client_data_json": credential.ClientDataJSON,
|
||||
"origin": credential.Origin,
|
||||
"algorithm": credential.Algorithm,
|
||||
}
|
||||
|
||||
// Create broadcast request (for future use with actual broadcast)
|
||||
_ = &BroadcastRequest{
|
||||
Message: msg,
|
||||
Gasless: gasless,
|
||||
AutoSign: true,
|
||||
}
|
||||
|
||||
// Simulate broadcast (TODO: Implement actual broadcast)
|
||||
return &BroadcastResponse{
|
||||
TxHash: generateTxHash(),
|
||||
Height: 12347,
|
||||
Code: 0,
|
||||
RawLog: "WebAuthn credential registered successfully",
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BroadcastVaultCreation broadcasts vault creation to blockchain
|
||||
func BroadcastVaultCreation(
|
||||
userDID string,
|
||||
vaultConfig map[string]any,
|
||||
) (*BroadcastResponse, error) {
|
||||
// Create MsgCreateVault
|
||||
msg := map[string]any{
|
||||
"@type": "/sonr.vault.v1.MsgCreateVault",
|
||||
"creator": userDID,
|
||||
"config": vaultConfig,
|
||||
}
|
||||
|
||||
// Create broadcast request (for future use with actual broadcast)
|
||||
_ = &BroadcastRequest{
|
||||
Message: msg,
|
||||
Gasless: true, // Vault creation is gasless for new users
|
||||
AutoSign: true,
|
||||
FromAddress: deriveAddressFromDID(userDID),
|
||||
}
|
||||
|
||||
// Simulate broadcast (TODO: Implement actual broadcast)
|
||||
return &BroadcastResponse{
|
||||
TxHash: generateTxHash(),
|
||||
Height: 12348,
|
||||
Code: 0,
|
||||
RawLog: "Vault created successfully",
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BroadcastDIDDocument broadcasts DID document to blockchain
|
||||
func BroadcastDIDDocument(didDoc map[string]any, gasless bool) (*BroadcastResponse, error) {
|
||||
// Create MsgCreateDIDDocument or MsgUpdateDIDDocument
|
||||
msg := map[string]any{
|
||||
"@type": "/sonr.did.v1.MsgCreateDIDDocument",
|
||||
"did_document": didDoc,
|
||||
}
|
||||
|
||||
// Create broadcast request (for future use with actual broadcast)
|
||||
_ = &BroadcastRequest{
|
||||
Message: msg,
|
||||
Gasless: gasless,
|
||||
AutoSign: true,
|
||||
}
|
||||
|
||||
// Simulate broadcast (TODO: Implement actual broadcast)
|
||||
return &BroadcastResponse{
|
||||
TxHash: generateTxHash(),
|
||||
Height: 12349,
|
||||
Code: 0,
|
||||
RawLog: "DID document created successfully",
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleTransactionStatus checks transaction status
|
||||
func HandleTransactionStatus(c echo.Context) error {
|
||||
txHash := c.Param("hash")
|
||||
if txHash == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Transaction hash is required",
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Query actual blockchain for transaction status
|
||||
// For now, return simulated status
|
||||
status := map[string]any{
|
||||
"tx_hash": txHash,
|
||||
"height": 12350,
|
||||
"status": "confirmed",
|
||||
"code": 0,
|
||||
"gas_used": 50000,
|
||||
"gas_wanted": 100000,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
// HandleEstimateGas estimates gas for a transaction
|
||||
func HandleEstimateGas(c echo.Context) error {
|
||||
var req BroadcastRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid request",
|
||||
})
|
||||
}
|
||||
|
||||
// Check if gasless eligible
|
||||
if isGaslessEligible(req.Message) {
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"gas_estimate": 0,
|
||||
"gasless": true,
|
||||
"fee": "0usnr",
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Implement actual gas estimation
|
||||
// For now, return default estimate
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"gas_estimate": 100000,
|
||||
"gasless": false,
|
||||
"fee": "100usnr",
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// isGaslessEligible checks if a message is eligible for gasless broadcasting
|
||||
func isGaslessEligible(message any) bool {
|
||||
// Check message type
|
||||
msgMap, ok := message.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
msgType, ok := msgMap["@type"].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// WebAuthn registration and vault creation are gasless
|
||||
gaslessTypes := []string{
|
||||
"/sonr.did.v1.MsgRegisterWebAuthnCredential",
|
||||
"/sonr.vault.v1.MsgCreateVault",
|
||||
"/sonr.did.v1.MsgCreateDIDDocument",
|
||||
}
|
||||
|
||||
for _, t := range gaslessTypes {
|
||||
if msgType == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// deriveAddressFromDID derives a blockchain address from a DID
|
||||
func deriveAddressFromDID(did string) string {
|
||||
// TODO: Implement actual address derivation
|
||||
// For now, return a placeholder address
|
||||
return "sonr1placeholder" + did[len(did)-10:]
|
||||
}
|
||||
|
||||
// generateTxHash generates a mock transaction hash
|
||||
func generateTxHash() string {
|
||||
// TODO: Replace with actual tx hash from broadcast
|
||||
return fmt.Sprintf("%X", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// CreateWebAuthnBroadcastHandler creates a handler that broadcasts WebAuthn credentials
|
||||
func CreateWebAuthnBroadcastHandler() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Get WebAuthn credential from context
|
||||
credential, ok := c.Get("webauthn_credential").(*WebAuthnCredential)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "No WebAuthn credential found",
|
||||
})
|
||||
}
|
||||
|
||||
// Broadcast to blockchain
|
||||
response, err := BroadcastWebAuthnRegistration(credential, true)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateVaultBroadcastHandler creates a handler that broadcasts vault creation
|
||||
func CreateVaultBroadcastHandler() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Get user DID from context
|
||||
userDID, ok := c.Get("user_did").(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "User DID not found",
|
||||
})
|
||||
}
|
||||
|
||||
// Get vault config from request
|
||||
var vaultConfig map[string]any
|
||||
if err := c.Bind(&vaultConfig); err != nil {
|
||||
// Use default config
|
||||
vaultConfig = map[string]any{
|
||||
"type": "standard",
|
||||
"encryption": "AES256",
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast vault creation
|
||||
response, err := BroadcastVaultCreation(userDID, vaultConfig)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import "errors"
|
||||
|
||||
// Common errors for OAuth2 and UCAN handling
|
||||
var (
|
||||
// Client errors
|
||||
ErrClientNotFound = errors.New("client not found")
|
||||
ErrInvalidClientCredentials = errors.New("invalid client credentials")
|
||||
|
||||
// Token errors
|
||||
ErrTokenNotFound = errors.New("token not found")
|
||||
ErrTokenExpired = errors.New("token has expired")
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrTokenRevoked = errors.New("token has been revoked")
|
||||
|
||||
// Authorization errors
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInsufficientScope = errors.New("insufficient scope")
|
||||
ErrInvalidScope = errors.New("invalid scope")
|
||||
ErrScopeNotAllowed = errors.New("scope not allowed for client")
|
||||
|
||||
// UCAN errors
|
||||
ErrInvalidAttenuation = errors.New("invalid attenuation")
|
||||
ErrInvalidDelegation = errors.New("invalid delegation")
|
||||
ErrBrokenChain = errors.New("broken delegation chain")
|
||||
ErrPrivilegeEscalation = errors.New("privilege escalation attempted")
|
||||
|
||||
// OIDC errors
|
||||
ErrInvalidRedirectURI = errors.New("invalid redirect URI")
|
||||
ErrInvalidResponseType = errors.New("invalid response type")
|
||||
ErrInvalidGrantType = errors.New("invalid grant type")
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetQueueFromPriority(t *testing.T) {
|
||||
tests := []struct {
|
||||
priority string
|
||||
expected string
|
||||
}{
|
||||
{"critical", "critical"},
|
||||
{"high", "critical"},
|
||||
{"low", "low"},
|
||||
{"", "default"},
|
||||
{"unknown", "default"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.priority, func(t *testing.T) {
|
||||
result := GetQueueFromPriority(tt.priority)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/sonr-io/sonr/types/ipfs"
|
||||
)
|
||||
|
||||
// HealthStatus represents the health status of the service
|
||||
type HealthStatus struct {
|
||||
Status string `json:"status"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Uptime string `json:"uptime"`
|
||||
Dependencies map[string]string `json:"dependencies"`
|
||||
}
|
||||
|
||||
// HealthChecker manages health and readiness checks
|
||||
type HealthChecker struct {
|
||||
startTime time.Time
|
||||
redisClient *asynq.Client
|
||||
ipfsClient ipfs.IPFSClient
|
||||
ready bool
|
||||
readyMu sync.RWMutex
|
||||
redisHealthy bool
|
||||
ipfsHealthy bool
|
||||
}
|
||||
|
||||
// NewHealthChecker creates a new health checker
|
||||
func NewHealthChecker(redisClient *asynq.Client, ipfsClient ipfs.IPFSClient) *HealthChecker {
|
||||
hc := &HealthChecker{
|
||||
startTime: time.Now(),
|
||||
redisClient: redisClient,
|
||||
ipfsClient: ipfsClient,
|
||||
ready: false,
|
||||
}
|
||||
|
||||
// Start background health checks
|
||||
go hc.startHealthChecks()
|
||||
|
||||
return hc
|
||||
}
|
||||
|
||||
// startHealthChecks runs periodic health checks
|
||||
func (hc *HealthChecker) startHealthChecks() {
|
||||
// Initial startup delay
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run initial check
|
||||
hc.checkDependencies()
|
||||
|
||||
for range ticker.C {
|
||||
hc.checkDependencies()
|
||||
}
|
||||
}
|
||||
|
||||
// checkDependencies checks all service dependencies
|
||||
func (hc *HealthChecker) checkDependencies() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check Redis connectivity
|
||||
hc.redisHealthy = hc.checkRedis(ctx)
|
||||
|
||||
// Check IPFS connectivity (optional)
|
||||
hc.ipfsHealthy = hc.checkIPFS(ctx)
|
||||
|
||||
// Update readiness based on critical dependencies
|
||||
hc.readyMu.Lock()
|
||||
hc.ready = hc.redisHealthy // Redis is required
|
||||
hc.readyMu.Unlock()
|
||||
}
|
||||
|
||||
// checkRedis verifies Redis connectivity
|
||||
func (hc *HealthChecker) checkRedis(ctx context.Context) bool {
|
||||
if hc.redisClient == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to ping Redis by enqueuing a test task
|
||||
testTask := asynq.NewTask("health:check", nil)
|
||||
_, err := hc.redisClient.EnqueueContext(ctx, testTask,
|
||||
asynq.Queue("health"),
|
||||
asynq.MaxRetry(0),
|
||||
asynq.Retention(1*time.Second)) // Auto-delete after 1 second
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// checkIPFS verifies IPFS connectivity
|
||||
func (hc *HealthChecker) checkIPFS(ctx context.Context) bool {
|
||||
if hc.ipfsClient == nil {
|
||||
return true // IPFS is optional
|
||||
}
|
||||
|
||||
// Check IPFS connectivity by getting version
|
||||
ch := make(chan bool, 1)
|
||||
go func() {
|
||||
// Try a simple operation to check connectivity
|
||||
if hc.ipfsClient != nil {
|
||||
// IPFS client exists, assume healthy for now
|
||||
// Real check would depend on actual IPFS client implementation
|
||||
ch <- true
|
||||
} else {
|
||||
ch <- false
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-ch:
|
||||
return result
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsReady returns whether the service is ready to handle requests
|
||||
func (hc *HealthChecker) IsReady() bool {
|
||||
hc.readyMu.RLock()
|
||||
defer hc.readyMu.RUnlock()
|
||||
return hc.ready
|
||||
}
|
||||
|
||||
// GetStatus returns the current health status
|
||||
func (hc *HealthChecker) GetStatus() HealthStatus {
|
||||
uptime := time.Since(hc.startTime)
|
||||
|
||||
deps := make(map[string]string)
|
||||
if hc.redisHealthy {
|
||||
deps["redis"] = "healthy"
|
||||
} else {
|
||||
deps["redis"] = "unhealthy"
|
||||
}
|
||||
|
||||
if hc.ipfsClient != nil {
|
||||
if hc.ipfsHealthy {
|
||||
deps["ipfs"] = "healthy"
|
||||
} else {
|
||||
deps["ipfs"] = "unhealthy"
|
||||
}
|
||||
} else {
|
||||
deps["ipfs"] = "not_configured"
|
||||
}
|
||||
|
||||
status := "healthy"
|
||||
if !hc.ready {
|
||||
status = "unhealthy"
|
||||
}
|
||||
|
||||
return HealthStatus{
|
||||
Status: status,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Uptime: uptime.String(),
|
||||
Dependencies: deps,
|
||||
}
|
||||
}
|
||||
|
||||
// Global health checker instance
|
||||
var healthChecker *HealthChecker
|
||||
|
||||
// InitHealthChecker initializes the global health checker
|
||||
func InitHealthChecker(redisClient *asynq.Client, ipfsClient ipfs.IPFSClient) {
|
||||
healthChecker = NewHealthChecker(redisClient, ipfsClient)
|
||||
}
|
||||
|
||||
// HealthCheckHandler returns health status (liveness probe)
|
||||
func HealthCheckHandler(c echo.Context) error {
|
||||
if healthChecker == nil {
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "starting"})
|
||||
}
|
||||
|
||||
status := healthChecker.GetStatus()
|
||||
if status.Status == "healthy" {
|
||||
return c.JSON(http.StatusOK, status)
|
||||
}
|
||||
return c.JSON(http.StatusServiceUnavailable, status)
|
||||
}
|
||||
|
||||
// ReadinessHandler returns readiness status (readiness probe)
|
||||
func ReadinessHandler(c echo.Context) error {
|
||||
if healthChecker == nil || !healthChecker.IsReady() {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"ready": "false",
|
||||
"reason": "service not ready",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{
|
||||
"ready": "true",
|
||||
})
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// ClientTypePublic represents a public OAuth2 client
|
||||
ClientTypePublic = "public"
|
||||
// ClientTypeConfidential represents a confidential OAuth2 client
|
||||
ClientTypeConfidential = "confidential"
|
||||
)
|
||||
|
||||
// ClientRegistry manages OAuth2 client registrations
|
||||
type ClientRegistry struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]*OAuth2Client
|
||||
}
|
||||
|
||||
// NewClientRegistry creates a new client registry
|
||||
func NewClientRegistry() *ClientRegistry {
|
||||
registry := &ClientRegistry{
|
||||
clients: make(map[string]*OAuth2Client),
|
||||
}
|
||||
|
||||
// Initialize with default clients for development
|
||||
registry.initializeDefaultClients()
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
// initializeDefaultClients adds default clients for development/testing
|
||||
func (r *ClientRegistry) initializeDefaultClients() {
|
||||
// Development public client (e.g., SPA)
|
||||
_ = r.RegisterClient(&OAuth2Client{
|
||||
ClientID: "sonr-web-app",
|
||||
ClientType: ClientTypePublic,
|
||||
RedirectURIs: []string{
|
||||
"http://localhost:3000/callback",
|
||||
"http://localhost:3001/callback",
|
||||
},
|
||||
AllowedScopes: []string{
|
||||
"openid",
|
||||
"profile",
|
||||
"vault:read",
|
||||
"vault:write",
|
||||
"service:manage",
|
||||
},
|
||||
AllowedGrants: []string{"authorization_code", "refresh_token"},
|
||||
TokenLifetime: time.Hour,
|
||||
RequirePKCE: true,
|
||||
TrustedClient: true,
|
||||
RequiresConsent: false,
|
||||
Metadata: map[string]string{
|
||||
"name": "Sonr Web Application",
|
||||
"description": "Official Sonr web application",
|
||||
"logo_uri": "https://sonr.io/logo.png",
|
||||
"client_uri": "https://app.sonr.io",
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
|
||||
// Development confidential client (e.g., backend service)
|
||||
_ = r.RegisterClient(&OAuth2Client{
|
||||
ClientID: "sonr-backend-service",
|
||||
ClientSecret: "development-secret-change-in-production",
|
||||
ClientType: ClientTypeConfidential,
|
||||
RedirectURIs: []string{"http://localhost:8081/callback"},
|
||||
AllowedScopes: []string{"openid", "profile", "vault:admin", "service:manage"},
|
||||
AllowedGrants: []string{"authorization_code", "refresh_token", "client_credentials"},
|
||||
TokenLifetime: time.Hour * 2,
|
||||
RequirePKCE: false,
|
||||
TrustedClient: true,
|
||||
RequiresConsent: false,
|
||||
Metadata: map[string]string{
|
||||
"name": "Sonr Backend Service",
|
||||
"description": "Backend service for Sonr ecosystem",
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
|
||||
// Example third-party client
|
||||
_ = r.RegisterClient(&OAuth2Client{
|
||||
ClientID: "example-third-party",
|
||||
ClientSecret: "third-party-secret",
|
||||
ClientType: ClientTypeConfidential,
|
||||
RedirectURIs: []string{"https://example.com/oauth/callback"},
|
||||
AllowedScopes: []string{"openid", "profile", "vault:read"},
|
||||
AllowedGrants: []string{"authorization_code", "refresh_token"},
|
||||
TokenLifetime: time.Hour,
|
||||
RequirePKCE: true,
|
||||
TrustedClient: false,
|
||||
RequiresConsent: true,
|
||||
Metadata: map[string]string{
|
||||
"name": "Example Third Party App",
|
||||
"description": "Example integration partner",
|
||||
"logo_uri": "https://example.com/logo.png",
|
||||
"client_uri": "https://example.com",
|
||||
"policy_uri": "https://example.com/privacy",
|
||||
"tos_uri": "https://example.com/terms",
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterClient registers a new OAuth2 client
|
||||
func (r *ClientRegistry) RegisterClient(client *OAuth2Client) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Validate client
|
||||
if err := r.validateClient(client); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate client ID if not provided
|
||||
if client.ClientID == "" {
|
||||
client.ClientID = generateOAuth2ClientID()
|
||||
}
|
||||
|
||||
// Generate client secret for confidential clients
|
||||
if client.ClientType == ClientTypeConfidential && client.ClientSecret == "" {
|
||||
client.ClientSecret = generateClientSecret()
|
||||
}
|
||||
|
||||
// Set timestamps
|
||||
if client.CreatedAt.IsZero() {
|
||||
client.CreatedAt = time.Now()
|
||||
}
|
||||
client.UpdatedAt = time.Now()
|
||||
|
||||
// Store client
|
||||
r.clients[client.ClientID] = client
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClient retrieves a client by ID
|
||||
func (r *ClientRegistry) GetClient(clientID string) (*OAuth2Client, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
client, exists := r.clients[clientID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("client not found: %s", clientID)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// UpdateClient updates an existing client
|
||||
func (r *ClientRegistry) UpdateClient(clientID string, updates *OAuth2Client) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
client, exists := r.clients[clientID]
|
||||
if !exists {
|
||||
return fmt.Errorf("client not found: %s", clientID)
|
||||
}
|
||||
|
||||
// Update allowed fields
|
||||
if len(updates.RedirectURIs) > 0 {
|
||||
client.RedirectURIs = updates.RedirectURIs
|
||||
}
|
||||
if len(updates.AllowedScopes) > 0 {
|
||||
client.AllowedScopes = updates.AllowedScopes
|
||||
}
|
||||
if len(updates.AllowedGrants) > 0 {
|
||||
client.AllowedGrants = updates.AllowedGrants
|
||||
}
|
||||
if updates.TokenLifetime > 0 {
|
||||
client.TokenLifetime = updates.TokenLifetime
|
||||
}
|
||||
if updates.Metadata != nil {
|
||||
client.Metadata = updates.Metadata
|
||||
}
|
||||
|
||||
client.RequirePKCE = updates.RequirePKCE
|
||||
client.RequiresConsent = updates.RequiresConsent
|
||||
client.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteClient removes a client from the registry
|
||||
func (r *ClientRegistry) DeleteClient(clientID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.clients[clientID]; !exists {
|
||||
return fmt.Errorf("client not found: %s", clientID)
|
||||
}
|
||||
|
||||
delete(r.clients, clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListClients returns all registered clients
|
||||
func (r *ClientRegistry) ListClients() []*OAuth2Client {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
clients := make([]*OAuth2Client, 0, len(r.clients))
|
||||
for _, client := range r.clients {
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
return clients
|
||||
}
|
||||
|
||||
// ValidateRedirectURI checks if a redirect URI is valid for the client
|
||||
func (c *OAuth2Client) ValidateRedirectURI(redirectURI string) bool {
|
||||
if redirectURI == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse the redirect URI
|
||||
parsedURI, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check against registered redirect URIs
|
||||
for _, registeredURI := range c.RedirectURIs {
|
||||
registeredParsed, err := url.Parse(registeredURI)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// For public clients, allow localhost with any port
|
||||
if c.ClientType == ClientTypePublic && registeredParsed.Hostname() == "localhost" &&
|
||||
parsedURI.Hostname() == "localhost" {
|
||||
if registeredParsed.Path == parsedURI.Path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Exact match for other cases
|
||||
if registeredURI == redirectURI {
|
||||
return true
|
||||
}
|
||||
|
||||
// Allow subdomain matching for trusted clients
|
||||
if c.TrustedClient && matchesWithSubdomain(registeredParsed, parsedURI) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateScopes checks if the requested scopes are allowed for the client
|
||||
func (c *OAuth2Client) ValidateScopes(requestedScopes []string) bool {
|
||||
if len(requestedScopes) == 0 {
|
||||
return true // No scopes requested is valid
|
||||
}
|
||||
|
||||
for _, scope := range requestedScopes {
|
||||
if !c.hasScope(scope) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// hasScope checks if a client has a specific scope
|
||||
func (c *OAuth2Client) hasScope(scope string) bool {
|
||||
for _, allowedScope := range c.AllowedScopes {
|
||||
if allowedScope == scope {
|
||||
return true
|
||||
}
|
||||
// Check for hierarchical scopes (e.g., vault:admin includes vault:read)
|
||||
if isHierarchicalScope(allowedScope, scope) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasGrantType checks if a client supports a specific grant type
|
||||
func (c *OAuth2Client) HasGrantType(grantType string) bool {
|
||||
for _, allowed := range c.AllowedGrants {
|
||||
if allowed == grantType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateClient validates client configuration
|
||||
func (r *ClientRegistry) validateClient(client *OAuth2Client) error {
|
||||
// Validate client type
|
||||
if client.ClientType != ClientTypePublic && client.ClientType != ClientTypeConfidential {
|
||||
return fmt.Errorf("invalid client type: %s", client.ClientType)
|
||||
}
|
||||
|
||||
// Validate redirect URIs
|
||||
if len(client.RedirectURIs) == 0 {
|
||||
return fmt.Errorf("at least one redirect URI is required")
|
||||
}
|
||||
|
||||
for _, uri := range client.RedirectURIs {
|
||||
if _, err := url.Parse(uri); err != nil {
|
||||
return fmt.Errorf("invalid redirect URI: %s", uri)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate grant types
|
||||
if len(client.AllowedGrants) == 0 {
|
||||
client.AllowedGrants = []string{"authorization_code"}
|
||||
}
|
||||
|
||||
validGrants := map[string]bool{
|
||||
"authorization_code": true,
|
||||
"implicit": true,
|
||||
"refresh_token": true,
|
||||
"client_credentials": true,
|
||||
"password": true,
|
||||
"urn:ietf:params:oauth:grant-type:device_code": true,
|
||||
}
|
||||
|
||||
for _, grant := range client.AllowedGrants {
|
||||
if !validGrants[grant] {
|
||||
return fmt.Errorf("invalid grant type: %s", grant)
|
||||
}
|
||||
}
|
||||
|
||||
// Client credentials grant requires confidential client
|
||||
if contains(client.AllowedGrants, "client_credentials") &&
|
||||
client.ClientType != ClientTypeConfidential {
|
||||
return fmt.Errorf("client_credentials grant requires confidential client")
|
||||
}
|
||||
|
||||
// Public clients should use PKCE
|
||||
if client.ClientType == ClientTypePublic && !client.RequirePKCE {
|
||||
// Log warning but don't fail
|
||||
fmt.Printf("Warning: Public client %s should use PKCE\n", client.ClientID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func generateOAuth2ClientID() string {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("client_%s", base64.RawURLEncoding.EncodeToString(bytes))
|
||||
}
|
||||
|
||||
func generateClientSecret() string {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
func matchesWithSubdomain(registered, requested *url.URL) bool {
|
||||
if registered.Scheme != requested.Scheme {
|
||||
return false
|
||||
}
|
||||
|
||||
if registered.Path != requested.Path {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if requested hostname is a subdomain of registered
|
||||
registeredHost := registered.Hostname()
|
||||
requestedHost := requested.Hostname()
|
||||
|
||||
if registeredHost == requestedHost {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check subdomain match (e.g., *.example.com matches sub.example.com)
|
||||
if strings.HasPrefix(registeredHost, "*.") {
|
||||
domain := strings.TrimPrefix(registeredHost, "*.")
|
||||
return strings.HasSuffix(requestedHost, domain)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isHierarchicalScope(allowed, requested string) bool {
|
||||
// Define scope hierarchy
|
||||
hierarchy := map[string][]string{
|
||||
"vault:admin": {"vault:write", "vault:read", "vault:sign"},
|
||||
"vault:write": {"vault:read"},
|
||||
"service:manage": {"service:read", "service:write"},
|
||||
"did:write": {"did:read"},
|
||||
}
|
||||
|
||||
childScopes, exists := hierarchy[allowed]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, child := range childScopes {
|
||||
if child == requested {
|
||||
return true
|
||||
}
|
||||
// Recursive check for nested hierarchies
|
||||
if isHierarchicalScope(child, requested) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sonr-io/sonr/crypto/ucan"
|
||||
)
|
||||
|
||||
// UCANDelegator handles UCAN token generation for OAuth flows
|
||||
type UCANDelegator struct {
|
||||
scopeMapper *ScopeMapper
|
||||
signer UCANSigner
|
||||
}
|
||||
|
||||
// UCANSigner interface for signing UCAN tokens
|
||||
type UCANSigner interface {
|
||||
Sign(token *ucan.Token) (string, error)
|
||||
GetIssuerDID() string
|
||||
}
|
||||
|
||||
// NewUCANDelegator creates a new UCAN delegator
|
||||
func NewUCANDelegator(signer UCANSigner) *UCANDelegator {
|
||||
if signer == nil {
|
||||
// Use blockchain signer by default
|
||||
signer, _ = NewBlockchainUCANSigner(nil, "did:sonr:oauth-provider")
|
||||
}
|
||||
return &UCANDelegator{
|
||||
scopeMapper: NewScopeMapper(),
|
||||
signer: signer,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDelegation creates a UCAN token for user-to-client delegation
|
||||
func (d *UCANDelegator) CreateDelegation(
|
||||
userDID string,
|
||||
clientID string,
|
||||
scopes []string,
|
||||
expiresAt time.Time,
|
||||
) (*ucan.Token, error) {
|
||||
// Build resource context for the user
|
||||
resourceContext := d.buildResourceContext(userDID)
|
||||
|
||||
// Map OAuth scopes to UCAN attenuations
|
||||
attenuations := d.scopeMapper.MapToUCAN(scopes, userDID, clientID, resourceContext)
|
||||
if len(attenuations) == 0 {
|
||||
return nil, fmt.Errorf("no valid attenuations for scopes: %v", scopes)
|
||||
}
|
||||
|
||||
// Create UCAN token
|
||||
token := &ucan.Token{
|
||||
Issuer: userDID,
|
||||
Audience: clientID,
|
||||
ExpiresAt: expiresAt.Unix(),
|
||||
NotBefore: time.Now().Unix(),
|
||||
Attenuations: attenuations,
|
||||
Facts: []ucan.Fact{
|
||||
{
|
||||
Data: d.createOAuthFact(scopes, "user_delegation"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Sign the token
|
||||
signedToken, err := d.signToken(token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign UCAN token: %w", err)
|
||||
}
|
||||
|
||||
token.Raw = signedToken
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// CreateServiceDelegation creates a UCAN token for service-to-service delegation
|
||||
func (d *UCANDelegator) CreateServiceDelegation(
|
||||
clientID string,
|
||||
scopes []string,
|
||||
expiresAt time.Time,
|
||||
) (*ucan.Token, error) {
|
||||
// Service delegations use the OAuth provider as issuer
|
||||
issuerDID := d.signer.GetIssuerDID()
|
||||
|
||||
// Build resource context for service
|
||||
resourceContext := map[string]string{
|
||||
"service_id": clientID,
|
||||
"type": "service",
|
||||
}
|
||||
|
||||
// Map OAuth scopes to UCAN attenuations
|
||||
attenuations := d.scopeMapper.MapToUCAN(scopes, issuerDID, clientID, resourceContext)
|
||||
if len(attenuations) == 0 {
|
||||
return nil, fmt.Errorf("no valid attenuations for scopes: %v", scopes)
|
||||
}
|
||||
|
||||
// Create UCAN token
|
||||
token := &ucan.Token{
|
||||
Issuer: issuerDID,
|
||||
Audience: clientID,
|
||||
ExpiresAt: expiresAt.Unix(),
|
||||
NotBefore: time.Now().Unix(),
|
||||
Attenuations: attenuations,
|
||||
Facts: []ucan.Fact{
|
||||
{
|
||||
Data: d.createOAuthFact(scopes, "service_delegation"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Sign the token
|
||||
signedToken, err := d.signToken(token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign UCAN token: %w", err)
|
||||
}
|
||||
|
||||
token.Raw = signedToken
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// CreateDelegationChain creates a chain of UCAN tokens for complex delegations
|
||||
func (d *UCANDelegator) CreateDelegationChain(
|
||||
userDID string,
|
||||
intermediaries []string,
|
||||
finalAudience string,
|
||||
scopes []string,
|
||||
expiresAt time.Time,
|
||||
) ([]*ucan.Token, error) {
|
||||
chain := make([]*ucan.Token, 0, len(intermediaries)+1)
|
||||
|
||||
currentIssuer := userDID
|
||||
proofs := []ucan.Proof{}
|
||||
|
||||
// Create delegation for each intermediary
|
||||
for _, intermediary := range intermediaries {
|
||||
token, err := d.createIntermediateDelegation(
|
||||
currentIssuer,
|
||||
intermediary,
|
||||
scopes,
|
||||
expiresAt,
|
||||
proofs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create intermediate delegation: %w", err)
|
||||
}
|
||||
|
||||
chain = append(chain, token)
|
||||
proofs = append(proofs, ucan.Proof(token.Raw))
|
||||
currentIssuer = intermediary
|
||||
}
|
||||
|
||||
// Create final delegation
|
||||
finalToken, err := d.createFinalDelegation(
|
||||
currentIssuer,
|
||||
finalAudience,
|
||||
scopes,
|
||||
expiresAt,
|
||||
proofs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create final delegation: %w", err)
|
||||
}
|
||||
|
||||
chain = append(chain, finalToken)
|
||||
return chain, nil
|
||||
}
|
||||
|
||||
// RevokeDelegation revokes a UCAN delegation
|
||||
func (d *UCANDelegator) RevokeDelegation(tokenID string) error {
|
||||
// TODO: Implement delegation revocation
|
||||
// This would typically involve:
|
||||
// 1. Adding the token to a revocation list
|
||||
// 2. Publishing revocation to a public ledger or database
|
||||
// 3. Notifying relevant parties
|
||||
return fmt.Errorf("delegation revocation not yet implemented")
|
||||
}
|
||||
|
||||
// ValidateDelegation validates a UCAN token delegation
|
||||
func (d *UCANDelegator) ValidateDelegation(token *ucan.Token, requiredScopes []string) error {
|
||||
// Check expiration
|
||||
if time.Now().Unix() > token.ExpiresAt {
|
||||
return fmt.Errorf("token expired")
|
||||
}
|
||||
|
||||
// Check not before
|
||||
if token.NotBefore > 0 && time.Now().Unix() < token.NotBefore {
|
||||
return fmt.Errorf("token not yet valid")
|
||||
}
|
||||
|
||||
// Verify the token has required capabilities
|
||||
for _, scope := range requiredScopes {
|
||||
if !d.tokenGrantsScope(token, scope) {
|
||||
return fmt.Errorf("token does not grant required scope: %s", scope)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Verify signature
|
||||
// TODO: Verify delegation chain if proofs exist
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
func (d *UCANDelegator) buildResourceContext(userDID string) map[string]string {
|
||||
// TODO: Fetch actual resource information for the user
|
||||
return map[string]string{
|
||||
"vault_address": fmt.Sprintf("vault:%s", userDID),
|
||||
"enclave_cid": fmt.Sprintf("cid:%s", userDID),
|
||||
"dwn_id": fmt.Sprintf("dwn:%s", userDID),
|
||||
"service_id": fmt.Sprintf("service:%s", userDID),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *UCANDelegator) createOAuthFact(scopes []string, delegationType string) json.RawMessage {
|
||||
fact := map[string]any{
|
||||
"oauth_scopes": scopes,
|
||||
"delegation_type": delegationType,
|
||||
"issued_at": time.Now().Unix(),
|
||||
"oauth_version": "2.0",
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(fact)
|
||||
return json.RawMessage(data)
|
||||
}
|
||||
|
||||
func (d *UCANDelegator) signToken(token *ucan.Token) (string, error) {
|
||||
if d.signer == nil {
|
||||
return "", fmt.Errorf("no signer configured")
|
||||
}
|
||||
|
||||
return d.signer.Sign(token)
|
||||
}
|
||||
|
||||
func (d *UCANDelegator) createIntermediateDelegation(
|
||||
issuer, audience string,
|
||||
scopes []string,
|
||||
expiresAt time.Time,
|
||||
proofs []ucan.Proof,
|
||||
) (*ucan.Token, error) {
|
||||
return d.createDelegationWithType(
|
||||
issuer,
|
||||
audience,
|
||||
scopes,
|
||||
expiresAt,
|
||||
proofs,
|
||||
"intermediate_delegation",
|
||||
)
|
||||
}
|
||||
|
||||
func (d *UCANDelegator) createFinalDelegation(
|
||||
issuer, audience string,
|
||||
scopes []string,
|
||||
expiresAt time.Time,
|
||||
proofs []ucan.Proof,
|
||||
) (*ucan.Token, error) {
|
||||
return d.createDelegationWithType(
|
||||
issuer,
|
||||
audience,
|
||||
scopes,
|
||||
expiresAt,
|
||||
proofs,
|
||||
"final_delegation",
|
||||
)
|
||||
}
|
||||
|
||||
// createDelegationWithType creates a delegation token with a specific type
|
||||
func (d *UCANDelegator) createDelegationWithType(
|
||||
issuer, audience string,
|
||||
scopes []string,
|
||||
expiresAt time.Time,
|
||||
proofs []ucan.Proof,
|
||||
delegationType string,
|
||||
) (*ucan.Token, error) {
|
||||
resourceContext := d.buildResourceContext(issuer)
|
||||
attenuations := d.scopeMapper.MapToUCAN(scopes, issuer, audience, resourceContext)
|
||||
|
||||
token := &ucan.Token{
|
||||
Issuer: issuer,
|
||||
Audience: audience,
|
||||
ExpiresAt: expiresAt.Unix(),
|
||||
NotBefore: time.Now().Unix(),
|
||||
Attenuations: attenuations,
|
||||
Proofs: proofs,
|
||||
Facts: []ucan.Fact{
|
||||
{
|
||||
Data: d.createOAuthFact(scopes, delegationType),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
signedToken, err := d.signToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token.Raw = signedToken
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (d *UCANDelegator) tokenGrantsScope(token *ucan.Token, scope string) bool {
|
||||
// Get the scope definition
|
||||
scopeDef, exists := d.scopeMapper.GetScope(scope)
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if any attenuation grants the required capabilities
|
||||
for _, attenuation := range token.Attenuations {
|
||||
if attenuation.Capability.Grants(scopeDef.UCANActions) {
|
||||
// Also check resource type matches
|
||||
if d.resourceMatches(attenuation.Resource, scopeDef.ResourceType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *UCANDelegator) resourceMatches(resource ucan.Resource, resourceType string) bool {
|
||||
// Simple scheme matching for now
|
||||
return resource.GetScheme() == resourceType || resource.GetScheme() == "*"
|
||||
}
|
||||
@@ -1,923 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/sonr-io/sonr/crypto/ucan"
|
||||
)
|
||||
|
||||
// OAuth2Provider extends OIDCProvider with full OAuth2 capabilities
|
||||
type OAuth2Provider struct {
|
||||
*OIDCProvider
|
||||
clientRegistry *ClientRegistry
|
||||
scopeMapper *ScopeMapper
|
||||
ucanDelegator *UCANDelegator
|
||||
authCodeStore *AuthCodeStore
|
||||
accessTokenStore *AccessTokenStore
|
||||
refreshTokenStore *RefreshTokenStore
|
||||
consentStore *ConsentStore
|
||||
config *OAuth2Config
|
||||
}
|
||||
|
||||
// AuthCodeStore manages authorization codes
|
||||
type AuthCodeStore struct {
|
||||
mu sync.RWMutex
|
||||
codes map[string]*OAuth2AuthorizationCode
|
||||
}
|
||||
|
||||
// AccessTokenStore manages access tokens
|
||||
type AccessTokenStore struct {
|
||||
mu sync.RWMutex
|
||||
tokens map[string]*OAuth2AccessToken
|
||||
}
|
||||
|
||||
// RefreshTokenStore manages refresh tokens
|
||||
type RefreshTokenStore struct {
|
||||
mu sync.RWMutex
|
||||
tokens map[string]*OAuth2RefreshToken
|
||||
}
|
||||
|
||||
// ConsentStore manages user consent records
|
||||
type ConsentStore struct {
|
||||
mu sync.RWMutex
|
||||
consents map[string]*UserConsent // key: userDID:clientID
|
||||
}
|
||||
|
||||
// UserConsent represents stored user consent
|
||||
type UserConsent struct {
|
||||
UserDID string `json:"user_did"`
|
||||
ClientID string `json:"client_id"`
|
||||
ApprovedScopes []string `json:"approved_scopes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
var oauth2Provider *OAuth2Provider
|
||||
|
||||
// InitializeOAuth2Provider initializes the OAuth2 provider
|
||||
func InitializeOAuth2Provider() {
|
||||
oauth2Provider = &OAuth2Provider{
|
||||
OIDCProvider: oidcProvider,
|
||||
clientRegistry: NewClientRegistry(),
|
||||
scopeMapper: NewScopeMapper(),
|
||||
ucanDelegator: NewUCANDelegator(nil),
|
||||
authCodeStore: &AuthCodeStore{codes: make(map[string]*OAuth2AuthorizationCode)},
|
||||
accessTokenStore: &AccessTokenStore{tokens: make(map[string]*OAuth2AccessToken)},
|
||||
refreshTokenStore: &RefreshTokenStore{tokens: make(map[string]*OAuth2RefreshToken)},
|
||||
consentStore: &ConsentStore{consents: make(map[string]*UserConsent)},
|
||||
config: getDefaultOAuth2Config(),
|
||||
}
|
||||
|
||||
// Start cleanup goroutine for expired tokens
|
||||
go oauth2Provider.cleanupExpiredTokens()
|
||||
}
|
||||
|
||||
// GetOAuth2Discovery returns OAuth2 discovery configuration
|
||||
func GetOAuth2Discovery(c echo.Context) error {
|
||||
if oauth2Provider == nil {
|
||||
InitializeOAuth2Provider()
|
||||
}
|
||||
return c.JSON(http.StatusOK, oauth2Provider.config)
|
||||
}
|
||||
|
||||
// HandleOAuth2Authorize handles OAuth2 authorization requests
|
||||
func HandleOAuth2Authorize(c echo.Context) error {
|
||||
if oauth2Provider == nil {
|
||||
InitializeOAuth2Provider()
|
||||
}
|
||||
|
||||
req := &OAuth2AuthorizationRequest{
|
||||
ResponseType: c.QueryParam("response_type"),
|
||||
ClientID: c.QueryParam("client_id"),
|
||||
RedirectURI: c.QueryParam("redirect_uri"),
|
||||
Scope: c.QueryParam("scope"),
|
||||
State: c.QueryParam("state"),
|
||||
CodeChallenge: c.QueryParam("code_challenge"),
|
||||
CodeChallengeMethod: c.QueryParam("code_challenge_method"),
|
||||
Nonce: c.QueryParam("nonce"),
|
||||
Prompt: c.QueryParam("prompt"),
|
||||
LoginHint: c.QueryParam("login_hint"),
|
||||
}
|
||||
|
||||
// Validate client
|
||||
client, err := oauth2Provider.clientRegistry.GetClient(req.ClientID)
|
||||
if err != nil {
|
||||
return oauth2Error(c, "invalid_client", "Unknown client", req.State)
|
||||
}
|
||||
|
||||
// Validate redirect URI
|
||||
if !client.ValidateRedirectURI(req.RedirectURI) {
|
||||
return oauth2Error(c, "invalid_request", "Invalid redirect URI", req.State)
|
||||
}
|
||||
|
||||
// Validate response type
|
||||
if !isValidResponseType(req.ResponseType) {
|
||||
return redirectError(
|
||||
c,
|
||||
req.RedirectURI,
|
||||
"unsupported_response_type",
|
||||
"Response type not supported",
|
||||
req.State,
|
||||
)
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
requestedScopes := parseScopes(req.Scope)
|
||||
if !client.ValidateScopes(requestedScopes) {
|
||||
return redirectError(
|
||||
c,
|
||||
req.RedirectURI,
|
||||
"invalid_scope",
|
||||
"Requested scope not allowed",
|
||||
req.State,
|
||||
)
|
||||
}
|
||||
|
||||
// Validate PKCE for public clients
|
||||
if client.ClientType == "public" && client.RequirePKCE {
|
||||
if req.CodeChallenge == "" {
|
||||
return redirectError(
|
||||
c,
|
||||
req.RedirectURI,
|
||||
"invalid_request",
|
||||
"PKCE required for public clients",
|
||||
req.State,
|
||||
)
|
||||
}
|
||||
if req.CodeChallengeMethod != PKCEMethodS256 {
|
||||
return redirectError(
|
||||
c,
|
||||
req.RedirectURI,
|
||||
"invalid_request",
|
||||
"Only S256 PKCE method supported",
|
||||
req.State,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
userDID := c.Get("user_did")
|
||||
if userDID == nil {
|
||||
// Store authorization request and redirect to authentication
|
||||
sessionID := generateSessionID()
|
||||
// TODO: Store auth request in session store
|
||||
authURL := fmt.Sprintf(
|
||||
"/auth/login?session_id=%s&return_to=%s",
|
||||
sessionID,
|
||||
c.Request().URL.String(),
|
||||
)
|
||||
return c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
// Check consent
|
||||
if client.RequiresConsent &&
|
||||
!oauth2Provider.hasValidConsent(userDID.(string), req.ClientID, requestedScopes) {
|
||||
// Render consent page
|
||||
return renderOAuth2ConsentPage(c, req, client)
|
||||
}
|
||||
|
||||
// Generate authorization code
|
||||
code := generateSecureToken(32)
|
||||
authCode := &OAuth2AuthorizationCode{
|
||||
Code: code,
|
||||
ClientID: req.ClientID,
|
||||
UserDID: userDID.(string),
|
||||
RedirectURI: req.RedirectURI,
|
||||
Scopes: requestedScopes,
|
||||
State: req.State,
|
||||
Nonce: req.Nonce,
|
||||
CodeChallenge: req.CodeChallenge,
|
||||
CodeChallengeMethod: req.CodeChallengeMethod,
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
UCANContext: oauth2Provider.buildUCANContext(userDID.(string)),
|
||||
}
|
||||
|
||||
// Store authorization code
|
||||
oauth2Provider.authCodeStore.Store(authCode)
|
||||
|
||||
// Build redirect URL
|
||||
redirectURL := buildAuthorizationRedirect(req.RedirectURI, code, req.State)
|
||||
return c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
|
||||
// HandleOAuth2Token handles OAuth2 token requests
|
||||
func HandleOAuth2Token(c echo.Context) error {
|
||||
if oauth2Provider == nil {
|
||||
InitializeOAuth2Provider()
|
||||
}
|
||||
|
||||
var req OAuth2TokenRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return oauth2TokenError(c, "invalid_request", "Invalid token request")
|
||||
}
|
||||
|
||||
// Authenticate client
|
||||
client, err := oauth2Provider.authenticateClient(c, &req)
|
||||
if err != nil {
|
||||
return oauth2TokenError(c, "invalid_client", "Client authentication failed")
|
||||
}
|
||||
|
||||
// Handle grant type
|
||||
switch req.GrantType {
|
||||
case "authorization_code":
|
||||
return oauth2Provider.handleAuthorizationCodeGrant(c, client, &req)
|
||||
case "refresh_token":
|
||||
return oauth2Provider.handleRefreshTokenGrant(c, client, &req)
|
||||
case "client_credentials":
|
||||
return oauth2Provider.handleClientCredentialsGrant(c, client, &req)
|
||||
default:
|
||||
return oauth2TokenError(c, "unsupported_grant_type", "Grant type not supported")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOAuth2Introspection handles token introspection requests
|
||||
func HandleOAuth2Introspection(c echo.Context) error {
|
||||
if oauth2Provider == nil {
|
||||
InitializeOAuth2Provider()
|
||||
}
|
||||
|
||||
var req OAuth2IntrospectionRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, &OAuth2IntrospectionResponse{Active: false})
|
||||
}
|
||||
|
||||
// Authenticate client
|
||||
client, err := oauth2Provider.authenticateClient(c, &OAuth2TokenRequest{
|
||||
ClientID: req.ClientID,
|
||||
ClientSecret: req.ClientSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusUnauthorized, &OAuth2IntrospectionResponse{Active: false})
|
||||
}
|
||||
|
||||
// Introspect token
|
||||
response := oauth2Provider.introspectToken(req.Token, req.TokenTypeHint, client)
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// HandleOAuth2Revocation handles token revocation requests
|
||||
func HandleOAuth2Revocation(c echo.Context) error {
|
||||
if oauth2Provider == nil {
|
||||
InitializeOAuth2Provider()
|
||||
}
|
||||
|
||||
var req OAuth2RevocationRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.NoContent(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Authenticate client
|
||||
client, err := oauth2Provider.authenticateClient(c, &OAuth2TokenRequest{
|
||||
ClientID: req.ClientID,
|
||||
ClientSecret: req.ClientSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return c.NoContent(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// Revoke token
|
||||
oauth2Provider.revokeToken(req.Token, req.TokenTypeHint, client)
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
func (p *OAuth2Provider) handleAuthorizationCodeGrant(
|
||||
c echo.Context,
|
||||
client *OAuth2Client,
|
||||
req *OAuth2TokenRequest,
|
||||
) error {
|
||||
// Retrieve authorization code
|
||||
authCode := p.authCodeStore.Exchange(req.Code)
|
||||
if authCode == nil {
|
||||
return oauth2TokenError(c, "invalid_grant", "Invalid authorization code")
|
||||
}
|
||||
|
||||
// Validate code hasn't expired
|
||||
if time.Now().After(authCode.ExpiresAt) {
|
||||
return oauth2TokenError(c, "invalid_grant", "Authorization code expired")
|
||||
}
|
||||
|
||||
// Validate client
|
||||
if authCode.ClientID != client.ClientID {
|
||||
return oauth2TokenError(c, "invalid_grant", "Code was issued to different client")
|
||||
}
|
||||
|
||||
// Validate redirect URI
|
||||
if authCode.RedirectURI != req.RedirectURI {
|
||||
return oauth2TokenError(c, "invalid_grant", "Redirect URI mismatch")
|
||||
}
|
||||
|
||||
// Validate PKCE if present
|
||||
if authCode.CodeChallenge != "" {
|
||||
if !p.validatePKCE(req.CodeVerifier, authCode.CodeChallenge, authCode.CodeChallengeMethod) {
|
||||
return oauth2TokenError(c, "invalid_grant", "Invalid PKCE verifier")
|
||||
}
|
||||
}
|
||||
|
||||
// Create UCAN delegation
|
||||
ucanToken, err := p.ucanDelegator.CreateDelegation(
|
||||
authCode.UserDID,
|
||||
client.ClientID,
|
||||
authCode.Scopes,
|
||||
time.Now().Add(time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
return oauth2TokenError(c, "server_error", "Failed to create delegation")
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
accessToken := p.generateAccessToken(authCode, ucanToken)
|
||||
refreshToken := p.generateRefreshToken(authCode)
|
||||
|
||||
// Store tokens
|
||||
p.accessTokenStore.Store(accessToken)
|
||||
p.refreshTokenStore.Store(refreshToken)
|
||||
|
||||
// Generate ID token if openid scope present
|
||||
var idToken string
|
||||
if contains(authCode.Scopes, "openid") {
|
||||
idToken, _ = generateIDToken(authCode.UserDID, client.ClientID, authCode.Nonce)
|
||||
}
|
||||
|
||||
// Return token response
|
||||
response := &OAuth2TokenResponse{
|
||||
AccessToken: accessToken.Token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
RefreshToken: refreshToken.Token,
|
||||
Scope: strings.Join(authCode.Scopes, " "),
|
||||
IDToken: idToken,
|
||||
UCANToken: ucanToken.Raw,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (p *OAuth2Provider) handleRefreshTokenGrant(
|
||||
c echo.Context,
|
||||
client *OAuth2Client,
|
||||
req *OAuth2TokenRequest,
|
||||
) error {
|
||||
// Retrieve refresh token
|
||||
oldRefreshToken := p.refreshTokenStore.Get(req.RefreshToken)
|
||||
if oldRefreshToken == nil {
|
||||
return oauth2TokenError(c, "invalid_grant", "Invalid refresh token")
|
||||
}
|
||||
|
||||
// Validate client
|
||||
if oldRefreshToken.ClientID != client.ClientID {
|
||||
return oauth2TokenError(c, "invalid_grant", "Token was issued to different client")
|
||||
}
|
||||
|
||||
// Validate expiration
|
||||
if time.Now().After(oldRefreshToken.ExpiresAt) {
|
||||
return oauth2TokenError(c, "invalid_grant", "Refresh token expired")
|
||||
}
|
||||
|
||||
// Rotate refresh token
|
||||
p.refreshTokenStore.Revoke(req.RefreshToken)
|
||||
|
||||
// Create new UCAN delegation
|
||||
ucanToken, err := p.ucanDelegator.CreateDelegation(
|
||||
oldRefreshToken.UserDID,
|
||||
client.ClientID,
|
||||
oldRefreshToken.Scopes,
|
||||
time.Now().Add(time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
return oauth2TokenError(c, "server_error", "Failed to create delegation")
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
newAccessToken := &OAuth2AccessToken{
|
||||
Token: generateSecureToken(32),
|
||||
UserDID: oldRefreshToken.UserDID,
|
||||
ClientID: client.ClientID,
|
||||
Scopes: oldRefreshToken.Scopes,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
IssuedAt: time.Now(),
|
||||
UCANToken: ucanToken,
|
||||
}
|
||||
|
||||
newRefreshToken := &OAuth2RefreshToken{
|
||||
Token: generateSecureToken(32),
|
||||
AccessToken: newAccessToken.Token,
|
||||
ClientID: client.ClientID,
|
||||
UserDID: oldRefreshToken.UserDID,
|
||||
Scopes: oldRefreshToken.Scopes,
|
||||
ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
|
||||
IssuedAt: time.Now(),
|
||||
RotationCount: oldRefreshToken.RotationCount + 1,
|
||||
}
|
||||
|
||||
// Store new tokens
|
||||
p.accessTokenStore.Store(newAccessToken)
|
||||
p.refreshTokenStore.Store(newRefreshToken)
|
||||
|
||||
// Return response
|
||||
response := &OAuth2TokenResponse{
|
||||
AccessToken: newAccessToken.Token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
RefreshToken: newRefreshToken.Token,
|
||||
Scope: strings.Join(newAccessToken.Scopes, " "),
|
||||
UCANToken: ucanToken.Raw,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (p *OAuth2Provider) handleClientCredentialsGrant(
|
||||
c echo.Context,
|
||||
client *OAuth2Client,
|
||||
req *OAuth2TokenRequest,
|
||||
) error {
|
||||
// Client credentials grant is only for confidential clients
|
||||
if client.ClientType != "confidential" {
|
||||
return oauth2TokenError(
|
||||
c,
|
||||
"unauthorized_client",
|
||||
"Client type not authorized for this grant",
|
||||
)
|
||||
}
|
||||
|
||||
// Parse requested scopes
|
||||
scopes := parseScopes(req.Scope)
|
||||
if !client.ValidateScopes(scopes) {
|
||||
return oauth2TokenError(c, "invalid_scope", "Requested scope not allowed")
|
||||
}
|
||||
|
||||
// Create service-to-service UCAN token
|
||||
ucanToken, err := p.ucanDelegator.CreateServiceDelegation(
|
||||
client.ClientID,
|
||||
scopes,
|
||||
time.Now().Add(time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
return oauth2TokenError(c, "server_error", "Failed to create delegation")
|
||||
}
|
||||
|
||||
// Generate access token
|
||||
accessToken := &OAuth2AccessToken{
|
||||
Token: generateSecureToken(32),
|
||||
UserDID: "", // No user for client credentials
|
||||
ClientID: client.ClientID,
|
||||
Scopes: scopes,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
IssuedAt: time.Now(),
|
||||
UCANToken: ucanToken,
|
||||
TokenType: "client_credentials",
|
||||
}
|
||||
|
||||
// Store token
|
||||
p.accessTokenStore.Store(accessToken)
|
||||
|
||||
// Return response
|
||||
response := &OAuth2TokenResponse{
|
||||
AccessToken: accessToken.Token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
Scope: strings.Join(scopes, " "),
|
||||
UCANToken: ucanToken.Raw,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (p *OAuth2Provider) authenticateClient(
|
||||
c echo.Context,
|
||||
req *OAuth2TokenRequest,
|
||||
) (*OAuth2Client, error) {
|
||||
// Try Basic Auth first
|
||||
if username, password, ok := c.Request().BasicAuth(); ok {
|
||||
client, err := p.clientRegistry.GetClient(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if client.ClientType == "confidential" &&
|
||||
subtle.ConstantTimeCompare([]byte(client.ClientSecret), []byte(password)) == 1 {
|
||||
return client, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid client credentials")
|
||||
}
|
||||
|
||||
// Try client_secret_post
|
||||
if req.ClientID != "" && req.ClientSecret != "" {
|
||||
client, err := p.clientRegistry.GetClient(req.ClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if client.ClientType == "confidential" &&
|
||||
subtle.ConstantTimeCompare([]byte(client.ClientSecret), []byte(req.ClientSecret)) == 1 {
|
||||
return client, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid client credentials")
|
||||
}
|
||||
|
||||
// Try client_assertion (JWT)
|
||||
if req.ClientAssertion != "" &&
|
||||
req.ClientAssertionType == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
|
||||
// TODO: Implement JWT client assertion validation
|
||||
return nil, fmt.Errorf("JWT client assertion not yet implemented")
|
||||
}
|
||||
|
||||
// Public client (no authentication)
|
||||
if req.ClientID != "" {
|
||||
client, err := p.clientRegistry.GetClient(req.ClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if client.ClientType == "public" {
|
||||
return client, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("client authentication required")
|
||||
}
|
||||
|
||||
func (p *OAuth2Provider) validatePKCE(verifier, challenge, method string) bool {
|
||||
if method == "" {
|
||||
method = PKCEMethodPlain
|
||||
}
|
||||
computed := computePKCEChallenge(verifier, method)
|
||||
return subtle.ConstantTimeCompare([]byte(computed), []byte(challenge)) == 1
|
||||
}
|
||||
|
||||
func (p *OAuth2Provider) hasValidConsent(userDID, clientID string, scopes []string) bool {
|
||||
p.consentStore.mu.RLock()
|
||||
defer p.consentStore.mu.RUnlock()
|
||||
|
||||
key := fmt.Sprintf("%s:%s", userDID, clientID)
|
||||
consent, exists := p.consentStore.consents[key]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().After(consent.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check all requested scopes are approved
|
||||
for _, scope := range scopes {
|
||||
if !contains(consent.ApprovedScopes, scope) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *OAuth2Provider) buildUCANContext(userDID string) *UCANAuthContext {
|
||||
// TODO: Fetch actual vault and DID document data
|
||||
return &UCANAuthContext{
|
||||
VaultAddress: fmt.Sprintf("vault_%s", userDID),
|
||||
EnclaveDataCID: fmt.Sprintf("cid_%s", userDID),
|
||||
Capabilities: []string{"read", "write", "sign"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OAuth2Provider) generateAccessToken(
|
||||
authCode *OAuth2AuthorizationCode,
|
||||
ucanToken *ucan.Token,
|
||||
) *OAuth2AccessToken {
|
||||
return &OAuth2AccessToken{
|
||||
Token: generateSecureToken(32),
|
||||
UserDID: authCode.UserDID,
|
||||
ClientID: authCode.ClientID,
|
||||
Scopes: authCode.Scopes,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
IssuedAt: time.Now(),
|
||||
UCANToken: ucanToken,
|
||||
SessionID: generateSessionID(),
|
||||
TokenType: "authorization_code",
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OAuth2Provider) generateRefreshToken(
|
||||
authCode *OAuth2AuthorizationCode,
|
||||
) *OAuth2RefreshToken {
|
||||
return &OAuth2RefreshToken{
|
||||
Token: generateSecureToken(32),
|
||||
ClientID: authCode.ClientID,
|
||||
UserDID: authCode.UserDID,
|
||||
Scopes: authCode.Scopes,
|
||||
ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
|
||||
IssuedAt: time.Now(),
|
||||
RotationCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OAuth2Provider) introspectToken(
|
||||
token, tokenTypeHint string,
|
||||
client *OAuth2Client,
|
||||
) *OAuth2IntrospectionResponse {
|
||||
// Try access token first
|
||||
if accessToken := p.accessTokenStore.Get(token); accessToken != nil {
|
||||
if accessToken.ClientID != client.ClientID {
|
||||
return &OAuth2IntrospectionResponse{Active: false}
|
||||
}
|
||||
return &OAuth2IntrospectionResponse{
|
||||
Active: time.Now().Before(accessToken.ExpiresAt),
|
||||
Scope: strings.Join(accessToken.Scopes, " "),
|
||||
ClientID: accessToken.ClientID,
|
||||
Username: accessToken.UserDID,
|
||||
TokenType: "Bearer",
|
||||
ExpiresAt: accessToken.ExpiresAt.Unix(),
|
||||
IssuedAt: accessToken.IssuedAt.Unix(),
|
||||
Subject: accessToken.UserDID,
|
||||
UCANToken: accessToken.UCANToken.Raw,
|
||||
}
|
||||
}
|
||||
|
||||
// Try refresh token
|
||||
if refreshToken := p.refreshTokenStore.Get(token); refreshToken != nil {
|
||||
if refreshToken.ClientID != client.ClientID {
|
||||
return &OAuth2IntrospectionResponse{Active: false}
|
||||
}
|
||||
return &OAuth2IntrospectionResponse{
|
||||
Active: time.Now().Before(refreshToken.ExpiresAt),
|
||||
Scope: strings.Join(refreshToken.Scopes, " "),
|
||||
ClientID: refreshToken.ClientID,
|
||||
Username: refreshToken.UserDID,
|
||||
TokenType: "refresh_token",
|
||||
ExpiresAt: refreshToken.ExpiresAt.Unix(),
|
||||
IssuedAt: refreshToken.IssuedAt.Unix(),
|
||||
Subject: refreshToken.UserDID,
|
||||
}
|
||||
}
|
||||
|
||||
return &OAuth2IntrospectionResponse{Active: false}
|
||||
}
|
||||
|
||||
func (p *OAuth2Provider) revokeToken(token, tokenTypeHint string, client *OAuth2Client) {
|
||||
// Try to revoke as access token
|
||||
if p.accessTokenStore.Revoke(token) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to revoke as refresh token
|
||||
p.refreshTokenStore.Revoke(token)
|
||||
}
|
||||
|
||||
func (p *OAuth2Provider) cleanupExpiredTokens() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Cleanup expired authorization codes
|
||||
p.authCodeStore.CleanupExpired()
|
||||
|
||||
// Cleanup expired access tokens
|
||||
p.accessTokenStore.CleanupExpired()
|
||||
|
||||
// Cleanup expired refresh tokens
|
||||
p.refreshTokenStore.CleanupExpired()
|
||||
}
|
||||
}
|
||||
|
||||
// Store methods for token stores
|
||||
|
||||
func (s *AuthCodeStore) Store(code *OAuth2AuthorizationCode) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.codes[code.Code] = code
|
||||
}
|
||||
|
||||
func (s *AuthCodeStore) Exchange(code string) *OAuth2AuthorizationCode {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
authCode, exists := s.codes[code]
|
||||
if !exists || authCode.Used {
|
||||
return nil
|
||||
}
|
||||
|
||||
authCode.Used = true
|
||||
return authCode
|
||||
}
|
||||
|
||||
func (s *AuthCodeStore) CleanupExpired() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for code, authCode := range s.codes {
|
||||
if now.After(authCode.ExpiresAt) {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AccessTokenStore) Store(token *OAuth2AccessToken) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.tokens[token.Token] = token
|
||||
}
|
||||
|
||||
func (s *AccessTokenStore) Get(token string) *OAuth2AccessToken {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.tokens[token]
|
||||
}
|
||||
|
||||
func (s *AccessTokenStore) Revoke(token string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.tokens[token]; exists {
|
||||
delete(s.tokens, token)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *AccessTokenStore) CleanupExpired() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for token, accessToken := range s.tokens {
|
||||
if now.After(accessToken.ExpiresAt) {
|
||||
delete(s.tokens, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RefreshTokenStore) Store(token *OAuth2RefreshToken) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.tokens[token.Token] = token
|
||||
}
|
||||
|
||||
func (s *RefreshTokenStore) Get(token string) *OAuth2RefreshToken {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.tokens[token]
|
||||
}
|
||||
|
||||
func (s *RefreshTokenStore) Revoke(token string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.tokens[token]; exists {
|
||||
delete(s.tokens, token)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *RefreshTokenStore) CleanupExpired() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for token, refreshToken := range s.tokens {
|
||||
if now.After(refreshToken.ExpiresAt) {
|
||||
delete(s.tokens, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func generateSecureToken(bytes int) string {
|
||||
b := make([]byte, bytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func parseScopes(scope string) []string {
|
||||
if scope == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Split(scope, " ")
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isValidResponseType(responseType string) bool {
|
||||
validTypes := []string{
|
||||
"code",
|
||||
"token",
|
||||
"id_token",
|
||||
"code id_token",
|
||||
"code token",
|
||||
"id_token token",
|
||||
"code id_token token",
|
||||
}
|
||||
return contains(validTypes, responseType)
|
||||
}
|
||||
|
||||
func oauth2Error(c echo.Context, error, description, state string) error {
|
||||
return c.JSON(http.StatusBadRequest, &OAuth2ErrorResponse{
|
||||
Error: error,
|
||||
ErrorDescription: description,
|
||||
State: state,
|
||||
})
|
||||
}
|
||||
|
||||
func oauth2TokenError(c echo.Context, error, description string) error {
|
||||
return c.JSON(http.StatusBadRequest, &OAuth2ErrorResponse{
|
||||
Error: error,
|
||||
ErrorDescription: description,
|
||||
})
|
||||
}
|
||||
|
||||
func redirectError(c echo.Context, redirectURI, error, description, state string) error {
|
||||
url := fmt.Sprintf("%s?error=%s&error_description=%s&state=%s",
|
||||
redirectURI, error, description, state)
|
||||
return c.Redirect(http.StatusFound, url)
|
||||
}
|
||||
|
||||
func buildAuthorizationRedirect(redirectURI, code, state string) string {
|
||||
if strings.Contains(redirectURI, "?") {
|
||||
return fmt.Sprintf("%s&code=%s&state=%s", redirectURI, code, state)
|
||||
}
|
||||
return fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state)
|
||||
}
|
||||
|
||||
func renderOAuth2ConsentPage(
|
||||
c echo.Context,
|
||||
req *OAuth2AuthorizationRequest,
|
||||
client *OAuth2Client,
|
||||
) error {
|
||||
// TODO: Render actual consent page
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"client": client,
|
||||
"scopes": parseScopes(req.Scope),
|
||||
"state": req.State,
|
||||
"client_id": req.ClientID,
|
||||
})
|
||||
}
|
||||
|
||||
func getDefaultOAuth2Config() *OAuth2Config {
|
||||
baseURL := "https://localhost:8080"
|
||||
return &OAuth2Config{
|
||||
Issuer: baseURL,
|
||||
AuthorizationEndpoint: baseURL + "/oauth2/authorize",
|
||||
TokenEndpoint: baseURL + "/oauth2/token",
|
||||
UserInfoEndpoint: baseURL + "/oauth2/userinfo",
|
||||
JWKSEndpoint: baseURL + "/oauth2/jwks",
|
||||
RegistrationEndpoint: baseURL + "/oauth2/register",
|
||||
IntrospectionEndpoint: baseURL + "/oauth2/introspect",
|
||||
RevocationEndpoint: baseURL + "/oauth2/revoke",
|
||||
ScopesSupported: []string{
|
||||
"openid", "profile", "email", "offline_access",
|
||||
"vault:read", "vault:write", "vault:sign", "vault:admin",
|
||||
"service:manage", "did:read", "did:write",
|
||||
},
|
||||
ResponseTypesSupported: []string{
|
||||
"code", "token", "id_token",
|
||||
"code id_token", "code token",
|
||||
"id_token token", "code id_token token",
|
||||
},
|
||||
ResponseModesSupported: []string{
|
||||
"query", "fragment", "form_post",
|
||||
},
|
||||
GrantTypesSupported: []string{
|
||||
"authorization_code", "implicit", "refresh_token",
|
||||
"client_credentials", "urn:ietf:params:oauth:grant-type:device_code",
|
||||
},
|
||||
SubjectTypesSupported: []string{
|
||||
"public", "pairwise",
|
||||
},
|
||||
IDTokenSigningAlgValuesSupported: []string{
|
||||
"ES256", "RS256", "HS256",
|
||||
},
|
||||
TokenEndpointAuthMethodsSupported: []string{
|
||||
"client_secret_basic", "client_secret_post",
|
||||
"client_secret_jwt", "private_key_jwt", "none",
|
||||
},
|
||||
ClaimsSupported: []string{
|
||||
"sub", "iss", "aud", "exp", "iat", "auth_time", "nonce",
|
||||
"name", "given_name", "family_name", "middle_name", "nickname",
|
||||
"preferred_username", "profile", "picture", "website", "email",
|
||||
"email_verified", "did", "vault_id", "ucan_capabilities",
|
||||
},
|
||||
CodeChallengeMethodsSupported: []string{
|
||||
PKCEMethodS256, PKCEMethodPlain,
|
||||
},
|
||||
ServiceDocumentation: baseURL + "/docs/oauth2",
|
||||
UILocalesSupported: []string{"en-US"},
|
||||
UCANSupported: true,
|
||||
}
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sonr-io/sonr/crypto/ucan"
|
||||
)
|
||||
|
||||
// RefreshTokenHandler handles OAuth2 refresh token flows with UCAN chains
|
||||
type RefreshTokenHandler struct {
|
||||
delegator *UCANDelegator
|
||||
signer *BlockchainUCANSigner
|
||||
tokenStore TokenStore
|
||||
clientStore ClientStore
|
||||
}
|
||||
|
||||
// RefreshTokenRequest represents an OAuth2 refresh token request
|
||||
type RefreshTokenRequest struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
}
|
||||
|
||||
// RefreshTokenResponse represents an OAuth2 refresh token response
|
||||
type RefreshTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
UCANToken string `json:"ucan_token,omitempty"`
|
||||
}
|
||||
|
||||
// UCANRefreshMetadata stores metadata for UCAN refresh chains
|
||||
type UCANRefreshMetadata struct {
|
||||
OriginalIssuer string `json:"original_issuer"`
|
||||
DelegationChain []string `json:"delegation_chain"`
|
||||
RefreshCount int `json:"refresh_count"`
|
||||
MaxRefreshCount int `json:"max_refresh_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastRefreshedAt time.Time `json:"last_refreshed_at"`
|
||||
AttenuationPath []Attenuation `json:"attenuation_path"`
|
||||
}
|
||||
|
||||
// Attenuation represents scope reduction in the delegation chain
|
||||
type Attenuation struct {
|
||||
FromScopes []string `json:"from_scopes"`
|
||||
ToScopes []string `json:"to_scopes"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// NewRefreshTokenHandler creates a new refresh token handler
|
||||
func NewRefreshTokenHandler(
|
||||
delegator *UCANDelegator,
|
||||
signer *BlockchainUCANSigner,
|
||||
tokenStore TokenStore,
|
||||
clientStore ClientStore,
|
||||
) *RefreshTokenHandler {
|
||||
return &RefreshTokenHandler{
|
||||
delegator: delegator,
|
||||
signer: signer,
|
||||
tokenStore: tokenStore,
|
||||
clientStore: clientStore,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRefreshToken handles OAuth2 refresh token requests
|
||||
func (h *RefreshTokenHandler) HandleRefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request (handle both JSON and form-encoded)
|
||||
var req RefreshTokenRequest
|
||||
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if strings.Contains(contentType, "application/json") {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.sendError(w, "invalid_request", "Failed to parse JSON request body")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Parse form data
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.sendError(w, "invalid_request", "Failed to parse form data")
|
||||
return
|
||||
}
|
||||
|
||||
req.GrantType = r.FormValue("grant_type")
|
||||
req.RefreshToken = r.FormValue("refresh_token")
|
||||
req.Scope = r.FormValue("scope")
|
||||
req.ClientID = r.FormValue("client_id")
|
||||
req.ClientSecret = r.FormValue("client_secret")
|
||||
}
|
||||
|
||||
// Validate grant type
|
||||
if req.GrantType != "refresh_token" {
|
||||
h.sendError(w, "unsupported_grant_type", "Only refresh_token grant type is supported")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate refresh token
|
||||
if req.RefreshToken == "" {
|
||||
h.sendError(w, "invalid_request", "Missing refresh_token parameter")
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate client
|
||||
clientID, clientSecret := h.extractClientCredentials(r, &req)
|
||||
ctx := r.Context()
|
||||
|
||||
if err := h.clientStore.ValidateClientCredentials(ctx, clientID, clientSecret); err != nil {
|
||||
h.sendError(w, "invalid_client", "Client authentication failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Get client information
|
||||
client, err := h.clientStore.GetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
h.sendError(w, "invalid_client", "Client not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Process refresh token
|
||||
response, err := h.processRefreshToken(ctx, &req, client)
|
||||
if err != nil {
|
||||
h.sendError(w, "invalid_grant", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Send response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// processRefreshToken processes the refresh token and returns new tokens
|
||||
func (h *RefreshTokenHandler) processRefreshToken(
|
||||
ctx context.Context,
|
||||
req *RefreshTokenRequest,
|
||||
client *OAuth2Client,
|
||||
) (*RefreshTokenResponse, error) {
|
||||
// Retrieve stored refresh token
|
||||
storedToken, err := h.tokenStore.GetToken(ctx, req.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid refresh token")
|
||||
}
|
||||
|
||||
// Validate token type
|
||||
if storedToken.TokenType != "refresh_token" {
|
||||
return nil, fmt.Errorf("token is not a refresh token")
|
||||
}
|
||||
|
||||
// Validate client binding
|
||||
if storedToken.ClientID != client.ClientID {
|
||||
return nil, fmt.Errorf("refresh token was issued to a different client")
|
||||
}
|
||||
|
||||
// Check if refresh token has expired
|
||||
if time.Now().After(storedToken.ExpiresAt) {
|
||||
return nil, fmt.Errorf("refresh token has expired")
|
||||
}
|
||||
|
||||
// Get refresh metadata
|
||||
metadata, err := h.getRefreshMetadata(ctx, req.RefreshToken)
|
||||
if err != nil {
|
||||
// Initialize metadata for first refresh
|
||||
metadata = &UCANRefreshMetadata{
|
||||
OriginalIssuer: storedToken.UserDID,
|
||||
DelegationChain: []string{},
|
||||
RefreshCount: 0,
|
||||
MaxRefreshCount: 10, // Default max refresh count
|
||||
CreatedAt: time.Now(),
|
||||
AttenuationPath: []Attenuation{},
|
||||
}
|
||||
}
|
||||
|
||||
// Check refresh count limit
|
||||
if metadata.RefreshCount >= metadata.MaxRefreshCount {
|
||||
return nil, fmt.Errorf("refresh token has reached maximum refresh count")
|
||||
}
|
||||
|
||||
// Parse requested scopes
|
||||
requestedScopes := storedToken.Scopes
|
||||
if req.Scope != "" {
|
||||
requestedScopes = strings.Split(req.Scope, " ")
|
||||
|
||||
// Validate scope reduction (attenuate permissions)
|
||||
if !h.validateScopeAttenuation(requestedScopes, storedToken.Scopes) {
|
||||
return nil, fmt.Errorf("requested scopes exceed refresh token scopes")
|
||||
}
|
||||
|
||||
// Record attenuation
|
||||
if !h.scopesEqual(requestedScopes, storedToken.Scopes) {
|
||||
metadata.AttenuationPath = append(metadata.AttenuationPath, Attenuation{
|
||||
FromScopes: storedToken.Scopes,
|
||||
ToScopes: requestedScopes,
|
||||
Timestamp: time.Now(),
|
||||
Reason: "Client requested scope reduction",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create new UCAN token with delegation chain
|
||||
newUCANToken, err := h.createRefreshedUCANToken(
|
||||
ctx,
|
||||
metadata,
|
||||
storedToken,
|
||||
client,
|
||||
requestedScopes,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create refreshed UCAN token: %w", err)
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
accessTokenID := h.generateTokenID()
|
||||
accessToken := &StoredToken{
|
||||
TokenID: accessTokenID,
|
||||
TokenType: "access_token",
|
||||
AccessToken: accessTokenID,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
Scopes: requestedScopes,
|
||||
ClientID: client.ClientID,
|
||||
UserDID: storedToken.UserDID,
|
||||
UCANToken: newUCANToken,
|
||||
}
|
||||
|
||||
if err := h.tokenStore.StoreToken(ctx, accessToken); err != nil {
|
||||
return nil, fmt.Errorf("failed to store new access token: %w", err)
|
||||
}
|
||||
|
||||
// Update refresh metadata
|
||||
metadata.RefreshCount++
|
||||
metadata.LastRefreshedAt = time.Now()
|
||||
metadata.DelegationChain = append(metadata.DelegationChain, newUCANToken)
|
||||
|
||||
// Optionally rotate refresh token
|
||||
newRefreshTokenID := ""
|
||||
if h.shouldRotateRefreshToken(metadata) {
|
||||
newRefreshTokenID = h.generateTokenID()
|
||||
newRefreshToken := &StoredToken{
|
||||
TokenID: newRefreshTokenID,
|
||||
TokenType: "refresh_token",
|
||||
RefreshToken: newRefreshTokenID,
|
||||
ExpiresAt: time.Now().Add(30 * 24 * time.Hour), // 30 days
|
||||
Scopes: requestedScopes,
|
||||
ClientID: client.ClientID,
|
||||
UserDID: storedToken.UserDID,
|
||||
}
|
||||
|
||||
if err := h.tokenStore.StoreToken(ctx, newRefreshToken); err != nil {
|
||||
// Non-fatal, continue with existing refresh token
|
||||
newRefreshTokenID = ""
|
||||
} else {
|
||||
// Revoke old refresh token
|
||||
h.tokenStore.RevokeToken(ctx, req.RefreshToken)
|
||||
|
||||
// Store metadata for new refresh token
|
||||
h.storeRefreshMetadata(ctx, newRefreshTokenID, metadata)
|
||||
}
|
||||
} else {
|
||||
// Update metadata for existing refresh token
|
||||
h.storeRefreshMetadata(ctx, req.RefreshToken, metadata)
|
||||
}
|
||||
|
||||
// Build response
|
||||
response := &RefreshTokenResponse{
|
||||
AccessToken: accessTokenID,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
Scope: strings.Join(requestedScopes, " "),
|
||||
UCANToken: newUCANToken,
|
||||
}
|
||||
|
||||
if newRefreshTokenID != "" {
|
||||
response.RefreshToken = newRefreshTokenID
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// createRefreshedUCANToken creates a new UCAN token with proper delegation chain
|
||||
func (h *RefreshTokenHandler) createRefreshedUCANToken(
|
||||
ctx context.Context,
|
||||
metadata *UCANRefreshMetadata,
|
||||
storedToken *StoredToken,
|
||||
client *OAuth2Client,
|
||||
scopes []string,
|
||||
) (string, error) {
|
||||
// Build proof chain from previous delegations
|
||||
proofs := make([]ucan.Proof, 0, len(metadata.DelegationChain))
|
||||
for _, tokenStr := range metadata.DelegationChain {
|
||||
proofs = append(proofs, ucan.Proof(tokenStr))
|
||||
}
|
||||
|
||||
// Add original token as proof if exists
|
||||
if storedToken.UCANToken != "" {
|
||||
proofs = append([]ucan.Proof{ucan.Proof(storedToken.UCANToken)}, proofs...)
|
||||
}
|
||||
|
||||
// Determine issuer and audience
|
||||
issuer := metadata.OriginalIssuer
|
||||
if issuer == "" {
|
||||
issuer = storedToken.UserDID
|
||||
}
|
||||
|
||||
audience := client.ClientID
|
||||
if did, ok := client.Metadata["client_did"]; ok {
|
||||
audience = did
|
||||
}
|
||||
|
||||
// Create resource context with refresh metadata
|
||||
resourceContext := map[string]string{
|
||||
"refresh_count": fmt.Sprintf("%d", metadata.RefreshCount),
|
||||
"original_issuer": metadata.OriginalIssuer,
|
||||
"delegation_type": "refresh_token",
|
||||
"client_id": client.ClientID,
|
||||
}
|
||||
|
||||
// Map OAuth scopes to UCAN attenuations
|
||||
attenuations := h.delegator.scopeMapper.MapToUCAN(scopes, issuer, audience, resourceContext)
|
||||
|
||||
// Create UCAN token with delegation chain
|
||||
ucanToken := &ucan.Token{
|
||||
Issuer: issuer,
|
||||
Audience: audience,
|
||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
NotBefore: time.Now().Unix(),
|
||||
Attenuations: attenuations,
|
||||
Proofs: proofs,
|
||||
Facts: []ucan.Fact{
|
||||
{
|
||||
Data: h.createRefreshFact(metadata, scopes),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Sign the token
|
||||
signedToken, err := h.signer.Sign(ucanToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign UCAN token: %w", err)
|
||||
}
|
||||
|
||||
// Validate the delegation chain
|
||||
if len(metadata.DelegationChain) > 0 {
|
||||
allTokens := append([]string{storedToken.UCANToken}, metadata.DelegationChain...)
|
||||
allTokens = append(allTokens, signedToken)
|
||||
|
||||
if err := h.signer.ValidateDelegationChain(allTokens); err != nil {
|
||||
return "", fmt.Errorf("invalid delegation chain: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return signedToken, nil
|
||||
}
|
||||
|
||||
// validateScopeAttenuation validates that requested scopes are properly attenuated
|
||||
func (h *RefreshTokenHandler) validateScopeAttenuation(requested, allowed []string) bool {
|
||||
// Build allowed scope map
|
||||
allowedMap := make(map[string]bool)
|
||||
for _, scope := range allowed {
|
||||
allowedMap[scope] = true
|
||||
}
|
||||
|
||||
// Check each requested scope
|
||||
for _, scope := range requested {
|
||||
if !allowedMap[scope] {
|
||||
// Check if a parent scope allows this
|
||||
found := false
|
||||
for _, allowedScope := range allowed {
|
||||
if h.delegator.scopeMapper.IsHierarchicalScope(allowedScope, scope) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// shouldRotateRefreshToken determines if refresh token should be rotated
|
||||
func (h *RefreshTokenHandler) shouldRotateRefreshToken(metadata *UCANRefreshMetadata) bool {
|
||||
// Rotate on every use for maximum security
|
||||
// Could be configured based on policy
|
||||
return true
|
||||
}
|
||||
|
||||
// extractClientCredentials extracts client credentials from request
|
||||
func (h *RefreshTokenHandler) extractClientCredentials(
|
||||
r *http.Request,
|
||||
req *RefreshTokenRequest,
|
||||
) (string, string) {
|
||||
// Try Basic Auth first
|
||||
if clientID, clientSecret, ok := r.BasicAuth(); ok {
|
||||
return clientID, clientSecret
|
||||
}
|
||||
|
||||
// Fall back to request body
|
||||
return req.ClientID, req.ClientSecret
|
||||
}
|
||||
|
||||
// getRefreshMetadata retrieves refresh metadata from storage
|
||||
func (h *RefreshTokenHandler) getRefreshMetadata(
|
||||
ctx context.Context,
|
||||
refreshTokenID string,
|
||||
) (*UCANRefreshMetadata, error) {
|
||||
// In production, this would retrieve from persistent storage
|
||||
// For now, return error to initialize new metadata
|
||||
return nil, fmt.Errorf("metadata not found")
|
||||
}
|
||||
|
||||
// storeRefreshMetadata stores refresh metadata
|
||||
func (h *RefreshTokenHandler) storeRefreshMetadata(
|
||||
ctx context.Context,
|
||||
refreshTokenID string,
|
||||
metadata *UCANRefreshMetadata,
|
||||
) error {
|
||||
// In production, this would persist to storage
|
||||
// For now, just return success
|
||||
return nil
|
||||
}
|
||||
|
||||
// createRefreshFact creates a fact for refresh token
|
||||
func (h *RefreshTokenHandler) createRefreshFact(
|
||||
metadata *UCANRefreshMetadata,
|
||||
scopes []string,
|
||||
) json.RawMessage {
|
||||
fact := map[string]any{
|
||||
"type": "refresh_token",
|
||||
"refresh_count": metadata.RefreshCount,
|
||||
"original_issuer": metadata.OriginalIssuer,
|
||||
"scopes": scopes,
|
||||
"refreshed_at": time.Now().Unix(),
|
||||
"delegation_length": len(metadata.DelegationChain),
|
||||
}
|
||||
|
||||
// Add attenuation info if present
|
||||
if len(metadata.AttenuationPath) > 0 {
|
||||
fact["attenuations"] = len(metadata.AttenuationPath)
|
||||
lastAttenuation := metadata.AttenuationPath[len(metadata.AttenuationPath)-1]
|
||||
fact["last_attenuation"] = map[string]any{
|
||||
"from": strings.Join(lastAttenuation.FromScopes, " "),
|
||||
"to": strings.Join(lastAttenuation.ToScopes, " "),
|
||||
"at": lastAttenuation.Timestamp.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(fact)
|
||||
return json.RawMessage(data)
|
||||
}
|
||||
|
||||
// scopesEqual checks if two scope slices are equal
|
||||
func (h *RefreshTokenHandler) scopesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
aMap := make(map[string]bool)
|
||||
for _, scope := range a {
|
||||
aMap[scope] = true
|
||||
}
|
||||
|
||||
for _, scope := range b {
|
||||
if !aMap[scope] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// generateTokenID generates a unique token identifier
|
||||
func (h *RefreshTokenHandler) generateTokenID() string {
|
||||
// In production, use a proper UUID or secure random generator
|
||||
return fmt.Sprintf("tok_%d_%s", time.Now().UnixNano(), h.randomString(16))
|
||||
}
|
||||
|
||||
// randomString generates a random string
|
||||
func (h *RefreshTokenHandler) randomString(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
result[i] = charset[time.Now().UnixNano()%int64(len(charset))]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// sendError sends an OAuth error response
|
||||
func (h *RefreshTokenHandler) sendError(w http.ResponseWriter, errorCode, errorDescription string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
response := map[string]string{
|
||||
"error": errorCode,
|
||||
"error_description": errorDescription,
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleUCANRefresh handles UCAN-specific refresh requests
|
||||
func (h *RefreshTokenHandler) HandleUCANRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse UCAN token from Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
h.sendError(w, "invalid_request", "Missing or invalid Authorization header")
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Verify the UCAN token
|
||||
ucanToken, err := h.signer.VerifySignature(tokenString)
|
||||
if err != nil {
|
||||
h.sendError(w, "invalid_grant", "Invalid UCAN token")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if token can be refreshed (not expired beyond grace period)
|
||||
gracePeriod := int64(300) // 5 minutes grace period
|
||||
if time.Now().Unix() > ucanToken.ExpiresAt+gracePeriod {
|
||||
h.sendError(w, "invalid_grant", "Token expired beyond grace period")
|
||||
return
|
||||
}
|
||||
|
||||
// Create refreshed token with extended expiration
|
||||
newToken, err := h.signer.RefreshToken(tokenString, time.Hour)
|
||||
if err != nil {
|
||||
h.sendError(w, "server_error", "Failed to refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
// Send response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"ucan_token": newToken,
|
||||
"token_type": "UCAN",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DynamicClientRegistrationRequest represents a client registration request per RFC 7591
|
||||
type DynamicClientRegistrationRequest struct {
|
||||
ClientName string `json:"client_name"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
GrantTypes []string `json:"grant_types,omitempty"`
|
||||
ResponseTypes []string `json:"response_types,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||
ApplicationType string `json:"application_type,omitempty"`
|
||||
Contacts []string `json:"contacts,omitempty"`
|
||||
LogoURI string `json:"logo_uri,omitempty"`
|
||||
ClientURI string `json:"client_uri,omitempty"`
|
||||
PolicyURI string `json:"policy_uri,omitempty"`
|
||||
TosURI string `json:"tos_uri,omitempty"`
|
||||
JwksURI string `json:"jwks_uri,omitempty"`
|
||||
SoftwareID string `json:"software_id,omitempty"`
|
||||
SoftwareVersion string `json:"software_version,omitempty"`
|
||||
}
|
||||
|
||||
// DynamicClientRegistrationResponse represents the response for client registration
|
||||
type DynamicClientRegistrationResponse struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
ClientName string `json:"client_name"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
ResponseTypes []string `json:"response_types"`
|
||||
Scope string `json:"scope"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
||||
ApplicationType string `json:"application_type"`
|
||||
ClientIDIssuedAt int64 `json:"client_id_issued_at"`
|
||||
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
|
||||
LogoURI string `json:"logo_uri,omitempty"`
|
||||
ClientURI string `json:"client_uri,omitempty"`
|
||||
PolicyURI string `json:"policy_uri,omitempty"`
|
||||
TosURI string `json:"tos_uri,omitempty"`
|
||||
JwksURI string `json:"jwks_uri,omitempty"`
|
||||
}
|
||||
|
||||
// HandleDynamicClientRegistration handles dynamic client registration per RFC 7591
|
||||
func (s *OAuth2Provider) HandleDynamicClientRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse registration request
|
||||
var req DynamicClientRegistrationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid registration request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.ClientName == "" {
|
||||
http.Error(w, "client_name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.RedirectURIs) == 0 {
|
||||
http.Error(w, "redirect_uris is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate redirect URIs
|
||||
for _, uri := range req.RedirectURIs {
|
||||
if !isValidRedirectURI(uri) {
|
||||
http.Error(w, "Invalid redirect URI: "+uri, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Set defaults if not provided
|
||||
if len(req.GrantTypes) == 0 {
|
||||
req.GrantTypes = []string{"authorization_code"}
|
||||
}
|
||||
|
||||
if len(req.ResponseTypes) == 0 {
|
||||
req.ResponseTypes = []string{"code"}
|
||||
}
|
||||
|
||||
if req.TokenEndpointAuthMethod == "" {
|
||||
// Default based on application type
|
||||
if req.ApplicationType == "native" || req.ApplicationType == "browser" {
|
||||
req.TokenEndpointAuthMethod = "none" // Public client
|
||||
} else {
|
||||
req.TokenEndpointAuthMethod = "client_secret_basic"
|
||||
}
|
||||
}
|
||||
|
||||
if req.ApplicationType == "" {
|
||||
req.ApplicationType = "web"
|
||||
}
|
||||
|
||||
// Validate grant types and response types
|
||||
if !validateGrantTypes(req.GrantTypes) {
|
||||
http.Error(w, "Invalid grant types", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !validateResponseTypes(req.ResponseTypes) {
|
||||
http.Error(w, "Invalid response types", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate scopes if scope mapper is available
|
||||
if req.Scope != "" && s.scopeMapper != nil {
|
||||
scopes := strings.Split(req.Scope, " ")
|
||||
for _, scope := range scopes {
|
||||
// Check if scope is valid using the scope mapper
|
||||
if _, exists := s.scopeMapper.GetScope(scope); !exists {
|
||||
http.Error(w, "Invalid scope: "+scope, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate client credentials
|
||||
clientID := generateDynamicClientID()
|
||||
var clientSecret string
|
||||
var clientSecretExpiresAt int64
|
||||
|
||||
// Only generate secret for confidential clients
|
||||
if req.TokenEndpointAuthMethod != "none" {
|
||||
clientSecret = generateDynamicClientSecret()
|
||||
// Client secrets expire in 1 year by default
|
||||
clientSecretExpiresAt = time.Now().Add(365 * 24 * time.Hour).Unix()
|
||||
}
|
||||
|
||||
// Create OAuth2 client
|
||||
client := &OAuth2Client{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURIs: req.RedirectURIs,
|
||||
AllowedScopes: strings.Split(req.Scope, " "),
|
||||
AllowedGrants: req.GrantTypes,
|
||||
TokenLifetime: time.Hour, // Default 1 hour
|
||||
RequirePKCE: false,
|
||||
TrustedClient: false,
|
||||
RequiresConsent: true,
|
||||
Metadata: map[string]string{
|
||||
"client_name": req.ClientName,
|
||||
"application_type": req.ApplicationType,
|
||||
"logo_uri": req.LogoURI,
|
||||
"client_uri": req.ClientURI,
|
||||
"policy_uri": req.PolicyURI,
|
||||
"tos_uri": req.TosURI,
|
||||
"jwks_uri": req.JwksURI,
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Set client type based on auth method
|
||||
if req.TokenEndpointAuthMethod == "none" {
|
||||
client.ClientType = "public"
|
||||
} else {
|
||||
client.ClientType = "confidential"
|
||||
}
|
||||
|
||||
// Determine if client requires PKCE
|
||||
if req.ApplicationType == "native" || req.ApplicationType == "browser" {
|
||||
client.RequirePKCE = true
|
||||
}
|
||||
|
||||
// Store client in the registry
|
||||
if s.clientRegistry != nil {
|
||||
if err := s.clientRegistry.RegisterClient(client); err != nil {
|
||||
http.Error(w, "Failed to register client", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build response
|
||||
resp := DynamicClientRegistrationResponse{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
ClientName: req.ClientName,
|
||||
RedirectURIs: req.RedirectURIs,
|
||||
GrantTypes: req.GrantTypes,
|
||||
ResponseTypes: req.ResponseTypes,
|
||||
Scope: req.Scope,
|
||||
TokenEndpointAuthMethod: req.TokenEndpointAuthMethod,
|
||||
ApplicationType: req.ApplicationType,
|
||||
ClientIDIssuedAt: time.Now().Unix(),
|
||||
ClientSecretExpiresAt: clientSecretExpiresAt,
|
||||
LogoURI: req.LogoURI,
|
||||
ClientURI: req.ClientURI,
|
||||
PolicyURI: req.PolicyURI,
|
||||
TosURI: req.TosURI,
|
||||
JwksURI: req.JwksURI,
|
||||
}
|
||||
|
||||
// Return registration response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// HandleClientConfiguration handles client configuration retrieval
|
||||
func (s *OAuth2Provider) HandleClientConfiguration(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract client ID from path or query
|
||||
clientID := r.URL.Query().Get("client_id")
|
||||
if clientID == "" {
|
||||
// Try to extract from path (e.g., /register/{client_id})
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) > 2 {
|
||||
clientID = parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
if clientID == "" {
|
||||
http.Error(w, "client_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate access token for client management
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="client_configuration"`)
|
||||
http.Error(w, "Access token required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Validate token in the access token store
|
||||
if s.accessTokenStore == nil {
|
||||
http.Error(w, "Token store not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Check token validity (simplified for now)
|
||||
// In production, this should validate the token properly
|
||||
if token == "" {
|
||||
http.Error(w, "Invalid or expired access token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Get client from registry
|
||||
if s.clientRegistry == nil {
|
||||
http.Error(w, "Client registry not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := s.clientRegistry.GetClient(clientID)
|
||||
if err != nil {
|
||||
http.Error(w, "Client not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Build response
|
||||
resp := DynamicClientRegistrationResponse{
|
||||
ClientID: client.ClientID,
|
||||
ClientName: client.Metadata["client_name"],
|
||||
RedirectURIs: client.RedirectURIs,
|
||||
GrantTypes: client.AllowedGrants,
|
||||
ResponseTypes: []string{"code", "token"}, // Default response types
|
||||
Scope: strings.Join(client.AllowedScopes, " "),
|
||||
TokenEndpointAuthMethod: getTokenEndpointAuthMethod(client),
|
||||
ApplicationType: client.Metadata["application_type"],
|
||||
ClientIDIssuedAt: client.CreatedAt.Unix(),
|
||||
LogoURI: client.Metadata["logo_uri"],
|
||||
ClientURI: client.Metadata["client_uri"],
|
||||
PolicyURI: client.Metadata["policy_uri"],
|
||||
TosURI: client.Metadata["tos_uri"],
|
||||
JwksURI: client.Metadata["jwks_uri"],
|
||||
}
|
||||
|
||||
// Return client configuration
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// getTokenEndpointAuthMethod determines the auth method from client type
|
||||
func getTokenEndpointAuthMethod(client *OAuth2Client) string {
|
||||
if client.ClientType == "public" {
|
||||
return "none"
|
||||
}
|
||||
return "client_secret_basic"
|
||||
}
|
||||
|
||||
// Helper functions for dynamic registration
|
||||
|
||||
func generateDynamicClientID() string {
|
||||
// Generate a random client ID for dynamic registration
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return "dyn_client_" + base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func generateDynamicClientSecret() string {
|
||||
// Generate a secure random secret for dynamic registration
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func isValidRedirectURI(uri string) bool {
|
||||
// Basic validation - in production, this should be more comprehensive
|
||||
if uri == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow localhost for development
|
||||
if strings.HasPrefix(uri, "http://localhost") || strings.HasPrefix(uri, "http://127.0.0.1") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Require HTTPS for production URIs
|
||||
if !strings.HasPrefix(uri, "https://") {
|
||||
// Allow custom schemes for native apps
|
||||
if strings.Contains(uri, "://") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func validateGrantTypes(grantTypes []string) bool {
|
||||
validGrants := map[string]bool{
|
||||
"authorization_code": true,
|
||||
"implicit": true,
|
||||
"refresh_token": true,
|
||||
"client_credentials": true,
|
||||
"password": true,
|
||||
}
|
||||
|
||||
for _, grant := range grantTypes {
|
||||
if !validGrants[grant] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateResponseTypes(responseTypes []string) bool {
|
||||
validTypes := map[string]bool{
|
||||
"code": true,
|
||||
"token": true,
|
||||
"id_token": true,
|
||||
}
|
||||
|
||||
for _, respType := range responseTypes {
|
||||
// Handle composite types like "code id_token"
|
||||
parts := strings.Split(respType, " ")
|
||||
for _, part := range parts {
|
||||
if !validTypes[part] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasScope(scopes []string, requiredScope string) bool {
|
||||
for _, scope := range scopes {
|
||||
if scope == requiredScope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sonr-io/sonr/crypto/ucan"
|
||||
)
|
||||
|
||||
// ScopeMapper manages OAuth scope to UCAN capability mapping
|
||||
type ScopeMapper struct {
|
||||
scopeDefinitions map[string]*OAuth2ScopeDefinition
|
||||
ucanTemplates map[string]*ucan.Attenuation
|
||||
}
|
||||
|
||||
// NewScopeMapper creates a new scope mapper with standard scopes
|
||||
func NewScopeMapper() *ScopeMapper {
|
||||
mapper := &ScopeMapper{
|
||||
scopeDefinitions: make(map[string]*OAuth2ScopeDefinition),
|
||||
ucanTemplates: make(map[string]*ucan.Attenuation),
|
||||
}
|
||||
|
||||
mapper.initializeStandardScopes()
|
||||
return mapper
|
||||
}
|
||||
|
||||
// initializeStandardScopes defines the standard OAuth scopes and their UCAN mappings
|
||||
func (m *ScopeMapper) initializeStandardScopes() {
|
||||
// OpenID Connect scopes
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "openid",
|
||||
Description: "OpenID Connect authentication",
|
||||
UCANActions: []string{"authenticate"},
|
||||
ResourceType: "identity",
|
||||
RequiresAuth: true,
|
||||
Sensitive: false,
|
||||
})
|
||||
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "profile",
|
||||
Description: "Access to user profile information",
|
||||
UCANActions: []string{"read"},
|
||||
ResourceType: "did",
|
||||
RequiresAuth: true,
|
||||
Sensitive: false,
|
||||
})
|
||||
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "email",
|
||||
Description: "Access to user email",
|
||||
UCANActions: []string{"read"},
|
||||
ResourceType: "contact",
|
||||
RequiresAuth: true,
|
||||
Sensitive: true,
|
||||
})
|
||||
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "offline_access",
|
||||
Description: "Maintain access when user is not present",
|
||||
UCANActions: []string{"refresh"},
|
||||
ResourceType: "session",
|
||||
RequiresAuth: true,
|
||||
Sensitive: true,
|
||||
})
|
||||
|
||||
// Vault scopes
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "vault:read",
|
||||
Description: "Read access to vault data",
|
||||
UCANActions: []string{"read"},
|
||||
ResourceType: "vault",
|
||||
RequiresAuth: true,
|
||||
Sensitive: false,
|
||||
ParentScope: "",
|
||||
})
|
||||
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "vault:write",
|
||||
Description: "Write access to vault data",
|
||||
UCANActions: []string{"read", "write"},
|
||||
ResourceType: "vault",
|
||||
RequiresAuth: true,
|
||||
Sensitive: true,
|
||||
ParentScope: "vault:read",
|
||||
})
|
||||
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "vault:sign",
|
||||
Description: "Signing operations with vault keys",
|
||||
UCANActions: []string{"read", "sign"},
|
||||
ResourceType: "vault",
|
||||
RequiresAuth: true,
|
||||
Sensitive: true,
|
||||
ParentScope: "vault:read",
|
||||
})
|
||||
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "vault:admin",
|
||||
Description: "Full administrative access to vault",
|
||||
UCANActions: []string{"read", "write", "sign", "export", "import", "delete", "admin"},
|
||||
ResourceType: "vault",
|
||||
RequiresAuth: true,
|
||||
Sensitive: true,
|
||||
ParentScope: "vault:write",
|
||||
ChildScopes: []string{"vault:read", "vault:write", "vault:sign"},
|
||||
})
|
||||
|
||||
// Service scopes
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "service:read",
|
||||
Description: "Read service information",
|
||||
UCANActions: []string{"read"},
|
||||
ResourceType: "service",
|
||||
RequiresAuth: true,
|
||||
Sensitive: false,
|
||||
})
|
||||
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "service:write",
|
||||
Description: "Create and update services",
|
||||
UCANActions: []string{"read", "write"},
|
||||
ResourceType: "service",
|
||||
RequiresAuth: true,
|
||||
Sensitive: true,
|
||||
ParentScope: "service:read",
|
||||
})
|
||||
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "service:manage",
|
||||
Description: "Full service management capabilities",
|
||||
UCANActions: []string{"read", "write", "delete", "admin"},
|
||||
ResourceType: "service",
|
||||
RequiresAuth: true,
|
||||
Sensitive: true,
|
||||
ParentScope: "service:write",
|
||||
ChildScopes: []string{"service:read", "service:write"},
|
||||
})
|
||||
|
||||
// DID scopes
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "did:read",
|
||||
Description: "Read DID documents",
|
||||
UCANActions: []string{"read"},
|
||||
ResourceType: "did",
|
||||
RequiresAuth: false,
|
||||
Sensitive: false,
|
||||
})
|
||||
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "did:write",
|
||||
Description: "Update DID documents",
|
||||
UCANActions: []string{"read", "write"},
|
||||
ResourceType: "did",
|
||||
RequiresAuth: true,
|
||||
Sensitive: true,
|
||||
ParentScope: "did:read",
|
||||
})
|
||||
|
||||
// DWN (Decentralized Web Node) scopes
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "dwn:read",
|
||||
Description: "Read data from DWN",
|
||||
UCANActions: []string{"read"},
|
||||
ResourceType: "dwn",
|
||||
RequiresAuth: true,
|
||||
Sensitive: false,
|
||||
})
|
||||
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "dwn:write",
|
||||
Description: "Write data to DWN",
|
||||
UCANActions: []string{"read", "write"},
|
||||
ResourceType: "dwn",
|
||||
RequiresAuth: true,
|
||||
Sensitive: true,
|
||||
ParentScope: "dwn:read",
|
||||
})
|
||||
|
||||
_ = m.RegisterScope(&OAuth2ScopeDefinition{
|
||||
Name: "dwn:admin",
|
||||
Description: "Full administrative access to DWN",
|
||||
UCANActions: []string{"read", "write", "admin", "protocols-configure"},
|
||||
ResourceType: "dwn",
|
||||
RequiresAuth: true,
|
||||
Sensitive: true,
|
||||
ParentScope: "dwn:write",
|
||||
ChildScopes: []string{"dwn:read", "dwn:write"},
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterScope registers a new scope definition
|
||||
func (m *ScopeMapper) RegisterScope(scope *OAuth2ScopeDefinition) error {
|
||||
if scope.Name == "" {
|
||||
return fmt.Errorf("scope name is required")
|
||||
}
|
||||
|
||||
m.scopeDefinitions[scope.Name] = scope
|
||||
|
||||
// Create UCAN template for this scope
|
||||
m.createUCANTemplate(scope)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetScope retrieves a scope definition
|
||||
func (m *ScopeMapper) GetScope(name string) (*OAuth2ScopeDefinition, bool) {
|
||||
scope, exists := m.scopeDefinitions[name]
|
||||
return scope, exists
|
||||
}
|
||||
|
||||
// MapToUCAN maps OAuth scopes to UCAN attenuations
|
||||
func (m *ScopeMapper) MapToUCAN(
|
||||
scopes []string,
|
||||
userDID string,
|
||||
clientID string,
|
||||
resourceContext map[string]string,
|
||||
) []ucan.Attenuation {
|
||||
attenuations := []ucan.Attenuation{}
|
||||
|
||||
for _, scopeName := range scopes {
|
||||
scope, exists := m.scopeDefinitions[scopeName]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create attenuation for this scope
|
||||
attenuation := m.createAttenuation(scope, userDID, clientID, resourceContext)
|
||||
attenuations = append(attenuations, attenuation)
|
||||
|
||||
// Add child scope attenuations if this is a parent scope
|
||||
for _, childScope := range scope.ChildScopes {
|
||||
if childDef, exists := m.scopeDefinitions[childScope]; exists {
|
||||
childAttenuation := m.createAttenuation(
|
||||
childDef,
|
||||
userDID,
|
||||
clientID,
|
||||
resourceContext,
|
||||
)
|
||||
attenuations = append(attenuations, childAttenuation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return attenuations
|
||||
}
|
||||
|
||||
// ValidateScopes validates that the requested scopes are valid
|
||||
func (m *ScopeMapper) ValidateScopes(scopes []string) error {
|
||||
for _, scope := range scopes {
|
||||
if _, exists := m.scopeDefinitions[scope]; !exists {
|
||||
return fmt.Errorf("invalid scope: %s", scope)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetScopeDescriptions returns human-readable descriptions for scopes
|
||||
func (m *ScopeMapper) GetScopeDescriptions(scopes []string) map[string]string {
|
||||
descriptions := make(map[string]string)
|
||||
|
||||
for _, scope := range scopes {
|
||||
if def, exists := m.scopeDefinitions[scope]; exists {
|
||||
descriptions[scope] = def.Description
|
||||
}
|
||||
}
|
||||
|
||||
return descriptions
|
||||
}
|
||||
|
||||
// GetSensitiveScopes returns only the sensitive scopes from a list
|
||||
func (m *ScopeMapper) GetSensitiveScopes(scopes []string) []string {
|
||||
sensitive := []string{}
|
||||
|
||||
for _, scope := range scopes {
|
||||
if def, exists := m.scopeDefinitions[scope]; exists && def.Sensitive {
|
||||
sensitive = append(sensitive, scope)
|
||||
}
|
||||
}
|
||||
|
||||
return sensitive
|
||||
}
|
||||
|
||||
// IsHierarchicalScope checks if one scope includes another
|
||||
func (m *ScopeMapper) IsHierarchicalScope(parentScope, childScope string) bool {
|
||||
parent, exists := m.scopeDefinitions[parentScope]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check direct children
|
||||
for _, child := range parent.ChildScopes {
|
||||
if child == childScope {
|
||||
return true
|
||||
}
|
||||
// Recursive check
|
||||
if m.IsHierarchicalScope(child, childScope) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
func (m *ScopeMapper) createUCANTemplate(scope *OAuth2ScopeDefinition) {
|
||||
// Create a template attenuation for this scope
|
||||
capability := &ucan.SimpleCapability{
|
||||
Action: strings.Join(scope.UCANActions, ","),
|
||||
}
|
||||
|
||||
resource := &SimpleResource{
|
||||
Scheme: scope.ResourceType,
|
||||
Value: "{resource_id}",
|
||||
}
|
||||
|
||||
m.ucanTemplates[scope.Name] = &ucan.Attenuation{
|
||||
Capability: capability,
|
||||
Resource: resource,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ScopeMapper) createAttenuation(
|
||||
scope *OAuth2ScopeDefinition,
|
||||
userDID string,
|
||||
clientID string,
|
||||
resourceContext map[string]string,
|
||||
) ucan.Attenuation {
|
||||
// Create capability based on scope actions
|
||||
var capability ucan.Capability
|
||||
if len(scope.UCANActions) == 1 {
|
||||
capability = &ucan.SimpleCapability{
|
||||
Action: scope.UCANActions[0],
|
||||
}
|
||||
} else {
|
||||
capability = &ucan.MultiCapability{
|
||||
Actions: scope.UCANActions,
|
||||
}
|
||||
}
|
||||
|
||||
// Create resource based on scope type and context
|
||||
resourceValue := m.resolveResourceValue(scope, userDID, resourceContext)
|
||||
resource := &SimpleResource{
|
||||
Scheme: scope.ResourceType,
|
||||
Value: resourceValue,
|
||||
}
|
||||
|
||||
return ucan.Attenuation{
|
||||
Capability: capability,
|
||||
Resource: resource,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ScopeMapper) resolveResourceValue(
|
||||
scope *OAuth2ScopeDefinition,
|
||||
userDID string,
|
||||
context map[string]string,
|
||||
) string {
|
||||
switch scope.ResourceType {
|
||||
case "did":
|
||||
return fmt.Sprintf("did:sonr:%s", userDID)
|
||||
case "vault":
|
||||
if vaultAddr, exists := context["vault_address"]; exists {
|
||||
return vaultAddr
|
||||
}
|
||||
return fmt.Sprintf("vault:%s", userDID)
|
||||
case "service":
|
||||
if serviceID, exists := context["service_id"]; exists {
|
||||
return serviceID
|
||||
}
|
||||
return "service:*"
|
||||
case "dwn":
|
||||
if dwnID, exists := context["dwn_id"]; exists {
|
||||
return dwnID
|
||||
}
|
||||
return fmt.Sprintf("dwn:%s", userDID)
|
||||
default:
|
||||
return "*"
|
||||
}
|
||||
}
|
||||
|
||||
// SimpleResource implements the Resource interface for OAuth scopes
|
||||
type SimpleResource struct {
|
||||
Scheme string
|
||||
Value string
|
||||
}
|
||||
|
||||
func (r *SimpleResource) GetScheme() string {
|
||||
return r.Scheme
|
||||
}
|
||||
|
||||
func (r *SimpleResource) GetValue() string {
|
||||
return r.Value
|
||||
}
|
||||
|
||||
func (r *SimpleResource) GetURI() string {
|
||||
return fmt.Sprintf("%s://%s", r.Scheme, r.Value)
|
||||
}
|
||||
|
||||
func (r *SimpleResource) Matches(other ucan.Resource) bool {
|
||||
if r.Scheme != other.GetScheme() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Wildcard matching
|
||||
if r.Value == "*" || other.GetValue() == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Exact match
|
||||
return r.Value == other.GetValue()
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
// PKCE challenge methods
|
||||
PKCEMethodS256 = "S256"
|
||||
PKCEMethodPlain = "plain"
|
||||
)
|
||||
|
||||
// PKCEValidator handles PKCE validation
|
||||
type PKCEValidator struct{}
|
||||
|
||||
// NewPKCEValidator creates a new PKCE validator
|
||||
func NewPKCEValidator() *PKCEValidator {
|
||||
return &PKCEValidator{}
|
||||
}
|
||||
|
||||
// GeneratePKCEPair generates a PKCE verifier and challenge pair
|
||||
func (p *PKCEValidator) GeneratePKCEPair() (verifier, challenge string, err error) {
|
||||
// Generate cryptographically secure verifier (43-128 characters)
|
||||
verifierBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(verifierBytes); err != nil {
|
||||
return "", "", fmt.Errorf("failed to generate PKCE verifier: %w", err)
|
||||
}
|
||||
verifier = base64.RawURLEncoding.EncodeToString(verifierBytes)
|
||||
|
||||
// Create S256 challenge
|
||||
challenge = p.CreateChallenge(verifier, PKCEMethodS256)
|
||||
|
||||
return verifier, challenge, nil
|
||||
}
|
||||
|
||||
// CreateChallenge creates a PKCE challenge from a verifier
|
||||
func (p *PKCEValidator) CreateChallenge(verifier, method string) string {
|
||||
switch method {
|
||||
case PKCEMethodS256:
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:])
|
||||
case PKCEMethodPlain:
|
||||
return verifier
|
||||
default:
|
||||
// Default to S256 for security
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:])
|
||||
}
|
||||
}
|
||||
|
||||
// Validate validates a PKCE verifier against a challenge
|
||||
func (p *PKCEValidator) Validate(verifier, challenge, method string) bool {
|
||||
if method == "" {
|
||||
method = PKCEMethodPlain
|
||||
}
|
||||
|
||||
computed := p.CreateChallenge(verifier, method)
|
||||
return subtle.ConstantTimeCompare([]byte(computed), []byte(challenge)) == 1
|
||||
}
|
||||
|
||||
// computePKCEChallenge computes a PKCE challenge (helper function)
|
||||
func computePKCEChallenge(verifier, method string) string {
|
||||
validator := NewPKCEValidator()
|
||||
return validator.CreateChallenge(verifier, method)
|
||||
}
|
||||
|
||||
// CSRFProtector handles CSRF protection
|
||||
type CSRFProtector struct {
|
||||
secret []byte
|
||||
}
|
||||
|
||||
// NewCSRFProtector creates a new CSRF protector
|
||||
func NewCSRFProtector(secret string) *CSRFProtector {
|
||||
return &CSRFProtector{
|
||||
secret: []byte(secret),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToken generates a CSRF token
|
||||
func (c *CSRFProtector) GenerateToken(sessionID string) (string, error) {
|
||||
// Create a unique token tied to the session
|
||||
h := hmac.New(sha256.New, c.secret)
|
||||
h.Write([]byte(sessionID))
|
||||
h.Write([]byte(time.Now().Format(time.RFC3339)))
|
||||
|
||||
tokenBytes := h.Sum(nil)
|
||||
return base64.RawURLEncoding.EncodeToString(tokenBytes), nil
|
||||
}
|
||||
|
||||
// ValidateToken validates a CSRF token
|
||||
func (c *CSRFProtector) ValidateToken(token, sessionID string) bool {
|
||||
// Decode the token
|
||||
tokenBytes, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Recreate the expected token
|
||||
h := hmac.New(sha256.New, c.secret)
|
||||
h.Write([]byte(sessionID))
|
||||
|
||||
// Note: In production, you'd want to include time validation
|
||||
// and possibly store tokens with expiration
|
||||
expectedBytes := h.Sum(nil)[:len(tokenBytes)]
|
||||
|
||||
return hmac.Equal(tokenBytes, expectedBytes)
|
||||
}
|
||||
|
||||
// StateValidator validates OAuth state parameters
|
||||
type StateValidator struct {
|
||||
states map[string]*StateEntry
|
||||
}
|
||||
|
||||
// StateEntry represents a stored state parameter
|
||||
type StateEntry struct {
|
||||
Value string
|
||||
ClientID string
|
||||
ExpiresAt time.Time
|
||||
Used bool
|
||||
}
|
||||
|
||||
// NewStateValidator creates a new state validator
|
||||
func NewStateValidator() *StateValidator {
|
||||
validator := &StateValidator{
|
||||
states: make(map[string]*StateEntry),
|
||||
}
|
||||
|
||||
// Start cleanup routine
|
||||
go validator.cleanup()
|
||||
|
||||
return validator
|
||||
}
|
||||
|
||||
// GenerateState generates a secure state parameter
|
||||
func (s *StateValidator) GenerateState() (string, error) {
|
||||
stateBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(stateBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate state: %w", err)
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(stateBytes), nil
|
||||
}
|
||||
|
||||
// StoreState stores a state parameter for validation
|
||||
func (s *StateValidator) StoreState(state, clientID string) {
|
||||
s.states[state] = &StateEntry{
|
||||
Value: state,
|
||||
ClientID: clientID,
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
Used: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateState validates and consumes a state parameter
|
||||
func (s *StateValidator) ValidateState(state, clientID string) bool {
|
||||
entry, exists := s.states[state]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(entry.ExpiresAt) {
|
||||
delete(s.states, state)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if already used
|
||||
if entry.Used {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check client ID matches
|
||||
if entry.ClientID != clientID {
|
||||
return false
|
||||
}
|
||||
|
||||
// Mark as used
|
||||
entry.Used = true
|
||||
return true
|
||||
}
|
||||
|
||||
// cleanup removes expired states
|
||||
func (s *StateValidator) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
for state, entry := range s.states {
|
||||
if now.After(entry.ExpiresAt) {
|
||||
delete(s.states, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JWTClientAuthenticator handles JWT client authentication
|
||||
type JWTClientAuthenticator struct {
|
||||
clientRegistry *ClientRegistry
|
||||
}
|
||||
|
||||
// NewJWTClientAuthenticator creates a new JWT client authenticator
|
||||
func NewJWTClientAuthenticator(registry *ClientRegistry) *JWTClientAuthenticator {
|
||||
return &JWTClientAuthenticator{
|
||||
clientRegistry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateClientAssertion validates a JWT client assertion
|
||||
func (j *JWTClientAuthenticator) ValidateClientAssertion(
|
||||
assertion, expectedAudience string,
|
||||
) (*OAuth2Client, error) {
|
||||
// Parse the JWT without verification first to get the claims
|
||||
token, _, err := jwt.NewParser().ParseUnverified(assertion, jwt.MapClaims{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse client assertion: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid claims format")
|
||||
}
|
||||
|
||||
// Extract client ID from issuer and subject
|
||||
clientID, ok := claims["iss"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing issuer claim")
|
||||
}
|
||||
|
||||
subject, ok := claims["sub"].(string)
|
||||
if !ok || subject != clientID {
|
||||
return nil, fmt.Errorf("issuer and subject must match")
|
||||
}
|
||||
|
||||
// Validate audience
|
||||
audience, ok := claims["aud"].(string)
|
||||
if !ok || audience != expectedAudience {
|
||||
return nil, fmt.Errorf("invalid audience")
|
||||
}
|
||||
|
||||
// Validate expiration
|
||||
if exp, ok := claims["exp"].(float64); ok {
|
||||
if time.Now().Unix() > int64(exp) {
|
||||
return nil, fmt.Errorf("assertion expired")
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("missing expiration")
|
||||
}
|
||||
|
||||
// Validate not before
|
||||
if nbf, ok := claims["nbf"].(float64); ok {
|
||||
if time.Now().Unix() < int64(nbf) {
|
||||
return nil, fmt.Errorf("assertion not yet valid")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate issued at
|
||||
if iat, ok := claims["iat"].(float64); ok {
|
||||
// Check that the assertion is not too old (5 minutes max)
|
||||
if time.Now().Unix()-int64(iat) > 300 {
|
||||
return nil, fmt.Errorf("assertion too old")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate JTI for replay protection
|
||||
if jti, ok := claims["jti"].(string); !ok || jti == "" {
|
||||
return nil, fmt.Errorf("missing jti claim")
|
||||
}
|
||||
// TODO: Store and check JTI to prevent replay attacks
|
||||
|
||||
// Get the client
|
||||
client, err := j.clientRegistry.GetClient(clientID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unknown client: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Verify the JWT signature using the client's registered public key
|
||||
// This requires storing client public keys in the registry
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// SecureTokenGenerator generates cryptographically secure tokens
|
||||
type SecureTokenGenerator struct {
|
||||
entropy int // bits of entropy
|
||||
}
|
||||
|
||||
// NewSecureTokenGenerator creates a new secure token generator
|
||||
func NewSecureTokenGenerator(entropyBits int) *SecureTokenGenerator {
|
||||
if entropyBits < 128 {
|
||||
entropyBits = 256 // Default to 256 bits for security
|
||||
}
|
||||
return &SecureTokenGenerator{
|
||||
entropy: entropyBits,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToken generates a secure random token
|
||||
func (g *SecureTokenGenerator) GenerateToken() (string, error) {
|
||||
bytes := make([]byte, g.entropy/8)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate secure token: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// GenerateHexToken generates a secure random token in hex format
|
||||
func (g *SecureTokenGenerator) GenerateHexToken() (string, error) {
|
||||
bytes := make([]byte, g.entropy/8)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate secure token: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// RateLimiter implements rate limiting for OAuth endpoints
|
||||
type RateLimiter struct {
|
||||
attempts map[string][]time.Time
|
||||
maxAttempts int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter
|
||||
func NewRateLimiter(maxAttempts int, window time.Duration) *RateLimiter {
|
||||
limiter := &RateLimiter{
|
||||
attempts: make(map[string][]time.Time),
|
||||
maxAttempts: maxAttempts,
|
||||
window: window,
|
||||
}
|
||||
|
||||
// Start cleanup routine
|
||||
go limiter.cleanup()
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
// Allow checks if a request should be allowed
|
||||
func (r *RateLimiter) Allow(key string) bool {
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-r.window)
|
||||
|
||||
// Get attempts for this key
|
||||
attempts := r.attempts[key]
|
||||
|
||||
// Filter out attempts outside the window
|
||||
validAttempts := []time.Time{}
|
||||
for _, attempt := range attempts {
|
||||
if attempt.After(windowStart) {
|
||||
validAttempts = append(validAttempts, attempt)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if under limit
|
||||
if len(validAttempts) >= r.maxAttempts {
|
||||
return false
|
||||
}
|
||||
|
||||
// Add this attempt
|
||||
validAttempts = append(validAttempts, now)
|
||||
r.attempts[key] = validAttempts
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// cleanup removes old entries
|
||||
func (r *RateLimiter) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-r.window)
|
||||
|
||||
for key, attempts := range r.attempts {
|
||||
validAttempts := []time.Time{}
|
||||
for _, attempt := range attempts {
|
||||
if attempt.After(windowStart) {
|
||||
validAttempts = append(validAttempts, attempt)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validAttempts) == 0 {
|
||||
delete(r.attempts, key)
|
||||
} else {
|
||||
r.attempts[key] = validAttempts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OriginValidator validates request origins for CORS
|
||||
type OriginValidator struct {
|
||||
allowedOrigins map[string]bool
|
||||
allowSubdomains bool
|
||||
}
|
||||
|
||||
// NewOriginValidator creates a new origin validator
|
||||
func NewOriginValidator(origins []string, allowSubdomains bool) *OriginValidator {
|
||||
validator := &OriginValidator{
|
||||
allowedOrigins: make(map[string]bool),
|
||||
allowSubdomains: allowSubdomains,
|
||||
}
|
||||
|
||||
for _, origin := range origins {
|
||||
validator.allowedOrigins[origin] = true
|
||||
}
|
||||
|
||||
return validator
|
||||
}
|
||||
|
||||
// IsAllowed checks if an origin is allowed
|
||||
func (o *OriginValidator) IsAllowed(origin string) bool {
|
||||
// Direct match
|
||||
if o.allowedOrigins[origin] {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check subdomain matching if enabled
|
||||
if o.allowSubdomains {
|
||||
for allowed := range o.allowedOrigins {
|
||||
if o.isSubdomainOf(origin, allowed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isSubdomainOf checks if origin is a subdomain of allowed
|
||||
func (o *OriginValidator) isSubdomainOf(origin, allowed string) bool {
|
||||
// Simple subdomain check
|
||||
// In production, use proper URL parsing
|
||||
if strings.HasPrefix(allowed, "*.") {
|
||||
domain := strings.TrimPrefix(allowed, "*")
|
||||
return strings.HasSuffix(origin, domain)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,650 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sonr-io/sonr/crypto/ucan"
|
||||
)
|
||||
|
||||
// TokenExchangeHandler implements RFC 8693 OAuth 2.0 Token Exchange
|
||||
type TokenExchangeHandler struct {
|
||||
delegator *UCANDelegator
|
||||
signer *BlockchainUCANSigner
|
||||
tokenStore TokenStore
|
||||
clientStore ClientStore
|
||||
}
|
||||
|
||||
// TokenStore interface for token persistence
|
||||
type TokenStore interface {
|
||||
GetToken(ctx context.Context, tokenID string) (*StoredToken, error)
|
||||
StoreToken(ctx context.Context, token *StoredToken) error
|
||||
RevokeToken(ctx context.Context, tokenID string) error
|
||||
}
|
||||
|
||||
// ClientStore interface for OAuth client information
|
||||
type ClientStore interface {
|
||||
GetClient(ctx context.Context, clientID string) (*OAuth2Client, error)
|
||||
ValidateClientCredentials(ctx context.Context, clientID, clientSecret string) error
|
||||
}
|
||||
|
||||
// StoredToken represents a stored OAuth token
|
||||
type StoredToken struct {
|
||||
TokenID string `json:"token_id"`
|
||||
TokenType string `json:"token_type"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ClientID string `json:"client_id"`
|
||||
UserDID string `json:"user_did,omitempty"`
|
||||
UCANToken string `json:"ucan_token"`
|
||||
}
|
||||
|
||||
// TokenExchangeRequest represents an RFC 8693 token exchange request
|
||||
type TokenExchangeRequest struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
Resource string `json:"resource,omitempty"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
RequestedTokenType string `json:"requested_token_type,omitempty"`
|
||||
SubjectToken string `json:"subject_token"`
|
||||
SubjectTokenType string `json:"subject_token_type"`
|
||||
ActorToken string `json:"actor_token,omitempty"`
|
||||
ActorTokenType string `json:"actor_token_type,omitempty"`
|
||||
}
|
||||
|
||||
// TokenExchangeResponse represents an RFC 8693 token exchange response
|
||||
type TokenExchangeResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IssuedTokenType string `json:"issued_token_type"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// Token type identifiers from RFC 8693
|
||||
const (
|
||||
TokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token"
|
||||
TokenTypeRefreshToken = "urn:ietf:params:oauth:token-type:refresh_token"
|
||||
TokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token"
|
||||
TokenTypeSAML1 = "urn:ietf:params:oauth:token-type:saml1"
|
||||
TokenTypeSAML2 = "urn:ietf:params:oauth:token-type:saml2"
|
||||
TokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt"
|
||||
TokenTypeUCAN = "urn:x-oath:params:oauth:token-type:ucan"
|
||||
)
|
||||
|
||||
// NewTokenExchangeHandler creates a new token exchange handler
|
||||
func NewTokenExchangeHandler(
|
||||
delegator *UCANDelegator,
|
||||
signer *BlockchainUCANSigner,
|
||||
tokenStore TokenStore,
|
||||
clientStore ClientStore,
|
||||
) *TokenExchangeHandler {
|
||||
return &TokenExchangeHandler{
|
||||
delegator: delegator,
|
||||
signer: signer,
|
||||
tokenStore: tokenStore,
|
||||
clientStore: clientStore,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTokenExchange handles RFC 8693 token exchange requests
|
||||
func (h *TokenExchangeHandler) HandleTokenExchange(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request
|
||||
var req TokenExchangeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.sendError(w, "invalid_request", "Failed to parse request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate grant type
|
||||
if req.GrantType != "urn:ietf:params:oauth:grant-type:token-exchange" {
|
||||
h.sendError(w, "unsupported_grant_type", "Only token-exchange grant type is supported")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if req.SubjectToken == "" || req.SubjectTokenType == "" {
|
||||
h.sendError(w, "invalid_request", "Missing required parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate client
|
||||
clientID, clientSecret, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
h.sendError(w, "invalid_client", "Client authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if err := h.clientStore.ValidateClientCredentials(ctx, clientID, clientSecret); err != nil {
|
||||
h.sendError(w, "invalid_client", "Client authentication failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Get client information
|
||||
client, err := h.clientStore.GetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
h.sendError(w, "invalid_client", "Client not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Process token exchange based on token types
|
||||
response, err := h.processTokenExchange(ctx, &req, client)
|
||||
if err != nil {
|
||||
h.sendError(w, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Send response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// processTokenExchange processes the token exchange based on token types
|
||||
func (h *TokenExchangeHandler) processTokenExchange(
|
||||
ctx context.Context,
|
||||
req *TokenExchangeRequest,
|
||||
client *OAuth2Client,
|
||||
) (*TokenExchangeResponse, error) {
|
||||
// Determine requested token type (default to access token)
|
||||
requestedType := req.RequestedTokenType
|
||||
if requestedType == "" {
|
||||
requestedType = TokenTypeAccessToken
|
||||
}
|
||||
|
||||
// Handle different subject token types
|
||||
switch req.SubjectTokenType {
|
||||
case TokenTypeAccessToken:
|
||||
return h.exchangeAccessToken(ctx, req, client, requestedType)
|
||||
case TokenTypeRefreshToken:
|
||||
return h.exchangeRefreshToken(ctx, req, client, requestedType)
|
||||
case TokenTypeJWT:
|
||||
return h.exchangeJWT(ctx, req, client, requestedType)
|
||||
case TokenTypeUCAN:
|
||||
return h.exchangeUCAN(ctx, req, client, requestedType)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported subject token type: %s", req.SubjectTokenType)
|
||||
}
|
||||
}
|
||||
|
||||
// exchangeAccessToken exchanges an access token for a new token
|
||||
func (h *TokenExchangeHandler) exchangeAccessToken(
|
||||
ctx context.Context,
|
||||
req *TokenExchangeRequest,
|
||||
client *OAuth2Client,
|
||||
requestedType string,
|
||||
) (*TokenExchangeResponse, error) {
|
||||
// Retrieve the subject token
|
||||
storedToken, err := h.tokenStore.GetToken(ctx, req.SubjectToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid subject token")
|
||||
}
|
||||
|
||||
// Validate token hasn't expired
|
||||
if time.Now().After(storedToken.ExpiresAt) {
|
||||
return nil, fmt.Errorf("subject token has expired")
|
||||
}
|
||||
|
||||
// Parse requested scopes (default to original scopes)
|
||||
requestedScopes := storedToken.Scopes
|
||||
if req.Scope != "" {
|
||||
requestedScopes = strings.Split(req.Scope, " ")
|
||||
// Validate requested scopes are subset of original
|
||||
if !h.isScopeSubset(requestedScopes, storedToken.Scopes) {
|
||||
return nil, fmt.Errorf("requested scopes exceed original token scopes")
|
||||
}
|
||||
}
|
||||
|
||||
// Determine audience (default to requested audience or client ID)
|
||||
audience := req.Audience
|
||||
if audience == "" {
|
||||
// Try to extract DID from client metadata
|
||||
if clientDID, ok := client.Metadata["client_did"]; ok {
|
||||
audience = clientDID
|
||||
} else {
|
||||
audience = client.ClientID
|
||||
}
|
||||
}
|
||||
|
||||
// Create new UCAN delegation based on requested type
|
||||
switch requestedType {
|
||||
case TokenTypeUCAN:
|
||||
return h.createUCANResponse(
|
||||
ctx,
|
||||
storedToken.UserDID,
|
||||
audience,
|
||||
requestedScopes,
|
||||
storedToken.UCANToken,
|
||||
)
|
||||
case TokenTypeAccessToken:
|
||||
return h.createAccessTokenResponse(ctx, storedToken.UserDID, audience, requestedScopes)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported requested token type: %s", requestedType)
|
||||
}
|
||||
}
|
||||
|
||||
// exchangeRefreshToken exchanges a refresh token for new tokens
|
||||
func (h *TokenExchangeHandler) exchangeRefreshToken(
|
||||
ctx context.Context,
|
||||
req *TokenExchangeRequest,
|
||||
client *OAuth2Client,
|
||||
requestedType string,
|
||||
) (*TokenExchangeResponse, error) {
|
||||
// Retrieve the refresh token
|
||||
storedToken, err := h.tokenStore.GetToken(ctx, req.SubjectToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid refresh token")
|
||||
}
|
||||
|
||||
// Validate it's actually a refresh token
|
||||
if storedToken.TokenType != "refresh_token" {
|
||||
return nil, fmt.Errorf("token is not a refresh token")
|
||||
}
|
||||
|
||||
// Create new tokens with same scopes
|
||||
clientDID := client.ClientID
|
||||
if did, ok := client.Metadata["client_did"]; ok {
|
||||
clientDID = did
|
||||
}
|
||||
return h.createAccessTokenResponse(ctx, storedToken.UserDID, clientDID, storedToken.Scopes)
|
||||
}
|
||||
|
||||
// exchangeJWT exchanges a JWT for a UCAN token
|
||||
func (h *TokenExchangeHandler) exchangeJWT(
|
||||
ctx context.Context,
|
||||
req *TokenExchangeRequest,
|
||||
client *OAuth2Client,
|
||||
requestedType string,
|
||||
) (*TokenExchangeResponse, error) {
|
||||
// Verify the JWT
|
||||
ucanToken, err := h.signer.VerifySignature(req.SubjectToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid JWT: %w", err)
|
||||
}
|
||||
|
||||
// Extract scopes from JWT claims
|
||||
scopes := h.extractScopesFromUCAN(ucanToken)
|
||||
|
||||
// Create response based on requested type
|
||||
clientDID := client.ClientID
|
||||
if did, ok := client.Metadata["client_did"]; ok {
|
||||
clientDID = did
|
||||
}
|
||||
|
||||
switch requestedType {
|
||||
case TokenTypeUCAN:
|
||||
return h.createUCANResponse(ctx, ucanToken.Issuer, clientDID, scopes, req.SubjectToken)
|
||||
case TokenTypeAccessToken:
|
||||
return h.createAccessTokenResponse(ctx, ucanToken.Issuer, clientDID, scopes)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported requested token type: %s", requestedType)
|
||||
}
|
||||
}
|
||||
|
||||
// exchangeUCAN exchanges a UCAN token for another token type
|
||||
func (h *TokenExchangeHandler) exchangeUCAN(
|
||||
ctx context.Context,
|
||||
req *TokenExchangeRequest,
|
||||
client *OAuth2Client,
|
||||
requestedType string,
|
||||
) (*TokenExchangeResponse, error) {
|
||||
// Verify the UCAN token
|
||||
ucanToken, err := h.signer.VerifySignature(req.SubjectToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid UCAN token: %w", err)
|
||||
}
|
||||
|
||||
// Validate delegation chain if actor token is provided
|
||||
if req.ActorToken != "" {
|
||||
actorToken, err := h.signer.VerifySignature(req.ActorToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid actor token: %w", err)
|
||||
}
|
||||
|
||||
// Validate actor can act on behalf of subject
|
||||
if actorToken.Audience != ucanToken.Issuer {
|
||||
return nil, fmt.Errorf("actor token audience doesn't match subject issuer")
|
||||
}
|
||||
}
|
||||
|
||||
// Extract scopes from UCAN
|
||||
scopes := h.extractScopesFromUCAN(ucanToken)
|
||||
|
||||
// Handle impersonation/delegation if actor token is present
|
||||
issuer := ucanToken.Issuer
|
||||
clientDID := client.ClientID
|
||||
if did, ok := client.Metadata["client_did"]; ok {
|
||||
clientDID = did
|
||||
}
|
||||
|
||||
if req.ActorToken != "" {
|
||||
// Actor is performing action on behalf of subject
|
||||
issuer = clientDID // Actor becomes the new issuer
|
||||
}
|
||||
|
||||
// Create response based on requested type
|
||||
switch requestedType {
|
||||
case TokenTypeAccessToken:
|
||||
return h.createAccessTokenResponse(ctx, issuer, clientDID, scopes)
|
||||
case TokenTypeUCAN:
|
||||
// Create delegated UCAN with proof chain
|
||||
proofs := []ucan.Proof{ucan.Proof(req.SubjectToken)}
|
||||
if req.ActorToken != "" {
|
||||
proofs = append(proofs, ucan.Proof(req.ActorToken))
|
||||
}
|
||||
return h.createDelegatedUCANResponse(ctx, issuer, clientDID, scopes, proofs)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported requested token type: %s", requestedType)
|
||||
}
|
||||
}
|
||||
|
||||
// createUCANResponse creates a UCAN token response
|
||||
func (h *TokenExchangeHandler) createUCANResponse(
|
||||
ctx context.Context,
|
||||
issuer, audience string,
|
||||
scopes []string,
|
||||
proof string,
|
||||
) (*TokenExchangeResponse, error) {
|
||||
// Create UCAN token with delegation
|
||||
ucanToken, err := h.delegator.CreateDelegation(
|
||||
issuer,
|
||||
audience,
|
||||
scopes,
|
||||
time.Now().Add(time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create UCAN token: %w", err)
|
||||
}
|
||||
|
||||
// Add proof if provided
|
||||
if proof != "" {
|
||||
ucanToken.Proofs = []ucan.Proof{ucan.Proof(proof)}
|
||||
}
|
||||
|
||||
// Sign the token
|
||||
signedToken, err := h.signer.Sign(ucanToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign UCAN token: %w", err)
|
||||
}
|
||||
|
||||
return &TokenExchangeResponse{
|
||||
AccessToken: signedToken,
|
||||
IssuedTokenType: TokenTypeUCAN,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
Scope: strings.Join(scopes, " "),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createDelegatedUCANResponse creates a delegated UCAN token with proof chain
|
||||
func (h *TokenExchangeHandler) createDelegatedUCANResponse(
|
||||
ctx context.Context,
|
||||
issuer, audience string,
|
||||
scopes []string,
|
||||
proofs []ucan.Proof,
|
||||
) (*TokenExchangeResponse, error) {
|
||||
// Build resource context
|
||||
resourceContext := map[string]string{
|
||||
"delegation_type": "token_exchange",
|
||||
"issued_at": fmt.Sprintf("%d", time.Now().Unix()),
|
||||
}
|
||||
|
||||
// Map scopes to attenuations
|
||||
attenuations := h.delegator.scopeMapper.MapToUCAN(scopes, issuer, audience, resourceContext)
|
||||
|
||||
// Create UCAN token with proof chain
|
||||
ucanToken := &ucan.Token{
|
||||
Issuer: issuer,
|
||||
Audience: audience,
|
||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
NotBefore: time.Now().Unix(),
|
||||
Attenuations: attenuations,
|
||||
Proofs: proofs,
|
||||
Facts: []ucan.Fact{
|
||||
{
|
||||
Data: h.createTokenExchangeFact(scopes),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Sign the token
|
||||
signedToken, err := h.signer.Sign(ucanToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign delegated UCAN token: %w", err)
|
||||
}
|
||||
|
||||
return &TokenExchangeResponse{
|
||||
AccessToken: signedToken,
|
||||
IssuedTokenType: TokenTypeUCAN,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
Scope: strings.Join(scopes, " "),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createAccessTokenResponse creates a standard OAuth access token response
|
||||
func (h *TokenExchangeHandler) createAccessTokenResponse(
|
||||
ctx context.Context,
|
||||
userDID, clientID string,
|
||||
scopes []string,
|
||||
) (*TokenExchangeResponse, error) {
|
||||
// Create UCAN-backed access token
|
||||
ucanToken, err := h.delegator.CreateDelegation(
|
||||
userDID,
|
||||
clientID,
|
||||
scopes,
|
||||
time.Now().Add(time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create access token: %w", err)
|
||||
}
|
||||
|
||||
// Generate token ID
|
||||
tokenID := h.generateTokenID()
|
||||
|
||||
// Store token
|
||||
storedToken := &StoredToken{
|
||||
TokenID: tokenID,
|
||||
TokenType: "access_token",
|
||||
AccessToken: tokenID,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
Scopes: scopes,
|
||||
ClientID: clientID,
|
||||
UserDID: userDID,
|
||||
UCANToken: ucanToken.Raw,
|
||||
}
|
||||
|
||||
if err := h.tokenStore.StoreToken(ctx, storedToken); err != nil {
|
||||
return nil, fmt.Errorf("failed to store token: %w", err)
|
||||
}
|
||||
|
||||
// Generate refresh token
|
||||
refreshTokenID := h.generateTokenID()
|
||||
refreshToken := &StoredToken{
|
||||
TokenID: refreshTokenID,
|
||||
TokenType: "refresh_token",
|
||||
RefreshToken: refreshTokenID,
|
||||
ExpiresAt: time.Now().Add(30 * 24 * time.Hour), // 30 days
|
||||
Scopes: scopes,
|
||||
ClientID: clientID,
|
||||
UserDID: userDID,
|
||||
}
|
||||
|
||||
if err := h.tokenStore.StoreToken(ctx, refreshToken); err != nil {
|
||||
// Non-fatal, continue without refresh token
|
||||
refreshTokenID = ""
|
||||
}
|
||||
|
||||
response := &TokenExchangeResponse{
|
||||
AccessToken: tokenID,
|
||||
IssuedTokenType: TokenTypeAccessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
Scope: strings.Join(scopes, " "),
|
||||
}
|
||||
|
||||
if refreshTokenID != "" {
|
||||
response.RefreshToken = refreshTokenID
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// extractScopesFromUCAN extracts OAuth scopes from UCAN attenuations
|
||||
func (h *TokenExchangeHandler) extractScopesFromUCAN(token *ucan.Token) []string {
|
||||
scopeMap := make(map[string]bool)
|
||||
|
||||
for _, att := range token.Attenuations {
|
||||
scheme := att.Resource.GetScheme()
|
||||
actions := att.Capability.GetActions()
|
||||
|
||||
// Map UCAN capabilities back to OAuth scopes
|
||||
for _, action := range actions {
|
||||
scope := h.mapUCANToScope(scheme, action)
|
||||
if scope != "" {
|
||||
scopeMap[scope] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scopes := make([]string, 0, len(scopeMap))
|
||||
for scope := range scopeMap {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
|
||||
return scopes
|
||||
}
|
||||
|
||||
// mapUCANToScope maps UCAN capability to OAuth scope
|
||||
func (h *TokenExchangeHandler) mapUCANToScope(scheme, action string) string {
|
||||
// Reverse mapping from UCAN to OAuth scopes
|
||||
switch scheme {
|
||||
case "vault":
|
||||
switch action {
|
||||
case "read":
|
||||
return "vault:read"
|
||||
case "write":
|
||||
return "vault:write"
|
||||
case "sign":
|
||||
return "vault:sign"
|
||||
case "*", "admin":
|
||||
return "vault:admin"
|
||||
}
|
||||
case "service", "svc":
|
||||
switch action {
|
||||
case "read":
|
||||
return "service:read"
|
||||
case "write":
|
||||
return "service:write"
|
||||
case "*", "admin":
|
||||
return "service:manage"
|
||||
}
|
||||
case "did":
|
||||
switch action {
|
||||
case "read":
|
||||
return "did:read"
|
||||
case "write", "update":
|
||||
return "did:write"
|
||||
}
|
||||
case "dwn":
|
||||
switch action {
|
||||
case "read":
|
||||
return "dwn:read"
|
||||
case "write":
|
||||
return "dwn:write"
|
||||
}
|
||||
}
|
||||
|
||||
// Default mapping
|
||||
return fmt.Sprintf("%s:%s", scheme, action)
|
||||
}
|
||||
|
||||
// isScopeSubset checks if requested scopes are subset of allowed scopes
|
||||
func (h *TokenExchangeHandler) isScopeSubset(requested, allowed []string) bool {
|
||||
allowedMap := make(map[string]bool)
|
||||
for _, scope := range allowed {
|
||||
allowedMap[scope] = true
|
||||
}
|
||||
|
||||
for _, scope := range requested {
|
||||
if !allowedMap[scope] {
|
||||
// Check if parent scope is allowed
|
||||
if !h.isParentScopeAllowed(scope, allowed) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// isParentScopeAllowed checks if a parent scope grants the requested scope
|
||||
func (h *TokenExchangeHandler) isParentScopeAllowed(requested string, allowed []string) bool {
|
||||
for _, scope := range allowed {
|
||||
if h.delegator.scopeMapper.IsHierarchicalScope(scope, requested) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// createTokenExchangeFact creates a fact for token exchange
|
||||
func (h *TokenExchangeHandler) createTokenExchangeFact(scopes []string) json.RawMessage {
|
||||
fact := map[string]any{
|
||||
"type": "token_exchange",
|
||||
"scopes": scopes,
|
||||
"issued_at": time.Now().Unix(),
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(fact)
|
||||
return json.RawMessage(data)
|
||||
}
|
||||
|
||||
// generateTokenID generates a unique token identifier
|
||||
func (h *TokenExchangeHandler) generateTokenID() string {
|
||||
// In production, use a proper UUID or random generator
|
||||
return fmt.Sprintf("tok_%d_%s", time.Now().UnixNano(), h.randomString(16))
|
||||
}
|
||||
|
||||
// randomString generates a random string of specified length
|
||||
func (h *TokenExchangeHandler) randomString(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
result[i] = charset[time.Now().UnixNano()%int64(len(charset))]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// sendError sends an OAuth error response
|
||||
func (h *TokenExchangeHandler) sendError(
|
||||
w http.ResponseWriter,
|
||||
errorCode, errorDescription string,
|
||||
) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
response := map[string]string{
|
||||
"error": errorCode,
|
||||
"error_description": errorDescription,
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/sonr-io/sonr/crypto/ucan"
|
||||
)
|
||||
|
||||
// OAuth2Client represents a registered OAuth2 client application
|
||||
type OAuth2Client struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
ClientType string `json:"client_type"` // "confidential" or "public"
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
AllowedScopes []string `json:"allowed_scopes"`
|
||||
AllowedGrants []string `json:"allowed_grants"`
|
||||
TokenLifetime time.Duration `json:"token_lifetime"`
|
||||
RequirePKCE bool `json:"require_pkce"`
|
||||
TrustedClient bool `json:"trusted_client"`
|
||||
RequiresConsent bool `json:"requires_consent"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// OAuth2AuthorizationRequest represents an OAuth2 authorization request
|
||||
type OAuth2AuthorizationRequest struct {
|
||||
ResponseType string `json:"response_type" form:"response_type" query:"response_type"`
|
||||
ClientID string `json:"client_id" form:"client_id" query:"client_id"`
|
||||
RedirectURI string `json:"redirect_uri" form:"redirect_uri" query:"redirect_uri"`
|
||||
Scope string `json:"scope" form:"scope" query:"scope"`
|
||||
State string `json:"state" form:"state" query:"state"`
|
||||
CodeChallenge string `json:"code_challenge" form:"code_challenge" query:"code_challenge"`
|
||||
CodeChallengeMethod string `json:"code_challenge_method" form:"code_challenge_method" query:"code_challenge_method"`
|
||||
Nonce string `json:"nonce" form:"nonce" query:"nonce"`
|
||||
Prompt string `json:"prompt" form:"prompt" query:"prompt"`
|
||||
MaxAge int `json:"max_age" form:"max_age" query:"max_age"`
|
||||
LoginHint string `json:"login_hint" form:"login_hint" query:"login_hint"`
|
||||
}
|
||||
|
||||
// OAuth2TokenRequest represents an OAuth2 token exchange request
|
||||
type OAuth2TokenRequest struct {
|
||||
GrantType string `json:"grant_type" form:"grant_type"`
|
||||
Code string `json:"code" form:"code"`
|
||||
RedirectURI string `json:"redirect_uri" form:"redirect_uri"`
|
||||
ClientID string `json:"client_id" form:"client_id"`
|
||||
ClientSecret string `json:"client_secret" form:"client_secret"`
|
||||
CodeVerifier string `json:"code_verifier" form:"code_verifier"`
|
||||
RefreshToken string `json:"refresh_token" form:"refresh_token"`
|
||||
Scope string `json:"scope" form:"scope"`
|
||||
Username string `json:"username" form:"username"`
|
||||
Password string `json:"password" form:"password"`
|
||||
Assertion string `json:"assertion" form:"assertion"`
|
||||
ClientAssertion string `json:"client_assertion" form:"client_assertion"`
|
||||
ClientAssertionType string `json:"client_assertion_type" form:"client_assertion_type"`
|
||||
}
|
||||
|
||||
// OAuth2TokenResponse represents an OAuth2 token response
|
||||
type OAuth2TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
UCANToken string `json:"ucan_token,omitempty"` // UCAN delegation token
|
||||
}
|
||||
|
||||
// OAuth2ErrorResponse represents an OAuth2 error response
|
||||
type OAuth2ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
ErrorURI string `json:"error_uri,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
// OAuth2AuthorizationCode represents an authorization code with UCAN context
|
||||
type OAuth2AuthorizationCode struct {
|
||||
Code string `json:"code"`
|
||||
ClientID string `json:"client_id"`
|
||||
UserDID string `json:"user_did"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
Scopes []string `json:"scopes"`
|
||||
State string `json:"state"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
CodeChallengeMethod string `json:"code_challenge_method"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Used bool `json:"used"`
|
||||
UCANContext *UCANAuthContext `json:"ucan_context"`
|
||||
}
|
||||
|
||||
// UCANAuthContext holds UCAN-related data for authorization
|
||||
type UCANAuthContext struct {
|
||||
VaultAddress string `json:"vault_address"`
|
||||
EnclaveDataCID string `json:"enclave_data_cid"`
|
||||
DIDDocument json.RawMessage `json:"did_document"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
// OAuth2AccessToken represents an access token with embedded UCAN
|
||||
type OAuth2AccessToken struct {
|
||||
Token string `json:"token"`
|
||||
UserDID string `json:"user_did"`
|
||||
ClientID string `json:"client_id"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
UCANToken *ucan.Token `json:"ucan_token"`
|
||||
SessionID string `json:"session_id"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// OAuth2RefreshToken represents a refresh token
|
||||
type OAuth2RefreshToken struct {
|
||||
Token string `json:"token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ClientID string `json:"client_id"`
|
||||
UserDID string `json:"user_did"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
RotationCount int `json:"rotation_count"`
|
||||
}
|
||||
|
||||
// OAuth2Session extends OIDCSession with OAuth2-specific fields
|
||||
type OAuth2Session struct {
|
||||
SessionID string `json:"session_id"`
|
||||
UserDID string `json:"user_did"`
|
||||
ClientID string `json:"client_id"`
|
||||
Scopes []string `json:"scopes"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
UCANToken *ucan.Token `json:"ucan_token"`
|
||||
ConsentGiven bool `json:"consent_given"`
|
||||
ConsentScopes []string `json:"consent_scopes"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// OAuth2ConsentRequest represents a consent request for a client
|
||||
type OAuth2ConsentRequest struct {
|
||||
UserDID string `json:"user_did"`
|
||||
ClientID string `json:"client_id"`
|
||||
RequestedScopes []string `json:"requested_scopes"`
|
||||
ConsentID string `json:"consent_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// OAuth2ConsentResponse represents a user's consent response
|
||||
type OAuth2ConsentResponse struct {
|
||||
ConsentID string `json:"consent_id"`
|
||||
Approved bool `json:"approved"`
|
||||
ApprovedScopes []string `json:"approved_scopes"`
|
||||
RememberChoice bool `json:"remember_choice"`
|
||||
}
|
||||
|
||||
// OAuth2ScopeDefinition defines an OAuth scope and its UCAN mapping
|
||||
type OAuth2ScopeDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
UCANActions []string `json:"ucan_actions"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
RequiresAuth bool `json:"requires_auth"`
|
||||
Sensitive bool `json:"sensitive"`
|
||||
ParentScope string `json:"parent_scope,omitempty"`
|
||||
ChildScopes []string `json:"child_scopes,omitempty"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
// OAuth2ClientRegistrationRequest represents a dynamic client registration request
|
||||
type OAuth2ClientRegistrationRequest struct {
|
||||
ClientName string `json:"client_name"`
|
||||
ClientType string `json:"client_type"` // "confidential" or "public"
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
GrantTypes []string `json:"grant_types,omitempty"`
|
||||
ResponseTypes []string `json:"response_types,omitempty"`
|
||||
Scopes string `json:"scopes,omitempty"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||
LogoURI string `json:"logo_uri,omitempty"`
|
||||
ClientURI string `json:"client_uri,omitempty"`
|
||||
PolicyURI string `json:"policy_uri,omitempty"`
|
||||
TOSUri string `json:"tos_uri,omitempty"`
|
||||
Contacts []string `json:"contacts,omitempty"`
|
||||
}
|
||||
|
||||
// OAuth2ClientRegistrationResponse represents a successful client registration
|
||||
type OAuth2ClientRegistrationResponse struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
|
||||
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
|
||||
RegistrationAccessToken string `json:"registration_access_token,omitempty"`
|
||||
RegistrationClientURI string `json:"registration_client_uri,omitempty"`
|
||||
}
|
||||
|
||||
// OAuth2IntrospectionRequest represents a token introspection request
|
||||
type OAuth2IntrospectionRequest struct {
|
||||
Token string `json:"token" form:"token"`
|
||||
TokenTypeHint string `json:"token_type_hint" form:"token_type_hint"`
|
||||
ClientID string `json:"client_id" form:"client_id"`
|
||||
ClientSecret string `json:"client_secret" form:"client_secret"`
|
||||
}
|
||||
|
||||
// OAuth2IntrospectionResponse represents a token introspection response
|
||||
type OAuth2IntrospectionResponse struct {
|
||||
Active bool `json:"active"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
ExpiresAt int64 `json:"exp,omitempty"`
|
||||
IssuedAt int64 `json:"iat,omitempty"`
|
||||
NotBefore int64 `json:"nbf,omitempty"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
Audience []string `json:"aud,omitempty"`
|
||||
Issuer string `json:"iss,omitempty"`
|
||||
JWTID string `json:"jti,omitempty"`
|
||||
UCANToken string `json:"ucan_token,omitempty"`
|
||||
}
|
||||
|
||||
// OAuth2RevocationRequest represents a token revocation request
|
||||
type OAuth2RevocationRequest struct {
|
||||
Token string `json:"token" form:"token"`
|
||||
TokenTypeHint string `json:"token_type_hint" form:"token_type_hint"`
|
||||
ClientID string `json:"client_id" form:"client_id"`
|
||||
ClientSecret string `json:"client_secret" form:"client_secret"`
|
||||
}
|
||||
|
||||
// OAuth2Config extends OIDCConfig with OAuth2-specific endpoints
|
||||
type OAuth2Config struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JWKSEndpoint string `json:"jwks_uri"`
|
||||
RegistrationEndpoint string `json:"registration_endpoint"`
|
||||
IntrospectionEndpoint string `json:"introspection_endpoint"`
|
||||
RevocationEndpoint string `json:"revocation_endpoint"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
ResponseModesSupported []string `json:"response_modes_supported,omitempty"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
ServiceDocumentation string `json:"service_documentation,omitempty"`
|
||||
UILocalesSupported []string `json:"ui_locales_supported,omitempty"`
|
||||
OpPolicyURI string `json:"op_policy_uri,omitempty"`
|
||||
OpTosURI string `json:"op_tos_uri,omitempty"`
|
||||
UCANSupported bool `json:"ucan_supported"` // Custom field for UCAN support
|
||||
}
|
||||
|
||||
// TokenValidationResult represents the result of token validation
|
||||
type TokenValidationResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
UserDID string `json:"user_did"`
|
||||
ClientID string `json:"client_id"`
|
||||
Scopes []string `json:"scopes"`
|
||||
UCANToken *ucan.Token `json:"ucan_token,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// OIDCProvider manages OIDC operations
|
||||
type OIDCProvider struct {
|
||||
mu sync.RWMutex
|
||||
codes map[string]*AuthorizationCode
|
||||
sessions map[string]*OIDCSession
|
||||
config any // TODO: Use bridge.OIDCProviderConfig
|
||||
}
|
||||
|
||||
var (
|
||||
oidcProvider = &OIDCProvider{
|
||||
codes: make(map[string]*AuthorizationCode),
|
||||
sessions: make(map[string]*OIDCSession),
|
||||
}
|
||||
codeExpiration = 10 * time.Minute
|
||||
)
|
||||
|
||||
// SetOIDCConfig sets the OIDC provider configuration
|
||||
func SetOIDCConfig(config any) {
|
||||
oidcProvider.mu.Lock()
|
||||
defer oidcProvider.mu.Unlock()
|
||||
oidcProvider.config = config
|
||||
}
|
||||
|
||||
// GetOIDCDiscovery returns OIDC discovery configuration
|
||||
func GetOIDCDiscovery(c echo.Context) error {
|
||||
config := &OIDCConfig{
|
||||
Issuer: "https://localhost:8080",
|
||||
AuthorizationEndpoint: "https://localhost:8080/oidc/authorize",
|
||||
TokenEndpoint: "https://localhost:8080/oidc/token",
|
||||
UserInfoEndpoint: "https://localhost:8080/oidc/userinfo",
|
||||
JWKSEndpoint: "https://localhost:8080/oidc/jwks",
|
||||
RevocationEndpoint: "https://localhost:8080/oidc/revoke",
|
||||
IntrospectionEndpoint: "https://localhost:8080/oidc/introspect",
|
||||
ScopesSupported: []string{
|
||||
"openid", "profile", "email", "did", "vault", "offline_access",
|
||||
},
|
||||
ResponseTypesSupported: []string{
|
||||
"code", "id_token", "code id_token",
|
||||
},
|
||||
GrantTypesSupported: []string{
|
||||
"authorization_code", "refresh_token", "client_credentials",
|
||||
},
|
||||
SubjectTypesSupported: []string{
|
||||
"public", "pairwise",
|
||||
},
|
||||
IDTokenSigningAlgValuesSupported: []string{
|
||||
"ES256", "RS256",
|
||||
},
|
||||
TokenEndpointAuthMethodsSupported: []string{
|
||||
"client_secret_post", "client_secret_basic", "none",
|
||||
},
|
||||
ClaimsSupported: []string{
|
||||
"sub", "name", "preferred_username", "email", "email_verified",
|
||||
"did", "vault_id", "updated_at",
|
||||
},
|
||||
CodeChallengeMethodsSupported: []string{
|
||||
"S256", "plain",
|
||||
},
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
// HandleOIDCAuthorization handles OIDC authorization requests
|
||||
func HandleOIDCAuthorization(c echo.Context) error {
|
||||
// Parse request parameters from query string for GET or form for POST
|
||||
req := OIDCAuthorizationRequest{
|
||||
ResponseType: c.QueryParam("response_type"),
|
||||
ClientID: c.QueryParam("client_id"),
|
||||
RedirectURI: c.QueryParam("redirect_uri"),
|
||||
Scope: c.QueryParam("scope"),
|
||||
State: c.QueryParam("state"),
|
||||
Nonce: c.QueryParam("nonce"),
|
||||
CodeChallenge: c.QueryParam("code_challenge"),
|
||||
CodeChallengeMethod: c.QueryParam("code_challenge_method"),
|
||||
}
|
||||
|
||||
// If no query params, try to bind from body (for POST requests)
|
||||
if req.ResponseType == "" {
|
||||
_ = c.Bind(&req) // Ignore bind errors and continue
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if req.ResponseType == "" || req.ClientID == "" || req.RedirectURI == "" || req.Scope == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing required parameters",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate response type
|
||||
if req.ResponseType != "code" && req.ResponseType != "id_token" &&
|
||||
req.ResponseType != "code id_token" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "unsupported_response_type",
|
||||
"error_description": "Response type not supported",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate scope includes openid
|
||||
if !strings.Contains(req.Scope, "openid") {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_scope",
|
||||
"error_description": "Scope must include 'openid'",
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Validate client_id and redirect_uri against registered clients
|
||||
|
||||
// For now, assume user is authenticated (in production, redirect to login)
|
||||
userDID := c.Get("user_did")
|
||||
if userDID == nil {
|
||||
// Redirect to WebAuthn authentication
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "authentication_required",
|
||||
"error_description": "User authentication required",
|
||||
})
|
||||
}
|
||||
|
||||
// Generate authorization code
|
||||
code := generateAuthorizationCode()
|
||||
|
||||
// Store authorization code
|
||||
authCode := &AuthorizationCode{
|
||||
Code: code,
|
||||
ClientID: req.ClientID,
|
||||
RedirectURI: req.RedirectURI,
|
||||
UserDID: userDID.(string),
|
||||
Scope: req.Scope,
|
||||
Nonce: req.Nonce,
|
||||
CodeChallenge: req.CodeChallenge,
|
||||
CodeChallengeMethod: req.CodeChallengeMethod,
|
||||
ExpiresAt: time.Now().Add(codeExpiration),
|
||||
Used: false,
|
||||
}
|
||||
|
||||
oidcProvider.mu.Lock()
|
||||
oidcProvider.codes[code] = authCode
|
||||
oidcProvider.mu.Unlock()
|
||||
|
||||
// Build redirect URL
|
||||
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", req.RedirectURI, code, req.State)
|
||||
|
||||
return c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
|
||||
// HandleOIDCToken handles OIDC token requests
|
||||
func HandleOIDCToken(c echo.Context) error {
|
||||
var req OIDCTokenRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid token request",
|
||||
})
|
||||
}
|
||||
|
||||
// Handle different grant types
|
||||
switch req.GrantType {
|
||||
case "authorization_code":
|
||||
return handleAuthorizationCodeGrant(c, &req)
|
||||
case "refresh_token":
|
||||
return handleRefreshTokenGrant(c, &req)
|
||||
case "client_credentials":
|
||||
return handleClientCredentialsGrant(c, &req)
|
||||
default:
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "unsupported_grant_type",
|
||||
"error_description": "Grant type not supported",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuthorizationCodeGrant processes authorization code grant
|
||||
func handleAuthorizationCodeGrant(c echo.Context, req *OIDCTokenRequest) error {
|
||||
if req.Code == "" || req.RedirectURI == "" || req.ClientID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing required parameters",
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve and validate authorization code
|
||||
oidcProvider.mu.Lock()
|
||||
authCode, exists := oidcProvider.codes[req.Code]
|
||||
if exists {
|
||||
delete(oidcProvider.codes, req.Code) // Single use
|
||||
}
|
||||
oidcProvider.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Invalid authorization code",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate code hasn't expired
|
||||
if time.Now().After(authCode.ExpiresAt) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Authorization code expired",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate code hasn't been used
|
||||
if authCode.Used {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Authorization code already used",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate client and redirect URI
|
||||
if authCode.ClientID != req.ClientID || authCode.RedirectURI != req.RedirectURI {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Invalid client or redirect URI",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate PKCE if present
|
||||
if authCode.CodeChallenge != "" {
|
||||
if req.CodeVerifier == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Code verifier required",
|
||||
})
|
||||
}
|
||||
|
||||
if !verifyPKCE(req.CodeVerifier, authCode.CodeChallenge, authCode.CodeChallengeMethod) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Invalid code verifier",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
authCode.Used = true
|
||||
|
||||
// Generate tokens
|
||||
accessToken := generateAccessToken(authCode.UserDID)
|
||||
refreshToken := generateRefreshToken()
|
||||
idToken, err := generateIDToken(authCode.UserDID, authCode.ClientID, authCode.Nonce)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "server_error",
|
||||
"error_description": "Failed to generate ID token",
|
||||
})
|
||||
}
|
||||
|
||||
// Create session
|
||||
session := &OIDCSession{
|
||||
SessionID: generateSessionID(),
|
||||
UserDID: authCode.UserDID,
|
||||
ClientID: authCode.ClientID,
|
||||
Scope: authCode.Scope,
|
||||
Nonce: authCode.Nonce,
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
IDToken: idToken,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
oidcProvider.mu.Lock()
|
||||
oidcProvider.sessions[accessToken] = session
|
||||
oidcProvider.mu.Unlock()
|
||||
|
||||
// Return tokens
|
||||
response := &OIDCTokenResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
RefreshToken: refreshToken,
|
||||
IDToken: idToken,
|
||||
Scope: authCode.Scope,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleRefreshTokenGrant processes refresh token grant
|
||||
func handleRefreshTokenGrant(c echo.Context, req *OIDCTokenRequest) error {
|
||||
// TODO: Implement refresh token grant
|
||||
return c.JSON(http.StatusNotImplemented, map[string]string{
|
||||
"error": "unsupported_grant_type",
|
||||
"error_description": "Refresh token grant not yet implemented",
|
||||
})
|
||||
}
|
||||
|
||||
// handleClientCredentialsGrant processes client credentials grant
|
||||
func handleClientCredentialsGrant(c echo.Context, req *OIDCTokenRequest) error {
|
||||
// TODO: Implement client credentials grant
|
||||
return c.JSON(http.StatusNotImplemented, map[string]string{
|
||||
"error": "unsupported_grant_type",
|
||||
"error_description": "Client credentials grant not yet implemented",
|
||||
})
|
||||
}
|
||||
|
||||
// HandleOIDCUserInfo handles OIDC userinfo requests
|
||||
func HandleOIDCUserInfo(c echo.Context) error {
|
||||
// Extract access token from Authorization header
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "invalid_token",
|
||||
"error_description": "Invalid access token",
|
||||
})
|
||||
}
|
||||
|
||||
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Validate access token
|
||||
oidcProvider.mu.RLock()
|
||||
session, exists := oidcProvider.sessions[accessToken]
|
||||
oidcProvider.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "invalid_token",
|
||||
"error_description": "Invalid or expired access token",
|
||||
})
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "invalid_token",
|
||||
"error_description": "Access token expired",
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Fetch actual user info from DID document or database
|
||||
userInfo := &OIDCUserInfo{
|
||||
Subject: session.UserDID,
|
||||
PreferredUsername: "user", // TODO: Get from DID document
|
||||
DID: session.UserDID,
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
// Add additional claims based on scope
|
||||
if strings.Contains(session.Scope, "profile") {
|
||||
userInfo.Name = "User Name" // TODO: Get from DID document
|
||||
}
|
||||
|
||||
if strings.Contains(session.Scope, "email") {
|
||||
userInfo.Email = "user@example.com" // TODO: Get from DID document
|
||||
userInfo.EmailVerified = true
|
||||
}
|
||||
|
||||
if strings.Contains(session.Scope, "vault") {
|
||||
userInfo.VaultID = "vault_" + session.UserDID // TODO: Get actual vault ID
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, userInfo)
|
||||
}
|
||||
|
||||
// HandleOIDCJWKS handles JWKS endpoint
|
||||
func HandleOIDCJWKS(c echo.Context) error {
|
||||
// TODO: Return actual public keys used for signing
|
||||
jwks := &JWKSet{
|
||||
Keys: []JWK{
|
||||
{
|
||||
KeyType: "EC",
|
||||
Use: "sig",
|
||||
KeyID: "1",
|
||||
Algorithm: "ES256",
|
||||
Curve: "P-256",
|
||||
// TODO: Add actual public key coordinates
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, jwks)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func generateAuthorizationCode() string {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
func generateAccessToken(userDID string) string {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
func generateRefreshToken() string {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
func generateSessionID() string {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
func generateIDToken(userDID, clientID, nonce string) (string, error) {
|
||||
claims := &DIDAuthClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userDID,
|
||||
Issuer: "https://localhost:8080",
|
||||
Audience: []string{clientID},
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
DID: userDID,
|
||||
}
|
||||
|
||||
if nonce != "" {
|
||||
claims.Extra = map[string]any{
|
||||
"nonce": nonce,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Sign with actual signing key
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte("temporary-secret-key"))
|
||||
}
|
||||
|
||||
func verifyPKCE(verifier, challenge, method string) bool {
|
||||
var computed string
|
||||
|
||||
switch method {
|
||||
case "S256":
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
computed = base64.RawURLEncoding.EncodeToString(h[:])
|
||||
case "plain":
|
||||
computed = verifier
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
return computed == challenge
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestOIDCDiscovery tests the OIDC discovery endpoint
|
||||
func TestOIDCDiscovery(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := GetOIDCDiscovery(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var config OIDCConfig
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify required fields
|
||||
assert.NotEmpty(t, config.Issuer)
|
||||
assert.NotEmpty(t, config.AuthorizationEndpoint)
|
||||
assert.NotEmpty(t, config.TokenEndpoint)
|
||||
assert.NotEmpty(t, config.UserInfoEndpoint)
|
||||
assert.NotEmpty(t, config.JWKSEndpoint)
|
||||
assert.Contains(t, config.ScopesSupported, "openid")
|
||||
assert.Contains(t, config.ResponseTypesSupported, "code")
|
||||
assert.Contains(t, config.GrantTypesSupported, "authorization_code")
|
||||
}
|
||||
|
||||
// TestOIDCAuthorizationFlow tests the authorization code flow
|
||||
func TestOIDCAuthorizationFlow(t *testing.T) {
|
||||
e := echo.New()
|
||||
|
||||
t.Run("ValidAuthorizationRequest", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/oidc/authorize", nil)
|
||||
q := req.URL.Query()
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", "test-client")
|
||||
q.Set("redirect_uri", "http://localhost:3000/callback")
|
||||
q.Set("scope", "openid profile")
|
||||
q.Set("state", "test-state")
|
||||
q.Set("nonce", "test-nonce")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
// Set authenticated user context
|
||||
c.Set("user_did", "did:sonr:testuser")
|
||||
c.Set("authenticated", true)
|
||||
|
||||
err := HandleOIDCAuthorization(c)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Should redirect with authorization code
|
||||
assert.Equal(t, http.StatusFound, rec.Code)
|
||||
location := rec.Header().Get("Location")
|
||||
assert.Contains(t, location, "code=")
|
||||
assert.Contains(t, location, "state=test-state")
|
||||
})
|
||||
|
||||
t.Run("MissingRequiredParameters", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/oidc/authorize", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := HandleOIDCAuthorization(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var errorResp map[string]string
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &errorResp)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", errorResp["error"])
|
||||
})
|
||||
|
||||
t.Run("InvalidResponseType", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/oidc/authorize", nil)
|
||||
q := req.URL.Query()
|
||||
q.Set("response_type", "invalid")
|
||||
q.Set("client_id", "test-client")
|
||||
q.Set("redirect_uri", "http://localhost:3000/callback")
|
||||
q.Set("scope", "openid")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := HandleOIDCAuthorization(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOIDCTokenExchange tests the token endpoint
|
||||
func TestOIDCTokenExchange(t *testing.T) {
|
||||
e := echo.New()
|
||||
|
||||
// Setup: Create an authorization code
|
||||
code := "test-auth-code"
|
||||
authCode := &AuthorizationCode{
|
||||
Code: code,
|
||||
ClientID: "test-client",
|
||||
RedirectURI: "http://localhost:3000/callback",
|
||||
UserDID: "did:sonr:testuser",
|
||||
Scope: "openid profile",
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
CodeChallenge: "test-challenge",
|
||||
CodeChallengeMethod: "S256",
|
||||
}
|
||||
|
||||
oidcProvider.mu.Lock()
|
||||
oidcProvider.codes[code] = authCode
|
||||
oidcProvider.mu.Unlock()
|
||||
|
||||
t.Run("ValidTokenExchange", func(t *testing.T) {
|
||||
body := strings.NewReader("grant_type=authorization_code&code=" + code +
|
||||
"&redirect_uri=http://localhost:3000/callback&client_id=test-client" +
|
||||
"&code_verifier=test-verifier")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/oidc/token", body)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
tokenReq := &OIDCTokenRequest{
|
||||
GrantType: "authorization_code",
|
||||
Code: code,
|
||||
RedirectURI: "http://localhost:3000/callback",
|
||||
ClientID: "test-client",
|
||||
CodeVerifier: "test-verifier",
|
||||
}
|
||||
|
||||
err := handleAuthorizationCodeGrant(c, tokenReq)
|
||||
assert.NoError(t, err)
|
||||
|
||||
if rec.Code == http.StatusOK {
|
||||
var tokenResp OIDCTokenResponse
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &tokenResp)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, tokenResp.AccessToken)
|
||||
assert.NotEmpty(t, tokenResp.IDToken)
|
||||
assert.Equal(t, "Bearer", tokenResp.TokenType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExpiredAuthorizationCode", func(t *testing.T) {
|
||||
expiredCode := "expired-code"
|
||||
expiredAuthCode := &AuthorizationCode{
|
||||
Code: expiredCode,
|
||||
ClientID: "test-client",
|
||||
RedirectURI: "http://localhost:3000/callback",
|
||||
UserDID: "did:sonr:testuser",
|
||||
ExpiresAt: time.Now().Add(-1 * time.Hour), // Expired
|
||||
}
|
||||
|
||||
oidcProvider.mu.Lock()
|
||||
oidcProvider.codes[expiredCode] = expiredAuthCode
|
||||
oidcProvider.mu.Unlock()
|
||||
|
||||
tokenReq := &OIDCTokenRequest{
|
||||
GrantType: "authorization_code",
|
||||
Code: expiredCode,
|
||||
RedirectURI: "http://localhost:3000/callback",
|
||||
ClientID: "test-client",
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/oidc/token", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := handleAuthorizationCodeGrant(c, tokenReq)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOIDCUserInfo tests the userinfo endpoint
|
||||
func TestOIDCUserInfo(t *testing.T) {
|
||||
e := echo.New()
|
||||
|
||||
// Setup: Create a session
|
||||
accessToken := "test-access-token"
|
||||
session := &OIDCSession{
|
||||
SessionID: "test-session",
|
||||
UserDID: "did:sonr:testuser",
|
||||
ClientID: "test-client",
|
||||
Scope: "openid profile email",
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
oidcProvider.mu.Lock()
|
||||
oidcProvider.sessions[accessToken] = session
|
||||
oidcProvider.mu.Unlock()
|
||||
|
||||
t.Run("ValidUserInfoRequest", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/oidc/userinfo", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := HandleOIDCUserInfo(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var userInfo map[string]any
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &userInfo)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "did:sonr:testuser", userInfo["sub"])
|
||||
})
|
||||
|
||||
t.Run("InvalidAccessToken", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/oidc/userinfo", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := HandleOIDCUserInfo(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
})
|
||||
|
||||
t.Run("MissingAuthorizationHeader", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/oidc/userinfo", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := HandleOIDCUserInfo(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPKCEFlow tests PKCE (Proof Key for Code Exchange) implementation
|
||||
func TestPKCEFlow(t *testing.T) {
|
||||
e := echo.New()
|
||||
|
||||
// Generate PKCE parameters
|
||||
codeVerifier := "test-code-verifier-string-that-is-long-enough"
|
||||
codeChallenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" // SHA256 of verifier
|
||||
|
||||
t.Run("AuthorizationWithPKCE", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/oidc/authorize", nil)
|
||||
q := req.URL.Query()
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", "test-client")
|
||||
q.Set("redirect_uri", "http://localhost:3000/callback")
|
||||
q.Set("scope", "openid")
|
||||
q.Set("code_challenge", codeChallenge)
|
||||
q.Set("code_challenge_method", "S256")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.Set("user_did", "did:sonr:testuser")
|
||||
c.Set("authenticated", true)
|
||||
|
||||
err := HandleOIDCAuthorization(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusFound, rec.Code)
|
||||
})
|
||||
|
||||
t.Run("TokenExchangeWithPKCE", func(t *testing.T) {
|
||||
// Create auth code with PKCE
|
||||
code := "pkce-auth-code"
|
||||
authCode := &AuthorizationCode{
|
||||
Code: code,
|
||||
ClientID: "test-client",
|
||||
RedirectURI: "http://localhost:3000/callback",
|
||||
UserDID: "did:sonr:testuser",
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: "S256",
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
}
|
||||
|
||||
oidcProvider.mu.Lock()
|
||||
oidcProvider.codes[code] = authCode
|
||||
oidcProvider.mu.Unlock()
|
||||
|
||||
tokenReq := &OIDCTokenRequest{
|
||||
GrantType: "authorization_code",
|
||||
Code: code,
|
||||
RedirectURI: "http://localhost:3000/callback",
|
||||
ClientID: "test-client",
|
||||
CodeVerifier: codeVerifier,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/oidc/token", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := handleAuthorizationCodeGrant(c, tokenReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
// With correct verifier, should succeed
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Logf("Response: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRefreshTokenFlow tests refresh token functionality
|
||||
func TestRefreshTokenFlow(t *testing.T) {
|
||||
e := echo.New()
|
||||
|
||||
// Create initial session with refresh token
|
||||
refreshToken := "test-refresh-token"
|
||||
session := &OIDCSession{
|
||||
SessionID: "test-session",
|
||||
UserDID: "did:sonr:testuser",
|
||||
ClientID: "test-client",
|
||||
Scope: "openid profile offline_access",
|
||||
AccessToken: "old-access-token",
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: time.Now().Add(-1 * time.Hour), // Expired access token
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
}
|
||||
|
||||
oidcProvider.mu.Lock()
|
||||
oidcProvider.sessions[refreshToken] = session
|
||||
oidcProvider.mu.Unlock()
|
||||
|
||||
t.Run("ValidRefreshToken", func(t *testing.T) {
|
||||
tokenReq := &OIDCTokenRequest{
|
||||
GrantType: "refresh_token",
|
||||
RefreshToken: refreshToken,
|
||||
ClientID: "test-client",
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/oidc/token", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := handleRefreshTokenGrant(c, tokenReq)
|
||||
assert.NoError(t, err)
|
||||
|
||||
if rec.Code == http.StatusOK {
|
||||
var tokenResp OIDCTokenResponse
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &tokenResp)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, tokenResp.AccessToken)
|
||||
assert.NotEqual(t, "old-access-token", tokenResp.AccessToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// SIOPProvider manages Self-Issued OpenID Provider operations
|
||||
type SIOPProvider struct {
|
||||
// No persistent state needed for SIOP as it's self-issued
|
||||
}
|
||||
|
||||
var siopProvider = &SIOPProvider{}
|
||||
|
||||
// HandleSIOPAuthorization handles SIOP authorization requests
|
||||
func HandleSIOPAuthorization(c echo.Context) error {
|
||||
var req SIOPRequest
|
||||
|
||||
// Check if request was already parsed by HandleSIOPRequest
|
||||
if parsedReq := c.Get("siop_request"); parsedReq != nil {
|
||||
req = *(parsedReq.(*SIOPRequest))
|
||||
} else {
|
||||
// Parse request parameters from query string for GET or form for POST
|
||||
req = SIOPRequest{
|
||||
ResponseType: c.QueryParam("response_type"),
|
||||
ClientID: c.QueryParam("client_id"),
|
||||
RedirectURI: c.QueryParam("redirect_uri"),
|
||||
Scope: c.QueryParam("scope"),
|
||||
Nonce: c.QueryParam("nonce"),
|
||||
State: c.QueryParam("state"),
|
||||
}
|
||||
|
||||
// Parse claims if provided
|
||||
if claimsStr := c.QueryParam("claims"); claimsStr != "" {
|
||||
_ = json.Unmarshal([]byte(claimsStr), &req.Claims) // Ignore claims parsing errors
|
||||
}
|
||||
|
||||
// If no query params, try to bind from body (for POST requests)
|
||||
if req.ResponseType == "" {
|
||||
_ = c.Bind(&req) // Ignore bind errors and continue with empty request
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if req.ResponseType == "" || req.ClientID == "" || req.RedirectURI == "" ||
|
||||
req.Scope == "" || req.Nonce == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing required parameters",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate response type (SIOP v2 supports id_token)
|
||||
if req.ResponseType != "id_token" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "unsupported_response_type",
|
||||
"error_description": "SIOP only supports id_token response type",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate scope includes openid
|
||||
if !strings.Contains(req.Scope, "openid") {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_scope",
|
||||
"error_description": "Scope must include 'openid'",
|
||||
})
|
||||
}
|
||||
|
||||
// Get user DID from context (assumes user is authenticated via WebAuthn)
|
||||
userDID := c.Get("user_did")
|
||||
if userDID == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "authentication_required",
|
||||
"error_description": "User authentication required for SIOP",
|
||||
})
|
||||
}
|
||||
|
||||
// Generate Self-Issued ID Token
|
||||
idToken, err := generateSelfIssuedIDToken(
|
||||
userDID.(string),
|
||||
req.ClientID,
|
||||
req.Nonce,
|
||||
req.RedirectURI,
|
||||
req.Claims,
|
||||
)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "server_error",
|
||||
"error_description": "Failed to generate self-issued ID token",
|
||||
})
|
||||
}
|
||||
|
||||
// Build response
|
||||
response := &SIOPResponse{
|
||||
IDToken: idToken,
|
||||
State: req.State,
|
||||
}
|
||||
|
||||
// If VP token is requested, include it
|
||||
if req.Claims.VPToken != nil {
|
||||
vpToken, err := generateVPToken(userDID.(string), req.Claims.VPToken)
|
||||
if err == nil {
|
||||
response.VPToken = vpToken
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect back to client with response
|
||||
redirectURL, err := buildSIOPRedirectURL(req.RedirectURI, response)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "server_error",
|
||||
"error_description": "Failed to build redirect URL",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
|
||||
// HandleSIOPRegistration handles dynamic client registration for SIOP
|
||||
func HandleSIOPRegistration(c echo.Context) error {
|
||||
var registration SIOPRegistration
|
||||
if err := c.Bind(®istration); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid registration request",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate registration parameters
|
||||
if registration.ClientName == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Client name is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Generate client ID for the registration
|
||||
clientID := generateClientID()
|
||||
|
||||
// Store registration (in production, persist this)
|
||||
// For now, return the registration confirmation
|
||||
response := map[string]any{
|
||||
"client_id": clientID,
|
||||
"client_name": registration.ClientName,
|
||||
"client_purpose": registration.ClientPurpose,
|
||||
"logo_uri": registration.LogoURI,
|
||||
"subject_syntax_types_supported": []string{"did"},
|
||||
"vp_formats": map[string]any{
|
||||
"jwt_vp": map[string]any{
|
||||
"alg": []string{"ES256", "EdDSA"},
|
||||
},
|
||||
"ldp_vp": map[string]any{
|
||||
"proof_type": []string{"Ed25519Signature2018"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// HandleSIOPMetadata returns SIOP provider metadata
|
||||
func HandleSIOPMetadata(c echo.Context) error {
|
||||
metadata := map[string]any{
|
||||
"issuer": "https://self-issued.me/v2",
|
||||
"authorization_endpoint": "openid://",
|
||||
"response_types_supported": []string{"id_token"},
|
||||
"scopes_supported": []string{
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"did",
|
||||
},
|
||||
"subject_types_supported": []string{"pairwise"},
|
||||
"id_token_signing_alg_values_supported": []string{"ES256", "EdDSA"},
|
||||
"request_object_signing_alg_values_supported": []string{"ES256", "EdDSA"},
|
||||
"subject_syntax_types_supported": []string{
|
||||
"did:key",
|
||||
"did:web",
|
||||
"did:ion",
|
||||
},
|
||||
"vp_formats_supported": map[string]any{
|
||||
"jwt_vp": map[string]any{
|
||||
"alg_values_supported": []string{"ES256", "EdDSA"},
|
||||
},
|
||||
"ldp_vp": map[string]any{
|
||||
"proof_type_values_supported": []string{"Ed25519Signature2018"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, metadata)
|
||||
}
|
||||
|
||||
// generateSelfIssuedIDToken generates a self-issued ID token
|
||||
func generateSelfIssuedIDToken(
|
||||
userDID, clientID, nonce, redirectURI string,
|
||||
requestedClaims SIOPClaims,
|
||||
) (string, error) {
|
||||
// Create base claims
|
||||
claims := jwt.MapClaims{
|
||||
"iss": "https://self-issued.me/v2",
|
||||
"sub": userDID,
|
||||
"aud": clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"nonce": nonce,
|
||||
"_sd_alg": "sha-256", // Selective disclosure algorithm
|
||||
"sub_jwk": map[string]any{
|
||||
// TODO: Include actual public key from DID document
|
||||
"kty": "EC",
|
||||
"crv": "P-256",
|
||||
"x": "placeholder_x",
|
||||
"y": "placeholder_y",
|
||||
},
|
||||
}
|
||||
|
||||
// Add requested claims from ID token
|
||||
if requestedClaims.IDToken != nil {
|
||||
for claimName := range requestedClaims.IDToken {
|
||||
// Add claim based on request
|
||||
switch claimName {
|
||||
case "email":
|
||||
claims["email"] = "user@example.com" // TODO: Get from DID document
|
||||
claims["email_verified"] = true
|
||||
case "name":
|
||||
claims["name"] = "User Name" // TODO: Get from DID document
|
||||
case "did":
|
||||
claims["did"] = userDID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sign with user's DID key (simplified for now)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte("temporary-siop-key"))
|
||||
}
|
||||
|
||||
// generateVPToken generates a Verifiable Presentation token
|
||||
func generateVPToken(userDID string, vpClaims map[string]ClaimRequest) (string, error) {
|
||||
// Create VP structure
|
||||
vp := map[string]any{
|
||||
"@context": []string{
|
||||
"https://www.w3.org/2018/credentials/v1",
|
||||
},
|
||||
"type": []string{"VerifiablePresentation"},
|
||||
"holder": userDID,
|
||||
"verifiableCredential": []any{
|
||||
// TODO: Include actual verifiable credentials
|
||||
},
|
||||
}
|
||||
|
||||
// Convert to JWT
|
||||
vpJSON, err := json.Marshal(vp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Sign VP (simplified for now)
|
||||
claims := jwt.MapClaims{
|
||||
"vp": string(vpJSON),
|
||||
"iss": userDID,
|
||||
"aud": "verifier",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"nonce": generateNonce(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte("temporary-vp-key"))
|
||||
}
|
||||
|
||||
// buildSIOPRedirectURL builds the redirect URL with SIOP response
|
||||
func buildSIOPRedirectURL(redirectURI string, response *SIOPResponse) (string, error) {
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Add response parameters
|
||||
q := u.Query()
|
||||
q.Set("id_token", response.IDToken)
|
||||
if response.VPToken != "" {
|
||||
q.Set("vp_token", response.VPToken)
|
||||
}
|
||||
if response.State != "" {
|
||||
q.Set("state", response.State)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// generateClientID generates a unique client ID
|
||||
func generateClientID() string {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("siop_client_%s", base64.RawURLEncoding.EncodeToString(bytes))
|
||||
}
|
||||
|
||||
// generateNonce generates a nonce for tokens
|
||||
func generateNonce() string {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
// ValidateSIOPRequest validates an incoming SIOP request
|
||||
func ValidateSIOPRequest(requestURI string) (*SIOPRequest, error) {
|
||||
// Parse the request URI
|
||||
u, err := url.Parse(requestURI)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid request URI: %w", err)
|
||||
}
|
||||
|
||||
// Validate that it's a proper URI with scheme and host
|
||||
if u.Scheme == "" && u.Host == "" {
|
||||
return nil, fmt.Errorf("invalid request URI: missing scheme or host")
|
||||
}
|
||||
|
||||
// Extract parameters
|
||||
q := u.Query()
|
||||
req := &SIOPRequest{
|
||||
ResponseType: q.Get("response_type"),
|
||||
ClientID: q.Get("client_id"),
|
||||
RedirectURI: q.Get("redirect_uri"),
|
||||
Scope: q.Get("scope"),
|
||||
Nonce: q.Get("nonce"),
|
||||
State: q.Get("state"),
|
||||
}
|
||||
|
||||
// Parse claims if present
|
||||
if claimsStr := q.Get("claims"); claimsStr != "" {
|
||||
if err := json.Unmarshal([]byte(claimsStr), &req.Claims); err != nil {
|
||||
return nil, fmt.Errorf("invalid claims parameter: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse registration if present
|
||||
if regStr := q.Get("registration"); regStr != "" {
|
||||
if err := json.Unmarshal([]byte(regStr), &req.Registration); err != nil {
|
||||
return nil, fmt.Errorf("invalid registration parameter: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// HandleSIOPRequest processes a complete SIOP request flow
|
||||
func HandleSIOPRequest(c echo.Context) error {
|
||||
// Get request URI from query parameter
|
||||
requestURI := c.QueryParam("request_uri")
|
||||
if requestURI == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing request_uri parameter",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate and parse the request
|
||||
req, err := ValidateSIOPRequest(requestURI)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid_request",
|
||||
"error_description": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Process as regular SIOP authorization
|
||||
c.Set("siop_request", req)
|
||||
return HandleSIOPAuthorization(c)
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestSIOPAuthorization tests Self-Issued OpenID Provider authorization
|
||||
func TestSIOPAuthorization(t *testing.T) {
|
||||
e := echo.New()
|
||||
|
||||
t.Run("ValidSIOPRequest", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/siop/authorize", nil)
|
||||
q := req.URL.Query()
|
||||
q.Set("response_type", "id_token")
|
||||
q.Set("client_id", "test-client")
|
||||
q.Set("redirect_uri", "http://localhost:3000/callback")
|
||||
q.Set("scope", "openid did")
|
||||
q.Set("nonce", "test-nonce")
|
||||
q.Set("state", "test-state")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
// Set authenticated user DID
|
||||
c.Set("user_did", "did:sonr:testuser")
|
||||
|
||||
err := HandleSIOPAuthorization(c)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Should redirect with ID token
|
||||
assert.Equal(t, http.StatusFound, rec.Code)
|
||||
location := rec.Header().Get("Location")
|
||||
assert.Contains(t, location, "id_token=")
|
||||
assert.Contains(t, location, "state=test-state")
|
||||
})
|
||||
|
||||
t.Run("InvalidResponseType", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/siop/authorize", nil)
|
||||
q := req.URL.Query()
|
||||
q.Set("response_type", "code") // SIOP only supports id_token
|
||||
q.Set("client_id", "test-client")
|
||||
q.Set("redirect_uri", "http://localhost:3000/callback")
|
||||
q.Set("scope", "openid")
|
||||
q.Set("nonce", "test-nonce")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := HandleSIOPAuthorization(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var errorResp map[string]string
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &errorResp)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "unsupported_response_type", errorResp["error"])
|
||||
})
|
||||
|
||||
t.Run("MissingScopeOpenID", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/siop/authorize", nil)
|
||||
q := req.URL.Query()
|
||||
q.Set("response_type", "id_token")
|
||||
q.Set("client_id", "test-client")
|
||||
q.Set("redirect_uri", "http://localhost:3000/callback")
|
||||
q.Set("scope", "profile email") // Missing 'openid'
|
||||
q.Set("nonce", "test-nonce")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := HandleSIOPAuthorization(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var errorResp map[string]string
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &errorResp)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "invalid_scope", errorResp["error"])
|
||||
})
|
||||
|
||||
t.Run("UnauthenticatedUser", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/siop/authorize", nil)
|
||||
q := req.URL.Query()
|
||||
q.Set("response_type", "id_token")
|
||||
q.Set("client_id", "test-client")
|
||||
q.Set("redirect_uri", "http://localhost:3000/callback")
|
||||
q.Set("scope", "openid")
|
||||
q.Set("nonce", "test-nonce")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
// No user_did set - user not authenticated
|
||||
|
||||
err := HandleSIOPAuthorization(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
|
||||
var errorResp map[string]string
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &errorResp)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "authentication_required", errorResp["error"])
|
||||
})
|
||||
}
|
||||
|
||||
// TestSIOPRegistration tests dynamic client registration for SIOP
|
||||
func TestSIOPRegistration(t *testing.T) {
|
||||
e := echo.New()
|
||||
|
||||
t.Run("ValidRegistration", func(t *testing.T) {
|
||||
registration := map[string]any{
|
||||
"client_name": "Test SIOP Client",
|
||||
"client_purpose": "Testing SIOP functionality",
|
||||
"logo_uri": "https://example.com/logo.png",
|
||||
}
|
||||
body, _ := json.Marshal(registration)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/siop/register", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := HandleSIOPRegistration(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var response map[string]any
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, response["client_id"])
|
||||
assert.Equal(t, "Test SIOP Client", response["client_name"])
|
||||
assert.Contains(t, response["subject_syntax_types_supported"], "did")
|
||||
})
|
||||
|
||||
t.Run("MissingClientName", func(t *testing.T) {
|
||||
registration := map[string]any{
|
||||
"client_purpose": "Testing",
|
||||
}
|
||||
body, _ := json.Marshal(registration)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/siop/register", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := HandleSIOPRegistration(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var errorResp map[string]string
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &errorResp)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", errorResp["error"])
|
||||
assert.Contains(t, errorResp["error_description"], "Client name is required")
|
||||
})
|
||||
}
|
||||
|
||||
// TestSIOPMetadata tests SIOP metadata endpoint
|
||||
func TestSIOPMetadata(t *testing.T) {
|
||||
e := echo.New()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/siop/metadata", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := HandleSIOPMetadata(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var metadata map[string]any
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &metadata)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify required SIOP metadata fields
|
||||
assert.Equal(t, "https://self-issued.me/v2", metadata["issuer"])
|
||||
assert.Equal(t, "openid://", metadata["authorization_endpoint"])
|
||||
assert.Contains(t, metadata["response_types_supported"], "id_token")
|
||||
assert.Contains(t, metadata["scopes_supported"], "openid")
|
||||
assert.Contains(t, metadata["subject_types_supported"], "pairwise")
|
||||
assert.NotNil(t, metadata["vp_formats_supported"])
|
||||
}
|
||||
|
||||
// TestValidateSIOPRequest tests SIOP request validation
|
||||
func TestValidateSIOPRequest(t *testing.T) {
|
||||
t.Run("ValidRequest", func(t *testing.T) {
|
||||
requestURI := "openid://?" +
|
||||
"response_type=id_token" +
|
||||
"&client_id=test-client" +
|
||||
"&redirect_uri=" + url.QueryEscape("http://localhost:3000/callback") +
|
||||
"&scope=openid+did" +
|
||||
"&nonce=test-nonce" +
|
||||
"&state=test-state"
|
||||
|
||||
req, err := ValidateSIOPRequest(requestURI)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, req)
|
||||
assert.Equal(t, "id_token", req.ResponseType)
|
||||
assert.Equal(t, "test-client", req.ClientID)
|
||||
assert.Equal(t, "openid did", req.Scope)
|
||||
assert.Equal(t, "test-nonce", req.Nonce)
|
||||
})
|
||||
|
||||
t.Run("InvalidURI", func(t *testing.T) {
|
||||
req, err := ValidateSIOPRequest("not-a-valid-uri")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, req)
|
||||
})
|
||||
|
||||
t.Run("WithClaims", func(t *testing.T) {
|
||||
claims := map[string]any{
|
||||
"id_token": map[string]any{
|
||||
"email": map[string]any{
|
||||
"essential": true,
|
||||
},
|
||||
"name": nil,
|
||||
},
|
||||
}
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
|
||||
requestURI := "openid://?" +
|
||||
"response_type=id_token" +
|
||||
"&client_id=test-client" +
|
||||
"&redirect_uri=" + url.QueryEscape("http://localhost:3000/callback") +
|
||||
"&scope=openid" +
|
||||
"&nonce=test-nonce" +
|
||||
"&claims=" + url.QueryEscape(string(claimsJSON))
|
||||
|
||||
req, err := ValidateSIOPRequest(requestURI)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, req)
|
||||
assert.NotNil(t, req.Claims.IDToken)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSIOPWithVPToken tests SIOP with Verifiable Presentation
|
||||
func TestSIOPWithVPToken(t *testing.T) {
|
||||
e := echo.New()
|
||||
|
||||
vpClaims := map[string]any{
|
||||
"vp_token": map[string]any{
|
||||
"presentation_definition": map[string]any{
|
||||
"id": "test-presentation",
|
||||
"input_descriptors": []map[string]any{
|
||||
{
|
||||
"id": "id_credential",
|
||||
"constraints": map[string]any{
|
||||
"fields": []map[string]any{
|
||||
{
|
||||
"path": []string{"$.type"},
|
||||
"filter": map[string]any{
|
||||
"type": "string",
|
||||
"const": "IdentityCredential",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
claimsJSON, _ := json.Marshal(vpClaims)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/siop/authorize", nil)
|
||||
q := req.URL.Query()
|
||||
q.Set("response_type", "id_token")
|
||||
q.Set("client_id", "test-client")
|
||||
q.Set("redirect_uri", "http://localhost:3000/callback")
|
||||
q.Set("scope", "openid")
|
||||
q.Set("nonce", "test-nonce")
|
||||
q.Set("claims", string(claimsJSON))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.Set("user_did", "did:sonr:testuser")
|
||||
|
||||
// Parse request to set claims
|
||||
siopReq := &SIOPRequest{
|
||||
ResponseType: "id_token",
|
||||
ClientID: "test-client",
|
||||
RedirectURI: "http://localhost:3000/callback",
|
||||
Scope: "openid",
|
||||
Nonce: "test-nonce",
|
||||
}
|
||||
err := json.Unmarshal(claimsJSON, &siopReq.Claims)
|
||||
assert.NoError(t, err)
|
||||
c.Set("siop_request", siopReq)
|
||||
|
||||
err = HandleSIOPAuthorization(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusFound, rec.Code)
|
||||
|
||||
location := rec.Header().Get("Location")
|
||||
assert.Contains(t, location, "id_token=")
|
||||
// VP token generation is optional, so we don't assert its presence
|
||||
}
|
||||
|
||||
// TestHandleSIOPRequest tests complete SIOP request flow
|
||||
func TestHandleSIOPRequest(t *testing.T) {
|
||||
e := echo.New()
|
||||
|
||||
t.Run("ValidRequestURI", func(t *testing.T) {
|
||||
requestURI := "openid://?" +
|
||||
"response_type=id_token" +
|
||||
"&client_id=test-client" +
|
||||
"&redirect_uri=" + url.QueryEscape("http://localhost:3000/callback") +
|
||||
"&scope=openid" +
|
||||
"&nonce=test-nonce"
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/siop/request", nil)
|
||||
q := req.URL.Query()
|
||||
q.Set("request_uri", requestURI)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.Set("user_did", "did:sonr:testuser")
|
||||
|
||||
err := HandleSIOPRequest(c)
|
||||
assert.NoError(t, err)
|
||||
// Should process as authorization request
|
||||
assert.Equal(t, http.StatusFound, rec.Code)
|
||||
})
|
||||
|
||||
t.Run("MissingRequestURI", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/siop/request", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := HandleSIOPRequest(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var errorResp map[string]string
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &errorResp)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", errorResp["error"])
|
||||
assert.Contains(t, errorResp["error_description"], "Missing request_uri")
|
||||
})
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// WebAuthnCredential represents a WebAuthn credential
|
||||
type WebAuthnCredential struct {
|
||||
CredentialID string `json:"credential_id"`
|
||||
RawID string `json:"raw_id"`
|
||||
ClientDataJSON string `json:"client_data_json"`
|
||||
AttestationObject string `json:"attestation_object"`
|
||||
Username string `json:"username"`
|
||||
Origin string `json:"origin"`
|
||||
PublicKey []byte `json:"public_key"`
|
||||
Algorithm int32 `json:"algorithm"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// WebAuthnRegistrationRequest represents a registration request
|
||||
type WebAuthnRegistrationRequest struct {
|
||||
Username string `json:"username"`
|
||||
Challenge string `json:"challenge,omitempty"`
|
||||
AutoCreateVault bool `json:"auto_create_vault"`
|
||||
BroadcastToChain bool `json:"broadcast_to_chain"`
|
||||
}
|
||||
|
||||
// WebAuthnAuthenticationRequest represents an authentication request
|
||||
type WebAuthnAuthenticationRequest struct {
|
||||
Username string `json:"username"`
|
||||
Challenge string `json:"challenge,omitempty"`
|
||||
}
|
||||
|
||||
// WebAuthnRegistrationResponse contains registration ceremony data
|
||||
type WebAuthnRegistrationResponse struct {
|
||||
Challenge string `json:"challenge"`
|
||||
RP WebAuthnRPEntity `json:"rp"`
|
||||
User WebAuthnUserEntity `json:"user"`
|
||||
PubKeyCredParams []WebAuthnCredParam `json:"pubKeyCredParams"`
|
||||
AuthenticatorSelection WebAuthnAuthenticatorSelection `json:"authenticatorSelection"`
|
||||
Timeout int `json:"timeout"`
|
||||
Attestation string `json:"attestation"`
|
||||
}
|
||||
|
||||
// WebAuthnAuthenticationResponse contains authentication ceremony data
|
||||
type WebAuthnAuthenticationResponse struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Timeout int `json:"timeout"`
|
||||
RPID string `json:"rpId"`
|
||||
AllowCredentials []WebAuthnAllowedCred `json:"allowCredentials"`
|
||||
UserVerification string `json:"userVerification"`
|
||||
}
|
||||
|
||||
// WebAuthnRPEntity represents relying party info
|
||||
type WebAuthnRPEntity struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// WebAuthnUserEntity represents user info
|
||||
type WebAuthnUserEntity struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
// WebAuthnCredParam represents credential parameters
|
||||
type WebAuthnCredParam struct {
|
||||
Type string `json:"type"`
|
||||
Alg int `json:"alg"`
|
||||
}
|
||||
|
||||
// WebAuthnAuthenticatorSelection represents authenticator requirements
|
||||
type WebAuthnAuthenticatorSelection struct {
|
||||
AuthenticatorAttachment string `json:"authenticatorAttachment,omitempty"`
|
||||
UserVerification string `json:"userVerification"`
|
||||
ResidentKey string `json:"residentKey,omitempty"`
|
||||
}
|
||||
|
||||
// WebAuthnAllowedCred represents allowed credentials for authentication
|
||||
type WebAuthnAllowedCred struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// OIDCConfig represents OpenID Connect configuration
|
||||
type OIDCConfig struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JWKSEndpoint string `json:"jwks_uri"`
|
||||
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
|
||||
IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"`
|
||||
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
}
|
||||
|
||||
// OIDCAuthorizationRequest represents an authorization request
|
||||
type OIDCAuthorizationRequest struct {
|
||||
ResponseType string `json:"response_type"`
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
Scope string `json:"scope"`
|
||||
State string `json:"state"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
CodeChallenge string `json:"code_challenge,omitempty"`
|
||||
CodeChallengeMethod string `json:"code_challenge_method,omitempty"`
|
||||
}
|
||||
|
||||
// OIDCTokenRequest represents a token request
|
||||
type OIDCTokenRequest struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
Code string `json:"code,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri,omitempty"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
CodeVerifier string `json:"code_verifier,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// OIDCTokenResponse represents a token response
|
||||
type OIDCTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// OIDCUserInfo represents user information
|
||||
type OIDCUserInfo struct {
|
||||
Subject string `json:"sub"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
DID string `json:"did,omitempty"`
|
||||
VaultID string `json:"vault_id,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||
Claims map[string]any `json:"claims,omitempty"`
|
||||
}
|
||||
|
||||
// JWKSet represents a JSON Web Key Set
|
||||
type JWKSet struct {
|
||||
Keys []JWK `json:"keys"`
|
||||
}
|
||||
|
||||
// JWK represents a JSON Web Key
|
||||
type JWK struct {
|
||||
KeyType string `json:"kty"`
|
||||
Use string `json:"use,omitempty"`
|
||||
KeyID string `json:"kid"`
|
||||
Algorithm string `json:"alg,omitempty"`
|
||||
N string `json:"n,omitempty"` // RSA modulus
|
||||
E string `json:"e,omitempty"` // RSA exponent
|
||||
X string `json:"x,omitempty"` // EC x coordinate
|
||||
Y string `json:"y,omitempty"` // EC y coordinate
|
||||
Curve string `json:"crv,omitempty"` // EC curve
|
||||
}
|
||||
|
||||
// SIOPRequest represents a Self-Issued OpenID Provider request
|
||||
type SIOPRequest struct {
|
||||
ResponseType string `json:"response_type"`
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
Scope string `json:"scope"`
|
||||
Nonce string `json:"nonce"`
|
||||
State string `json:"state,omitempty"`
|
||||
Claims SIOPClaims `json:"claims,omitempty"`
|
||||
Registration SIOPRegistration `json:"registration,omitempty"`
|
||||
}
|
||||
|
||||
// SIOPClaims represents claims requested in SIOP
|
||||
type SIOPClaims struct {
|
||||
IDToken map[string]ClaimRequest `json:"id_token,omitempty"`
|
||||
VPToken map[string]ClaimRequest `json:"vp_token,omitempty"`
|
||||
}
|
||||
|
||||
// ClaimRequest represents a claim request
|
||||
type ClaimRequest struct {
|
||||
Essential bool `json:"essential,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Values []string `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
// SIOPRegistration represents client registration in SIOP
|
||||
type SIOPRegistration struct {
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
ClientPurpose string `json:"client_purpose,omitempty"`
|
||||
LogoURI string `json:"logo_uri,omitempty"`
|
||||
SubjectSyntaxTypesSupported []string `json:"subject_syntax_types_supported,omitempty"`
|
||||
VPFormats map[string]any `json:"vp_formats,omitempty"`
|
||||
}
|
||||
|
||||
// SIOPResponse represents a Self-Issued OpenID Provider response
|
||||
type SIOPResponse struct {
|
||||
IDToken string `json:"id_token"`
|
||||
VPToken string `json:"vp_token,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
// DIDAuthClaims represents DID-based authentication claims
|
||||
type DIDAuthClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
DID string `json:"did"`
|
||||
Challenge string `json:"challenge,omitempty"`
|
||||
WebAuthn *WebAuthnCredential `json:"webauthn,omitempty"`
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// AuthorizationCode represents an OAuth2 authorization code
|
||||
type AuthorizationCode struct {
|
||||
Code string
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
UserDID string
|
||||
Scope string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
ExpiresAt time.Time
|
||||
Used bool
|
||||
}
|
||||
|
||||
// OIDCSession represents an active OIDC session
|
||||
type OIDCSession struct {
|
||||
SessionID string
|
||||
UserDID string
|
||||
ClientID string
|
||||
Scope string
|
||||
Nonce string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
IDToken string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// BroadcastRequest represents a blockchain broadcast request
|
||||
type BroadcastRequest struct {
|
||||
Message any `json:"message"`
|
||||
Gasless bool `json:"gasless"`
|
||||
AutoSign bool `json:"auto_sign"`
|
||||
FromAddress string `json:"from_address,omitempty"`
|
||||
}
|
||||
|
||||
// BroadcastResponse represents a blockchain broadcast response
|
||||
type BroadcastResponse struct {
|
||||
TxHash string `json:"tx_hash"`
|
||||
Height int64 `json:"height,omitempty"`
|
||||
Code uint32 `json:"code,omitempty"`
|
||||
RawLog string `json:"raw_log,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
@@ -1,602 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/sonr-io/sonr/crypto/ucan"
|
||||
"github.com/sonr-io/sonr/x/did/types"
|
||||
)
|
||||
|
||||
// BlockchainUCANSigner implements UCAN signing with blockchain keys
|
||||
type BlockchainUCANSigner struct {
|
||||
didKeeper DIDKeeperInterface
|
||||
issuerDID string
|
||||
signingMethod jwt.SigningMethod
|
||||
privateKey any
|
||||
}
|
||||
|
||||
// DIDKeeperInterface defines the minimal interface needed from DID keeper
|
||||
type DIDKeeperInterface interface {
|
||||
GetDIDDocument(ctx context.Context, did string) (*types.DIDDocument, error)
|
||||
GetVerificationMethod(
|
||||
ctx context.Context,
|
||||
did string,
|
||||
methodID string,
|
||||
) (*types.VerificationMethod, error)
|
||||
}
|
||||
|
||||
// NewBlockchainUCANSigner creates a new blockchain-integrated UCAN signer
|
||||
func NewBlockchainUCANSigner(
|
||||
didKeeper DIDKeeperInterface,
|
||||
issuerDID string,
|
||||
) (*BlockchainUCANSigner, error) {
|
||||
return &BlockchainUCANSigner{
|
||||
didKeeper: didKeeper,
|
||||
issuerDID: issuerDID,
|
||||
signingMethod: jwt.SigningMethodES256, // Default to ES256
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Sign signs a UCAN token with blockchain keys
|
||||
func (s *BlockchainUCANSigner) Sign(token *ucan.Token) (string, error) {
|
||||
// Build JWT claims from UCAN token
|
||||
claims := s.buildClaims(token)
|
||||
|
||||
// Get signing key
|
||||
signingKey, method, err := s.getSigningKey(context.Background())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get signing key: %w", err)
|
||||
}
|
||||
|
||||
// Create JWT token
|
||||
jwtToken := jwt.NewWithClaims(method, claims)
|
||||
|
||||
// Sign token
|
||||
signedToken, err := jwtToken.SignedString(signingKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
return signedToken, nil
|
||||
}
|
||||
|
||||
// GetIssuerDID returns the issuer DID
|
||||
func (s *BlockchainUCANSigner) GetIssuerDID() string {
|
||||
if s.issuerDID == "" {
|
||||
return "did:sonr:oauth-provider"
|
||||
}
|
||||
return s.issuerDID
|
||||
}
|
||||
|
||||
// buildClaims builds JWT claims from UCAN token
|
||||
func (s *BlockchainUCANSigner) buildClaims(token *ucan.Token) jwt.MapClaims {
|
||||
claims := jwt.MapClaims{
|
||||
"iss": token.Issuer,
|
||||
"aud": token.Audience,
|
||||
"exp": token.ExpiresAt,
|
||||
"iat": time.Now().Unix(),
|
||||
"nbf": token.NotBefore,
|
||||
}
|
||||
|
||||
// Add attenuations
|
||||
if len(token.Attenuations) > 0 {
|
||||
attClaims := make([]map[string]any, len(token.Attenuations))
|
||||
for i, att := range token.Attenuations {
|
||||
attClaims[i] = s.serializeAttenuation(att)
|
||||
}
|
||||
claims["att"] = attClaims
|
||||
}
|
||||
|
||||
// Add proofs if present
|
||||
if len(token.Proofs) > 0 {
|
||||
proofClaims := make([]string, len(token.Proofs))
|
||||
for i, proof := range token.Proofs {
|
||||
proofClaims[i] = string(proof)
|
||||
}
|
||||
claims["prf"] = proofClaims
|
||||
}
|
||||
|
||||
// Add facts if present
|
||||
if len(token.Facts) > 0 {
|
||||
factClaims := make([]json.RawMessage, len(token.Facts))
|
||||
for i, fact := range token.Facts {
|
||||
factClaims[i] = fact.Data
|
||||
}
|
||||
claims["fct"] = factClaims
|
||||
}
|
||||
|
||||
// Add UCAN version
|
||||
claims["ucv"] = "0.10.0"
|
||||
|
||||
return claims
|
||||
}
|
||||
|
||||
// serializeAttenuation serializes an attenuation for JWT claims
|
||||
func (s *BlockchainUCANSigner) serializeAttenuation(att ucan.Attenuation) map[string]any {
|
||||
result := map[string]any{
|
||||
"with": att.Resource.GetURI(),
|
||||
}
|
||||
|
||||
// Handle capability serialization
|
||||
actions := att.Capability.GetActions()
|
||||
if len(actions) == 1 {
|
||||
result["can"] = actions[0]
|
||||
} else {
|
||||
result["can"] = actions
|
||||
}
|
||||
|
||||
// Add resource-specific metadata
|
||||
scheme := att.Resource.GetScheme()
|
||||
switch scheme {
|
||||
case "vault":
|
||||
result["type"] = "vault_operation"
|
||||
case "service", "svc":
|
||||
result["type"] = "service_operation"
|
||||
case "did":
|
||||
result["type"] = "identity_operation"
|
||||
case "dwn":
|
||||
result["type"] = "data_operation"
|
||||
case "dex", "pool":
|
||||
result["type"] = "trading_operation"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// getSigningKey retrieves the signing key from blockchain
|
||||
func (s *BlockchainUCANSigner) getSigningKey(ctx context.Context) (any, jwt.SigningMethod, error) {
|
||||
// If we have a cached private key, use it
|
||||
if s.privateKey != nil {
|
||||
return s.privateKey, s.signingMethod, nil
|
||||
}
|
||||
|
||||
// For OAuth provider, use a service key
|
||||
if s.issuerDID == "did:sonr:oauth-provider" || s.issuerDID == "" {
|
||||
// Generate or retrieve service key
|
||||
privateKey, publicKey, err := s.generateServiceKey()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
s.privateKey = privateKey
|
||||
s.signingMethod = jwt.SigningMethodEdDSA // Update to EdDSA for Ed25519 keys
|
||||
_ = publicKey // Store public key if needed
|
||||
|
||||
return privateKey, jwt.SigningMethodEdDSA, nil
|
||||
}
|
||||
|
||||
// For DID-based signing, retrieve from DID document
|
||||
if s.didKeeper != nil {
|
||||
didDoc, err := s.didKeeper.GetDIDDocument(ctx, s.issuerDID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get DID document: %w", err)
|
||||
}
|
||||
|
||||
// Use the first verification method for signing
|
||||
if len(didDoc.VerificationMethod) > 0 {
|
||||
vm := didDoc.VerificationMethod[0]
|
||||
|
||||
// Extract key based on verification method kind
|
||||
switch vm.VerificationMethodKind {
|
||||
case "Ed25519VerificationKey2020":
|
||||
// Extract Ed25519 key
|
||||
privateKey, err := s.extractEd25519Key(vm)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
s.privateKey = privateKey
|
||||
s.signingMethod = jwt.SigningMethodEdDSA
|
||||
return privateKey, jwt.SigningMethodEdDSA, nil
|
||||
|
||||
case "EcdsaSecp256k1VerificationKey2019":
|
||||
// Extract Secp256k1 key
|
||||
privateKey, err := s.extractSecp256k1Key(vm)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
s.privateKey = privateKey
|
||||
s.signingMethod = jwt.SigningMethodES256
|
||||
return privateKey, jwt.SigningMethodES256, nil
|
||||
|
||||
default:
|
||||
return nil, nil, fmt.Errorf(
|
||||
"unsupported verification method type: %s",
|
||||
vm.VerificationMethodKind,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("no signing key available")
|
||||
}
|
||||
|
||||
// generateServiceKey generates a new service key pair
|
||||
func (s *BlockchainUCANSigner) generateServiceKey() (ed25519.PrivateKey, ed25519.PublicKey, error) {
|
||||
// In production, this should retrieve from secure storage
|
||||
// For now, generate a new key pair
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate key pair: %w", err)
|
||||
}
|
||||
|
||||
return privateKey, publicKey, nil
|
||||
}
|
||||
|
||||
// extractEd25519Key extracts Ed25519 private key from verification method
|
||||
func (s *BlockchainUCANSigner) extractEd25519Key(
|
||||
vm *types.VerificationMethod,
|
||||
) (ed25519.PrivateKey, error) {
|
||||
// In production, this would retrieve the private key from secure storage
|
||||
// based on the public key in the verification method
|
||||
|
||||
// For now, return error as we don't have access to private keys
|
||||
return nil, fmt.Errorf("private key retrieval not implemented")
|
||||
}
|
||||
|
||||
// extractSecp256k1Key extracts Secp256k1 private key from verification method
|
||||
func (s *BlockchainUCANSigner) extractSecp256k1Key(
|
||||
vm *types.VerificationMethod,
|
||||
) (*secp256k1.PrivKey, error) {
|
||||
// In production, this would retrieve the private key from secure storage
|
||||
// based on the public key in the verification method
|
||||
|
||||
// For now, return error as we don't have access to private keys
|
||||
return nil, fmt.Errorf("private key retrieval not implemented")
|
||||
}
|
||||
|
||||
// VerifySignature verifies a UCAN token signature
|
||||
func (s *BlockchainUCANSigner) VerifySignature(tokenString string) (*ucan.Token, error) {
|
||||
// Parse token without verification first to get issuer
|
||||
token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid claims format")
|
||||
}
|
||||
|
||||
issuer, ok := claims["iss"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no issuer in token")
|
||||
}
|
||||
|
||||
// Get public key for issuer
|
||||
publicKey, err := s.getPublicKey(context.Background(), issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
|
||||
// Verify token with public key
|
||||
parsedToken, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
|
||||
return publicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token verification failed: %w", err)
|
||||
}
|
||||
|
||||
if !parsedToken.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// Convert JWT claims to UCAN token
|
||||
return s.claimsToUCAN(claims, tokenString)
|
||||
}
|
||||
|
||||
// getPublicKey retrieves public key for a DID
|
||||
func (s *BlockchainUCANSigner) getPublicKey(ctx context.Context, did string) (any, error) {
|
||||
if did == "did:sonr:oauth-provider" {
|
||||
// Return service public key
|
||||
// In production, this would be retrieved from configuration
|
||||
return []byte("oauth-provider-public-key"), nil
|
||||
}
|
||||
|
||||
if s.didKeeper != nil {
|
||||
didDoc, err := s.didKeeper.GetDIDDocument(ctx, did)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get DID document: %w", err)
|
||||
}
|
||||
|
||||
if len(didDoc.VerificationMethod) > 0 {
|
||||
vm := didDoc.VerificationMethod[0]
|
||||
|
||||
// Extract public key based on verification method kind
|
||||
switch vm.VerificationMethodKind {
|
||||
case "Ed25519VerificationKey2020":
|
||||
// Decode base64 public key
|
||||
publicKeyBytes, err := base64.StdEncoding.DecodeString(vm.PublicKeyMultibase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ed25519.PublicKey(publicKeyBytes), nil
|
||||
|
||||
case "EcdsaSecp256k1VerificationKey2019":
|
||||
// Decode and return Secp256k1 public key
|
||||
publicKeyBytes, err := base64.StdEncoding.DecodeString(vm.PublicKeyMultibase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicKeyBytes, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf(
|
||||
"unsupported verification method type: %s",
|
||||
vm.VerificationMethodKind,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no public key found for DID: %s", did)
|
||||
}
|
||||
|
||||
// claimsToUCAN converts JWT claims to UCAN token
|
||||
func (s *BlockchainUCANSigner) claimsToUCAN(
|
||||
claims jwt.MapClaims,
|
||||
rawToken string,
|
||||
) (*ucan.Token, error) {
|
||||
token := &ucan.Token{
|
||||
Raw: rawToken,
|
||||
}
|
||||
|
||||
// Extract standard claims
|
||||
if iss, ok := claims["iss"].(string); ok {
|
||||
token.Issuer = iss
|
||||
}
|
||||
if aud, ok := claims["aud"].(string); ok {
|
||||
token.Audience = aud
|
||||
}
|
||||
if exp, ok := claims["exp"].(float64); ok {
|
||||
token.ExpiresAt = int64(exp)
|
||||
}
|
||||
if nbf, ok := claims["nbf"].(float64); ok {
|
||||
token.NotBefore = int64(nbf)
|
||||
}
|
||||
|
||||
// Extract attenuations
|
||||
if attClaims, ok := claims["att"].([]any); ok {
|
||||
attenuations := make([]ucan.Attenuation, 0, len(attClaims))
|
||||
for _, attItem := range attClaims {
|
||||
if attMap, ok := attItem.(map[string]any); ok {
|
||||
att, err := s.parseAttenuation(attMap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse attenuation: %w", err)
|
||||
}
|
||||
attenuations = append(attenuations, att)
|
||||
}
|
||||
}
|
||||
token.Attenuations = attenuations
|
||||
}
|
||||
|
||||
// Extract proofs
|
||||
if proofClaims, ok := claims["prf"].([]any); ok {
|
||||
proofs := make([]ucan.Proof, 0, len(proofClaims))
|
||||
for _, proofItem := range proofClaims {
|
||||
if proofStr, ok := proofItem.(string); ok {
|
||||
proofs = append(proofs, ucan.Proof(proofStr))
|
||||
}
|
||||
}
|
||||
token.Proofs = proofs
|
||||
}
|
||||
|
||||
// Extract facts
|
||||
if factClaims, ok := claims["fct"].([]any); ok {
|
||||
facts := make([]ucan.Fact, 0, len(factClaims))
|
||||
for _, factItem := range factClaims {
|
||||
factData, _ := json.Marshal(factItem)
|
||||
facts = append(facts, ucan.Fact{
|
||||
Data: json.RawMessage(factData),
|
||||
})
|
||||
}
|
||||
token.Facts = facts
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// parseAttenuation parses an attenuation from JWT claims
|
||||
func (s *BlockchainUCANSigner) parseAttenuation(attMap map[string]any) (ucan.Attenuation, error) {
|
||||
// Extract resource URI
|
||||
resourceURI, ok := attMap["with"].(string)
|
||||
if !ok {
|
||||
return ucan.Attenuation{}, fmt.Errorf("missing resource URI")
|
||||
}
|
||||
|
||||
// Parse resource
|
||||
resource := &SimpleResource{
|
||||
Scheme: "generic",
|
||||
Value: resourceURI,
|
||||
}
|
||||
|
||||
// Extract scheme from URI if possible
|
||||
if len(resourceURI) > 0 {
|
||||
for _, scheme := range []string{"vault:", "service:", "did:", "dwn:", "dex:", "pool:"} {
|
||||
if len(resourceURI) >= len(scheme) && resourceURI[:len(scheme)] == scheme {
|
||||
resource.Scheme = scheme[:len(scheme)-1]
|
||||
resource.Value = resourceURI[len(scheme):]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract capability
|
||||
var capability ucan.Capability
|
||||
switch can := attMap["can"].(type) {
|
||||
case string:
|
||||
capability = &ucan.SimpleCapability{Action: can}
|
||||
case []any:
|
||||
actions := make([]string, 0, len(can))
|
||||
for _, action := range can {
|
||||
if actionStr, ok := action.(string); ok {
|
||||
actions = append(actions, actionStr)
|
||||
}
|
||||
}
|
||||
capability = &ucan.MultiCapability{Actions: actions}
|
||||
default:
|
||||
return ucan.Attenuation{}, fmt.Errorf("invalid capability format")
|
||||
}
|
||||
|
||||
return ucan.Attenuation{
|
||||
Capability: capability,
|
||||
Resource: resource,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetPrivateKey sets a private key for signing (for testing)
|
||||
func (s *BlockchainUCANSigner) SetPrivateKey(privateKey any, method jwt.SigningMethod) {
|
||||
s.privateKey = privateKey
|
||||
s.signingMethod = method
|
||||
}
|
||||
|
||||
// CreateDelegationToken creates a UCAN token for delegation
|
||||
func (s *BlockchainUCANSigner) CreateDelegationToken(
|
||||
issuer, audience string,
|
||||
attenuations []ucan.Attenuation,
|
||||
proofs []ucan.Proof,
|
||||
expiresIn time.Duration,
|
||||
) (string, error) {
|
||||
token := &ucan.Token{
|
||||
Issuer: issuer,
|
||||
Audience: audience,
|
||||
ExpiresAt: time.Now().Add(expiresIn).Unix(),
|
||||
NotBefore: time.Now().Unix(),
|
||||
Attenuations: attenuations,
|
||||
Proofs: proofs,
|
||||
}
|
||||
|
||||
return s.Sign(token)
|
||||
}
|
||||
|
||||
// ValidateDelegationChain validates a chain of UCAN delegations
|
||||
func (s *BlockchainUCANSigner) ValidateDelegationChain(tokens []string) error {
|
||||
if len(tokens) == 0 {
|
||||
return fmt.Errorf("empty delegation chain")
|
||||
}
|
||||
|
||||
var previousToken *ucan.Token
|
||||
for i, tokenString := range tokens {
|
||||
// Verify current token
|
||||
token, err := s.VerifySignature(tokenString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify token %d: %w", i, err)
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().Unix() > token.ExpiresAt {
|
||||
return fmt.Errorf("token %d has expired", i)
|
||||
}
|
||||
|
||||
// Check not before
|
||||
if token.NotBefore > 0 && time.Now().Unix() < token.NotBefore {
|
||||
return fmt.Errorf("token %d not yet valid", i)
|
||||
}
|
||||
|
||||
// Validate delegation chain
|
||||
if previousToken != nil {
|
||||
// Check that previous token's audience matches current issuer
|
||||
if previousToken.Audience != token.Issuer {
|
||||
return fmt.Errorf("broken delegation chain at token %d", i)
|
||||
}
|
||||
|
||||
// Check that capabilities are properly attenuated
|
||||
if !s.isProperlyAttenuated(previousToken.Attenuations, token.Attenuations) {
|
||||
return fmt.Errorf("improper attenuation at token %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
previousToken = token
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isProperlyAttenuated checks if child attenuations are properly attenuated from parent
|
||||
func (s *BlockchainUCANSigner) isProperlyAttenuated(parent, child []ucan.Attenuation) bool {
|
||||
// Child cannot have more permissions than parent
|
||||
for _, childAtt := range child {
|
||||
found := false
|
||||
for _, parentAtt := range parent {
|
||||
// Check if child attenuation is covered by parent
|
||||
if s.attenuationCovers(parentAtt, childAtt) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// attenuationCovers checks if parent attenuation covers child
|
||||
func (s *BlockchainUCANSigner) attenuationCovers(parent, child ucan.Attenuation) bool {
|
||||
// Check resource match
|
||||
if parent.Resource.GetScheme() != child.Resource.GetScheme() {
|
||||
// Wildcard scheme matches all
|
||||
if parent.Resource.GetScheme() != "*" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check capability coverage
|
||||
parentActions := parent.Capability.GetActions()
|
||||
childActions := child.Capability.GetActions()
|
||||
|
||||
// If parent has wildcard, it covers everything
|
||||
for _, action := range parentActions {
|
||||
if action == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check each child action is in parent
|
||||
for _, childAction := range childActions {
|
||||
found := false
|
||||
for _, parentAction := range parentActions {
|
||||
if parentAction == childAction {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// RefreshToken creates a new token from an existing one with updated expiration
|
||||
func (s *BlockchainUCANSigner) RefreshToken(
|
||||
tokenString string,
|
||||
newExpiration time.Duration,
|
||||
) (string, error) {
|
||||
// Verify existing token
|
||||
token, err := s.VerifySignature(tokenString)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to verify token: %w", err)
|
||||
}
|
||||
|
||||
// Create new token with same claims but new expiration
|
||||
newToken := &ucan.Token{
|
||||
Issuer: token.Issuer,
|
||||
Audience: token.Audience,
|
||||
ExpiresAt: time.Now().Add(newExpiration).Unix(),
|
||||
NotBefore: time.Now().Unix(),
|
||||
Attenuations: token.Attenuations,
|
||||
Proofs: append(token.Proofs, ucan.Proof(tokenString)), // Add original as proof
|
||||
Facts: token.Facts,
|
||||
}
|
||||
|
||||
return s.Sign(newToken)
|
||||
}
|
||||
@@ -1,535 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/sonr-io/sonr/bridge/tasks"
|
||||
"github.com/sonr-io/sonr/crypto/mpc"
|
||||
"github.com/sonr-io/sonr/types/ipfs"
|
||||
)
|
||||
|
||||
// VaultHandlers holds all vault-related handlers and their dependencies
|
||||
type VaultHandlers struct {
|
||||
IPFSClient ipfs.IPFSClient
|
||||
ConnectionManager *ConnectionManager
|
||||
SSEManager *SSEManager
|
||||
}
|
||||
|
||||
// NewVaultHandlers creates a new VaultHandlers instance
|
||||
func NewVaultHandlers(
|
||||
ipfsClient ipfs.IPFSClient,
|
||||
connManager *ConnectionManager,
|
||||
sseManager *SSEManager,
|
||||
) *VaultHandlers {
|
||||
return &VaultHandlers{
|
||||
IPFSClient: ipfsClient,
|
||||
ConnectionManager: connManager,
|
||||
SSEManager: sseManager,
|
||||
}
|
||||
}
|
||||
|
||||
// GetQueueFromPriority determines the appropriate queue based on priority
|
||||
func GetQueueFromPriority(priority string) string {
|
||||
switch priority {
|
||||
case "critical", "high":
|
||||
return "critical"
|
||||
case "low":
|
||||
return "low"
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateHandler handles vault generation requests
|
||||
func (vh *VaultHandlers) GenerateHandler(client *asynq.Client) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Extract user info from JWT token
|
||||
user := c.Get("user").(*jwt.Token)
|
||||
claims := user.Claims.(jwt.MapClaims)
|
||||
userID := claims["user_id"].(string)
|
||||
|
||||
var req struct {
|
||||
UserID int `json:"user_id"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid JSON payload"})
|
||||
}
|
||||
|
||||
task, err := tasks.NewUCANDIDTask(req.UserID)
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to create task"},
|
||||
)
|
||||
}
|
||||
|
||||
queue := GetQueueFromPriority(req.Priority)
|
||||
info, err := client.Enqueue(task, asynq.Queue(queue))
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to enqueue task"},
|
||||
)
|
||||
}
|
||||
|
||||
// Broadcast task status to WebSocket and SSE connections
|
||||
go func() {
|
||||
message := TaskStatusMessage{
|
||||
TaskID: info.ID,
|
||||
Status: "enqueued",
|
||||
Time: time.Now(),
|
||||
}
|
||||
vh.ConnectionManager.BroadcastToTask(info.ID, message)
|
||||
vh.SSEManager.BroadcastToSSE(info.ID, message)
|
||||
|
||||
// Simulate task progression for demonstration
|
||||
// In a real implementation, this would be triggered by the actual task processors
|
||||
time.Sleep(1 * time.Second)
|
||||
processingMessage := TaskStatusMessage{
|
||||
TaskID: info.ID,
|
||||
Status: "processing",
|
||||
Progress: 50,
|
||||
Time: time.Now(),
|
||||
}
|
||||
vh.ConnectionManager.BroadcastToTask(info.ID, processingMessage)
|
||||
vh.SSEManager.BroadcastToSSE(info.ID, processingMessage)
|
||||
|
||||
// Simulate completion
|
||||
time.Sleep(2 * time.Second)
|
||||
completedMessage := TaskStatusMessage{
|
||||
TaskID: info.ID,
|
||||
Status: "completed",
|
||||
Progress: 100,
|
||||
Time: time.Now(),
|
||||
}
|
||||
vh.ConnectionManager.BroadcastToTask(info.ID, completedMessage)
|
||||
vh.SSEManager.BroadcastToSSE(info.ID, completedMessage)
|
||||
}()
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"task_id": info.ID,
|
||||
"queue": queue,
|
||||
"status": "enqueued",
|
||||
"user_id": userID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SignHandler handles vault signing requests
|
||||
func (vh *VaultHandlers) SignHandler(client *asynq.Client) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var req struct {
|
||||
Message []byte `json:"message"`
|
||||
Enclave *mpc.EnclaveData `json:"enclave,omitempty"` // Direct enclave data (fallback)
|
||||
EnclaveCID string `json:"enclave_cid,omitempty"` // IPFS CID reference
|
||||
Password []byte `json:"password,omitempty"` // Decryption password for IPFS stored data
|
||||
UCANToken string `json:"ucan_token,omitempty"` // UCAN token for authorization
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid JSON payload"})
|
||||
}
|
||||
|
||||
if len(req.Message) == 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Message is required"})
|
||||
}
|
||||
|
||||
// Handle enclave data - either direct or via IPFS CID
|
||||
var enclave *mpc.EnclaveData
|
||||
if req.EnclaveCID != "" {
|
||||
// Retrieve enclave from IPFS
|
||||
if vh.IPFSClient == nil {
|
||||
return c.JSON(
|
||||
http.StatusServiceUnavailable,
|
||||
map[string]string{"error": "IPFS client not available"},
|
||||
)
|
||||
}
|
||||
|
||||
encryptedData, err := vh.IPFSClient.Get(req.EnclaveCID)
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusNotFound,
|
||||
map[string]string{"error": "Failed to retrieve enclave from IPFS"},
|
||||
)
|
||||
}
|
||||
|
||||
// Create temporary enclave for decryption
|
||||
tempEnclave := &mpc.EnclaveData{}
|
||||
decryptedData, err := tempEnclave.Decrypt(req.Password, encryptedData)
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Failed to decrypt enclave data"},
|
||||
)
|
||||
}
|
||||
|
||||
// Unmarshal decrypted data
|
||||
enclave = &mpc.EnclaveData{}
|
||||
if err := enclave.Unmarshal(decryptedData); err != nil {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Failed to parse enclave data"},
|
||||
)
|
||||
}
|
||||
} else if req.Enclave != nil {
|
||||
// Use directly provided enclave data
|
||||
enclave = req.Enclave
|
||||
} else {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Either enclave or enclave_cid is required"})
|
||||
}
|
||||
|
||||
task, err := tasks.NewUCANSignTask(
|
||||
0,
|
||||
req.Message,
|
||||
) // TODO: Extract userID from JWT or context
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to create task"},
|
||||
)
|
||||
}
|
||||
|
||||
queue := GetQueueFromPriority(req.Priority)
|
||||
info, err := client.Enqueue(task, asynq.Queue(queue))
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to enqueue task"},
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"task_id": info.ID,
|
||||
"queue": queue,
|
||||
"status": "enqueued",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyHandler handles vault verification requests
|
||||
func (vh *VaultHandlers) VerifyHandler(client *asynq.Client) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var req struct {
|
||||
PublicKey []byte `json:"public_key"`
|
||||
Message []byte `json:"message"`
|
||||
Signature []byte `json:"signature"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid JSON payload"})
|
||||
}
|
||||
|
||||
if len(req.PublicKey) == 0 || len(req.Message) == 0 || len(req.Signature) == 0 {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "PublicKey, message, and signature are required"},
|
||||
)
|
||||
}
|
||||
|
||||
task, err := tasks.NewUCANVerifyTask(
|
||||
0,
|
||||
req.Message,
|
||||
req.Signature,
|
||||
) // TODO: Extract userID from JWT or context
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to create task"},
|
||||
)
|
||||
}
|
||||
|
||||
queue := GetQueueFromPriority(req.Priority)
|
||||
info, err := client.Enqueue(task, asynq.Queue(queue))
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to enqueue task"},
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"task_id": info.ID,
|
||||
"queue": queue,
|
||||
"status": "enqueued",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ExportHandler handles vault export requests
|
||||
func (vh *VaultHandlers) ExportHandler(client *asynq.Client) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var req struct {
|
||||
Enclave *mpc.EnclaveData `json:"enclave,omitempty"` // Direct enclave data
|
||||
EnclaveCID string `json:"enclave_cid,omitempty"` // IPFS CID reference
|
||||
Password []byte `json:"password"` // For encryption/decryption
|
||||
StoreIPFS bool `json:"store_ipfs,omitempty"` // Whether to store result in IPFS
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid JSON payload"})
|
||||
}
|
||||
|
||||
if len(req.Password) == 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Password is required"})
|
||||
}
|
||||
|
||||
// Handle enclave data - either direct or via IPFS CID
|
||||
var enclave *mpc.EnclaveData
|
||||
if req.EnclaveCID != "" {
|
||||
// Retrieve enclave from IPFS
|
||||
if vh.IPFSClient == nil {
|
||||
return c.JSON(
|
||||
http.StatusServiceUnavailable,
|
||||
map[string]string{"error": "IPFS client not available"},
|
||||
)
|
||||
}
|
||||
|
||||
encryptedData, err := vh.IPFSClient.Get(req.EnclaveCID)
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusNotFound,
|
||||
map[string]string{"error": "Failed to retrieve enclave from IPFS"},
|
||||
)
|
||||
}
|
||||
|
||||
// Create temporary enclave for decryption
|
||||
tempEnclave := &mpc.EnclaveData{}
|
||||
decryptedData, err := tempEnclave.Decrypt(req.Password, encryptedData)
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Failed to decrypt enclave data"},
|
||||
)
|
||||
}
|
||||
|
||||
// Unmarshal decrypted data
|
||||
enclave = &mpc.EnclaveData{}
|
||||
if err := enclave.Unmarshal(decryptedData); err != nil {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Failed to parse enclave data"},
|
||||
)
|
||||
}
|
||||
} else if req.Enclave != nil {
|
||||
// Use directly provided enclave data
|
||||
enclave = req.Enclave
|
||||
} else {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Either enclave or enclave_cid is required"})
|
||||
}
|
||||
|
||||
// If store_ipfs is true, encrypt and store the enclave in IPFS first
|
||||
if req.StoreIPFS && vh.IPFSClient != nil {
|
||||
encryptedData, err := enclave.Encrypt(req.Password)
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to encrypt enclave data"},
|
||||
)
|
||||
}
|
||||
|
||||
cid, err := vh.IPFSClient.Add(encryptedData)
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to store enclave in IPFS"},
|
||||
)
|
||||
}
|
||||
|
||||
// Return the CID for future reference
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"cid": cid,
|
||||
"status": "stored",
|
||||
"message": "Enclave data encrypted and stored in IPFS",
|
||||
})
|
||||
}
|
||||
|
||||
// Export functionality replaced with UCAN token creation for data access
|
||||
// Convert export operation to UCAN token generation with export permissions
|
||||
attenuations := []map[string]any{
|
||||
{
|
||||
"can": []string{"export", "read"},
|
||||
"with": "vault://exported-data",
|
||||
},
|
||||
}
|
||||
task, err := tasks.NewUCANTokenTask(
|
||||
0,
|
||||
"did:sonr:export-recipient",
|
||||
attenuations,
|
||||
time.Now().Add(24*time.Hour).Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to create task"},
|
||||
)
|
||||
}
|
||||
|
||||
queue := GetQueueFromPriority(req.Priority)
|
||||
info, err := client.Enqueue(task, asynq.Queue(queue))
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to enqueue task"},
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"task_id": info.ID,
|
||||
"queue": queue,
|
||||
"status": "enqueued",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ImportHandler handles vault import requests
|
||||
func (vh *VaultHandlers) ImportHandler(client *asynq.Client) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var req struct {
|
||||
CID string `json:"cid"`
|
||||
Password []byte `json:"password"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid JSON payload"})
|
||||
}
|
||||
|
||||
if req.CID == "" || len(req.Password) == 0 {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "CID and password are required"},
|
||||
)
|
||||
}
|
||||
|
||||
// Import functionality replaced with UCAN token creation for data import
|
||||
// Convert import operation to UCAN token generation with import permissions
|
||||
attenuations := []map[string]any{
|
||||
{
|
||||
"can": []string{"import", "write"},
|
||||
"with": fmt.Sprintf("ipfs://%s", req.CID),
|
||||
},
|
||||
}
|
||||
task, err := tasks.NewUCANTokenTask(
|
||||
0,
|
||||
"did:sonr:import-recipient",
|
||||
attenuations,
|
||||
time.Now().Add(1*time.Hour).Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to create task"},
|
||||
)
|
||||
}
|
||||
|
||||
queue := GetQueueFromPriority(req.Priority)
|
||||
info, err := client.Enqueue(task, asynq.Queue(queue))
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to enqueue task"},
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"task_id": info.ID,
|
||||
"queue": queue,
|
||||
"status": "enqueued",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshHandler handles vault refresh requests
|
||||
func (vh *VaultHandlers) RefreshHandler(client *asynq.Client) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var req struct {
|
||||
Enclave *mpc.EnclaveData `json:"enclave,omitempty"` // Direct enclave data
|
||||
EnclaveCID string `json:"enclave_cid,omitempty"` // IPFS CID reference
|
||||
Password []byte `json:"password,omitempty"` // Decryption password for IPFS stored data
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid JSON payload"})
|
||||
}
|
||||
|
||||
// Handle enclave data - either direct or via IPFS CID
|
||||
var enclave *mpc.EnclaveData
|
||||
if req.EnclaveCID != "" {
|
||||
// Retrieve enclave from IPFS
|
||||
if vh.IPFSClient == nil {
|
||||
return c.JSON(
|
||||
http.StatusServiceUnavailable,
|
||||
map[string]string{"error": "IPFS client not available"},
|
||||
)
|
||||
}
|
||||
|
||||
encryptedData, err := vh.IPFSClient.Get(req.EnclaveCID)
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusNotFound,
|
||||
map[string]string{"error": "Failed to retrieve enclave from IPFS"},
|
||||
)
|
||||
}
|
||||
|
||||
// Create temporary enclave for decryption
|
||||
tempEnclave := &mpc.EnclaveData{}
|
||||
decryptedData, err := tempEnclave.Decrypt(req.Password, encryptedData)
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Failed to decrypt enclave data"},
|
||||
)
|
||||
}
|
||||
|
||||
// Unmarshal decrypted data
|
||||
enclave = &mpc.EnclaveData{}
|
||||
if err := enclave.Unmarshal(decryptedData); err != nil {
|
||||
return c.JSON(
|
||||
http.StatusBadRequest,
|
||||
map[string]string{"error": "Failed to parse enclave data"},
|
||||
)
|
||||
}
|
||||
} else if req.Enclave != nil {
|
||||
// Use directly provided enclave data
|
||||
enclave = req.Enclave
|
||||
} else {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Either enclave or enclave_cid is required"})
|
||||
}
|
||||
|
||||
// Refresh functionality replaced with UCAN DID generation for new identity
|
||||
// Convert refresh operation to DID generation which includes key refresh
|
||||
task, err := tasks.NewUCANDIDTask(0) // TODO: Extract userID from JWT or context
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to create task"},
|
||||
)
|
||||
}
|
||||
|
||||
queue := GetQueueFromPriority(req.Priority)
|
||||
info, err := client.Enqueue(task, asynq.Queue(queue))
|
||||
if err != nil {
|
||||
return c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
map[string]string{"error": "Failed to enqueue task"},
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"task_id": info.ID,
|
||||
"queue": queue,
|
||||
"status": "enqueued",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/sonr-io/sonr/types/webauthn"
|
||||
"github.com/sonr-io/sonr/types/webauthn/webauthncbor"
|
||||
)
|
||||
|
||||
// WebAuthnStore manages WebAuthn sessions and credentials
|
||||
type WebAuthnStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*WebAuthnSession
|
||||
credentials map[string][]*WebAuthnCredential
|
||||
}
|
||||
|
||||
// WebAuthnSession holds session data for WebAuthn ceremonies
|
||||
type WebAuthnSession struct {
|
||||
Challenge string
|
||||
Username string
|
||||
CreatedAt time.Time
|
||||
SessionType string // "registration" or "authentication"
|
||||
}
|
||||
|
||||
var (
|
||||
webAuthnStore = &WebAuthnStore{
|
||||
sessions: make(map[string]*WebAuthnSession),
|
||||
credentials: make(map[string][]*WebAuthnCredential),
|
||||
}
|
||||
sessionTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// BeginWebAuthnRegistration starts WebAuthn registration ceremony
|
||||
func BeginWebAuthnRegistration(c echo.Context) error {
|
||||
var req WebAuthnRegistrationRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid request format",
|
||||
})
|
||||
}
|
||||
|
||||
if req.Username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Username is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Store the request in context for later use in FinishWebAuthnRegistration
|
||||
c.Set("webauthn_registration_request", &req)
|
||||
|
||||
// Generate challenge
|
||||
challenge, err := generateChallenge()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to generate challenge",
|
||||
})
|
||||
}
|
||||
|
||||
// Store session
|
||||
session := &WebAuthnSession{
|
||||
Challenge: challenge,
|
||||
Username: req.Username,
|
||||
CreatedAt: time.Now(),
|
||||
SessionType: "registration",
|
||||
}
|
||||
|
||||
webAuthnStore.mu.Lock()
|
||||
webAuthnStore.sessions[req.Username] = session
|
||||
webAuthnStore.mu.Unlock()
|
||||
|
||||
// Create registration response
|
||||
response := WebAuthnRegistrationResponse{
|
||||
Challenge: challenge,
|
||||
RP: WebAuthnRPEntity{
|
||||
ID: "localhost", // TODO: Get from config
|
||||
Name: "Sonr Identity Platform",
|
||||
},
|
||||
User: WebAuthnUserEntity{
|
||||
ID: base64.URLEncoding.EncodeToString([]byte(req.Username)),
|
||||
Name: req.Username,
|
||||
DisplayName: req.Username,
|
||||
},
|
||||
PubKeyCredParams: []WebAuthnCredParam{
|
||||
{Type: "public-key", Alg: -7}, // ES256
|
||||
{Type: "public-key", Alg: -257}, // RS256
|
||||
},
|
||||
AuthenticatorSelection: WebAuthnAuthenticatorSelection{
|
||||
AuthenticatorAttachment: "platform",
|
||||
UserVerification: "required",
|
||||
ResidentKey: "preferred",
|
||||
},
|
||||
Timeout: 60000,
|
||||
Attestation: "direct",
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// FinishWebAuthnRegistration completes WebAuthn registration ceremony
|
||||
func FinishWebAuthnRegistration(c echo.Context) error {
|
||||
username := c.QueryParam("username")
|
||||
if username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Username is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Parse registration response
|
||||
var regResponse map[string]any
|
||||
if err := c.Bind(®Response); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid registration response",
|
||||
})
|
||||
}
|
||||
|
||||
// Get stored session
|
||||
webAuthnStore.mu.RLock()
|
||||
session, exists := webAuthnStore.sessions[username]
|
||||
webAuthnStore.mu.RUnlock()
|
||||
|
||||
if !exists || session.SessionType != "registration" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "No registration session found",
|
||||
})
|
||||
}
|
||||
|
||||
// Check session timeout
|
||||
if time.Since(session.CreatedAt) > sessionTimeout {
|
||||
webAuthnStore.mu.Lock()
|
||||
delete(webAuthnStore.sessions, username)
|
||||
webAuthnStore.mu.Unlock()
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Registration session expired",
|
||||
})
|
||||
}
|
||||
|
||||
// Extract credential data
|
||||
credentialID, _ := regResponse["id"].(string)
|
||||
rawID, _ := regResponse["rawId"].(string)
|
||||
response, _ := regResponse["response"].(map[string]any)
|
||||
clientDataJSON, _ := response["clientDataJSON"].(string)
|
||||
attestationObject, _ := response["attestationObject"].(string)
|
||||
|
||||
// Verify client data
|
||||
if err := verifyClientData(clientDataJSON, session.Challenge, "webauthn.create"); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": fmt.Sprintf("Client data verification failed: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Extract public key from attestation
|
||||
publicKey, algorithm, err := extractPublicKeyFromAttestation(attestationObject)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": fmt.Sprintf("Failed to extract public key: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Create credential
|
||||
credential := &WebAuthnCredential{
|
||||
CredentialID: credentialID,
|
||||
RawID: rawID,
|
||||
ClientDataJSON: clientDataJSON,
|
||||
AttestationObject: attestationObject,
|
||||
Username: username,
|
||||
Origin: "localhost", // TODO: Extract from client data
|
||||
PublicKey: publicKey,
|
||||
Algorithm: algorithm,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Store credential
|
||||
webAuthnStore.mu.Lock()
|
||||
webAuthnStore.credentials[username] = append(webAuthnStore.credentials[username], credential)
|
||||
delete(webAuthnStore.sessions, username)
|
||||
webAuthnStore.mu.Unlock()
|
||||
|
||||
// Check if we should broadcast to blockchain
|
||||
broadcastReq, ok := c.Get("broadcast_to_chain").(bool)
|
||||
if !ok {
|
||||
// Check from original request stored in context
|
||||
if origReq, exists := c.Get("webauthn_registration_request").(*WebAuthnRegistrationRequest); exists {
|
||||
broadcastReq = origReq.BroadcastToChain
|
||||
}
|
||||
}
|
||||
|
||||
var broadcastResult *BroadcastResponse
|
||||
if broadcastReq {
|
||||
// Broadcast WebAuthn credential to blockchain as gasless transaction
|
||||
result, err := BroadcastWebAuthnRegistration(credential, true)
|
||||
if err != nil {
|
||||
// Log error but don't fail registration
|
||||
c.Logger().Error("Failed to broadcast WebAuthn credential:", err)
|
||||
} else {
|
||||
broadcastResult = result
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should create a vault
|
||||
autoCreateVault, ok := c.Get("auto_create_vault").(bool)
|
||||
if !ok {
|
||||
// Check from original request
|
||||
if origReq, exists := c.Get("webauthn_registration_request").(*WebAuthnRegistrationRequest); exists {
|
||||
autoCreateVault = origReq.AutoCreateVault
|
||||
}
|
||||
}
|
||||
|
||||
var vaultResult *BroadcastResponse
|
||||
if autoCreateVault {
|
||||
// Create vault for the user
|
||||
vaultConfig := map[string]any{
|
||||
"type": "standard",
|
||||
"encryption": "AES256",
|
||||
"owner": username,
|
||||
}
|
||||
|
||||
userDID := fmt.Sprintf("did:sonr:%s", username)
|
||||
result, err := BroadcastVaultCreation(userDID, vaultConfig)
|
||||
if err != nil {
|
||||
// Log error but don't fail registration
|
||||
c.Logger().Error("Failed to create vault:", err)
|
||||
} else {
|
||||
vaultResult = result
|
||||
}
|
||||
}
|
||||
|
||||
finalResponse := map[string]any{
|
||||
"success": true,
|
||||
"message": "Registration completed successfully",
|
||||
"credentialId": credentialID,
|
||||
}
|
||||
|
||||
if broadcastResult != nil {
|
||||
finalResponse["broadcast"] = broadcastResult
|
||||
}
|
||||
|
||||
if vaultResult != nil {
|
||||
finalResponse["vault"] = vaultResult
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, finalResponse)
|
||||
}
|
||||
|
||||
// BeginWebAuthnAuthentication starts WebAuthn authentication ceremony
|
||||
func BeginWebAuthnAuthentication(c echo.Context) error {
|
||||
var req WebAuthnAuthenticationRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid request format",
|
||||
})
|
||||
}
|
||||
|
||||
if req.Username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Username is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user has credentials
|
||||
webAuthnStore.mu.RLock()
|
||||
credentials, exists := webAuthnStore.credentials[req.Username]
|
||||
webAuthnStore.mu.RUnlock()
|
||||
|
||||
if !exists || len(credentials) == 0 {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{
|
||||
"error": "No credentials found for user",
|
||||
})
|
||||
}
|
||||
|
||||
// Generate challenge
|
||||
challenge, err := generateChallenge()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to generate challenge",
|
||||
})
|
||||
}
|
||||
|
||||
// Store session
|
||||
session := &WebAuthnSession{
|
||||
Challenge: challenge,
|
||||
Username: req.Username,
|
||||
CreatedAt: time.Now(),
|
||||
SessionType: "authentication",
|
||||
}
|
||||
|
||||
webAuthnStore.mu.Lock()
|
||||
webAuthnStore.sessions[req.Username] = session
|
||||
webAuthnStore.mu.Unlock()
|
||||
|
||||
// Build allowed credentials
|
||||
allowCredentials := make([]WebAuthnAllowedCred, len(credentials))
|
||||
for i, cred := range credentials {
|
||||
allowCredentials[i] = WebAuthnAllowedCred{
|
||||
Type: "public-key",
|
||||
ID: cred.CredentialID,
|
||||
}
|
||||
}
|
||||
|
||||
// Create authentication response
|
||||
response := WebAuthnAuthenticationResponse{
|
||||
Challenge: challenge,
|
||||
Timeout: 60000,
|
||||
RPID: "localhost", // TODO: Get from config
|
||||
AllowCredentials: allowCredentials,
|
||||
UserVerification: "required",
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// FinishWebAuthnAuthentication completes WebAuthn authentication ceremony
|
||||
func FinishWebAuthnAuthentication(c echo.Context) error {
|
||||
username := c.QueryParam("username")
|
||||
if username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Username is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Parse authentication response
|
||||
var authResponse map[string]any
|
||||
if err := c.Bind(&authResponse); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid authentication response",
|
||||
})
|
||||
}
|
||||
|
||||
// Get stored session
|
||||
webAuthnStore.mu.RLock()
|
||||
session, exists := webAuthnStore.sessions[username]
|
||||
webAuthnStore.mu.RUnlock()
|
||||
|
||||
if !exists || session.SessionType != "authentication" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "No authentication session found",
|
||||
})
|
||||
}
|
||||
|
||||
// Check session timeout
|
||||
if time.Since(session.CreatedAt) > sessionTimeout {
|
||||
webAuthnStore.mu.Lock()
|
||||
delete(webAuthnStore.sessions, username)
|
||||
webAuthnStore.mu.Unlock()
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Authentication session expired",
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response data
|
||||
credentialID, _ := authResponse["id"].(string)
|
||||
response, _ := authResponse["response"].(map[string]any)
|
||||
clientDataJSON, _ := response["clientDataJSON"].(string)
|
||||
authenticatorData, _ := response["authenticatorData"].(string)
|
||||
signature, _ := response["signature"].(string)
|
||||
userHandle, _ := response["userHandle"].(string)
|
||||
|
||||
// Verify client data
|
||||
if err := verifyClientData(clientDataJSON, session.Challenge, "webauthn.get"); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": fmt.Sprintf("Client data verification failed: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Find matching credential
|
||||
webAuthnStore.mu.RLock()
|
||||
credentials := webAuthnStore.credentials[username]
|
||||
webAuthnStore.mu.RUnlock()
|
||||
|
||||
var matchedCredential *WebAuthnCredential
|
||||
for _, cred := range credentials {
|
||||
if cred.CredentialID == credentialID {
|
||||
matchedCredential = cred
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchedCredential == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "Invalid credential",
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Verify signature using the stored public key
|
||||
// This would require implementing proper WebAuthn signature verification
|
||||
|
||||
// Clean up session
|
||||
webAuthnStore.mu.Lock()
|
||||
delete(webAuthnStore.sessions, username)
|
||||
webAuthnStore.mu.Unlock()
|
||||
|
||||
// Create authenticated session
|
||||
userDID := fmt.Sprintf("did:sonr:%s", username)
|
||||
authSession := &OIDCSession{
|
||||
SessionID: generateSessionID(),
|
||||
UserDID: userDID,
|
||||
ClientID: "webauthn-client",
|
||||
Scope: "openid profile did vault",
|
||||
AccessToken: generateAccessToken(userDID),
|
||||
RefreshToken: generateRefreshToken(),
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Store session for OIDC compatibility
|
||||
oidcProvider.mu.Lock()
|
||||
oidcProvider.sessions[authSession.AccessToken] = authSession
|
||||
oidcProvider.mu.Unlock()
|
||||
|
||||
// Set user context for downstream handlers
|
||||
c.Set("user_did", userDID)
|
||||
c.Set("authenticated", true)
|
||||
c.Set("auth_method", "webauthn")
|
||||
c.Set("credential_id", credentialID)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": "Authentication successful",
|
||||
"credentialId": credentialID,
|
||||
"accessToken": authSession.AccessToken,
|
||||
"expiresIn": 3600,
|
||||
"userDID": userDID,
|
||||
"sessionId": authSession.SessionID,
|
||||
"authenticatorData": authenticatorData,
|
||||
"signature": signature,
|
||||
"userHandle": userHandle,
|
||||
})
|
||||
}
|
||||
|
||||
// generateChallenge creates a cryptographically secure challenge
|
||||
func generateChallenge() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// verifyClientData verifies client data JSON
|
||||
func verifyClientData(clientDataJSON, expectedChallenge, expectedType string) error {
|
||||
clientData, err := webauthn.ValidateClientDataJSONFormat(clientDataJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse client data: %w", err)
|
||||
}
|
||||
|
||||
if clientData.Challenge != expectedChallenge {
|
||||
return fmt.Errorf("challenge mismatch")
|
||||
}
|
||||
|
||||
if clientData.Type != expectedType {
|
||||
return fmt.Errorf(
|
||||
"invalid client data type: expected %s, got %s",
|
||||
expectedType,
|
||||
clientData.Type,
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Verify origin from config
|
||||
expectedOrigins := []string{
|
||||
"http://localhost",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:8081",
|
||||
"http://localhost:8082",
|
||||
"http://localhost:8083",
|
||||
"http://localhost:8084",
|
||||
}
|
||||
|
||||
validOrigin := false
|
||||
for _, origin := range expectedOrigins {
|
||||
if clientData.Origin == origin {
|
||||
validOrigin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !validOrigin {
|
||||
return fmt.Errorf("invalid origin: %s", clientData.Origin)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractPublicKeyFromAttestation extracts public key from attestation object
|
||||
func extractPublicKeyFromAttestation(attestationObjectB64 string) ([]byte, int32, error) {
|
||||
// Validate format
|
||||
if err := webauthn.ValidateAttestationObjectFormat(attestationObjectB64); err != nil {
|
||||
return nil, 0, fmt.Errorf("invalid attestation format: %w", err)
|
||||
}
|
||||
|
||||
// Decode attestation object
|
||||
attestationBytes, err := base64.RawURLEncoding.DecodeString(attestationObjectB64)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to decode attestation: %w", err)
|
||||
}
|
||||
|
||||
// Parse CBOR
|
||||
var attestationObj webauthn.AttestationObject
|
||||
if err := webauthncbor.Unmarshal(attestationBytes, &attestationObj); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to unmarshal attestation: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal authenticator data
|
||||
if err := attestationObj.AuthData.Unmarshal(attestationObj.RawAuthData); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to unmarshal auth data: %w", err)
|
||||
}
|
||||
|
||||
// Check for attested credential data
|
||||
if !attestationObj.AuthData.Flags.HasAttestedCredentialData() {
|
||||
return nil, 0, fmt.Errorf("no attested credential data")
|
||||
}
|
||||
|
||||
publicKey := attestationObj.AuthData.AttData.CredentialPublicKey
|
||||
if len(publicKey) == 0 {
|
||||
return nil, 0, fmt.Errorf("no public key found")
|
||||
}
|
||||
|
||||
// Default to ES256 algorithm
|
||||
algorithm := int32(-7)
|
||||
|
||||
return publicKey, algorithm, nil
|
||||
}
|
||||
|
||||
// GetWebAuthnCredentials retrieves credentials for a user
|
||||
func GetWebAuthnCredentials(c echo.Context) error {
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Username is required",
|
||||
})
|
||||
}
|
||||
|
||||
webAuthnStore.mu.RLock()
|
||||
credentials, exists := webAuthnStore.credentials[username]
|
||||
webAuthnStore.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{
|
||||
"error": "No credentials found for user",
|
||||
})
|
||||
}
|
||||
|
||||
// Return sanitized credentials (without sensitive data)
|
||||
sanitized := make([]map[string]any, len(credentials))
|
||||
for i, cred := range credentials {
|
||||
sanitized[i] = map[string]any{
|
||||
"credentialId": cred.CredentialID,
|
||||
"createdAt": cred.CreatedAt,
|
||||
"algorithm": cred.Algorithm,
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, sanitized)
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// TaskStatusMessage represents a WebSocket message for task status updates
|
||||
type TaskStatusMessage struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
Progress int `json:"progress,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Time time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ConnectionManager manages WebSocket connections for task status broadcasting
|
||||
type ConnectionManager struct {
|
||||
connections map[string]map[*websocket.Conn]bool // taskID -> connections
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewConnectionManager creates a new connection manager
|
||||
func NewConnectionManager() *ConnectionManager {
|
||||
return &ConnectionManager{
|
||||
connections: make(map[string]map[*websocket.Conn]bool),
|
||||
mutex: sync.RWMutex{},
|
||||
}
|
||||
}
|
||||
|
||||
// AddConnection adds a WebSocket connection for a specific task ID
|
||||
func (cm *ConnectionManager) AddConnection(taskID string, conn *websocket.Conn) {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
if cm.connections[taskID] == nil {
|
||||
cm.connections[taskID] = make(map[*websocket.Conn]bool)
|
||||
}
|
||||
cm.connections[taskID][conn] = true
|
||||
log.Printf("WebSocket connection added for task: %s", taskID)
|
||||
}
|
||||
|
||||
// RemoveConnection removes a WebSocket connection
|
||||
func (cm *ConnectionManager) RemoveConnection(taskID string, conn *websocket.Conn) {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
if connections, exists := cm.connections[taskID]; exists {
|
||||
delete(connections, conn)
|
||||
if len(connections) == 0 {
|
||||
delete(cm.connections, taskID)
|
||||
}
|
||||
}
|
||||
log.Printf("WebSocket connection removed for task: %s", taskID)
|
||||
}
|
||||
|
||||
// BroadcastToTask broadcasts a message to all connections listening to a specific task
|
||||
func (cm *ConnectionManager) BroadcastToTask(taskID string, message TaskStatusMessage) {
|
||||
cm.mutex.RLock()
|
||||
connections, exists := cm.connections[taskID]
|
||||
cm.mutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
for conn := range connections {
|
||||
if err := conn.WriteJSON(message); err != nil {
|
||||
log.Printf("Error broadcasting to WebSocket: %v", err)
|
||||
cm.RemoveConnection(taskID, conn)
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SSEManager manages Server-Sent Event connections for task status streaming
|
||||
type SSEManager struct {
|
||||
connections map[string]map[chan TaskStatusMessage]bool // taskID -> channels
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSSEManager creates a new SSE manager
|
||||
func NewSSEManager() *SSEManager {
|
||||
return &SSEManager{
|
||||
connections: make(map[string]map[chan TaskStatusMessage]bool),
|
||||
mutex: sync.RWMutex{},
|
||||
}
|
||||
}
|
||||
|
||||
// AddSSEConnection adds an SSE channel for a specific task ID
|
||||
func (sm *SSEManager) AddSSEConnection(taskID string, ch chan TaskStatusMessage) {
|
||||
sm.mutex.Lock()
|
||||
defer sm.mutex.Unlock()
|
||||
|
||||
if sm.connections[taskID] == nil {
|
||||
sm.connections[taskID] = make(map[chan TaskStatusMessage]bool)
|
||||
}
|
||||
sm.connections[taskID][ch] = true
|
||||
log.Printf("SSE connection added for task: %s", taskID)
|
||||
}
|
||||
|
||||
// RemoveSSEConnection removes an SSE channel
|
||||
func (sm *SSEManager) RemoveSSEConnection(taskID string, ch chan TaskStatusMessage) {
|
||||
sm.mutex.Lock()
|
||||
defer sm.mutex.Unlock()
|
||||
|
||||
if channels, exists := sm.connections[taskID]; exists {
|
||||
delete(channels, ch)
|
||||
if len(channels) == 0 {
|
||||
delete(sm.connections, taskID)
|
||||
}
|
||||
}
|
||||
close(ch)
|
||||
log.Printf("SSE connection removed for task: %s", taskID)
|
||||
}
|
||||
|
||||
// BroadcastToSSE broadcasts a message to all SSE connections listening to a specific task
|
||||
func (sm *SSEManager) BroadcastToSSE(taskID string, message TaskStatusMessage) {
|
||||
sm.mutex.RLock()
|
||||
channels, exists := sm.connections[taskID]
|
||||
sm.mutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
for ch := range channels {
|
||||
select {
|
||||
case ch <- message:
|
||||
// Message sent successfully
|
||||
default:
|
||||
// Channel is blocked, remove it
|
||||
log.Printf("SSE channel blocked, removing for task: %s", taskID)
|
||||
sm.RemoveSSEConnection(taskID, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocketHandler handles WebSocket connections for real-time task status updates
|
||||
func WebSocketHandler(
|
||||
upgrader *websocket.Upgrader,
|
||||
connectionManager *ConnectionManager,
|
||||
) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
taskID := c.Param("task_id")
|
||||
if taskID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Task ID is required"})
|
||||
}
|
||||
|
||||
// Upgrade HTTP connection to WebSocket
|
||||
ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade failed: %v", err)
|
||||
return err
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
// Add connection to manager
|
||||
connectionManager.AddConnection(taskID, ws)
|
||||
defer connectionManager.RemoveConnection(taskID, ws)
|
||||
|
||||
// Send initial connection confirmation
|
||||
initialMessage := TaskStatusMessage{
|
||||
TaskID: taskID,
|
||||
Status: "connected",
|
||||
Time: time.Now(),
|
||||
}
|
||||
if err := ws.WriteJSON(initialMessage); err != nil {
|
||||
log.Printf("Error sending initial message: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Keep connection alive and handle incoming messages
|
||||
for {
|
||||
// Read message from client (ping/pong for keepalive)
|
||||
_, _, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("WebSocket read error: %v", err)
|
||||
break
|
||||
}
|
||||
// Echo back a pong message to keep connection alive
|
||||
pongMessage := TaskStatusMessage{
|
||||
TaskID: taskID,
|
||||
Status: "pong",
|
||||
Time: time.Now(),
|
||||
}
|
||||
if err := ws.WriteJSON(pongMessage); err != nil {
|
||||
log.Printf("Error sending pong: %v", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SSEHandler handles Server-Sent Events for streaming task status updates
|
||||
func SSEHandler(sseManager *SSEManager) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
taskID := c.Param("task_id")
|
||||
if taskID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Task ID is required"})
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
c.Response().Header().Set("Content-Type", "text/event-stream")
|
||||
c.Response().Header().Set("Cache-Control", "no-cache")
|
||||
c.Response().Header().Set("Connection", "keep-alive")
|
||||
c.Response().Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Response().Header().Set("Access-Control-Allow-Headers", "Cache-Control")
|
||||
|
||||
// Create a channel for this SSE connection
|
||||
messageCh := make(chan TaskStatusMessage, 10)
|
||||
sseManager.AddSSEConnection(taskID, messageCh)
|
||||
defer sseManager.RemoveSSEConnection(taskID, messageCh)
|
||||
|
||||
// Send initial connection message
|
||||
initialMessage := TaskStatusMessage{
|
||||
TaskID: taskID,
|
||||
Status: "connected",
|
||||
Time: time.Now(),
|
||||
}
|
||||
fmt.Fprintf(
|
||||
c.Response().Writer,
|
||||
"data: {\"task_id\":\"%s\",\"status\":\"connected\",\"timestamp\":\"%s\"}\n\n",
|
||||
taskID,
|
||||
initialMessage.Time.Format(time.RFC3339),
|
||||
)
|
||||
c.Response().Flush()
|
||||
|
||||
// Keep connection alive and send messages
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-messageCh:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Format message as SSE data
|
||||
sseData := fmt.Sprintf(
|
||||
"data: {\"task_id\":\"%s\",\"status\":\"%s\",\"progress\":%d,\"timestamp\":\"%s\"}",
|
||||
message.TaskID,
|
||||
message.Status,
|
||||
message.Progress,
|
||||
message.Time.Format(time.RFC3339),
|
||||
)
|
||||
|
||||
if message.Error != "" {
|
||||
sseData = fmt.Sprintf(
|
||||
"data: {\"task_id\":\"%s\",\"status\":\"%s\",\"error\":\"%s\",\"timestamp\":\"%s\"}",
|
||||
message.TaskID,
|
||||
message.Status,
|
||||
message.Error,
|
||||
message.Time.Format(time.RFC3339),
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Fprintf(c.Response().Writer, "%s\n\n", sseData)
|
||||
c.Response().Flush()
|
||||
|
||||
case <-c.Request().Context().Done():
|
||||
// Client disconnected
|
||||
return nil
|
||||
|
||||
case <-time.After(30 * time.Second):
|
||||
// Send keepalive message every 30 seconds
|
||||
fmt.Fprintf(
|
||||
c.Response().Writer,
|
||||
"data: {\"task_id\":\"%s\",\"status\":\"keepalive\",\"timestamp\":\"%s\"}\n\n",
|
||||
taskID,
|
||||
time.Now().Format(time.RFC3339),
|
||||
)
|
||||
c.Response().Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/sonr-io/sonr/bridge/tasks"
|
||||
)
|
||||
|
||||
// QueueManager handles Asynq server setup and task registration
|
||||
type QueueManager struct {
|
||||
server *asynq.Server
|
||||
mux *asynq.ServeMux
|
||||
config *Config
|
||||
}
|
||||
|
||||
// NewQueueManager creates a new queue manager with the given configuration
|
||||
func NewQueueManager(config *Config) *QueueManager {
|
||||
// Initialize UCAN task processing server with optimized queue configuration
|
||||
srv := asynq.NewServer(
|
||||
asynq.RedisClientOpt{Addr: config.RedisAddr},
|
||||
config.AsynqConfig,
|
||||
)
|
||||
|
||||
// Register UCAN-based task handlers
|
||||
mux := asynq.NewServeMux()
|
||||
registerTaskHandlers(mux)
|
||||
|
||||
return &QueueManager{
|
||||
server: srv,
|
||||
mux: mux,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// registerTaskHandlers registers all UCAN-based task handlers
|
||||
func registerTaskHandlers(mux *asynq.ServeMux) {
|
||||
// Core UCAN token operations
|
||||
mux.Handle(tasks.TypeUCANToken, tasks.NewUCANProcessor())
|
||||
mux.Handle(tasks.TypeUCANAttenuation, tasks.NewUCANAttenuationProcessor())
|
||||
|
||||
// MPC-based cryptographic operations
|
||||
mux.Handle(tasks.TypeUCANSign, tasks.NewUCANSignProcessor())
|
||||
mux.Handle(tasks.TypeUCANVerify, tasks.NewUCANVerifyProcessor())
|
||||
|
||||
// DID operations (replaces both TypeVaultGenerate and TypeVaultRefresh)
|
||||
// Serves as proxy for future x/did module integration
|
||||
mux.Handle(tasks.TypeUCANDIDGeneration, tasks.NewUCANDIDProcessor())
|
||||
|
||||
log.Printf("UCAN task handlers registered successfully")
|
||||
}
|
||||
|
||||
// Run starts the Asynq server with the registered task handlers
|
||||
func (qm *QueueManager) Run() error {
|
||||
log.Printf("Starting Asynq task server with Redis at %s", qm.config.RedisAddr)
|
||||
return qm.server.Run(qm.mux)
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the Asynq server
|
||||
func (qm *QueueManager) Shutdown() {
|
||||
qm.server.Shutdown()
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/sonr-io/sonr/crypto/mpc"
|
||||
)
|
||||
|
||||
// MockAsynqClient provides a test double for asynq.Client
|
||||
type MockAsynqClient struct {
|
||||
enqueuedTasks []MockTask
|
||||
}
|
||||
|
||||
type MockTask struct {
|
||||
Type string
|
||||
Payload []byte
|
||||
Queue string
|
||||
}
|
||||
|
||||
func (m *MockAsynqClient) Enqueue(task *asynq.Task, opts ...asynq.Option) (*asynq.TaskInfo, error) {
|
||||
mockTask := MockTask{
|
||||
Type: task.Type(),
|
||||
Payload: task.Payload(),
|
||||
Queue: "default",
|
||||
}
|
||||
m.enqueuedTasks = append(m.enqueuedTasks, mockTask)
|
||||
|
||||
return &asynq.TaskInfo{
|
||||
ID: "test-task-id",
|
||||
Type: task.Type(),
|
||||
Queue: mockTask.Queue,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockAsynqClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupTestEcho creates a test Echo server and mock client for benchmarking
|
||||
func setupTestEcho() (*echo.Echo, *MockAsynqClient) {
|
||||
mockClient := &MockAsynqClient{}
|
||||
config := &Config{
|
||||
JWTSecret: []byte("test-secret"),
|
||||
IPFSClient: &MockIPFSClient{},
|
||||
}
|
||||
s := NewServer(config)
|
||||
return s.Echo(), mockClient
|
||||
}
|
||||
|
||||
// BenchmarkHealthCheckHandler measures the performance of the health check endpoint
|
||||
func BenchmarkHealthCheckHandler(b *testing.B) {
|
||||
e, _ := setupTestEcho()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
e.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
b.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkGenerateHandler measures the performance of the generate endpoint
|
||||
func BenchmarkGenerateHandler(b *testing.B) {
|
||||
e, client := setupTestEcho()
|
||||
|
||||
payload := map[string]any{
|
||||
"user_id": 123,
|
||||
"priority": "default",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(payload)
|
||||
requestBody := buf.Bytes()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
req := httptest.NewRequest("POST", "/vault/generate", bytes.NewReader(requestBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
e.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
b.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.StopTimer()
|
||||
b.Logf("Tasks enqueued: %d", len(client.enqueuedTasks))
|
||||
}
|
||||
|
||||
// BenchmarkSignHandler measures the performance of the sign endpoint
|
||||
func BenchmarkSignHandler(b *testing.B) {
|
||||
e, client := setupTestEcho()
|
||||
|
||||
payload := map[string]any{
|
||||
"message": []byte("benchmark test message"),
|
||||
"enclave": &mpc.EnclaveData{},
|
||||
"priority": "default",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(payload)
|
||||
requestBody := buf.Bytes()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
req := httptest.NewRequest("POST", "/vault/sign", bytes.NewReader(requestBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
e.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
b.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.StopTimer()
|
||||
b.Logf("Tasks enqueued: %d", len(client.enqueuedTasks))
|
||||
}
|
||||
|
||||
// BenchmarkMemoryAllocation measures memory allocation patterns
|
||||
func BenchmarkMemoryAllocation(b *testing.B) {
|
||||
e, _ := setupTestEcho()
|
||||
|
||||
payload := map[string]any{
|
||||
"user_id": 123,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(payload)
|
||||
requestBody := buf.Bytes()
|
||||
|
||||
var m1, m2 runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&m1)
|
||||
|
||||
b.ReportAllocs()
|
||||
|
||||
for b.Loop() {
|
||||
req := httptest.NewRequest("POST", "/vault/generate", bytes.NewReader(requestBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
e.ServeHTTP(rr, req)
|
||||
}
|
||||
|
||||
b.StopTimer()
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&m2)
|
||||
|
||||
b.Logf("Memory allocated per operation: %d bytes", (m2.TotalAlloc-m1.TotalAlloc)/uint64(b.N))
|
||||
b.Logf("Total allocations: %d", m2.Mallocs-m1.Mallocs)
|
||||
}
|
||||
|
||||
// BenchmarkLatencyMeasurement measures end-to-end latency
|
||||
func BenchmarkLatencyMeasurement(b *testing.B) {
|
||||
e, _ := setupTestEcho()
|
||||
|
||||
payload := map[string]any{
|
||||
"user_id": 123,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(payload)
|
||||
requestBody := buf.Bytes()
|
||||
|
||||
var totalLatency time.Duration
|
||||
minLatency := time.Hour
|
||||
var maxLatency time.Duration
|
||||
|
||||
for b.Loop() {
|
||||
start := time.Now()
|
||||
|
||||
req := httptest.NewRequest("POST", "/vault/generate", bytes.NewReader(requestBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
e.ServeHTTP(rr, req)
|
||||
|
||||
latency := time.Since(start)
|
||||
totalLatency += latency
|
||||
|
||||
if latency < minLatency {
|
||||
minLatency = latency
|
||||
}
|
||||
if latency > maxLatency {
|
||||
maxLatency = latency
|
||||
}
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
b.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
b.StopTimer()
|
||||
|
||||
avgLatency := totalLatency / time.Duration(b.N)
|
||||
b.Logf("Average latency: %v", avgLatency)
|
||||
b.Logf("Min latency: %v", minLatency)
|
||||
b.Logf("Max latency: %v", maxLatency)
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// Package server provides the HTTP server for the highway server
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/hibiken/asynq"
|
||||
echojwt "github.com/labstack/echo-jwt/v4"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/sonr-io/sonr/bridge/handlers"
|
||||
"github.com/sonr-io/sonr/types/ipfs"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultHTTPAddr = ":8080"
|
||||
)
|
||||
|
||||
// Config holds server configuration
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
JWTSecret []byte
|
||||
IPFSClient ipfs.IPFSClient
|
||||
}
|
||||
|
||||
// Server represents the HTTP server
|
||||
type Server struct {
|
||||
config *Config
|
||||
echo *echo.Echo
|
||||
upgrader *websocket.Upgrader
|
||||
connectionManager *handlers.ConnectionManager
|
||||
sseManager *handlers.SSEManager
|
||||
vaultHandlers *handlers.VaultHandlers
|
||||
}
|
||||
|
||||
// NewServer creates a new server instance
|
||||
func NewServer(config *Config) *Server {
|
||||
// WebSocket upgrader with CORS settings
|
||||
upgrader := &websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Allow all origins in development
|
||||
},
|
||||
}
|
||||
|
||||
// Create connection managers
|
||||
connectionManager := handlers.NewConnectionManager()
|
||||
sseManager := handlers.NewSSEManager()
|
||||
|
||||
// Create vault handlers
|
||||
vaultHandlers := handlers.NewVaultHandlers(config.IPFSClient, connectionManager, sseManager)
|
||||
|
||||
return &Server{
|
||||
config: config,
|
||||
echo: echo.New(),
|
||||
upgrader: upgrader,
|
||||
connectionManager: connectionManager,
|
||||
sseManager: sseManager,
|
||||
vaultHandlers: vaultHandlers,
|
||||
}
|
||||
}
|
||||
|
||||
// Echo returns the underlying Echo instance for testing
|
||||
func (s *Server) Echo() *echo.Echo {
|
||||
return s.echo
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
func (s *Server) Start(client *asynq.Client) error {
|
||||
s.setupMiddleware()
|
||||
s.setupRoutes(client)
|
||||
|
||||
addr := s.config.HTTPAddr
|
||||
if addr == "" {
|
||||
addr = DefaultHTTPAddr
|
||||
}
|
||||
return s.echo.Start(addr)
|
||||
}
|
||||
|
||||
// setupMiddleware configures Echo middleware
|
||||
func (s *Server) setupMiddleware() {
|
||||
s.echo.Use(middleware.Logger())
|
||||
s.echo.Use(middleware.Recover())
|
||||
s.echo.Use(middleware.CORS())
|
||||
}
|
||||
|
||||
// setupRoutes configures all routes
|
||||
func (s *Server) setupRoutes(client *asynq.Client) {
|
||||
// Initialize health checker
|
||||
handlers.InitHealthChecker(client, s.config.IPFSClient)
|
||||
|
||||
// Public endpoints (no authentication required)
|
||||
s.echo.GET("/health", handlers.HealthCheckHandler) // Liveness probe
|
||||
s.echo.GET("/ready", handlers.ReadinessHandler) // Readiness probe
|
||||
s.echo.POST("/auth/login", handlers.LoginHandler(s.config.JWTSecret))
|
||||
|
||||
// JWT middleware configuration
|
||||
jwtConfig := echojwt.Config{
|
||||
SigningKey: s.config.JWTSecret,
|
||||
SigningMethod: "HS256",
|
||||
}
|
||||
|
||||
// Protected vault endpoints group with JWT middleware
|
||||
vault := s.echo.Group("/vault")
|
||||
vault.Use(echojwt.WithConfig(jwtConfig))
|
||||
vault.POST("/generate", s.vaultHandlers.GenerateHandler(client))
|
||||
vault.POST("/sign", s.vaultHandlers.SignHandler(client))
|
||||
vault.POST("/verify", s.vaultHandlers.VerifyHandler(client))
|
||||
vault.POST("/export", s.vaultHandlers.ExportHandler(client))
|
||||
vault.POST("/import", s.vaultHandlers.ImportHandler(client))
|
||||
vault.POST("/refresh", s.vaultHandlers.RefreshHandler(client))
|
||||
|
||||
// WebSocket endpoint for real-time task status updates
|
||||
vault.GET("/ws/:task_id", handlers.WebSocketHandler(s.upgrader, s.connectionManager))
|
||||
|
||||
// Server-Sent Events endpoint for task progress streaming
|
||||
vault.GET("/events/:task_id", handlers.SSEHandler(s.sseManager))
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/sonr-io/sonr/bridge/handlers"
|
||||
"github.com/sonr-io/sonr/types/ipfs"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockIPFSClient provides a test implementation of IPFSClient for server tests
|
||||
type MockIPFSClient struct{}
|
||||
|
||||
func (m *MockIPFSClient) Add(data []byte) (string, error) { return "mock-cid", nil }
|
||||
|
||||
func (m *MockIPFSClient) AddFile(
|
||||
file ipfs.File,
|
||||
) (string, error) {
|
||||
return "mock-file-cid", nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) AddFolder(
|
||||
folder ipfs.Folder,
|
||||
) (string, error) {
|
||||
return "mock-folder-cid", nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) Get(
|
||||
cid string,
|
||||
) ([]byte, error) {
|
||||
return []byte("mock-ipfs-data"), nil
|
||||
}
|
||||
func (m *MockIPFSClient) GetFile(cid string) (ipfs.File, error) { return nil, nil }
|
||||
func (m *MockIPFSClient) GetFolder(cid string) (ipfs.Folder, error) { return nil, nil }
|
||||
func (m *MockIPFSClient) Pin(cid string, name string) error { return nil }
|
||||
func (m *MockIPFSClient) Unpin(cid string) error { return nil }
|
||||
func (m *MockIPFSClient) Exists(cid string) (bool, error) { return true, nil }
|
||||
func (m *MockIPFSClient) IsPinned(ipns string) (bool, error) { return true, nil }
|
||||
func (m *MockIPFSClient) Ls(cid string) ([]string, error) {
|
||||
return []string{"mock-file1", "mock-file2"}, nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) NodeStatus() (*ipfs.NodeStatus, error) {
|
||||
return &ipfs.NodeStatus{
|
||||
PeerID: "mock-peer-id",
|
||||
Version: "mock-version",
|
||||
PeerType: "kubo",
|
||||
ConnectedPeers: 3,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func setupTestServer() *Server {
|
||||
config := &Config{
|
||||
JWTSecret: []byte("test-secret"),
|
||||
IPFSClient: &MockIPFSClient{},
|
||||
}
|
||||
s := NewServer(config)
|
||||
|
||||
// Setup routes manually for testing since we can't call Start() which would block
|
||||
e := s.Echo()
|
||||
|
||||
// Setup middleware
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
|
||||
// Setup routes manually (mimicking server.setupRoutes)
|
||||
e.GET("/health", handlers.HealthCheckHandler)
|
||||
e.POST("/auth/login", handlers.LoginHandler(config.JWTSecret))
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func TestHealthCheckHandler(t *testing.T) {
|
||||
s := setupTestServer()
|
||||
e := s.Echo()
|
||||
|
||||
req, err := http.NewRequest("GET", "/health", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
e.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
||||
|
||||
var response map[string]string
|
||||
err = json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
// When health checker is not initialized, it returns "starting" status
|
||||
// This is expected behavior in test environment
|
||||
assert.Equal(t, "starting", response["status"])
|
||||
}
|
||||
|
||||
func TestVaultHandlersCreation(t *testing.T) {
|
||||
// Test the vault handlers creation
|
||||
vaultHandlers := handlers.NewVaultHandlers(
|
||||
&MockIPFSClient{},
|
||||
handlers.NewConnectionManager(),
|
||||
handlers.NewSSEManager(),
|
||||
)
|
||||
assert.NotNil(t, vaultHandlers)
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
// Package tasks provides UCAN-based task processing for token attenuation operations.
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/asynkron/protoactor-go/actor"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/sonr-io/sonr/x/dwn/client/plugin"
|
||||
)
|
||||
|
||||
// ╭─────────────────────────────────────────────────────────╮
|
||||
// │ Processor │
|
||||
// ╰─────────────────────────────────────────────────────────╯
|
||||
|
||||
// UCANAttenuationProcessor implements asynq.Handler interface for UCAN attenuation operations.
|
||||
type UCANAttenuationProcessor struct {
|
||||
asynq.Handler
|
||||
pid *actor.PID
|
||||
}
|
||||
|
||||
// NewUCANAttenuationProcessor creates a new UCANAttenuationProcessor for the specified actor system.
|
||||
func NewUCANAttenuationProcessor() *UCANAttenuationProcessor {
|
||||
pid := system.Root.SpawnPrefix(plugin.Props(), TypeUCANAttenuation)
|
||||
return &UCANAttenuationProcessor{
|
||||
pid: pid,
|
||||
}
|
||||
}
|
||||
|
||||
// ╭─────────────────────────────────────────────────────────╮
|
||||
// │ Payload │
|
||||
// ╰─────────────────────────────────────────────────────────╯
|
||||
|
||||
// UCANAttenuationPayload contains parameters for UCAN token attenuation task.
|
||||
type UCANAttenuationPayload struct {
|
||||
UserID int `json:"user_id"`
|
||||
ParentToken string `json:"parent_token"`
|
||||
AudienceDID string `json:"audience_did"`
|
||||
Attenuations []map[string]any `json:"attenuations,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// NewUCANAttenuationTask creates a new UCAN token attenuation task.
|
||||
func NewUCANAttenuationTask(
|
||||
userID int,
|
||||
parentToken string,
|
||||
audienceDID string,
|
||||
attenuations []map[string]any,
|
||||
expiresAt int64,
|
||||
) (*asynq.Task, error) {
|
||||
payload, err := json.Marshal(UCANAttenuationPayload{
|
||||
UserID: userID,
|
||||
ParentToken: parentToken,
|
||||
AudienceDID: audienceDID,
|
||||
Attenuations: attenuations,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return asynq.NewTask(TypeUCANAttenuation, payload), nil
|
||||
}
|
||||
|
||||
// ╭───────────────────────────────────────────────────────╮
|
||||
// │ Handler │
|
||||
// ╰───────────────────────────────────────────────────────╯
|
||||
|
||||
// ProcessTask processes the UCAN token attenuation task.
|
||||
func (processor *UCANAttenuationProcessor) ProcessTask(ctx context.Context, t *asynq.Task) error {
|
||||
var p UCANAttenuationPayload
|
||||
if err := json.Unmarshal(t.Payload(), &p); err != nil {
|
||||
return fmt.Errorf("json.Unmarshal failed: %v: %w", err, asynq.SkipRetry)
|
||||
}
|
||||
|
||||
// Create attenuated token request for the actor
|
||||
request := &plugin.NewAttenuatedTokenRequest{
|
||||
ParentToken: p.ParentToken,
|
||||
AudienceDID: p.AudienceDID,
|
||||
Attenuations: p.Attenuations,
|
||||
ExpiresAt: p.ExpiresAt,
|
||||
}
|
||||
|
||||
resp, err := system.Root.RequestFuture(processor.pid, request, KRequestTimeout).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch resp := resp.(type) {
|
||||
case *plugin.UCANTokenResponse:
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("UCAN token attenuation failed: %s", resp.Error)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid response type: %T", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// ╭─────────────────────────────────────────────────────────╮
|
||||
// │ DID Generation Processor │
|
||||
// ╰─────────────────────────────────────────────────────────╯
|
||||
|
||||
// UCANDIDProcessor implements asynq.Handler interface for DID generation operations.
|
||||
type UCANDIDProcessor struct {
|
||||
asynq.Handler
|
||||
pid *actor.PID
|
||||
}
|
||||
|
||||
// NewUCANDIDProcessor creates a new UCANDIDProcessor for the specified actor system.
|
||||
func NewUCANDIDProcessor() *UCANDIDProcessor {
|
||||
pid := system.Root.SpawnPrefix(plugin.Props(), TypeUCANDIDGeneration)
|
||||
return &UCANDIDProcessor{
|
||||
pid: pid,
|
||||
}
|
||||
}
|
||||
|
||||
// UCANDIDPayload contains parameters for DID generation task.
|
||||
type UCANDIDPayload struct {
|
||||
UserID int `json:"user_id"`
|
||||
}
|
||||
|
||||
// NewUCANDIDTask creates a new DID generation task.
|
||||
func NewUCANDIDTask(userID int) (*asynq.Task, error) {
|
||||
payload, err := json.Marshal(UCANDIDPayload{
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return asynq.NewTask(TypeUCANDIDGeneration, payload), nil
|
||||
}
|
||||
|
||||
// ProcessTask processes the DID generation task.
|
||||
func (processor *UCANDIDProcessor) ProcessTask(ctx context.Context, t *asynq.Task) error {
|
||||
var p UCANDIDPayload
|
||||
if err := json.Unmarshal(t.Payload(), &p); err != nil {
|
||||
return fmt.Errorf("json.Unmarshal failed: %v: %w", err, asynq.SkipRetry)
|
||||
}
|
||||
|
||||
// Request DID generation from the actor
|
||||
resp, err := system.Root.RequestFuture(processor.pid, &plugin.GetIssuerDIDResponse{}, KRequestTimeout).
|
||||
Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch resp := resp.(type) {
|
||||
case *plugin.GetIssuerDIDResponse:
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("DID generation failed: %s", resp.Error)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid response type: %T", resp)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// Package tasks provides UCAN-based task processing for the refactored Motor plugin.
|
||||
// This package handles asynchronous UCAN token creation, signing operations,
|
||||
// and DID management tasks using the MPC-based plugin architecture.
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/asynkron/protoactor-go/actor"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/sonr-io/sonr/x/dwn/client/plugin"
|
||||
)
|
||||
|
||||
// ╭─────────────────────────────────────────────────────────╮
|
||||
// │ Processor │
|
||||
// ╰─────────────────────────────────────────────────────────╯
|
||||
|
||||
// UCANProcessor implements asynq.Handler interface for UCAN operations.
|
||||
type UCANProcessor struct {
|
||||
asynq.Handler
|
||||
pid *actor.PID
|
||||
}
|
||||
|
||||
// NewUCANProcessor creates a new UCANProcessor for the specified actor system.
|
||||
func NewUCANProcessor() *UCANProcessor {
|
||||
pid := system.Root.SpawnPrefix(plugin.Props(), TypeUCANToken)
|
||||
return &UCANProcessor{
|
||||
pid: pid,
|
||||
}
|
||||
}
|
||||
|
||||
// ╭─────────────────────────────────────────────────────────╮
|
||||
// │ Payload │
|
||||
// ╰─────────────────────────────────────────────────────────╯
|
||||
|
||||
// UCANTokenPayload contains parameters for UCAN token creation task.
|
||||
type UCANTokenPayload struct {
|
||||
UserID int `json:"user_id"`
|
||||
AudienceDID string `json:"audience_did"`
|
||||
Attenuations []map[string]any `json:"attenuations,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// NewUCANTokenTask creates a new UCAN token creation task.
|
||||
func NewUCANTokenTask(
|
||||
userID int,
|
||||
audienceDID string,
|
||||
attenuations []map[string]any,
|
||||
expiresAt int64,
|
||||
) (*asynq.Task, error) {
|
||||
payload, err := json.Marshal(UCANTokenPayload{
|
||||
UserID: userID,
|
||||
AudienceDID: audienceDID,
|
||||
Attenuations: attenuations,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return asynq.NewTask(TypeUCANToken, payload), nil
|
||||
}
|
||||
|
||||
// ╭───────────────────────────────────────────────────────╮
|
||||
// │ Handler │
|
||||
// ╰───────────────────────────────────────────────────────╯
|
||||
|
||||
// ProcessTask processes the UCAN token creation task.
|
||||
func (processor *UCANProcessor) ProcessTask(ctx context.Context, t *asynq.Task) error {
|
||||
var p UCANTokenPayload
|
||||
if err := json.Unmarshal(t.Payload(), &p); err != nil {
|
||||
return fmt.Errorf("json.Unmarshal failed: %v: %w", err, asynq.SkipRetry)
|
||||
}
|
||||
|
||||
// Create UCAN token request for the actor
|
||||
request := &plugin.NewOriginTokenRequest{
|
||||
AudienceDID: p.AudienceDID,
|
||||
Attenuations: p.Attenuations,
|
||||
ExpiresAt: p.ExpiresAt,
|
||||
}
|
||||
|
||||
resp, err := system.Root.RequestFuture(processor.pid, request, KRequestTimeout).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch resp := resp.(type) {
|
||||
case *plugin.UCANTokenResponse:
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("UCAN token creation failed: %s", resp.Error)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid response type: %T", resp)
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
// Package tasks provides UCAN-based task processing for MPC signing operations.
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/asynkron/protoactor-go/actor"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/sonr-io/sonr/x/dwn/client/plugin"
|
||||
)
|
||||
|
||||
// ╭─────────────────────────────────────────────────────────╮
|
||||
// │ Processor │
|
||||
// ╰─────────────────────────────────────────────────────────╯
|
||||
|
||||
// UCANSignProcessor implements asynq.Handler interface for UCAN signing operations.
|
||||
type UCANSignProcessor struct {
|
||||
asynq.Handler
|
||||
pid *actor.PID
|
||||
}
|
||||
|
||||
// NewUCANSignProcessor creates a new UCANSignProcessor for the specified actor system.
|
||||
func NewUCANSignProcessor() *UCANSignProcessor {
|
||||
pid := system.Root.SpawnPrefix(plugin.Props(), TypeUCANSign)
|
||||
return &UCANSignProcessor{
|
||||
pid: pid,
|
||||
}
|
||||
}
|
||||
|
||||
// ╭─────────────────────────────────────────────────────────╮
|
||||
// │ Payload │
|
||||
// ╰─────────────────────────────────────────────────────────╯
|
||||
|
||||
// UCANSignPayload contains parameters for UCAN signing task.
|
||||
type UCANSignPayload struct {
|
||||
UserID int `json:"user_id"`
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
// NewUCANSignTask creates a new UCAN signing task.
|
||||
func NewUCANSignTask(userID int, data []byte) (*asynq.Task, error) {
|
||||
payload, err := json.Marshal(UCANSignPayload{
|
||||
UserID: userID,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return asynq.NewTask(TypeUCANSign, payload), nil
|
||||
}
|
||||
|
||||
// ╭───────────────────────────────────────────────────────╮
|
||||
// │ Handler │
|
||||
// ╰───────────────────────────────────────────────────────╯
|
||||
|
||||
// ProcessTask processes the UCAN signing task.
|
||||
func (processor *UCANSignProcessor) ProcessTask(ctx context.Context, t *asynq.Task) error {
|
||||
var p UCANSignPayload
|
||||
if err := json.Unmarshal(t.Payload(), &p); err != nil {
|
||||
return fmt.Errorf("json.Unmarshal failed: %v: %w", err, asynq.SkipRetry)
|
||||
}
|
||||
|
||||
// Create signing request for the actor
|
||||
request := &plugin.SignDataRequest{
|
||||
Data: p.Data,
|
||||
}
|
||||
|
||||
resp, err := system.Root.RequestFuture(processor.pid, request, KRequestTimeout).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch resp := resp.(type) {
|
||||
case *plugin.SignDataResponse:
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("UCAN signing failed: %s", resp.Error)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid response type: %T", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// ╭─────────────────────────────────────────────────────────╮
|
||||
// │ Verification Processor │
|
||||
// ╰─────────────────────────────────────────────────────────╯
|
||||
|
||||
// UCANVerifyProcessor implements asynq.Handler interface for UCAN verification operations.
|
||||
type UCANVerifyProcessor struct {
|
||||
asynq.Handler
|
||||
pid *actor.PID
|
||||
}
|
||||
|
||||
// NewUCANVerifyProcessor creates a new UCANVerifyProcessor for the specified actor system.
|
||||
func NewUCANVerifyProcessor() *UCANVerifyProcessor {
|
||||
pid := system.Root.SpawnPrefix(plugin.Props(), TypeUCANVerify)
|
||||
return &UCANVerifyProcessor{
|
||||
pid: pid,
|
||||
}
|
||||
}
|
||||
|
||||
// UCANVerifyPayload contains parameters for UCAN verification task.
|
||||
type UCANVerifyPayload struct {
|
||||
UserID int `json:"user_id"`
|
||||
Data []byte `json:"data"`
|
||||
Signature []byte `json:"signature"`
|
||||
}
|
||||
|
||||
// NewUCANVerifyTask creates a new UCAN verification task.
|
||||
func NewUCANVerifyTask(userID int, data, signature []byte) (*asynq.Task, error) {
|
||||
payload, err := json.Marshal(UCANVerifyPayload{
|
||||
UserID: userID,
|
||||
Data: data,
|
||||
Signature: signature,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return asynq.NewTask(TypeUCANVerify, payload), nil
|
||||
}
|
||||
|
||||
// ProcessTask processes the UCAN verification task.
|
||||
func (processor *UCANVerifyProcessor) ProcessTask(ctx context.Context, t *asynq.Task) error {
|
||||
var p UCANVerifyPayload
|
||||
if err := json.Unmarshal(t.Payload(), &p); err != nil {
|
||||
return fmt.Errorf("json.Unmarshal failed: %v: %w", err, asynq.SkipRetry)
|
||||
}
|
||||
|
||||
// Create verification request for the actor
|
||||
request := &plugin.VerifyDataRequest{
|
||||
Data: p.Data,
|
||||
Signature: p.Signature,
|
||||
}
|
||||
|
||||
resp, err := system.Root.RequestFuture(processor.pid, request, KRequestTimeout).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch resp := resp.(type) {
|
||||
case *plugin.VerifyDataResponse:
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("UCAN verification failed: %s", resp.Error)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid response type: %T", resp)
|
||||
}
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/asynkron/protoactor-go/actor"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/sonr-io/sonr/crypto/mpc"
|
||||
"github.com/sonr-io/sonr/x/dwn/client/plugin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// generateTestEnclaveData creates a test enclave data for testing purposes
|
||||
func generateTestEnclaveData(t *testing.T) *mpc.EnclaveData {
|
||||
t.Helper()
|
||||
|
||||
// Generate a new enclave for testing
|
||||
enclave, err := mpc.NewEnclave()
|
||||
require.NoError(t, err, "failed to generate test enclave")
|
||||
|
||||
return enclave.GetData()
|
||||
}
|
||||
|
||||
// MockUCANActor for testing UCAN task processors
|
||||
type MockUCANActor struct {
|
||||
responses map[string]any
|
||||
}
|
||||
|
||||
func NewMockUCANActor() *MockUCANActor {
|
||||
return &MockUCANActor{
|
||||
responses: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
// SlowMockUCANActor simulates timeout scenarios
|
||||
type SlowMockUCANActor struct{}
|
||||
|
||||
func (s *SlowMockUCANActor) Receive(c actor.Context) {
|
||||
switch c.Message().(type) {
|
||||
case *plugin.NewOriginTokenRequest:
|
||||
// Simulate slow response (longer than KRequestTimeout)
|
||||
time.Sleep(KRequestTimeout + time.Second)
|
||||
c.Respond(&plugin.UCANTokenResponse{})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockUCANActor) Receive(c actor.Context) {
|
||||
switch c.Message().(type) {
|
||||
case *actor.Started:
|
||||
// Actor started
|
||||
case *plugin.NewOriginTokenRequest:
|
||||
c.Respond(&plugin.UCANTokenResponse{
|
||||
Token: "mock-ucan-token",
|
||||
Issuer: "did:sonr:mock-issuer",
|
||||
Address: "mock-address",
|
||||
})
|
||||
case *plugin.SignDataRequest:
|
||||
c.Respond(&plugin.SignDataResponse{
|
||||
Signature: []byte("mock-signature"),
|
||||
})
|
||||
case *plugin.VerifyDataRequest:
|
||||
c.Respond(&plugin.VerifyDataResponse{
|
||||
Valid: true,
|
||||
})
|
||||
case *plugin.NewAttenuatedTokenRequest:
|
||||
c.Respond(&plugin.UCANTokenResponse{
|
||||
Token: "mock-attenuated-token",
|
||||
Issuer: "did:sonr:mock-issuer",
|
||||
Address: "mock-address",
|
||||
})
|
||||
case *plugin.GetIssuerDIDResponse:
|
||||
c.Respond(&plugin.GetIssuerDIDResponse{
|
||||
IssuerDID: "did:sonr:mock-issuer",
|
||||
Address: "mock-address",
|
||||
ChainCode: "mock-chain-code",
|
||||
})
|
||||
default:
|
||||
c.Respond(&actor.DeadLetterResponse{})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCANTokenTask(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userID int
|
||||
audienceDID string
|
||||
attenuations []map[string]any
|
||||
expiresAt int64
|
||||
}{
|
||||
{
|
||||
name: "valid UCAN token request",
|
||||
userID: 123,
|
||||
audienceDID: "did:sonr:audience",
|
||||
attenuations: []map[string]any{
|
||||
{"can": []string{"sign"}, "with": "vault://example"},
|
||||
},
|
||||
expiresAt: time.Now().Add(24 * time.Hour).Unix(),
|
||||
},
|
||||
{
|
||||
name: "zero user ID",
|
||||
userID: 0,
|
||||
audienceDID: "did:sonr:audience",
|
||||
attenuations: []map[string]any{},
|
||||
expiresAt: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
task, err := NewUCANTokenTask(tt.userID, tt.audienceDID, tt.attenuations, tt.expiresAt)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, TypeUCANToken, task.Type())
|
||||
|
||||
var payload UCANTokenPayload
|
||||
err = json.Unmarshal(task.Payload(), &payload)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.userID, payload.UserID)
|
||||
assert.Equal(t, tt.audienceDID, payload.AudienceDID)
|
||||
// Handle JSON unmarshaling type conversion ([]string becomes []any)
|
||||
assert.Equal(t, len(tt.attenuations), len(payload.Attenuations))
|
||||
for i, expectedAtt := range tt.attenuations {
|
||||
actualAtt := payload.Attenuations[i]
|
||||
for key, expectedVal := range expectedAtt {
|
||||
actualVal, exists := actualAtt[key]
|
||||
assert.True(t, exists, "key %s should exist", key)
|
||||
|
||||
// Handle []string to []any conversion
|
||||
if expectedSlice, ok := expectedVal.([]string); ok {
|
||||
actualSlice, ok := actualVal.([]any)
|
||||
assert.True(t, ok, "expected []any for key %s", key)
|
||||
assert.Equal(t, len(expectedSlice), len(actualSlice))
|
||||
for j, expectedItem := range expectedSlice {
|
||||
assert.Equal(t, expectedItem, actualSlice[j])
|
||||
}
|
||||
} else {
|
||||
assert.Equal(t, expectedVal, actualVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.Equal(t, tt.expiresAt, payload.ExpiresAt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCANSignTask(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userID int
|
||||
data []byte
|
||||
}{
|
||||
{
|
||||
name: "valid sign request",
|
||||
userID: 123,
|
||||
data: []byte("test data to sign"),
|
||||
},
|
||||
{
|
||||
name: "empty data",
|
||||
userID: 456,
|
||||
data: []byte{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
task, err := NewUCANSignTask(tt.userID, tt.data)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, TypeUCANSign, task.Type())
|
||||
|
||||
var payload UCANSignPayload
|
||||
err = json.Unmarshal(task.Payload(), &payload)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.userID, payload.UserID)
|
||||
assert.Equal(t, tt.data, payload.Data)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCANVerifyTask(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userID int
|
||||
data []byte
|
||||
signature []byte
|
||||
}{
|
||||
{
|
||||
name: "valid verify request",
|
||||
userID: 123,
|
||||
data: []byte("test data to verify"),
|
||||
signature: []byte("test-signature"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
task, err := NewUCANVerifyTask(tt.userID, tt.data, tt.signature)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, TypeUCANVerify, task.Type())
|
||||
|
||||
var payload UCANVerifyPayload
|
||||
err = json.Unmarshal(task.Payload(), &payload)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.userID, payload.UserID)
|
||||
assert.Equal(t, tt.data, payload.Data)
|
||||
assert.Equal(t, tt.signature, payload.Signature)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCANAttenuationTask(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userID int
|
||||
parentToken string
|
||||
audienceDID string
|
||||
attenuations []map[string]any
|
||||
expiresAt int64
|
||||
}{
|
||||
{
|
||||
name: "valid attenuation request",
|
||||
userID: 123,
|
||||
parentToken: "parent-ucan-token",
|
||||
audienceDID: "did:sonr:delegated",
|
||||
attenuations: []map[string]any{
|
||||
{"can": []string{"read"}, "with": "vault://example"},
|
||||
},
|
||||
expiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
task, err := NewUCANAttenuationTask(
|
||||
tt.userID,
|
||||
tt.parentToken,
|
||||
tt.audienceDID,
|
||||
tt.attenuations,
|
||||
tt.expiresAt,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, TypeUCANAttenuation, task.Type())
|
||||
|
||||
var payload UCANAttenuationPayload
|
||||
err = json.Unmarshal(task.Payload(), &payload)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.userID, payload.UserID)
|
||||
assert.Equal(t, tt.parentToken, payload.ParentToken)
|
||||
assert.Equal(t, tt.audienceDID, payload.AudienceDID)
|
||||
// Handle JSON unmarshaling type conversion ([]string becomes []any)
|
||||
assert.Equal(t, len(tt.attenuations), len(payload.Attenuations))
|
||||
for i, expectedAtt := range tt.attenuations {
|
||||
actualAtt := payload.Attenuations[i]
|
||||
for key, expectedVal := range expectedAtt {
|
||||
actualVal, exists := actualAtt[key]
|
||||
assert.True(t, exists, "key %s should exist", key)
|
||||
|
||||
// Handle []string to []any conversion
|
||||
if expectedSlice, ok := expectedVal.([]string); ok {
|
||||
actualSlice, ok := actualVal.([]any)
|
||||
assert.True(t, ok, "expected []any for key %s", key)
|
||||
assert.Equal(t, len(expectedSlice), len(actualSlice))
|
||||
for j, expectedItem := range expectedSlice {
|
||||
assert.Equal(t, expectedItem, actualSlice[j])
|
||||
}
|
||||
} else {
|
||||
assert.Equal(t, expectedVal, actualVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.Equal(t, tt.expiresAt, payload.ExpiresAt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCANDIDTask(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userID int
|
||||
}{
|
||||
{"valid DID request", 123},
|
||||
{"zero user ID", 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
task, err := NewUCANDIDTask(tt.userID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, TypeUCANDIDGeneration, task.Type())
|
||||
|
||||
var payload UCANDIDPayload
|
||||
err = json.Unmarshal(task.Payload(), &payload)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.userID, payload.UserID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test processor functionality with mock actors
|
||||
func TestUCANTokenProcessor(t *testing.T) {
|
||||
// Create actor system
|
||||
system := actor.NewActorSystem()
|
||||
defer system.Shutdown()
|
||||
|
||||
// Create mock actor - use the mock instead of real plugin
|
||||
mockActor := system.Root.Spawn(actor.PropsFromProducer(func() actor.Actor {
|
||||
return NewMockUCANActor()
|
||||
}))
|
||||
|
||||
// Create processor with mock actor
|
||||
processor := &UCANProcessor{pid: mockActor}
|
||||
|
||||
// Create test task
|
||||
task, err := NewUCANTokenTask(123, "did:sonr:audience", []map[string]any{
|
||||
{"can": []string{"sign"}, "with": "vault://example"},
|
||||
}, time.Now().Add(24*time.Hour).Unix())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Process the task - the error is expected because the mock actor returns a dead letter
|
||||
// The processor should handle this gracefully
|
||||
err = processor.ProcessTask(context.Background(), task)
|
||||
|
||||
// For now, we expect this to fail with the dead letter error
|
||||
// In a real scenario, the actor would be properly initialized with enclave data
|
||||
assert.Error(t, err, "expected error due to mock actor limitations")
|
||||
assert.Contains(t, err.Error(), "dead letter", "should get dead letter error from mock")
|
||||
}
|
||||
|
||||
// TestUCANActorWithRealEnclave tests the real UCAN actor with generated enclave data
|
||||
func TestUCANActorWithRealEnclave(t *testing.T) {
|
||||
// Skip this test if we're not in integration test mode
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
// Skip this test in CI environments where WASM plugin is not available
|
||||
if os.Getenv("CI") == "true" || os.Getenv("GITHUB_ACTIONS") == "true" {
|
||||
t.Skip("skipping WASM enclave test in CI environment")
|
||||
}
|
||||
|
||||
// Generate test enclave data
|
||||
enclaveData := generateTestEnclaveData(t)
|
||||
|
||||
// Create enclave config with test data
|
||||
config := plugin.DefaultEnclaveConfig()
|
||||
config.EnclaveData = enclaveData
|
||||
|
||||
// Create actor system
|
||||
system := actor.NewActorSystem()
|
||||
defer system.Shutdown()
|
||||
|
||||
// Create real UCAN actor with the enclave configuration
|
||||
realActor := system.Root.Spawn(plugin.PropsWithConfig(config))
|
||||
|
||||
// Wait for actor to initialize (longer wait for CI environments)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Test creating a UCAN token through the actor
|
||||
req := &plugin.NewOriginTokenRequest{
|
||||
AudienceDID: "did:sonr:test-audience",
|
||||
Attenuations: []map[string]any{
|
||||
{"can": []string{"read"}, "with": "vault://test"},
|
||||
},
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
||||
}
|
||||
|
||||
// Send request to actor with enhanced error handling and longer timeout for CI
|
||||
result := system.Root.RequestFuture(realActor, req, 10*time.Second)
|
||||
response, err := result.Result()
|
||||
if err != nil {
|
||||
t.Logf("Actor request failed: %v", err)
|
||||
|
||||
// If it's a timeout or WASM-related error, that's expected in test environments
|
||||
if err.Error() == "future: timeout" || strings.Contains(err.Error(), "wasm") {
|
||||
t.Skip("skipping test due to WASM enclave not available in test environment")
|
||||
}
|
||||
}
|
||||
|
||||
// The response could be an error if the actor didn't initialize properly
|
||||
// For now, just verify we got some response (could be error or success) - but only if not a timeout
|
||||
if err == nil || err.Error() != "future: timeout" {
|
||||
assert.NotNil(t, response, "should receive some response from actor")
|
||||
}
|
||||
|
||||
// Check if this is a WASM-related error (expected when WASM plugin is not available)
|
||||
if err == nil && response != nil {
|
||||
// The response could be a string or an error
|
||||
responseStr := ""
|
||||
if errResponse, ok := response.(error); ok {
|
||||
responseStr = errResponse.Error()
|
||||
} else if strResponse, ok := response.(string); ok {
|
||||
responseStr = strResponse
|
||||
} else {
|
||||
responseStr = fmt.Sprintf("%+v", response)
|
||||
}
|
||||
|
||||
if responseStr != "" && (strings.Contains(responseStr, "wasm error: unreachable") ||
|
||||
strings.Contains(responseStr, "wasm not available") ||
|
||||
strings.Contains(responseStr, "runtime.notInitialized")) {
|
||||
t.Log("WASM plugin not available (expected in test environment)")
|
||||
} else {
|
||||
t.Logf("Actor successfully processed request: %+v", response)
|
||||
}
|
||||
} else {
|
||||
t.Logf("Actor response: %+v, error: %v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidJSONPayload(t *testing.T) {
|
||||
// Create processor
|
||||
processor := NewUCANProcessor()
|
||||
|
||||
// Create task with invalid JSON
|
||||
task := asynq.NewTask(TypeUCANToken, []byte("invalid json"))
|
||||
|
||||
// Process the task - should fail with skip retry
|
||||
err := processor.ProcessTask(context.Background(), task)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "json.Unmarshal failed")
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Package tasks provides UCAN-based tasks for the MPC enclave service.
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/asynkron/protoactor-go/actor"
|
||||
)
|
||||
|
||||
var system = actor.NewActorSystem()
|
||||
|
||||
const KRequestTimeout = 20 * time.Second
|
||||
|
||||
// A list of UCAN-based task types.
|
||||
const (
|
||||
TypeUCANToken = "ucan:token" // Create UCAN origin tokens
|
||||
TypeUCANAttenuation = "ucan:attenuation" // Create attenuated UCAN tokens
|
||||
TypeUCANSign = "ucan:sign" // MPC-based data signing
|
||||
TypeUCANVerify = "ucan:verify" // MPC-based signature verification
|
||||
TypeUCANDIDGeneration = "ucan:did:generation" // DID generation from MPC enclave
|
||||
TypeUCANTokenValidation = "ucan:token:validation" // UCAN token validation
|
||||
)
|
||||
@@ -1,101 +0,0 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/sonr-io/sonr/types/ipfs"
|
||||
)
|
||||
|
||||
// MockIPFSClient provides a test implementation of IPFSClient
|
||||
type MockIPFSClient struct{}
|
||||
|
||||
func (m *MockIPFSClient) Add(data []byte) (string, error) {
|
||||
return "mock-cid", nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) AddFile(file ipfs.File) (string, error) {
|
||||
return "mock-file-cid", nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) AddFolder(folder ipfs.Folder) (string, error) {
|
||||
return "mock-folder-cid", nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) Get(cid string) ([]byte, error) {
|
||||
return []byte("mock-ipfs-data"), nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) GetFile(cid string) (ipfs.File, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) GetFolder(cid string) (ipfs.Folder, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) Pin(cid string, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) Unpin(cid string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) Exists(cid string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) IsPinned(ipns string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) Ls(cid string) ([]string, error) {
|
||||
return []string{"mock-file1", "mock-file2"}, nil
|
||||
}
|
||||
|
||||
func (m *MockIPFSClient) NodeStatus() (*ipfs.NodeStatus, error) {
|
||||
return &ipfs.NodeStatus{
|
||||
PeerID: "mock-peer-id",
|
||||
Version: "mock-version",
|
||||
PeerType: "kubo",
|
||||
ConnectedPeers: 5,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AsynqClientInterface defines the interface we need for testing
|
||||
type AsynqClientInterface interface {
|
||||
Enqueue(task *asynq.Task, opts ...asynq.Option) (*asynq.TaskInfo, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// MockAsynqClient provides a test double for asynq.Client
|
||||
type MockAsynqClient struct {
|
||||
enqueuedTasks []MockTask
|
||||
}
|
||||
|
||||
type MockTask struct {
|
||||
Type string
|
||||
Payload []byte
|
||||
Queue string
|
||||
}
|
||||
|
||||
func (m *MockAsynqClient) Enqueue(task *asynq.Task, opts ...asynq.Option) (*asynq.TaskInfo, error) {
|
||||
mockTask := MockTask{
|
||||
Type: task.Type(),
|
||||
Payload: task.Payload(),
|
||||
Queue: "default", // Default queue
|
||||
}
|
||||
|
||||
// For testing purposes, we'll just use the default queue
|
||||
// In real implementation, options would be parsed properly
|
||||
m.enqueuedTasks = append(m.enqueuedTasks, mockTask)
|
||||
|
||||
return &asynq.TaskInfo{
|
||||
ID: "test-task-id",
|
||||
Type: task.Type(),
|
||||
Queue: mockTask.Queue,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockAsynqClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
DOCKER := $(shell which docker)
|
||||
PROJECT_NAME = sonr-client-sdk
|
||||
|
||||
# golangci-lint Docker image version
|
||||
GOLANGCI_VERSION := v2.3.1
|
||||
|
||||
###############################################################################
|
||||
### Build ###
|
||||
###############################################################################
|
||||
|
||||
build:
|
||||
@gum log --level info "Building Go client SDK..."
|
||||
@go build ./...
|
||||
|
||||
###############################################################################
|
||||
### Formatting ###
|
||||
###############################################################################
|
||||
|
||||
format fmt:
|
||||
@gum log --level info "Formatting Go code with golangci-lint Docker..."
|
||||
@$(DOCKER) run --rm -t -v $$(pwd):/app -w /app \
|
||||
--user $$(id -u):$$(id -g) \
|
||||
-v $$(go env GOCACHE):/.cache/go-build -e GOCACHE=/.cache/go-build \
|
||||
-v $$(go env GOMODCACHE):/.cache/mod -e GOMODCACHE=/.cache/mod \
|
||||
-v ~/.cache/golangci-lint:/.cache/golangci-lint -e GOLANGCI_LINT_CACHE=/.cache/golangci-lint \
|
||||
golangci/golangci-lint:$(GOLANGCI_VERSION) golangci-lint run --fix --issues-exit-code=0
|
||||
|
||||
###############################################################################
|
||||
### Linting ###
|
||||
###############################################################################
|
||||
|
||||
lint:
|
||||
@gum log --level info "Running golangci-lint Docker..."
|
||||
@$(DOCKER) run --rm -t -v $$(pwd):/app -w /app \
|
||||
--user $$(id -u):$$(id -g) \
|
||||
-v $$(go env GOCACHE):/.cache/go-build -e GOCACHE=/.cache/go-build \
|
||||
-v $$(go env GOMODCACHE):/.cache/mod -e GOMODCACHE=/.cache/mod \
|
||||
-v ~/.cache/golangci-lint:/.cache/golangci-lint -e GOLANGCI_LINT_CACHE=/.cache/golangci-lint \
|
||||
golangci/golangci-lint:$(GOLANGCI_VERSION) golangci-lint run --timeout=10m
|
||||
|
||||
###############################################################################
|
||||
### Testing ###
|
||||
###############################################################################
|
||||
|
||||
test:
|
||||
@gum log --level info "Running all tests..."
|
||||
@go test -mod=readonly -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
|
||||
test-unit:
|
||||
@gum log --level info "Running unit tests..."
|
||||
@go test -mod=readonly -short -race ./...
|
||||
|
||||
test-integration:
|
||||
@gum log --level info "Running integration tests..."
|
||||
@go test -mod=readonly -race -tags=integration ./tests/integration/...
|
||||
|
||||
test-verbose:
|
||||
@gum log --level info "Running tests with verbose output..."
|
||||
@go test -mod=readonly -v -race ./...
|
||||
|
||||
test-cover:
|
||||
@gum log --level info "Running tests with coverage..."
|
||||
@go test -mod=readonly -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
@go tool cover -html=coverage.txt -o coverage.html
|
||||
@gum log --level info "Coverage report generated: coverage.html"
|
||||
|
||||
benchmark:
|
||||
@gum log --level info "Running benchmarks..."
|
||||
@go test -mod=readonly -bench=. -benchmem ./...
|
||||
|
||||
###############################################################################
|
||||
### Dependencies ###
|
||||
###############################################################################
|
||||
|
||||
deps:
|
||||
@gum log --level info "Installing dependencies..."
|
||||
@go mod download
|
||||
|
||||
tidy:
|
||||
@gum log --level info "Tidying dependencies..."
|
||||
@go mod tidy
|
||||
|
||||
verify:
|
||||
@gum log --level info "Verifying dependencies..."
|
||||
@go mod verify
|
||||
|
||||
###############################################################################
|
||||
### Clean ###
|
||||
###############################################################################
|
||||
|
||||
clean:
|
||||
@gum log --level info "Cleaning build artifacts..."
|
||||
@rm -f coverage.txt coverage.html
|
||||
@go clean -cache
|
||||
|
||||
###############################################################################
|
||||
### Help ###
|
||||
###############################################################################
|
||||
|
||||
help:
|
||||
@gum log --level info "Available targets:"
|
||||
@gum log --level info " build - Build the Go client SDK"
|
||||
@gum log --level info " format/fmt - Format Go code using golangci-lint"
|
||||
@gum log --level info " lint - Run golangci-lint"
|
||||
@gum log --level info " test - Run all tests with coverage"
|
||||
@gum log --level info " test-unit - Run unit tests only"
|
||||
@gum log --level info " test-integration - Run integration tests"
|
||||
@gum log --level info " test-verbose - Run tests with verbose output"
|
||||
@gum log --level info " test-cover - Run tests and generate HTML coverage report"
|
||||
@gum log --level info " benchmark - Run benchmarks"
|
||||
@gum log --level info " deps - Download dependencies"
|
||||
@gum log --level info " tidy - Tidy and verify dependencies"
|
||||
@gum log --level info " verify - Verify dependencies"
|
||||
@gum log --level info " clean - Clean build artifacts"
|
||||
@gum log --level info " help - Show this help message"
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
.PHONY: build format fmt lint test test-unit test-integration test-verbose test-cover benchmark deps tidy verify clean help
|
||||
@@ -1,169 +0,0 @@
|
||||
# Sonr Go Client SDK
|
||||
|
||||
The official Go client SDK for the Sonr blockchain, providing idiomatic Go interfaces for interacting with Sonr's decentralized identity, data storage, and service management features.
|
||||
|
||||
## Features
|
||||
|
||||
- **Blockchain Interaction**: Query chain state and broadcast transactions
|
||||
- **Module Support**: Comprehensive support for DID, DWN, SVC, and UCAN modules
|
||||
- **WebAuthn Integration**: Passwordless authentication with hardware-backed keys
|
||||
- **Transaction Building**: Simplified transaction construction and broadcasting
|
||||
- **Key Management**: Secure keyring integration with multiple backends
|
||||
- **Network Configuration**: Pre-configured endpoints for testnet and mainnet
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/sonr-io/sonr/client
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic SDK Setup
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
client "github.com/sonr-io/sonr/client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize SDK with testnet configuration
|
||||
sdk, err := client.NewWithNetwork("testnet")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer sdk.Close()
|
||||
|
||||
// Or create with custom configuration
|
||||
cfg := client.DefaultConfig()
|
||||
cfg.Network.GRPCEndpoint = "localhost:9090"
|
||||
sdk, err = client.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Check connection
|
||||
if !sdk.IsConnected() {
|
||||
log.Fatal("Failed to connect to network")
|
||||
}
|
||||
|
||||
// Access the underlying Sonr client
|
||||
sonrClient := sdk.Client()
|
||||
|
||||
// Query chain info
|
||||
ctx := context.Background()
|
||||
info, err := sonrClient.Query().GetNodeInfo(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Printf("Connected to chain: %s", info.Network)
|
||||
log.Printf("SDK Version: %s", client.Version())
|
||||
}
|
||||
```
|
||||
|
||||
### Transaction Broadcasting
|
||||
|
||||
```go
|
||||
// Get the client from SDK
|
||||
sonrClient := sdk.Client()
|
||||
|
||||
// Create and broadcast a transaction
|
||||
tx := sonrClient.Transaction().
|
||||
WithChainID("sonrtest_1-1").
|
||||
WithGasPrice(0.001, "usnr").
|
||||
WithMemo("Hello Sonr!")
|
||||
|
||||
// Add messages to transaction
|
||||
tx.AddMessage(/* your message */)
|
||||
|
||||
// Sign and broadcast
|
||||
result, err := tx.SignAndBroadcast(ctx, keyring)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Printf("Transaction hash: %s", result.TxHash)
|
||||
```
|
||||
|
||||
### Module-Specific Operations
|
||||
|
||||
```go
|
||||
// Get the client from SDK
|
||||
sonrClient := sdk.Client()
|
||||
|
||||
// DID operations
|
||||
didClient := sonrClient.DID()
|
||||
did, err := didClient.CreateDID(ctx, didOpts)
|
||||
|
||||
// DWN operations
|
||||
dwnClient := sonrClient.DWN()
|
||||
record, err := dwnClient.CreateRecord(ctx, recordData)
|
||||
|
||||
// Service operations
|
||||
svcClient := sonrClient.SVC()
|
||||
service, err := svcClient.RegisterService(ctx, serviceConfig)
|
||||
|
||||
// UCAN operations
|
||||
ucanClient := sonrClient.UCAN()
|
||||
capability, err := ucanClient.CreateCapability(ctx, capabilitySpec)
|
||||
```
|
||||
|
||||
## API Overview
|
||||
|
||||
### Core Client (`sonr.Client`)
|
||||
|
||||
The main client provides access to:
|
||||
|
||||
- **Query operations**: Read blockchain state
|
||||
- **Transaction building**: Create and broadcast transactions
|
||||
- **Module clients**: Access to specialized functionality
|
||||
- **Connection management**: Handle multiple endpoint types
|
||||
|
||||
### Module Clients
|
||||
|
||||
- **DID Client**: W3C DID operations with WebAuthn support
|
||||
- **DWN Client**: Decentralized Web Node data management
|
||||
- **SVC Client**: Service registration and management
|
||||
- **UCAN Client**: User-Controlled Authorization Networks
|
||||
|
||||
### Key Management
|
||||
|
||||
- **Keyring integration**: Support for multiple keyring backends
|
||||
- **Hardware keys**: WebAuthn and hardware wallet support
|
||||
- **Multi-signature**: Threshold signature schemes
|
||||
|
||||
### Network Configuration
|
||||
|
||||
Pre-configured networks:
|
||||
|
||||
- **Testnet**: `sonrtest_1-1` with development endpoints
|
||||
- **Mainnet**: Production network configuration (coming soon)
|
||||
|
||||
## Examples
|
||||
|
||||
See the `examples/` directory for complete working examples:
|
||||
|
||||
- **CLI Tool**: Example command-line application
|
||||
- **Web Integration**: HTTP API server
|
||||
- **Key Management**: Keyring and signing examples
|
||||
- **Module Operations**: Comprehensive module usage
|
||||
|
||||
## Documentation
|
||||
|
||||
- [API Reference](https://pkg.go.dev/github.com/sonr-io/sonr/client)
|
||||
- [Sonr Documentation](https://sonr.dev)
|
||||
- [Blockchain Guide](https://sonr.dev/blockchain)
|
||||
|
||||
## Contributing
|
||||
|
||||
This SDK is part of the main Sonr repository. Please see the main project's contributing guidelines.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the Apache 2.0 License.
|
||||
@@ -1,363 +0,0 @@
|
||||
// Package auth provides gasless transaction support for WebAuthn operations.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
"github.com/cosmos/cosmos-sdk/types/tx/signing"
|
||||
|
||||
"github.com/sonr-io/sonr/client/config"
|
||||
"github.com/sonr-io/sonr/client/errors"
|
||||
"github.com/sonr-io/sonr/client/tx"
|
||||
didtypes "github.com/sonr-io/sonr/x/did/types"
|
||||
)
|
||||
|
||||
// GaslessTransactionManager handles gasless transactions for WebAuthn.
|
||||
type GaslessTransactionManager interface {
|
||||
// CreateGaslessRegistration creates a gasless WebAuthn registration transaction.
|
||||
CreateGaslessRegistration(ctx context.Context, credential *WebAuthnCredential, opts *GaslessRegistrationOptions) (*GaslessTransaction, error)
|
||||
|
||||
// BroadcastGasless broadcasts a gasless transaction.
|
||||
BroadcastGasless(ctx context.Context, tx *GaslessTransaction) (*BroadcastResult, error)
|
||||
|
||||
// IsEligibleForGasless checks if a transaction is eligible for gasless processing.
|
||||
IsEligibleForGasless(msgs []sdk.Msg) bool
|
||||
|
||||
// EstimateGaslessGas estimates gas for a gasless transaction.
|
||||
EstimateGaslessGas(msgType string) uint64
|
||||
}
|
||||
|
||||
// GaslessRegistrationOptions configures gasless WebAuthn registration.
|
||||
type GaslessRegistrationOptions struct {
|
||||
Username string `json:"username"`
|
||||
AutoCreateVault bool `json:"auto_create_vault"`
|
||||
WebAuthnChallenge []byte `json:"webauthn_challenge"`
|
||||
DIDDocument map[string]any `json:"did_document,omitempty"`
|
||||
}
|
||||
|
||||
// GaslessTransaction represents a gasless transaction.
|
||||
type GaslessTransaction struct {
|
||||
Messages []sdk.Msg `json:"messages"`
|
||||
Memo string `json:"memo"`
|
||||
GasLimit uint64 `json:"gas_limit"`
|
||||
SignerAddress string `json:"signer_address"`
|
||||
SignMode signing.SignMode `json:"sign_mode"`
|
||||
TxBytes []byte `json:"tx_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// BroadcastResult contains the result of broadcasting a transaction.
|
||||
type BroadcastResult struct {
|
||||
TxHash string `json:"tx_hash"`
|
||||
Height int64 `json:"height"`
|
||||
Code uint32 `json:"code"`
|
||||
RawLog string `json:"raw_log"`
|
||||
GasUsed int64 `json:"gas_used"`
|
||||
GasWanted int64 `json:"gas_wanted"`
|
||||
}
|
||||
|
||||
// gaslessManager implements GaslessTransactionManager.
|
||||
type gaslessManager struct {
|
||||
txBuilder tx.TxBuilder
|
||||
broadcaster tx.Broadcaster
|
||||
config *config.NetworkConfig
|
||||
}
|
||||
|
||||
// NewGaslessTransactionManager creates a new gasless transaction manager.
|
||||
func NewGaslessTransactionManager(
|
||||
txBuilder tx.TxBuilder,
|
||||
broadcaster tx.Broadcaster,
|
||||
cfg *config.NetworkConfig,
|
||||
) GaslessTransactionManager {
|
||||
return &gaslessManager{
|
||||
txBuilder: txBuilder,
|
||||
broadcaster: broadcaster,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateGaslessRegistration creates a gasless WebAuthn registration transaction.
|
||||
func (gm *gaslessManager) CreateGaslessRegistration(
|
||||
ctx context.Context,
|
||||
credential *WebAuthnCredential,
|
||||
opts *GaslessRegistrationOptions,
|
||||
) (*GaslessTransaction, error) {
|
||||
// Generate deterministic address from WebAuthn credential
|
||||
signerAddr := gm.generateAddressFromWebAuthn(credential)
|
||||
|
||||
// Convert credential ID to base64 string
|
||||
credentialID := base64.RawURLEncoding.EncodeToString(credential.RawID)
|
||||
|
||||
// Create WebAuthn credential for the message
|
||||
webauthnCred := didtypes.WebAuthnCredential{
|
||||
CredentialId: credentialID,
|
||||
PublicKey: credential.PublicKey,
|
||||
AttestationType: credential.AttestationType,
|
||||
Origin: "http://localhost", // Default origin
|
||||
Algorithm: -7, // ES256 algorithm
|
||||
CreatedAt: time.Now().Unix(),
|
||||
RpId: "localhost",
|
||||
RpName: "Sonr Local",
|
||||
Transports: credential.Transports,
|
||||
UserVerified: false, // Default to false for gasless
|
||||
}
|
||||
|
||||
// Set user verification if flags are available
|
||||
if credential.Flags != nil {
|
||||
webauthnCred.UserVerified = credential.Flags.UserVerified
|
||||
}
|
||||
|
||||
// Create the registration message
|
||||
msg := &didtypes.MsgRegisterWebAuthnCredential{
|
||||
Controller: signerAddr.String(),
|
||||
Username: opts.Username,
|
||||
WebauthnCredential: webauthnCred,
|
||||
AutoCreateVault: opts.AutoCreateVault,
|
||||
}
|
||||
|
||||
// Create gasless transaction
|
||||
gaslessTx := &GaslessTransaction{
|
||||
Messages: []sdk.Msg{msg},
|
||||
Memo: "WebAuthn Gasless Registration",
|
||||
GasLimit: 200000, // Fixed gas limit for WebAuthn registration
|
||||
SignerAddress: signerAddr.String(),
|
||||
SignMode: signing.SignMode_SIGN_MODE_DIRECT,
|
||||
}
|
||||
|
||||
// Build the transaction using fluent interface
|
||||
gm.txBuilder = gm.txBuilder.
|
||||
ClearMessages().
|
||||
AddMessage(msg).
|
||||
WithMemo(gaslessTx.Memo).
|
||||
WithGasLimit(gaslessTx.GasLimit).
|
||||
WithFee(sdk.NewCoins()) // Zero fees for gasless
|
||||
|
||||
// Build unsigned transaction
|
||||
unsignedTx, err := gm.txBuilder.Build()
|
||||
if err != nil {
|
||||
return nil, errors.WrapError(err, errors.ErrInvalidTransaction, "failed to build gasless transaction")
|
||||
}
|
||||
|
||||
// Store transaction bytes
|
||||
gaslessTx.TxBytes = unsignedTx.SignBytes
|
||||
|
||||
return gaslessTx, nil
|
||||
}
|
||||
|
||||
// BroadcastGasless broadcasts a gasless transaction.
|
||||
func (gm *gaslessManager) BroadcastGasless(ctx context.Context, tx *GaslessTransaction) (*BroadcastResult, error) {
|
||||
// Broadcast the transaction using sync mode
|
||||
resp, err := gm.broadcaster.BroadcastSync(ctx, tx.TxBytes)
|
||||
if err != nil {
|
||||
return nil, errors.WrapError(err, errors.ErrBroadcastFailed, "failed to broadcast gasless transaction")
|
||||
}
|
||||
|
||||
result := &BroadcastResult{
|
||||
TxHash: resp.TxHash,
|
||||
Height: resp.Height,
|
||||
Code: resp.Code,
|
||||
RawLog: resp.Log,
|
||||
GasUsed: resp.GasUsed,
|
||||
GasWanted: resp.GasWanted,
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
if resp.Code != 0 {
|
||||
return result, fmt.Errorf("transaction failed with code %d: %s", resp.Code, resp.Log)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IsEligibleForGasless checks if a transaction is eligible for gasless processing.
|
||||
func (gm *gaslessManager) IsEligibleForGasless(msgs []sdk.Msg) bool {
|
||||
// Only single message transactions are eligible
|
||||
if len(msgs) != 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check message type
|
||||
msgType := sdk.MsgTypeURL(msgs[0])
|
||||
|
||||
// WebAuthn registration is gasless
|
||||
if msgType == "/did.v1.MsgRegisterWebAuthnCredential" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Future: Add other gasless message types here
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// EstimateGaslessGas estimates gas for a gasless transaction.
|
||||
func (gm *gaslessManager) EstimateGaslessGas(msgType string) uint64 {
|
||||
switch msgType {
|
||||
case "/did.v1.MsgRegisterWebAuthnCredential":
|
||||
return 200000 // Fixed gas for WebAuthn registration
|
||||
default:
|
||||
return 100000 // Default gas estimate
|
||||
}
|
||||
}
|
||||
|
||||
// generateAddressFromWebAuthn generates a deterministic address from WebAuthn credential.
|
||||
func (gm *gaslessManager) generateAddressFromWebAuthn(credential *WebAuthnCredential) sdk.AccAddress {
|
||||
// Use the credential ID as seed for address generation
|
||||
// This ensures the same credential always generates the same address
|
||||
|
||||
// In production, this would use a proper derivation scheme
|
||||
// For now, we'll use the first 20 bytes of the credential ID
|
||||
addrBytes := make([]byte, 20)
|
||||
copy(addrBytes, credential.RawID[:min(20, len(credential.RawID))])
|
||||
|
||||
return sdk.AccAddress(addrBytes)
|
||||
}
|
||||
|
||||
// Helper function for min
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// WebAuthnGaslessClient provides a high-level interface for gasless WebAuthn operations.
|
||||
type WebAuthnGaslessClient struct {
|
||||
webauthnClient WebAuthnClient
|
||||
gaslessManager GaslessTransactionManager
|
||||
config *config.NetworkConfig
|
||||
}
|
||||
|
||||
// NewWebAuthnGaslessClient creates a new WebAuthn gasless client.
|
||||
func NewWebAuthnGaslessClient(
|
||||
webauthnClient WebAuthnClient,
|
||||
gaslessManager GaslessTransactionManager,
|
||||
cfg *config.NetworkConfig,
|
||||
) *WebAuthnGaslessClient {
|
||||
return &WebAuthnGaslessClient{
|
||||
webauthnClient: webauthnClient,
|
||||
gaslessManager: gaslessManager,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterGasless performs gasless WebAuthn registration.
|
||||
func (wgc *WebAuthnGaslessClient) RegisterGasless(
|
||||
ctx context.Context,
|
||||
username string,
|
||||
displayName string,
|
||||
) (*GaslessRegistrationResult, error) {
|
||||
// Create registration options
|
||||
regOpts := &RegistrationOptions{
|
||||
Username: username,
|
||||
DisplayName: displayName,
|
||||
Timeout: 60000,
|
||||
UserVerification: "preferred",
|
||||
AttestationType: "none",
|
||||
}
|
||||
|
||||
// Begin WebAuthn registration
|
||||
challenge, err := wgc.webauthnClient.BeginRegistration(ctx, regOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return challenge for browser to complete
|
||||
// The actual credential will be created by the browser
|
||||
result := &GaslessRegistrationResult{
|
||||
Challenge: challenge,
|
||||
GaslessEligible: true,
|
||||
EstimatedGas: wgc.gaslessManager.EstimateGaslessGas("/did.v1.MsgRegisterWebAuthnCredential"),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CompleteGaslessRegistration completes the gasless registration after browser response.
|
||||
func (wgc *WebAuthnGaslessClient) CompleteGaslessRegistration(
|
||||
ctx context.Context,
|
||||
challenge *RegistrationChallenge,
|
||||
response *AuthenticatorAttestationResponse,
|
||||
username string,
|
||||
autoCreateVault bool,
|
||||
) (*BroadcastResult, error) {
|
||||
// Complete WebAuthn registration to get credential
|
||||
credential, err := wgc.webauthnClient.CompleteRegistration(ctx, challenge, response)
|
||||
if err != nil {
|
||||
// For now, create a mock credential since CompleteRegistration is not fully implemented
|
||||
// In production, this would properly parse the attestation response
|
||||
credential = &WebAuthnCredential{
|
||||
ID: base64.URLEncoding.EncodeToString(response.AttestationObject[:32]),
|
||||
RawID: response.AttestationObject[:32],
|
||||
PublicKey: response.AttestationObject[32:64], // Mock public key
|
||||
AttestationType: "none",
|
||||
Flags: &AuthenticatorFlags{
|
||||
UserPresent: true,
|
||||
UserVerified: true,
|
||||
},
|
||||
Authenticator: &AuthenticatorData{
|
||||
RPIDHash: challenge.Challenge,
|
||||
},
|
||||
UserID: string(challenge.User.ID),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// Create gasless registration options
|
||||
gaslessOpts := &GaslessRegistrationOptions{
|
||||
Username: username,
|
||||
AutoCreateVault: autoCreateVault,
|
||||
WebAuthnChallenge: challenge.Challenge,
|
||||
}
|
||||
|
||||
// Create gasless transaction
|
||||
gaslessTx, err := wgc.gaslessManager.CreateGaslessRegistration(ctx, credential, gaslessOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Broadcast gasless transaction
|
||||
return wgc.gaslessManager.BroadcastGasless(ctx, gaslessTx)
|
||||
}
|
||||
|
||||
// GaslessRegistrationResult contains the result of initiating gasless registration.
|
||||
type GaslessRegistrationResult struct {
|
||||
Challenge *RegistrationChallenge `json:"challenge"`
|
||||
GaslessEligible bool `json:"gasless_eligible"`
|
||||
EstimatedGas uint64 `json:"estimated_gas"`
|
||||
}
|
||||
|
||||
// ValidateGaslessEligibility validates if a user is eligible for gasless transactions.
|
||||
func ValidateGaslessEligibility(credential *WebAuthnCredential) error {
|
||||
// Validate credential is not nil
|
||||
if credential == nil {
|
||||
return fmt.Errorf("credential cannot be nil")
|
||||
}
|
||||
|
||||
// Validate credential has required fields
|
||||
if len(credential.RawID) == 0 {
|
||||
return fmt.Errorf("credential ID cannot be empty")
|
||||
}
|
||||
|
||||
if len(credential.PublicKey) == 0 {
|
||||
return fmt.Errorf("public key cannot be empty")
|
||||
}
|
||||
|
||||
// Validate user presence and verification
|
||||
if credential.Flags != nil {
|
||||
if !credential.Flags.UserPresent {
|
||||
return fmt.Errorf("user presence is required for gasless transactions")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGaslessEndpoint returns the gasless transaction endpoint for the network.
|
||||
func GetGaslessEndpoint(cfg *config.NetworkConfig) string {
|
||||
// For now, gasless transactions use the same RPC endpoint
|
||||
// In the future, this could be a separate endpoint
|
||||
return cfg.RPC
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
"github.com/cosmos/cosmos-sdk/types/tx/signing"
|
||||
|
||||
"github.com/sonr-io/sonr/client/config"
|
||||
"github.com/sonr-io/sonr/client/keys"
|
||||
"github.com/sonr-io/sonr/client/tx"
|
||||
didtypes "github.com/sonr-io/sonr/x/did/types"
|
||||
)
|
||||
|
||||
// MockTxBuilder implements tx.TxBuilder for testing.
|
||||
type MockTxBuilder struct {
|
||||
messages []sdk.Msg
|
||||
config *tx.TxConfig
|
||||
}
|
||||
|
||||
func NewMockTxBuilder() *MockTxBuilder {
|
||||
return &MockTxBuilder{
|
||||
messages: make([]sdk.Msg, 0),
|
||||
config: &tx.TxConfig{
|
||||
ChainID: "test-chain",
|
||||
GasPrice: 0.001,
|
||||
GasDenom: "usnr",
|
||||
GasLimit: 200000,
|
||||
GasAdjustment: 1.5,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) WithChainID(chainID string) tx.TxBuilder {
|
||||
m.config.ChainID = chainID
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) WithGasPrice(price float64, denom string) tx.TxBuilder {
|
||||
m.config.GasPrice = price
|
||||
m.config.GasDenom = denom
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) WithGasLimit(limit uint64) tx.TxBuilder {
|
||||
m.config.GasLimit = limit
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) WithMemo(memo string) tx.TxBuilder {
|
||||
m.config.Memo = memo
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) WithTimeoutHeight(height uint64) tx.TxBuilder {
|
||||
m.config.TimeoutHeight = height
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) AddMessage(msg sdk.Msg) tx.TxBuilder {
|
||||
m.messages = append(m.messages, msg)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) AddMessages(msgs ...sdk.Msg) tx.TxBuilder {
|
||||
m.messages = append(m.messages, msgs...)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) ClearMessages() tx.TxBuilder {
|
||||
m.messages = make([]sdk.Msg, 0)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) WithFee(amount sdk.Coins) tx.TxBuilder {
|
||||
m.config.Fee = amount
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) WithGasAdjustment(adjustment float64) tx.TxBuilder {
|
||||
m.config.GasAdjustment = adjustment
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) EstimateGas(ctx context.Context) (uint64, error) {
|
||||
return 200000, nil
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) Sign(ctx context.Context, keyring keys.KeyringManager) (*tx.SignedTx, error) {
|
||||
unsignedTx, _ := m.Build()
|
||||
return &tx.SignedTx{
|
||||
UnsignedTx: unsignedTx,
|
||||
Signature: []byte("mock-signature"),
|
||||
PubKey: []byte("mock-pubkey"),
|
||||
TxBytes: []byte("mock-tx-bytes"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) SignAndBroadcast(ctx context.Context, keyring keys.KeyringManager) (*tx.BroadcastResult, error) {
|
||||
return &tx.BroadcastResult{
|
||||
TxHash: "mock-tx-hash",
|
||||
Height: 12345,
|
||||
Code: 0,
|
||||
Log: "success",
|
||||
GasUsed: 100000,
|
||||
GasWanted: 200000,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) Broadcast(ctx context.Context, signedTx *tx.SignedTx) (*tx.BroadcastResult, error) {
|
||||
return &tx.BroadcastResult{
|
||||
TxHash: "mock-tx-hash",
|
||||
Height: 12345,
|
||||
Code: 0,
|
||||
Log: "success",
|
||||
GasUsed: 100000,
|
||||
GasWanted: 200000,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) Simulate(ctx context.Context) (*tx.SimulateResult, error) {
|
||||
return &tx.SimulateResult{
|
||||
GasWanted: 200000,
|
||||
GasUsed: 100000,
|
||||
Log: "simulation success",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) Build() (*tx.UnsignedTx, error) {
|
||||
return &tx.UnsignedTx{
|
||||
Messages: m.messages,
|
||||
Config: m.config,
|
||||
SignBytes: []byte("mock-sign-bytes"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) BuildSigned(signature []byte, pubKey []byte) (*tx.SignedTx, error) {
|
||||
unsignedTx, _ := m.Build()
|
||||
return &tx.SignedTx{
|
||||
UnsignedTx: unsignedTx,
|
||||
Signature: signature,
|
||||
PubKey: pubKey,
|
||||
TxBytes: append(unsignedTx.SignBytes, signature...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockTxBuilder) Config() *tx.TxConfig {
|
||||
return m.config
|
||||
}
|
||||
|
||||
// MockBroadcaster implements tx.Broadcaster for testing.
|
||||
type MockBroadcaster struct {
|
||||
broadcastedTxs [][]byte
|
||||
shouldFail bool
|
||||
}
|
||||
|
||||
func NewMockBroadcaster() *MockBroadcaster {
|
||||
return &MockBroadcaster{
|
||||
broadcastedTxs: make([][]byte, 0),
|
||||
shouldFail: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockBroadcaster) Broadcast(ctx context.Context, txBytes []byte, mode tx.BroadcastMode) (*tx.BroadcastResult, error) {
|
||||
m.broadcastedTxs = append(m.broadcastedTxs, txBytes)
|
||||
|
||||
if m.shouldFail {
|
||||
return &tx.BroadcastResult{
|
||||
Code: 1,
|
||||
Log: "mock error",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &tx.BroadcastResult{
|
||||
TxHash: "mock-tx-hash",
|
||||
Height: 12345,
|
||||
Code: 0,
|
||||
Log: "success",
|
||||
GasUsed: 100000,
|
||||
GasWanted: 200000,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockBroadcaster) BroadcastSync(ctx context.Context, txBytes []byte) (*tx.BroadcastResult, error) {
|
||||
return m.Broadcast(ctx, txBytes, tx.BroadcastModeSync)
|
||||
}
|
||||
|
||||
func (m *MockBroadcaster) BroadcastAsync(ctx context.Context, txBytes []byte) (*tx.BroadcastResult, error) {
|
||||
return m.Broadcast(ctx, txBytes, tx.BroadcastModeAsync)
|
||||
}
|
||||
|
||||
func (m *MockBroadcaster) BroadcastBlock(ctx context.Context, txBytes []byte) (*tx.BroadcastResult, error) {
|
||||
return m.Broadcast(ctx, txBytes, tx.BroadcastModeBlock)
|
||||
}
|
||||
|
||||
func (m *MockBroadcaster) BroadcastWithRetry(ctx context.Context, txBytes []byte, mode tx.BroadcastMode, maxRetries int) (*tx.BroadcastResult, error) {
|
||||
return m.Broadcast(ctx, txBytes, mode)
|
||||
}
|
||||
|
||||
func (m *MockBroadcaster) WaitForConfirmation(ctx context.Context, txHash string, timeout time.Duration) (*tx.TxConfirmation, error) {
|
||||
return &tx.TxConfirmation{
|
||||
TxHash: txHash,
|
||||
BlockHeight: 12345,
|
||||
BlockTime: time.Now(),
|
||||
Code: 0,
|
||||
Log: "confirmed",
|
||||
GasWanted: 200000,
|
||||
GasUsed: 100000,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockBroadcaster) WithRetryConfig(config tx.RetryConfig) tx.Broadcaster {
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *MockBroadcaster) WithTimeout(timeout time.Duration) tx.Broadcaster {
|
||||
return m
|
||||
}
|
||||
|
||||
// GaslessTestSuite tests gasless transaction functionality.
|
||||
type GaslessTestSuite struct {
|
||||
suite.Suite
|
||||
manager GaslessTransactionManager
|
||||
txBuilder tx.TxBuilder
|
||||
broadcaster tx.Broadcaster
|
||||
config *config.NetworkConfig
|
||||
}
|
||||
|
||||
func (suite *GaslessTestSuite) SetupTest() {
|
||||
cfg := config.LocalNetwork()
|
||||
suite.config = &cfg
|
||||
suite.txBuilder = NewMockTxBuilder()
|
||||
suite.broadcaster = NewMockBroadcaster()
|
||||
suite.manager = NewGaslessTransactionManager(
|
||||
suite.txBuilder,
|
||||
suite.broadcaster,
|
||||
suite.config,
|
||||
)
|
||||
}
|
||||
|
||||
func (suite *GaslessTestSuite) TestCreateGaslessRegistration() {
|
||||
// Create test WebAuthn credential
|
||||
credential := &WebAuthnCredential{
|
||||
ID: "test-credential-id",
|
||||
RawID: []byte("test-raw-id"),
|
||||
PublicKey: []byte("test-public-key"),
|
||||
AttestationType: "none",
|
||||
Transports: []string{"usb"},
|
||||
Flags: &AuthenticatorFlags{
|
||||
UserPresent: true,
|
||||
UserVerified: true,
|
||||
},
|
||||
Authenticator: &AuthenticatorData{
|
||||
RPIDHash: []byte("test-rp-id-hash"),
|
||||
},
|
||||
}
|
||||
|
||||
// Create options
|
||||
opts := &GaslessRegistrationOptions{
|
||||
Username: "testuser",
|
||||
AutoCreateVault: true,
|
||||
WebAuthnChallenge: []byte("test-challenge"),
|
||||
}
|
||||
|
||||
// Create gasless registration
|
||||
gaslessTx, err := suite.manager.CreateGaslessRegistration(context.Background(), credential, opts)
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().NotNil(gaslessTx)
|
||||
|
||||
// Verify transaction fields
|
||||
suite.Equal("WebAuthn Gasless Registration", gaslessTx.Memo)
|
||||
suite.Equal(uint64(200000), gaslessTx.GasLimit)
|
||||
suite.Equal(signing.SignMode_SIGN_MODE_DIRECT, gaslessTx.SignMode)
|
||||
suite.Len(gaslessTx.Messages, 1)
|
||||
|
||||
// Verify message type
|
||||
msg, ok := gaslessTx.Messages[0].(*didtypes.MsgRegisterWebAuthnCredential)
|
||||
suite.Require().True(ok)
|
||||
suite.Equal("testuser", msg.Username)
|
||||
suite.True(msg.AutoCreateVault)
|
||||
}
|
||||
|
||||
func (suite *GaslessTestSuite) TestBroadcastGasless() {
|
||||
// Create test transaction
|
||||
gaslessTx := &GaslessTransaction{
|
||||
Messages: []sdk.Msg{
|
||||
&didtypes.MsgRegisterWebAuthnCredential{
|
||||
Controller: "sonr1xyz...",
|
||||
Username: "testuser",
|
||||
},
|
||||
},
|
||||
Memo: "Test Gasless",
|
||||
GasLimit: 200000,
|
||||
SignerAddress: "sonr1xyz...",
|
||||
SignMode: signing.SignMode_SIGN_MODE_DIRECT,
|
||||
TxBytes: []byte("test-tx-bytes"),
|
||||
}
|
||||
|
||||
// Broadcast transaction
|
||||
result, err := suite.manager.BroadcastGasless(context.Background(), gaslessTx)
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().NotNil(result)
|
||||
|
||||
// Verify result
|
||||
suite.Equal("mock-tx-hash", result.TxHash)
|
||||
suite.Equal(int64(12345), result.Height)
|
||||
suite.Equal(uint32(0), result.Code)
|
||||
}
|
||||
|
||||
func (suite *GaslessTestSuite) TestIsEligibleForGasless() {
|
||||
// Test WebAuthn registration message
|
||||
webauthnMsg := &didtypes.MsgRegisterWebAuthnCredential{
|
||||
Controller: "sonr1xyz...",
|
||||
Username: "testuser",
|
||||
}
|
||||
|
||||
// Should be eligible
|
||||
suite.True(suite.manager.IsEligibleForGasless([]sdk.Msg{webauthnMsg}))
|
||||
|
||||
// Multiple messages should not be eligible
|
||||
suite.False(suite.manager.IsEligibleForGasless([]sdk.Msg{webauthnMsg, webauthnMsg}))
|
||||
|
||||
// Other message types should not be eligible
|
||||
otherMsg := &didtypes.MsgCreateDID{
|
||||
Controller: "sonr1xyz...",
|
||||
}
|
||||
suite.False(suite.manager.IsEligibleForGasless([]sdk.Msg{otherMsg}))
|
||||
}
|
||||
|
||||
func (suite *GaslessTestSuite) TestEstimateGaslessGas() {
|
||||
// Test WebAuthn registration gas estimate
|
||||
gas := suite.manager.EstimateGaslessGas("/did.v1.MsgRegisterWebAuthnCredential")
|
||||
suite.Equal(uint64(200000), gas)
|
||||
|
||||
// Test default gas estimate
|
||||
gas = suite.manager.EstimateGaslessGas("/unknown.message.type")
|
||||
suite.Equal(uint64(100000), gas)
|
||||
}
|
||||
|
||||
func TestGaslessTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(GaslessTestSuite))
|
||||
}
|
||||
|
||||
// TestValidateGaslessEligibility tests credential validation.
|
||||
func TestValidateGaslessEligibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
credential *WebAuthnCredential
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "valid credential",
|
||||
credential: &WebAuthnCredential{
|
||||
RawID: []byte("test-id"),
|
||||
PublicKey: []byte("test-key"),
|
||||
Flags: &AuthenticatorFlags{
|
||||
UserPresent: true,
|
||||
},
|
||||
},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "nil credential",
|
||||
credential: nil,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "empty credential ID",
|
||||
credential: &WebAuthnCredential{
|
||||
PublicKey: []byte("test-key"),
|
||||
Flags: &AuthenticatorFlags{
|
||||
UserPresent: true,
|
||||
},
|
||||
},
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "empty public key",
|
||||
credential: &WebAuthnCredential{
|
||||
RawID: []byte("test-id"),
|
||||
Flags: &AuthenticatorFlags{
|
||||
UserPresent: true,
|
||||
},
|
||||
},
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "no user presence",
|
||||
credential: &WebAuthnCredential{
|
||||
RawID: []byte("test-id"),
|
||||
PublicKey: []byte("test-key"),
|
||||
Flags: &AuthenticatorFlags{
|
||||
UserPresent: false,
|
||||
},
|
||||
},
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateGaslessEligibility(tt.credential)
|
||||
if tt.wantError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetGaslessEndpoint tests endpoint retrieval.
|
||||
func TestGetGaslessEndpoint(t *testing.T) {
|
||||
cfg := &config.NetworkConfig{
|
||||
RPC: "http://localhost:26657",
|
||||
}
|
||||
|
||||
endpoint := GetGaslessEndpoint(cfg)
|
||||
require.Equal(t, "http://localhost:26657", endpoint)
|
||||
}
|
||||
|
||||
// TestWebAuthnCredentialConversion tests conversion between credential types.
|
||||
func TestWebAuthnCredentialConversion(t *testing.T) {
|
||||
// Create client credential
|
||||
clientCred := &WebAuthnCredential{
|
||||
ID: base64.RawURLEncoding.EncodeToString([]byte("test-id")),
|
||||
RawID: []byte("test-id"),
|
||||
PublicKey: []byte("test-public-key"),
|
||||
AttestationType: "none",
|
||||
Transports: []string{"usb", "nfc"},
|
||||
Flags: &AuthenticatorFlags{
|
||||
UserPresent: true,
|
||||
UserVerified: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Convert to protobuf credential
|
||||
protoCred := didtypes.WebAuthnCredential{
|
||||
CredentialId: base64.RawURLEncoding.EncodeToString(clientCred.RawID),
|
||||
PublicKey: clientCred.PublicKey,
|
||||
AttestationType: clientCred.AttestationType,
|
||||
Origin: "http://localhost",
|
||||
Algorithm: -7, // ES256
|
||||
CreatedAt: time.Now().Unix(),
|
||||
RpId: "localhost",
|
||||
RpName: "Sonr Local",
|
||||
Transports: clientCred.Transports,
|
||||
UserVerified: clientCred.Flags.UserVerified,
|
||||
}
|
||||
|
||||
// Verify conversion
|
||||
require.Equal(t, base64.RawURLEncoding.EncodeToString([]byte("test-id")), protoCred.CredentialId)
|
||||
require.Equal(t, clientCred.PublicKey, protoCred.PublicKey)
|
||||
require.Equal(t, clientCred.AttestationType, protoCred.AttestationType)
|
||||
require.Equal(t, clientCred.Transports, protoCred.Transports)
|
||||
require.Equal(t, clientCred.Flags.UserVerified, protoCred.UserVerified)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user