mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-02 17:31:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
+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,150 @@
|
||||
[
|
||||
{
|
||||
"scope": "deps",
|
||||
"path": "go.sum"
|
||||
},
|
||||
{
|
||||
"scope": "deps",
|
||||
"path": "go.mod"
|
||||
},
|
||||
{
|
||||
"scope": "api",
|
||||
"path": "api/dex"
|
||||
},
|
||||
{
|
||||
"scope": "api",
|
||||
"path": "api/did"
|
||||
},
|
||||
{
|
||||
"scope": "api",
|
||||
"path": "api/dwn"
|
||||
},
|
||||
{
|
||||
"scope": "api",
|
||||
"path": "api/svc"
|
||||
},
|
||||
{
|
||||
"scope": "api",
|
||||
"path": "api"
|
||||
},
|
||||
{
|
||||
"scope": "ante",
|
||||
"path": "app/ante"
|
||||
},
|
||||
{
|
||||
"scope": "cli",
|
||||
"path": "app/commands"
|
||||
},
|
||||
{
|
||||
"scope": "app",
|
||||
"path": "app/context"
|
||||
},
|
||||
{
|
||||
"scope": "app",
|
||||
"path": "app/decorators"
|
||||
},
|
||||
{
|
||||
"scope": "app",
|
||||
"path": "app/params"
|
||||
},
|
||||
{
|
||||
"scope": "app",
|
||||
"path": "app/upgrades"
|
||||
},
|
||||
{
|
||||
"scope": "app",
|
||||
"path": "app"
|
||||
},
|
||||
{
|
||||
"scope": "chains",
|
||||
"path": "chains"
|
||||
},
|
||||
{
|
||||
"scope": "cmd",
|
||||
"path": "cmd/snrd"
|
||||
},
|
||||
{
|
||||
"scope": "cmd",
|
||||
"path": "cmd"
|
||||
},
|
||||
{
|
||||
"scope": "docs",
|
||||
"path": "docs"
|
||||
},
|
||||
{
|
||||
"scope": "config",
|
||||
"path": "networks/localnet"
|
||||
},
|
||||
{
|
||||
"scope": "config",
|
||||
"path": "networks/testnet"
|
||||
},
|
||||
{
|
||||
"scope": "config",
|
||||
"path": "networks"
|
||||
},
|
||||
{
|
||||
"scope": "dex",
|
||||
"path": "proto/dex"
|
||||
},
|
||||
{
|
||||
"scope": "did",
|
||||
"path": "proto/did"
|
||||
},
|
||||
{
|
||||
"scope": "dwn",
|
||||
"path": "proto/dwn"
|
||||
},
|
||||
{
|
||||
"scope": "svc",
|
||||
"path": "proto/svc"
|
||||
},
|
||||
{
|
||||
"scope": "proto",
|
||||
"path": "proto"
|
||||
},
|
||||
{
|
||||
"scope": "scripts",
|
||||
"path": "scripts"
|
||||
},
|
||||
{
|
||||
"scope": "test",
|
||||
"path": "test/e2e"
|
||||
},
|
||||
{
|
||||
"scope": "test",
|
||||
"path": "test/integration"
|
||||
},
|
||||
{
|
||||
"scope": "test",
|
||||
"path": "test/oauth"
|
||||
},
|
||||
{
|
||||
"scope": "test",
|
||||
"path": "test"
|
||||
},
|
||||
{
|
||||
"scope": "dex",
|
||||
"path": "x/dex"
|
||||
},
|
||||
{
|
||||
"scope": "did",
|
||||
"path": "x/did"
|
||||
},
|
||||
{
|
||||
"scope": "dwn",
|
||||
"path": "x/dwn"
|
||||
},
|
||||
{
|
||||
"scope": "svc",
|
||||
"path": "x/svc"
|
||||
},
|
||||
{
|
||||
"scope": "ci",
|
||||
"path": "Makefile"
|
||||
},
|
||||
{
|
||||
"scope": "ci",
|
||||
"path": ".github"
|
||||
}
|
||||
]
|
||||
@@ -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/**
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-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,8 @@ 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
|
||||
|
||||
+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
|
||||
|
||||
@@ -290,31 +258,22 @@ test-build-snrd: build
|
||||
@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 +299,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
|
||||
|
||||
###############################################################################
|
||||
### Protobuf ###
|
||||
@@ -373,41 +318,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 +368,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 +386,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 +401,6 @@ 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 "📚 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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user