* clear

* feat: Add everything

* fix: Commenht
This commit is contained in:
Prad Nukala
2025-10-03 14:45:52 -04:00
committed by GitHub
parent 43b4a11c06
commit 13e6c3e84d
1935 changed files with 655061 additions and 40058 deletions
+16
View File
@@ -0,0 +1,16 @@
[tool.commitizen]
name = "cz_customize"
tag_format = "web-auth/v$version"
ignored_tag_formats = ["*/v${version}", "v${version}"]
version_scheme = "semver"
version_provider = "npm"
update_changelog_on_bump = true
major_version_zero = true
pre_bump_hooks = ["bash ../../scripts/hook-bump-pre.sh"]
post_bump_hooks = ["pnpm --filter '@sonr.io/auth' deploy:cloudflare"]
[tool.commitizen.customize]
bump_pattern = "^(feat|fix|refactor|perf|BREAKING CHANGE)"
bump_map = { "BREAKING CHANGE" = "MAJOR", "feat" = "MINOR", "fix" = "PATCH", "refactor" = "PATCH", "perf" = "PATCH" }
default_bump = "PATCH"
changelog_pattern = "^(feat|fix|refactor|docs|build)\\(auth\\)(!)?:"
+135
View File
@@ -0,0 +1,135 @@
# Sonr Auth - Environment Configuration
# ==============================================================================
# Bridge URL Configuration
# ==============================================================================
# Bridge service URL (backend Go service)
# Production: https://api.sonr.id
# Development: http://localhost:8080
NEXT_PUBLIC_API_URL=http://localhost:8080
# ==============================================================================
# OpenID Connect Provider Configuration
# ==============================================================================
# OIDC Issuer URL (must be publicly accessible)
# This URL will be used in discovery documents and token validation
# Production: https://auth.sonr.id
# Development: http://localhost:3000
OIDC_ISSUER=http://localhost:3000
# Public URL for OIDC provider (if different from issuer)
# Used for external references and redirects
OIDC_PUBLIC_URL=http://localhost:3000
# Private key path for JWT token signing (PEM format)
# Generate with: openssl ecparam -genkey -name prime256v1 -noout -out oidc-signing.pem
OIDC_SIGNING_KEY_PATH=./keys/oidc-signing.pem
# Private key path for JWT token encryption (optional)
# Generate with: openssl ecparam -genkey -name prime256v1 -noout -out oidc-encryption.pem
OIDC_ENCRYPTION_KEY_PATH=./keys/oidc-encryption.pem
# ==============================================================================
# WebAuthn Configuration
# ==============================================================================
# Relying Party ID (usually the domain)
# Must match the domain where WebAuthn is used
# Production: sonr.id
# Development: localhost
WEBAUTHN_RP_ID=localhost
# Relying Party Name (displayed to users)
WEBAUTHN_RP_NAME=Sonr Identity Platform
# WebAuthn timeout in milliseconds
WEBAUTHN_TIMEOUT=60000
# ==============================================================================
# JWT Configuration
# ==============================================================================
# Secret key for JWT token generation and validation
# Use a strong, random string in production
# Generate with: openssl rand -base64 32
JWT_SECRET=highway-ucan-secret-key
# ==============================================================================
# Redis Configuration (for session storage)
# ==============================================================================
# Redis connection string
# Production: Use Redis cluster or managed Redis service
# Development: Local Redis instance
REDIS_ADDR=127.0.0.1:6379
# Redis password (if required)
# REDIS_PASSWORD=your-redis-password
# Redis database number
# REDIS_DB=0
# ==============================================================================
# OIDC Client Configuration
# ==============================================================================
# Default client ID for self-issued tokens
OIDC_CLIENT_ID=sonr-auth
# Allowed redirect URIs (comma-separated)
# Add all domains that will use this OIDC provider
OIDC_ALLOWED_REDIRECT_URIS=http://localhost:3000/callback,http://localhost:3001/callback,https://localhost:3000/callback,https://localhost:3001/callback
# Supported scopes (comma-separated)
OIDC_SUPPORTED_SCOPES=openid,profile,email,did,vault,offline_access
# ==============================================================================
# Development Configuration
# ==============================================================================
# Enable debug logging
DEBUG=false
# Log level (error, warn, info, debug)
LOG_LEVEL=info
# Enable CORS for development
CORS_ENABLED=true
# CORS allowed origins (comma-separated)
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,https://localhost:3000,https://localhost:3001
# ==============================================================================
# Production Configuration
# ==============================================================================
# Domain for secure cookies
# COOKIE_DOMAIN=.sonr.id
# Secure cookie settings (true for HTTPS)
# COOKIE_SECURE=true
# SameSite cookie attribute (strict, lax, none)
# COOKIE_SAME_SITE=lax
# Session timeout in milliseconds (default: 1 hour)
# SESSION_TIMEOUT=3600000
# Refresh token TTL in milliseconds (default: 7 days)
# REFRESH_TOKEN_TTL=604800000
# ==============================================================================
# Optional Integrations
# ==============================================================================
# IPFS configuration (if using decentralized storage)
# IPFS_API_URL=http://localhost:5001
# Blockchain RPC endpoint (if direct blockchain interaction is needed)
# BLOCKCHAIN_RPC_URL=http://localhost:26657
# Analytics configuration
# ANALYTICS_ENABLED=false
# ANALYTICS_API_KEY=your-analytics-key
+23
View File
@@ -0,0 +1,23 @@
# Staging Environment Configuration for Auth App
NEXT_PUBLIC_ENVIRONMENT=staging
NEXT_PUBLIC_API_URL=https://highway-api-staging.workers.dev
NEXT_PUBLIC_APP_URL=https://staging.auth.sonr.id
NEXT_PUBLIC_DASHBOARD_URL=https://staging.app.sonr.id
NEXT_PUBLIC_MAIN_SITE_URL=https://staging.sonr.id
# WebAuthn Configuration
NEXT_PUBLIC_RP_ID=staging.highway.sonr.id
NEXT_PUBLIC_RP_NAME="Highway Staging"
# Feature Flags
NEXT_PUBLIC_ENABLE_DEBUG=true
NEXT_PUBLIC_ENABLE_DEV_TOOLS=true
# Analytics (if applicable)
NEXT_PUBLIC_ANALYTICS_ID=staging-analytics-id
# Error Tracking
NEXT_PUBLIC_SENTRY_DSN=staging-sentry-dsn
# Logging
NEXT_PUBLIC_LOG_LEVEL=info
+71
View File
@@ -0,0 +1,71 @@
# Use Node.js Alpine as base image
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy root package files and workspace configuration
COPY ../../package.json ../../pnpm-lock.yaml ../../pnpm-workspace.yaml ./
COPY ../../turbo.json ./
# Copy package.json files for all workspace dependencies
COPY ../../packages/es/package.json ./packages/es/
COPY ../../packages/sdk/package.json ./packages/sdk/
COPY ../../packages/ui/package.json ./packages/ui/
COPY ../../packages/com/package.json ./packages/com/
COPY ../../packages/pkl/package.json ./packages/pkl/
COPY ../../packages/cli/package.json ./packages/cli/
COPY ./package.json ./web/auth/
# Install dependencies
RUN pnpm install --frozen-lockfile
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy installed dependencies
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.pnpm ./.pnpm
# Copy source code
COPY ../.. .
# Build the application
WORKDIR /app/web/auth
RUN pnpm build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy built application
COPY --from=builder /app/web/auth/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/web/auth/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/web/auth/.next/static ./.next/static
USER nextjs
# Expose port
EXPOSE 3001
ENV PORT=3001
ENV HOSTNAME="0.0.0.0"
# Start the application
CMD ["node", "server.js"]
+158
View File
@@ -0,0 +1,158 @@
# Auth Frontend
This is the Next.js authentication frontend for Highway WebAuthn gateway. It provides a modern, responsive interface for passwordless authentication using WebAuthn passkeys.
## Features
- **Passwordless Authentication**: WebAuthn registration and login with passkeys
- **Modern UI**: Clean, responsive design with Tailwind CSS
- **Session Management**: Persistent sessions with automatic validation
- **Browser Compatibility**: WebAuthn feature detection and fallback messaging
- **Accessibility**: ARIA labels, keyboard navigation, and screen reader support
- **TypeScript**: Full type safety with custom hooks and components
## Pages
### Home (`/`)
- Landing page with feature highlights
- Navigation to registration and login
- Automatic redirect to dashboard if authenticated
### Registration (`/register`)
- WebAuthn passkey registration flow
- Username and display name input
- Real-time validation and error handling
### Login (`/login`)
- WebAuthn passkey authentication
- Username input with passkey verification
- Remember me functionality
### Dashboard (`/dashboard`)
- User profile information
- Account security features
- Session management controls
## Components
### Custom Hooks
- `useWebAuthn`: WebAuthn registration and authentication logic
- `useSession`: Session management and user state
### UI Components (from `@sonr.io/ui`)
- `Button`: Configurable button with loading states
- `Input`: Form input with validation and help text
- `ErrorAlert`: Dismissible error notifications
## Development
To start the development server:
```bash
pnpm dev
```
This will start the Next.js development server with hot reload at `http://localhost:3000`.
## Testing
```bash
# Run tests
pnpm test
# Test WebAuthn in browser
# Visit http://localhost:3000 and test registration/login flow
```
## Deployment
To deploy to Cloudflare Pages:
```bash
pnpm deploy
```
The app is configured for static export and optimized for Cloudflare Pages deployment.
## Configuration
### Environment Variables
```env
# API endpoint
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
# Development
NEXT_PUBLIC_API_URL=http://localhost:8080
```
### Next.js Configuration
The app uses:
- Static export for Cloudflare Pages
- Unoptimized images for static hosting
- Trailing slash for proper routing
## Development Roadmap
### ✅ **Phase 3: WebAuthn Frontend Implementation** (Current)
- [x] **Next.js 14 Setup**: App directory with TypeScript configuration
- [x] **WebAuthn Integration**: Complete registration and authentication flows
- [x] **Custom Hooks**: useWebAuthn and useSession for state management
- [x] **UI Components**: Reusable Button, Input, and ErrorAlert components
- [x] **Authentication Pages**: Registration, login, and dashboard interfaces
- [x] **Session Management**: Persistent sessions with browser compatibility
- [x] **Responsive Design**: Mobile-first approach with Tailwind CSS
- [x] **Accessibility**: ARIA labels, keyboard navigation, and screen reader support
### 🚧 **Phase 5: Production Readiness** (Next)
- [ ] **Performance Optimization**: Code splitting and bundle optimization
- [ ] **Testing Suite**: Unit tests for components and hooks
- [ ] **E2E Testing**: Cypress or Playwright for full user flow testing
- [ ] **Error Boundaries**: React error boundaries for graceful error handling
- [ ] **Analytics**: User behavior tracking and conversion metrics
- [ ] **Internationalization**: Multi-language support
### 🔮 **Future Enhancements**
- [ ] **Advanced UI**: Dark mode, animations, and enhanced UX
- [ ] **Progressive Web App**: PWA features for mobile experience
- [ ] **Multi-factor Authentication**: Additional security options
- [ ] **Account Management**: Profile editing and security settings
- [ ] **Admin Dashboard**: User management and system monitoring
- [ ] **Mobile App**: React Native or native mobile applications
## Architecture
The frontend follows modern React patterns:
- **App Router**: Next.js 13+ app directory structure
- **Custom Hooks**: Reusable logic for WebAuthn and session management
- **Component Library**: Shared UI components with TypeScript
- **State Management**: React hooks for local state, no external state library
- **Styling**: Tailwind CSS with custom component styling
## Security
- **WebAuthn**: Cryptographic authentication without passwords
- **Session Validation**: Automatic session validation and renewal
- **Input Validation**: Client-side validation with server-side verification
- **HTTPS Only**: Secure connection required for WebAuthn
- **Content Security Policy**: CSP headers for XSS protection
## Browser Support
- **Chrome**: Full WebAuthn support
- **Firefox**: Full WebAuthn support
- **Safari**: Full WebAuthn support (iOS 14+)
- **Edge**: Full WebAuthn support
- **Mobile**: Android and iOS with platform authenticators
+33
View File
@@ -0,0 +1,33 @@
{
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
"files": {
"includes": ["src/**/*.{js,jsx,ts,tsx}"],
"ignoreUnknown": true
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"correctness": {
"noUnusedVariables": "warn"
},
"suspicious": {
"noImplicitAnyLet": "off"
},
"complexity": {
"useOptionalChain": "off"
},
"style": {
"noNonNullAssertion": "warn",
"noParameterAssign": "warn"
},
"a11y": {
"noSvgWithoutTitle": "warn"
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"utils": "@sonr.io/ui/lib/utils",
"ui": "@sonr.io/ui/components"
},
"iconLibrary": "lucide-react"
}
+16
View File
@@ -0,0 +1,16 @@
module.exports = {
testEnvironment: 'jsdom',
testPathIgnorePatterns: ['/node_modules/', '/.next/'],
collectCoverage: true,
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/pages/_app.tsx',
'!src/pages/_document.tsx',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }],
},
transformIgnorePatterns: ['/node_modules/', '^.+\\.module\\.(css|sass|scss)$'],
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+69
View File
@@ -0,0 +1,69 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Environment variables
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
NEXT_PUBLIC_WEBAUTHN_RP_ID: process.env.NEXT_PUBLIC_WEBAUTHN_RP_ID || 'localhost',
NEXT_PUBLIC_WEBAUTHN_RP_NAME: process.env.NEXT_PUBLIC_WEBAUTHN_RP_NAME || 'Sonr Auth',
NEXT_PUBLIC_CHAIN_ID: process.env.NEXT_PUBLIC_CHAIN_ID || 'sonrtest_1-1',
},
// OpenNext handles the output configuration
// output is managed by @opennextjs/cloudflare
// Enable trailing slashes for better static hosting
trailingSlash: true,
// Image optimization settings for static export
images: {
unoptimized: true,
},
// Webpack configuration for edge runtime compatibility
webpack: (config, { isServer, webpack }) => {
// Handle node: protocol imports
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(/^node:/, (resource) => {
resource.request = resource.request.replace(/^node:/, '');
})
);
// Add fallbacks for node modules in browser/edge
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
stream: false,
crypto: false,
buffer: false,
util: false,
process: false,
};
}
// Ignore node-specific modules
config.resolve.alias = {
...config.resolve.alias,
'node:fs': false,
'node:path': false,
'node:stream': false,
'node:buffer': false,
'node:util': false,
'node:process': false,
};
return config;
},
// TypeScript and ESLint configuration
typescript: {
ignoreBuildErrors: process.env.NODE_ENV === 'production',
},
eslint: {
ignoreDuringBuilds: process.env.NODE_ENV === 'production',
},
};
module.exports = nextConfig;
+3
View File
@@ -0,0 +1,3 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig();
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@sonr.io/auth",
"version": "0.0.3",
"private": true,
"scripts": {
"dev": "wrangler dev",
"build": "next build",
"build:cloudflare": "wrangler build",
"start": "next start",
"preview": "wrangler preview",
"lint": "biome lint .",
"format": "biome format . --write",
"check": "biome check . --write",
"deploy:cloudflare": "wrangler deploy",
"release": "cz --no-raise 6,21 bump --yes --increment PATCH"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@simplewebauthn/browser": "^9.0.0",
"@sonr.io/es": "workspace:*",
"@sonr.io/sdk": "workspace:*",
"@sonr.io/ui": "workspace:*",
"next": "15.5.2",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@biomejs/biome": "^2.1.2",
"@browser-echo/next": "^1.0.1",
"@cloudflare/next-on-pages": "^1.13.16",
"@opennextjs/cloudflare": "^1.8.0",
"@types/react": "^18.2.0",
"autoprefixer": "^10.4.16",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0",
"vercel": "^47.0.5",
"wrangler": "^4.34.0"
}
}
@@ -0,0 +1,76 @@
import { type NextRequest, NextResponse } from 'next/server';
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
/**
* GET /.well-known/openid-configuration
*
* OpenID Connect Discovery endpoint that returns the OIDC provider configuration.
* This endpoint proxies the request to the Go bridge handler at /oidc/discovery.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
try {
// Forward the request to the Go bridge handler
const response = await fetch(`${BRIDGE_URL}/oidc/discovery`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
// Forward relevant headers
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
},
});
if (!response.ok) {
console.error('OIDC Discovery error:', response.status, response.statusText);
return NextResponse.json(
{
error: 'discovery_error',
error_description: 'Failed to retrieve OIDC discovery configuration',
},
{ status: response.status }
);
}
const discoveryConfig = await response.json();
// Return the discovery configuration with CORS headers
return NextResponse.json(discoveryConfig, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
} catch (error) {
console.error('OIDC Discovery proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during discovery configuration retrieval',
},
{ status: 500 }
);
}
}
/**
* OPTIONS /.well-known/openid-configuration
*
* Handle preflight CORS requests for the discovery endpoint.
*/
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
});
}
@@ -0,0 +1,30 @@
import { GET } from '../.well-known/openid-configuration/route';
describe('OIDC Discovery Endpoint', () => {
it('returns a valid OpenID Connect configuration', async () => {
const response = await GET(new Request('https://example.com/.well-known/openid-configuration'));
expect(response.status).toBe(200);
const config = await response.json();
// Basic structure validation
expect(config).toHaveProperty('issuer');
expect(config).toHaveProperty('authorization_endpoint');
expect(config).toHaveProperty('token_endpoint');
expect(config).toHaveProperty('userinfo_endpoint');
expect(config).toHaveProperty('jwks_uri');
// Validate specific SIOP requirements
expect(config.subject_syntax_types_supported).toContain('did');
expect(config.id_token_types_supported).toContain('subject-signed_id_token');
});
it('responds with correct CORS headers', async () => {
const response = await GET(new Request('https://example.com/.well-known/openid-configuration'));
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET');
expect(response.headers.get('Content-Type')).toBe('application/json');
});
});
@@ -0,0 +1,71 @@
import { NextRequest } from 'next/server';
import { POST } from '../token/route';
describe('OIDC Token Endpoint', () => {
it('handles authorization code token exchange', async () => {
const mockRequest = new NextRequest(new URL('https://example.com/token'), {
method: 'POST',
body: JSON.stringify({
grant_type: 'authorization_code',
code: 'valid_code',
client_id: 'test_client',
redirect_uri: 'https://example.com/callback',
code_verifier: 'test_verifier',
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const tokens = await response.json();
expect(tokens).toHaveProperty('access_token');
expect(tokens).toHaveProperty('token_type', 'Bearer');
expect(tokens).toHaveProperty('expires_in');
});
it('handles refresh token grant', async () => {
const mockRequest = new NextRequest(new URL('https://example.com/token'), {
method: 'POST',
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: 'valid_refresh_token',
client_id: 'test_client',
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const tokens = await response.json();
expect(tokens).toHaveProperty('access_token');
expect(tokens).toHaveProperty('token_type', 'Bearer');
});
it('rejects invalid grant types', async () => {
const mockRequest = new NextRequest(new URL('https://example.com/token'), {
method: 'POST',
body: JSON.stringify({
grant_type: 'invalid_grant',
client_id: 'test_client',
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const response = await POST(mockRequest);
expect(response.status).toBe(400);
const error = await response.json();
expect(error).toHaveProperty('error', 'unsupported_grant_type');
});
});
@@ -0,0 +1,227 @@
import { type NextRequest, NextResponse } from 'next/server';
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
interface OIDCAuthorizationParams {
response_type: string;
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
nonce?: string;
code_challenge?: string;
code_challenge_method?: string;
}
/**
* GET /api/oidc/authorize
*
* OIDC Authorization endpoint that initiates the authorization code flow.
* This endpoint proxies the request to the Go bridge handler at /oidc/authorize.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
try {
const { searchParams } = new URL(request.url);
// Extract and validate required OIDC parameters
const authParams: OIDCAuthorizationParams = {
response_type: searchParams.get('response_type') || '',
client_id: searchParams.get('client_id') || '',
redirect_uri: searchParams.get('redirect_uri') || '',
scope: searchParams.get('scope') || '',
state: searchParams.get('state') || undefined,
nonce: searchParams.get('nonce') || undefined,
code_challenge: searchParams.get('code_challenge') || undefined,
code_challenge_method: searchParams.get('code_challenge_method') || undefined,
};
// Validate required parameters
if (
!authParams.response_type ||
!authParams.client_id ||
!authParams.redirect_uri ||
!authParams.scope
) {
return NextResponse.json(
{
error: 'invalid_request',
error_description:
'Missing required parameters: response_type, client_id, redirect_uri, or scope',
},
{ status: 400 }
);
}
// Build query string for the bridge handler
const queryParams = new URLSearchParams();
Object.entries(authParams).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
queryParams.append(key, value);
}
});
// Forward the request to the Go bridge handler
const response = await fetch(`${BRIDGE_URL}/oidc/authorize?${queryParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
// Forward authentication and session headers
Authorization: request.headers.get('Authorization') || '',
Cookie: request.headers.get('Cookie') || '',
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
Referer: request.headers.get('Referer') || '',
},
});
// Handle different response types from the bridge
if (response.status === 302) {
// Bridge handler returned a redirect - follow it
const location = response.headers.get('Location');
if (location) {
return NextResponse.redirect(location);
}
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({
error: 'authorization_error',
error_description: 'Authorization request failed',
}));
console.error('OIDC Authorization error:', response.status, errorData);
return NextResponse.json(errorData, { status: response.status });
}
const responseData = await response.json();
// Return the authorization response with appropriate headers
return NextResponse.json(responseData, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
'Access-Control-Allow-Credentials': 'true',
},
});
} catch (error) {
console.error('OIDC Authorization proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during authorization',
},
{ status: 500 }
);
}
}
/**
* POST /api/oidc/authorize
*
* Handle authorization requests sent via POST (form-encoded).
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const formData = await request.formData();
// Extract parameters from form data
const authParams: OIDCAuthorizationParams = {
response_type: formData.get('response_type')?.toString() || '',
client_id: formData.get('client_id')?.toString() || '',
redirect_uri: formData.get('redirect_uri')?.toString() || '',
scope: formData.get('scope')?.toString() || '',
state: formData.get('state')?.toString() || undefined,
nonce: formData.get('nonce')?.toString() || undefined,
code_challenge: formData.get('code_challenge')?.toString() || undefined,
code_challenge_method: formData.get('code_challenge_method')?.toString() || undefined,
};
// Validate required parameters
if (
!authParams.response_type ||
!authParams.client_id ||
!authParams.redirect_uri ||
!authParams.scope
) {
return NextResponse.json(
{
error: 'invalid_request',
error_description:
'Missing required parameters: response_type, client_id, redirect_uri, or scope',
},
{ status: 400 }
);
}
// Forward as form data to the bridge handler
const bridgeFormData = new FormData();
Object.entries(authParams).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
bridgeFormData.append(key, value);
}
});
const response = await fetch(`${BRIDGE_URL}/oidc/authorize`, {
method: 'POST',
body: bridgeFormData,
headers: {
// Forward authentication and session headers
Authorization: request.headers.get('Authorization') || '',
Cookie: request.headers.get('Cookie') || '',
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
Referer: request.headers.get('Referer') || '',
},
});
// Handle redirect response
if (response.status === 302) {
const location = response.headers.get('Location');
if (location) {
return NextResponse.redirect(location);
}
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({
error: 'authorization_error',
error_description: 'Authorization request failed',
}));
return NextResponse.json(errorData, { status: response.status });
}
const responseData = await response.json();
return NextResponse.json(responseData);
} catch (error) {
console.error('OIDC Authorization POST proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during authorization',
},
{ status: 500 }
);
}
}
/**
* OPTIONS /api/oidc/authorize
*
* Handle preflight CORS requests for the authorization endpoint.
*/
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Cookie',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
},
});
}
+158
View File
@@ -0,0 +1,158 @@
import { type NextRequest, NextResponse } from 'next/server';
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
interface JWK {
kty: string;
use?: string;
kid: string;
alg?: string;
n?: string; // RSA modulus
e?: string; // RSA exponent
x?: string; // EC x coordinate
y?: string; // EC y coordinate
crv?: string; // EC curve
}
interface JWKSet {
keys: JWK[];
}
/**
* GET /api/oidc/jwks
*
* OIDC JSON Web Key Set (JWKS) endpoint that returns public keys used for token verification.
* This endpoint proxies the request to the Go bridge handler at /oidc/jwks.
* The returned keys are used by relying parties to verify JWT tokens issued by this OIDC provider.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
try {
// Forward the request to the Go bridge handler
const response = await fetch(`${BRIDGE_URL}/oidc/jwks`, {
method: 'GET',
headers: {
Accept: 'application/json',
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
},
});
if (!response.ok) {
console.error('OIDC JWKS error:', response.status, response.statusText);
let errorData;
try {
errorData = await response.json();
} catch {
errorData = {
error: 'server_error',
error_description: 'Failed to retrieve JSON Web Key Set',
};
}
return NextResponse.json(errorData, { status: response.status });
}
const jwks: JWKSet = await response.json();
// Validate JWKS structure
if (!jwks || !Array.isArray(jwks.keys)) {
console.error('Invalid JWKS response structure:', jwks);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Invalid JWKS response from server',
},
{ status: 500 }
);
}
// Validate each key in the set
const validKeys = jwks.keys.filter((key: JWK) => {
// Basic validation for required JWK fields
if (!key.kty || !key.kid) {
console.warn('Invalid JWK missing required fields:', key);
return false;
}
// Validate key type specific fields
if (key.kty === 'RSA' && (!key.n || !key.e)) {
console.warn('Invalid RSA JWK missing n or e:', key);
return false;
}
if (key.kty === 'EC' && (!key.x || !key.y || !key.crv)) {
console.warn('Invalid EC JWK missing x, y, or crv:', key);
return false;
}
return true;
});
const validatedJWKS: JWKSet = {
keys: validKeys,
};
// Return the JWKS with appropriate headers
return NextResponse.json(validatedJWKS, {
status: 200,
headers: {
'Content-Type': 'application/json',
// JWKS can be cached longer since keys don't change frequently
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=43200',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type',
// Security headers for key endpoint
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
},
});
} catch (error) {
console.error('OIDC JWKS proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during JWKS retrieval',
},
{ status: 500 }
);
}
}
/**
* OPTIONS /api/oidc/jwks
*
* Handle preflight CORS requests for the JWKS endpoint.
*/
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
},
});
}
/**
* POST /api/oidc/jwks
*
* Return method not allowed for POST requests to JWKS endpoint.
*/
export async function POST(): Promise<NextResponse> {
return NextResponse.json(
{
error: 'method_not_allowed',
error_description: 'POST method not allowed for JWKS endpoint. Use GET.',
},
{
status: 405,
headers: {
Allow: 'GET, OPTIONS',
},
}
);
}
+196
View File
@@ -0,0 +1,196 @@
import { type NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-static';
export const revalidate = 3600; // Revalidate every hour
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
interface OIDCTokenRequest {
grant_type: string;
code?: string;
redirect_uri?: string;
client_id: string;
client_secret?: string;
code_verifier?: string;
refresh_token?: string;
}
interface OIDCTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
id_token?: string;
scope?: string;
}
/**
* POST /api/oidc/token
*
* OIDC Token endpoint that exchanges authorization codes for access tokens.
* This endpoint proxies the request to the Go bridge handler at /oidc/token.
* Supports authorization_code, refresh_token, and client_credentials grant types.
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const contentType = request.headers.get('Content-Type') || '';
let tokenRequest: OIDCTokenRequest;
// Parse request based on content type
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
tokenRequest = {
grant_type: formData.get('grant_type')?.toString() || '',
code: formData.get('code')?.toString() || undefined,
redirect_uri: formData.get('redirect_uri')?.toString() || undefined,
client_id: formData.get('client_id')?.toString() || '',
client_secret: formData.get('client_secret')?.toString() || undefined,
code_verifier: formData.get('code_verifier')?.toString() || undefined,
refresh_token: formData.get('refresh_token')?.toString() || undefined,
};
} else if (contentType.includes('application/json')) {
tokenRequest = await request.json();
} else {
return NextResponse.json(
{
error: 'invalid_request',
error_description:
'Content-Type must be application/x-www-form-urlencoded or application/json',
},
{ status: 400 }
);
}
// Validate required parameters
if (!tokenRequest.grant_type || !tokenRequest.client_id) {
return NextResponse.json(
{
error: 'invalid_request',
error_description: 'Missing required parameters: grant_type and client_id',
},
{ status: 400 }
);
}
// Validate grant type specific parameters
if (tokenRequest.grant_type === 'authorization_code') {
if (!tokenRequest.code || !tokenRequest.redirect_uri) {
return NextResponse.json(
{
error: 'invalid_request',
error_description:
'Missing required parameters for authorization_code grant: code and redirect_uri',
},
{ status: 400 }
);
}
} else if (tokenRequest.grant_type === 'refresh_token') {
if (!tokenRequest.refresh_token) {
return NextResponse.json(
{
error: 'invalid_request',
error_description: 'Missing required parameter for refresh_token grant: refresh_token',
},
{ status: 400 }
);
}
}
// Prepare form data for the bridge handler (OIDC typically uses form encoding)
const bridgeFormData = new FormData();
Object.entries(tokenRequest).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
bridgeFormData.append(key, value);
}
});
// Forward the request to the Go bridge handler
const response = await fetch(`${BRIDGE_URL}/oidc/token`, {
method: 'POST',
body: bridgeFormData,
headers: {
Accept: 'application/json',
// Forward client authentication headers
Authorization: request.headers.get('Authorization') || '',
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
},
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = {
error: 'server_error',
error_description: 'Token request failed',
};
}
console.error('OIDC Token error:', response.status, errorData);
return NextResponse.json(errorData, { status: response.status });
}
const tokenResponse: OIDCTokenResponse = await response.json();
// Return the token response with appropriate headers
return NextResponse.json(tokenResponse, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
'Access-Control-Allow-Credentials': 'true',
},
});
} catch (error) {
console.error('OIDC Token proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during token exchange',
},
{ status: 500 }
);
}
}
/**
* OPTIONS /api/oidc/token
*
* Handle preflight CORS requests for the token endpoint.
*/
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
},
});
}
/**
* GET /api/oidc/token
*
* Return method not allowed for GET requests to token endpoint.
*/
export async function GET(): Promise<NextResponse> {
return NextResponse.json(
{
error: 'invalid_request',
error_description: 'GET method not allowed for token endpoint. Use POST.',
},
{
status: 405,
headers: {
Allow: 'POST, OPTIONS',
},
}
);
}
+227
View File
@@ -0,0 +1,227 @@
import { type NextRequest, NextResponse } from 'next/server';
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
interface OIDCUserInfo {
sub: string;
name?: string;
preferred_username?: string;
email?: string;
email_verified?: boolean;
did?: string;
vault_id?: string;
updated_at?: number;
claims?: Record<string, unknown>;
}
/**
* GET /api/oidc/userinfo
*
* OIDC UserInfo endpoint that returns user information for a valid access token.
* This endpoint proxies the request to the Go bridge handler at /oidc/userinfo.
* Requires a valid Bearer token in the Authorization header.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
try {
// Extract access token from Authorization header
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{
error: 'invalid_token',
error_description:
'Missing or invalid Authorization header. Expected format: Bearer <access_token>',
},
{
status: 401,
headers: {
'WWW-Authenticate': 'Bearer realm="OIDC UserInfo", error="invalid_token"',
},
}
);
}
// Forward the request to the Go bridge handler
const response = await fetch(`${BRIDGE_URL}/oidc/userinfo`, {
method: 'GET',
headers: {
Accept: 'application/json',
// Forward the Authorization header with the access token
Authorization: authHeader,
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
},
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
// Handle different error statuses
switch (response.status) {
case 401:
errorData = {
error: 'invalid_token',
error_description: 'Access token is invalid or expired',
};
break;
case 403:
errorData = {
error: 'insufficient_scope',
error_description: 'Access token does not have sufficient scope',
};
break;
default:
errorData = {
error: 'server_error',
error_description: 'UserInfo request failed',
};
}
}
console.error('OIDC UserInfo error:', response.status, errorData);
const responseHeaders: HeadersInit = {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
};
// Add WWW-Authenticate header for 401 responses
if (response.status === 401) {
responseHeaders['WWW-Authenticate'] = 'Bearer realm="OIDC UserInfo", error="invalid_token"';
}
return NextResponse.json(errorData, {
status: response.status,
headers: responseHeaders,
});
}
const userInfo: OIDCUserInfo = await response.json();
// Return the user information with appropriate headers
return NextResponse.json(userInfo, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
'Access-Control-Allow-Credentials': 'true',
},
});
} catch (error) {
console.error('OIDC UserInfo proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during userinfo retrieval',
},
{ status: 500 }
);
}
}
/**
* POST /api/oidc/userinfo
*
* OIDC UserInfo endpoint that accepts POST requests with access token in form data.
* This is an alternative method for clients that prefer POST over GET.
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const contentType = request.headers.get('Content-Type') || '';
let accessToken: string | null = null;
// Extract access token from different sources
const authHeader = request.headers.get('Authorization');
if (authHeader?.startsWith('Bearer ')) {
accessToken = authHeader.substring(7);
} else if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
accessToken = formData.get('access_token')?.toString() || null;
} else if (contentType.includes('application/json')) {
const body = await request.json();
accessToken = body.access_token || null;
}
if (!accessToken) {
return NextResponse.json(
{
error: 'invalid_token',
error_description: 'Access token required in Authorization header or request body',
},
{
status: 401,
headers: {
'WWW-Authenticate': 'Bearer realm="OIDC UserInfo", error="invalid_token"',
},
}
);
}
// Forward the request to the Go bridge handler
const response = await fetch(`${BRIDGE_URL}/oidc/userinfo`, {
method: 'GET', // Bridge handler expects GET
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
},
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = {
error: 'server_error',
error_description: 'UserInfo request failed',
};
}
return NextResponse.json(errorData, { status: response.status });
}
const userInfo: OIDCUserInfo = await response.json();
return NextResponse.json(userInfo, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
},
});
} catch (error) {
console.error('OIDC UserInfo POST proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during userinfo retrieval',
},
{ status: 500 }
);
}
}
/**
* OPTIONS /api/oidc/userinfo
*
* Handle preflight CORS requests for the userinfo endpoint.
*/
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
},
});
}
+299
View File
@@ -0,0 +1,299 @@
'use client';
import {
ArrowLeftIcon,
ArrowTopRightOnSquareIcon,
ShieldCheckIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { useEffect, useState } from 'react';
interface OAuthGrant {
id: string;
clientId: string;
clientName: string;
clientLogo?: string;
scopes: string[];
capabilities: UCANCapability[];
grantedAt: string;
lastUsed: string;
expiresAt?: string;
status: 'active' | 'expired' | 'revoked';
}
interface UCANCapability {
action: string;
resource: string;
caveats?: Record<string, any>;
}
export default function CapabilitiesPage() {
const [grants, setGrants] = useState<OAuthGrant[]>([]);
const [loading, setLoading] = useState(true);
const [selectedGrant, setSelectedGrant] = useState<OAuthGrant | null>(null);
const [revoking, setRevoking] = useState<string | null>(null);
useEffect(() => {
fetchGrants();
}, []);
const fetchGrants = async () => {
try {
// TODO: Replace with actual API call
const mockGrants: OAuthGrant[] = [
{
id: 'grant_1',
clientId: 'example-app',
clientName: 'Example Application',
scopes: ['vault:read', 'vault:write', 'profile'],
capabilities: [
{ action: 'read', resource: 'vault:*' },
{ action: 'write', resource: 'vault:*' },
{ action: 'read', resource: 'profile:*' },
],
grantedAt: '2024-01-15T10:00:00Z',
lastUsed: '2024-01-20T15:30:00Z',
status: 'active',
},
{
id: 'grant_2',
clientId: 'defi-wallet',
clientName: 'DeFi Wallet',
scopes: ['dwn:read', 'svc:register'],
capabilities: [
{ action: 'read', resource: 'dwn:*' },
{ action: 'register', resource: 'svc:*' },
],
grantedAt: '2024-01-10T08:00:00Z',
lastUsed: '2024-01-18T12:00:00Z',
expiresAt: '2024-02-10T08:00:00Z',
status: 'active',
},
];
setGrants(mockGrants);
} catch (error) {
console.error('Failed to fetch grants:', error);
} finally {
setLoading(false);
}
};
const revokeGrant = async (grantId: string) => {
setRevoking(grantId);
try {
// TODO: Implement actual revocation API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setGrants((prev) =>
prev.map((grant) =>
grant.id === grantId ? { ...grant, status: 'revoked' as const } : grant
)
);
if (selectedGrant?.id === grantId) {
setSelectedGrant(null);
}
} catch (error) {
console.error('Failed to revoke grant:', error);
} finally {
setRevoking(null);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusColor = (status: OAuthGrant['status']) => {
switch (status) {
case 'active':
return 'bg-green-100 text-green-800';
case 'expired':
return 'bg-yellow-100 text-yellow-800';
case 'revoked':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<Link
href="/"
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 mb-4"
>
<ArrowLeftIcon className="w-4 h-4 mr-1" />
Back to Home
</Link>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">OAuth Capabilities</h1>
<p className="mt-2 text-gray-600">
Manage applications and services that have access to your account
</p>
</div>
<ShieldCheckIcon className="w-10 h-10 text-blue-600" />
</div>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
) : grants.length === 0 ? (
<div className="bg-white rounded-lg shadow p-8 text-center">
<ShieldCheckIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No Active Grants</h3>
<p className="text-gray-600">
You haven't granted any applications access to your account yet.
</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Grants List */}
<div className="lg:col-span-2 space-y-4">
{grants.map((grant) => (
<div
key={grant.id}
className={`bg-white rounded-lg shadow p-6 cursor-pointer transition-all hover:shadow-lg ${
selectedGrant?.id === grant.id ? 'ring-2 ring-blue-500' : ''
}`}
onClick={() => setSelectedGrant(grant)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center">
{grant.clientLogo ? (
<img
src={grant.clientLogo}
alt={grant.clientName}
className="w-10 h-10 rounded-lg mr-3"
/>
) : (
<div className="w-10 h-10 bg-gray-200 rounded-lg mr-3 flex items-center justify-center">
<span className="text-gray-600 font-semibold">
{grant.clientName[0]}
</span>
</div>
)}
<div>
<h3 className="text-lg font-semibold text-gray-900">
{grant.clientName}
</h3>
<p className="text-sm text-gray-500">Client ID: {grant.clientId}</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{grant.scopes.map((scope) => (
<span
key={scope}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
>
{scope}
</span>
))}
</div>
<div className="mt-4 text-sm text-gray-600">
<p>Granted: {formatDate(grant.grantedAt)}</p>
<p>Last used: {formatDate(grant.lastUsed)}</p>
{grant.expiresAt && <p>Expires: {formatDate(grant.expiresAt)}</p>}
</div>
</div>
<div className="flex flex-col items-end ml-4">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(grant.status)}`}
>
{grant.status}
</span>
{grant.status === 'active' && (
<button
onClick={(e) => {
e.stopPropagation();
revokeGrant(grant.id);
}}
disabled={revoking === grant.id}
className="mt-4 inline-flex items-center px-3 py-1 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
>
{revoking === grant.id ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-700" />
) : (
<>
<TrashIcon className="w-4 h-4 mr-1" />
Revoke
</>
)}
</button>
)}
</div>
</div>
</div>
))}
</div>
{/* Grant Details */}
{selectedGrant && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Capability Details</h3>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">UCAN Capabilities</h4>
<div className="space-y-2">
{selectedGrant.capabilities.map((cap, index) => (
<div key={index} className="p-3 bg-gray-50 rounded-lg text-sm">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{cap.action}</span>
<span className="text-gray-600">{cap.resource}</span>
</div>
{cap.caveats && Object.keys(cap.caveats).length > 0 && (
<div className="mt-2 text-xs text-gray-500">
Caveats: {JSON.stringify(cap.caveats)}
</div>
)}
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Delegation Chain</h4>
<button className="inline-flex items-center text-sm text-blue-600 hover:text-blue-500">
View full delegation chain
<ArrowTopRightOnSquareIcon className="w-3 h-3 ml-1" />
</button>
</div>
<div className="pt-4 border-t">
<h4 className="text-sm font-medium text-gray-700 mb-2">Audit Log</h4>
<p className="text-sm text-gray-600">View all activities for this grant</p>
<button className="mt-2 inline-flex items-center text-sm text-blue-600 hover:text-blue-500">
View audit log
<ArrowTopRightOnSquareIcon className="w-3 h-3 ml-1" />
</button>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
'use client';
import { useSession } from '@/hooks/useSession';
import { Button, ErrorAlert } from '@sonr.io/ui';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function DashboardPage() {
const { user, isAuthenticated, isLoading, error, logout } = useSession();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, isLoading, router]);
const handleLogout = async () => {
await logout();
router.push('/');
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated || !user) {
return null; // Will redirect to login
}
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div className="flex items-center">
<h1 className="text-2xl font-bold text-gray-900">Highway Dashboard</h1>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700">
Welcome, {user.displayName || user.username}
</span>
<Button variant="secondary" size="sm" onClick={handleLogout}>
Sign Out
</Button>
</div>
</div>
</div>
</div>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{error && (
<div className="mb-6">
<ErrorAlert message={error} />
</div>
)}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<h2 className="text-xl font-semibold text-gray-900">Account Information</h2>
<p className="mt-2 text-sm text-gray-700">
Your account details and authentication status
</p>
</div>
</div>
<div className="mt-6 border-t border-gray-200 pt-6">
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-gray-500">Username</dt>
<dd className="mt-1 text-sm text-gray-900">{user.username}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Display Name</dt>
<dd className="mt-1 text-sm text-gray-900">{user.displayName || 'Not set'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Account Created</dt>
<dd className="mt-1 text-sm text-gray-900">
{new Date(user.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Authentication Method</dt>
<dd className="mt-1 text-sm text-gray-900">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
WebAuthn Passkey
</span>
</dd>
</div>
</dl>
</div>
</div>
</div>
<div className="mt-6 bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Security Features
</h3>
<ul className="space-y-3">
<li className="flex items-center">
<svg
className="h-5 w-5 text-green-500 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
role="img"
aria-label="Check mark"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-700">Passwordless Authentication</span>
</li>
<li className="flex items-center">
<svg
className="h-5 w-5 text-green-500 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
role="img"
aria-label="Check mark"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-700">Biometric Verification</span>
</li>
<li className="flex items-center">
<svg
className="h-5 w-5 text-green-500 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
role="img"
aria-label="Check mark"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-700">Phishing Resistant</span>
</li>
<li className="flex items-center">
<svg
className="h-5 w-5 text-green-500 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
role="img"
aria-label="Check mark"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-700">Hardware Security Key Support</span>
</li>
</ul>
</div>
</div>
</div>
</main>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
/* Additional app-specific styles */
:root {
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
body {
font-feature-settings: "rlig" 1, "calt" 1;
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Highway - Sonr Authentication Gateway',
description:
'Passwordless authentication using WebAuthn passkeys for the Sonr blockchain ecosystem',
keywords: ['webauthn', 'passkey', 'authentication', 'sonr', 'blockchain'],
authors: [{ name: 'Sonr' }],
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
+150
View File
@@ -0,0 +1,150 @@
'use client';
import { useWebAuthn } from '@/hooks/useWebAuthn';
import { Button, ErrorAlert, Input } from '@sonr.io/ui';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [isSupported, setIsSupported] = useState<boolean | null>(null);
const { authenticateUser, isLoading, error, clearError } = useWebAuthn();
const router = useRouter();
// Check WebAuthn support on component mount
useState(() => {
const checkSupport = async () => {
if (typeof window !== 'undefined') {
const supported =
window.PublicKeyCredential &&
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable ===
'function';
setIsSupported(supported);
}
};
checkSupport();
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
if (!username.trim()) {
return;
}
try {
const success = await authenticateUser(username.trim());
if (success) {
router.push('/dashboard');
}
} catch (_err) {
// Error is handled by the hook
}
};
if (isSupported === false) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
WebAuthn Not Supported
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Your browser doesn't support WebAuthn. Please use a modern browser like Chrome,
Firefox, Safari, or Edge.
</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-green-100">
<svg
className="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Authentication Successful Icon"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Use your passkey to securely access your account
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && <ErrorAlert message={error} onDismiss={clearError} />}
<div>
<Input
label="Username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
autoComplete="username"
helpText="Enter the username you registered with"
/>
</div>
<div>
<Button
type="submit"
size="lg"
isLoading={isLoading}
disabled={!username.trim() || isLoading}
className="w-full"
>
{isLoading ? 'Signing In...' : 'Sign In with Passkey'}
</Button>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
Don't have an account?{' '}
<a href="/register" className="font-medium text-blue-600 hover:text-blue-500">
Create one here
</a>
</p>
</div>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gray-50 text-gray-500">Secure & Simple</span>
</div>
</div>
<div className="mt-4 text-sm text-gray-600 space-y-2">
<p> No passwords to type or remember</p>
<p> Authenticate with your device's biometrics</p>
<p> Protected against phishing and breaches</p>
</div>
</div>
</div>
</div>
);
}
+325
View File
@@ -0,0 +1,325 @@
'use client';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@sonr.io/ui';
import { Button } from '@sonr.io/ui';
import { Alert, AlertDescription } from '@sonr.io/ui';
import { OAuth2Client, parseCallbackUrl } from '@sonr.io/ui';
import { AlertCircle, Check, Shield } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { Suspense } from 'react';
interface AuthorizePageProps {
searchParams: { [key: string]: string | string[] | undefined };
}
function AuthorizeContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = React.useState(false);
// Extract OAuth parameters
const clientId = searchParams.get('client_id');
const redirectUri = searchParams.get('redirect_uri');
const responseType = searchParams.get('response_type');
const scope = searchParams.get('scope');
const state = searchParams.get('state');
const codeChallenge = searchParams.get('code_challenge');
const codeChallengeMethod = searchParams.get('code_challenge_method');
// Get client information (in production, fetch from server)
const clientInfo = React.useMemo(() => {
// Mock client data - replace with actual client registry lookup
const clients: Record<string, { name: string; logo?: string; trusted: boolean }> = {
dev_client_123: { name: 'Development Client', trusted: true },
example_app: { name: 'Example Application', trusted: false },
};
return clients[clientId || ''] || { name: 'Unknown Application', trusted: false };
}, [clientId]);
// Check if user is authenticated
React.useEffect(() => {
const checkAuth = async () => {
try {
// Check for existing session
const token = localStorage.getItem('sonr_auth_token');
if (token) {
setIsAuthenticated(true);
}
} catch (err) {
console.error('Auth check failed:', err);
}
};
checkAuth();
}, []);
// Validate request parameters
React.useEffect(() => {
if (!clientId || !redirectUri || !responseType) {
setError('Missing required OAuth parameters');
return;
}
if (responseType !== 'code' && responseType !== 'token') {
setError('Invalid response type. Only "code" and "token" are supported.');
return;
}
// Validate PKCE for public clients
if (!codeChallenge && responseType === 'code') {
setError('PKCE code challenge is required for public clients');
return;
}
if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
setError('Only S256 code challenge method is supported');
return;
}
}, [clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod]);
// Handle authorization approval
const handleApprove = React.useCallback(async () => {
setIsLoading(true);
setError(null);
try {
if (!isAuthenticated) {
// Redirect to login with return URL
const returnUrl = `/oauth/authorize?${searchParams.toString()}`;
router.push(`/login?return_url=${encodeURIComponent(returnUrl)}`);
return;
}
// Generate authorization code
const response = await fetch('/api/oauth/authorize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('sonr_auth_token')}`,
},
body: JSON.stringify({
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
approved: true,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error_description || 'Authorization failed');
}
const result = await response.json();
// Redirect to client with authorization code
const redirectUrl = new URL(redirectUri!);
if (responseType === 'code') {
redirectUrl.searchParams.set('code', result.code);
} else {
// Implicit flow - add token to fragment
const fragment = new URLSearchParams({
access_token: result.access_token,
token_type: 'Bearer',
expires_in: result.expires_in.toString(),
scope: scope || '',
});
if (state) fragment.set('state', state);
redirectUrl.hash = fragment.toString();
}
if (state) {
redirectUrl.searchParams.set('state', state);
}
window.location.href = redirectUrl.toString();
} catch (err) {
setError(err instanceof Error ? err.message : 'Authorization failed');
} finally {
setIsLoading(false);
}
}, [
isAuthenticated,
clientId,
redirectUri,
responseType,
scope,
state,
codeChallenge,
codeChallengeMethod,
searchParams,
router,
]);
// Handle denial
const handleDeny = React.useCallback(() => {
const redirectUrl = new URL(redirectUri!);
redirectUrl.searchParams.set('error', 'access_denied');
redirectUrl.searchParams.set('error_description', 'User denied authorization');
if (state) {
redirectUrl.searchParams.set('state', state);
}
window.location.href = redirectUrl.toString();
}, [redirectUri, state]);
// Parse requested scopes
const requestedScopes = React.useMemo(() => {
if (!scope) return [];
const scopeDescriptions: Record<
string,
{ title: string; description: string; icon: React.ReactNode }
> = {
openid: {
title: 'Basic Profile',
description: 'Your Sonr ID and basic profile information',
icon: <Shield className="h-4 w-4" />,
},
profile: {
title: 'Profile Information',
description: 'Your name, picture, and other profile details',
icon: <Shield className="h-4 w-4" />,
},
'vault:read': {
title: 'Read Vault Data',
description: 'Read access to your encrypted vault',
icon: <Shield className="h-4 w-4" />,
},
'vault:write': {
title: 'Write Vault Data',
description: 'Create and modify data in your vault',
icon: <Shield className="h-4 w-4" />,
},
'vault:sign': {
title: 'Sign with Vault Keys',
description: 'Sign transactions and messages with your vault keys',
icon: <Shield className="h-4 w-4" />,
},
'service:manage': {
title: 'Manage Services',
description: 'Register and manage services on your behalf',
icon: <Shield className="h-4 w-4" />,
},
};
return scope.split(' ').map((s) => ({
scope: s,
...(scopeDescriptions[s] || {
title: s,
description: `Access to ${s}`,
icon: <Shield className="h-4 w-4" />,
}),
}));
}, [scope]);
if (error) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
Authorization Error
</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
<CardFooter>
<Button variant="outline" onClick={() => window.history.back()}>
Go Back
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
<Card className="w-full max-w-md">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{clientInfo.logo && (
<img src={clientInfo.logo} alt={clientInfo.name} className="h-10 w-10 rounded" />
)}
<div>
<CardTitle>{clientInfo.name}</CardTitle>
<CardDescription>wants to access your Sonr account</CardDescription>
</div>
</div>
{clientInfo.trusted && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
Verified
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{!isAuthenticated && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
You need to sign in to continue with authorization
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<p className="text-sm font-medium">This application will be able to:</p>
<div className="space-y-2">
{requestedScopes.map(({ scope, title, description, icon }) => (
<div key={scope} className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div className="mt-0.5">{icon}</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
))}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">
By authorizing, you allow this application to access your information in accordance
with its terms of service and privacy policy.
</p>
</div>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" onClick={handleDeny} disabled={isLoading} className="flex-1">
Deny
</Button>
<Button onClick={handleApprove} disabled={isLoading} className="flex-1">
{isLoading ? 'Authorizing...' : isAuthenticated ? 'Authorize' : 'Sign In & Authorize'}
</Button>
</CardFooter>
</Card>
</div>
);
}
export default function AuthorizePage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<AuthorizeContent />
</Suspense>
);
}
@@ -0,0 +1,451 @@
'use client';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@sonr.io/ui';
import { Button } from '@sonr.io/ui';
import { Alert, AlertDescription } from '@sonr.io/ui';
import { OAuth2Client, parseCallbackUrl } from '@sonr.io/ui';
import { AlertCircle, Check, Shield } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import React from 'react';
interface ClientBranding {
id: string;
name: string;
description?: string;
logo?: string;
verified: boolean;
theme?: {
primaryColor?: string;
accentColor?: string;
backgroundColor?: string;
cardBackground?: string;
borderRadius?: string;
fontFamily?: string;
};
customCSS?: string;
}
export default function ThemedAuthorizePage() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = React.useState(false);
// Extract OAuth parameters
const clientId = searchParams.get('client_id');
const redirectUri = searchParams.get('redirect_uri');
const responseType = searchParams.get('response_type');
const scope = searchParams.get('scope');
const state = searchParams.get('state');
const codeChallenge = searchParams.get('code_challenge');
const codeChallengeMethod = searchParams.get('code_challenge_method');
// Get client branding information (in production, fetch from server)
const clientBranding = React.useMemo<ClientBranding>(() => {
// Mock client data with branding - replace with actual client registry lookup
const clients: Record<string, ClientBranding> = {
branded_app: {
id: 'branded_app',
name: 'Branded Application',
logo: '/logos/branded-app.svg',
verified: true,
theme: {
primaryColor: '#4F46E5',
accentColor: '#7C3AED',
backgroundColor: '#F9FAFB',
cardBackground: '#FFFFFF',
borderRadius: '12px',
fontFamily: '"Inter", system-ui, sans-serif',
},
customCSS: `
.authorize-card {
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}
.scope-item {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
`,
},
minimal_app: {
id: 'minimal_app',
name: 'Minimal Application',
verified: false,
theme: {
primaryColor: '#000000',
accentColor: '#666666',
backgroundColor: '#FFFFFF',
cardBackground: '#FAFAFA',
borderRadius: '4px',
fontFamily: 'monospace',
},
},
default: {
id: clientId || 'unknown',
name: 'Unknown Application',
verified: false,
},
};
return clients[clientId || ''] || clients.default;
}, [clientId]);
// Apply custom theme
React.useEffect(() => {
if (clientBranding.theme) {
const theme = clientBranding.theme;
const root = document.documentElement;
if (theme.primaryColor) {
root.style.setProperty('--brand-primary', theme.primaryColor);
}
if (theme.accentColor) {
root.style.setProperty('--brand-accent', theme.accentColor);
}
if (theme.backgroundColor) {
root.style.setProperty('--brand-background', theme.backgroundColor);
}
if (theme.cardBackground) {
root.style.setProperty('--brand-card', theme.cardBackground);
}
if (theme.borderRadius) {
root.style.setProperty('--brand-radius', theme.borderRadius);
}
if (theme.fontFamily) {
root.style.setProperty('--brand-font', theme.fontFamily);
}
}
// Apply custom CSS if provided
if (clientBranding.customCSS) {
const styleElement = document.createElement('style');
styleElement.textContent = clientBranding.customCSS;
document.head.appendChild(styleElement);
return () => {
document.head.removeChild(styleElement);
};
}
}, [clientBranding]);
// Check if user is authenticated
React.useEffect(() => {
const checkAuth = async () => {
try {
const token = localStorage.getItem('sonr_auth_token');
if (token) {
setIsAuthenticated(true);
}
} catch (err) {
console.error('Auth check failed:', err);
}
};
checkAuth();
}, []);
// Validate request parameters
React.useEffect(() => {
if (!clientId || !redirectUri || !responseType) {
setError('Missing required OAuth parameters');
return;
}
if (responseType !== 'code' && responseType !== 'token') {
setError('Invalid response type. Only "code" and "token" are supported.');
return;
}
if (!codeChallenge && responseType === 'code') {
setError('PKCE code challenge is required for public clients');
return;
}
if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
setError('Only S256 code challenge method is supported');
return;
}
}, [clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod]);
// Handle authorization approval
const handleApprove = React.useCallback(async () => {
setIsLoading(true);
setError(null);
try {
if (!isAuthenticated) {
const returnUrl = `/oauth/authorize?${searchParams.toString()}`;
router.push(`/login?return_url=${encodeURIComponent(returnUrl)}`);
return;
}
const response = await fetch('/api/oauth/authorize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('sonr_auth_token')}`,
},
body: JSON.stringify({
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
approved: true,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error_description || 'Authorization failed');
}
const result = await response.json();
const redirectUrl = new URL(redirectUri!);
if (responseType === 'code') {
redirectUrl.searchParams.set('code', result.code);
} else {
const fragment = new URLSearchParams({
access_token: result.access_token,
token_type: 'Bearer',
expires_in: result.expires_in.toString(),
scope: scope || '',
});
if (state) fragment.set('state', state);
redirectUrl.hash = fragment.toString();
}
if (state) {
redirectUrl.searchParams.set('state', state);
}
window.location.href = redirectUrl.toString();
} catch (err) {
setError(err instanceof Error ? err.message : 'Authorization failed');
} finally {
setIsLoading(false);
}
}, [
isAuthenticated,
clientId,
redirectUri,
responseType,
scope,
state,
codeChallenge,
codeChallengeMethod,
searchParams,
router,
]);
// Handle denial
const handleDeny = React.useCallback(() => {
const redirectUrl = new URL(redirectUri!);
redirectUrl.searchParams.set('error', 'access_denied');
redirectUrl.searchParams.set('error_description', 'User denied authorization');
if (state) {
redirectUrl.searchParams.set('state', state);
}
window.location.href = redirectUrl.toString();
}, [redirectUri, state]);
// Parse requested scopes with custom icons
const requestedScopes = React.useMemo(() => {
if (!scope) return [];
const scopeDescriptions: Record<
string,
{ title: string; description: string; icon: React.ReactNode }
> = {
openid: {
title: 'Basic Profile',
description: 'Your Sonr ID and basic profile information',
icon: <Shield className="h-4 w-4" />,
},
profile: {
title: 'Profile Information',
description: 'Your name, picture, and other profile details',
icon: <Shield className="h-4 w-4" />,
},
'vault:read': {
title: 'Read Vault Data',
description: 'Read access to your encrypted vault',
icon: <Shield className="h-4 w-4" />,
},
'vault:write': {
title: 'Write Vault Data',
description: 'Create and modify data in your vault',
icon: <Shield className="h-4 w-4" />,
},
'vault:sign': {
title: 'Sign with Vault Keys',
description: 'Sign transactions and messages with your vault keys',
icon: <Shield className="h-4 w-4" />,
},
'service:manage': {
title: 'Manage Services',
description: 'Register and manage services on your behalf',
icon: <Shield className="h-4 w-4" />,
},
};
return scope.split(' ').map((s) => ({
scope: s,
...(scopeDescriptions[s] || {
title: s,
description: `Access to ${s}`,
icon: <Shield className="h-4 w-4" />,
}),
}));
}, [scope]);
if (error) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
Authorization Error
</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
<CardFooter>
<Button variant="outline" onClick={() => window.history.back()}>
Go Back
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
<div
className="flex min-h-screen items-center justify-center p-4"
style={{
background: clientBranding.theme?.backgroundColor || 'var(--background)',
fontFamily: clientBranding.theme?.fontFamily || 'inherit',
}}
>
<Card
className="authorize-card w-full max-w-md"
style={{
background: clientBranding.theme?.cardBackground || 'var(--card)',
borderRadius: clientBranding.theme?.borderRadius || 'var(--radius)',
}}
>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{clientBranding.logo && (
<img
src={clientBranding.logo}
alt={clientBranding.name}
className="h-12 w-12 rounded"
style={{
borderRadius: clientBranding.theme?.borderRadius || 'var(--radius)',
}}
/>
)}
<div>
<CardTitle
style={{
color: clientBranding.theme?.primaryColor || 'inherit',
}}
>
{clientBranding.name}
</CardTitle>
<CardDescription>wants to access your Sonr account</CardDescription>
</div>
</div>
{clientBranding.verified && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Check
className="h-4 w-4"
style={{ color: clientBranding.theme?.accentColor || 'rgb(34 197 94)' }}
/>
Verified
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{!isAuthenticated && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
You need to sign in to continue with authorization
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<p className="text-sm font-medium">This application will be able to:</p>
<div className="space-y-2">
{requestedScopes.map(({ scope, title, description, icon }) => (
<div
key={scope}
className="scope-item flex items-start gap-3 p-3 rounded-lg"
style={{
borderRadius: `calc(${clientBranding.theme?.borderRadius || 'var(--radius)'} * 0.5)`,
}}
>
<div className="mt-0.5">{icon}</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium">{title}</p>
<p className="text-xs opacity-90">{description}</p>
</div>
</div>
))}
</div>
</div>
<div
className="rounded-lg p-3"
style={{
background: clientBranding.theme?.cardBackground
? `color-mix(in srgb, ${clientBranding.theme.cardBackground} 95%, black)`
: 'var(--muted)',
borderRadius: `calc(${clientBranding.theme?.borderRadius || 'var(--radius)'} * 0.5)`,
}}
>
<p className="text-xs text-muted-foreground">
By authorizing, you allow this application to access your information in accordance
with its terms of service and privacy policy.
</p>
</div>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" onClick={handleDeny} disabled={isLoading} className="flex-1">
Deny
</Button>
<Button
onClick={handleApprove}
disabled={isLoading}
className="flex-1"
style={{
background: clientBranding.theme?.primaryColor || 'var(--primary)',
borderRadius: `calc(${clientBranding.theme?.borderRadius || 'var(--radius)'} * 0.5)`,
}}
>
{isLoading ? 'Authorizing...' : isAuthenticated ? 'Authorize' : 'Sign In & Authorize'}
</Button>
</CardFooter>
</Card>
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@sonr.io/ui';
import { Alert, AlertDescription } from '@sonr.io/ui';
import { Button } from '@sonr.io/ui';
import { useSignInWithSonr } from '@sonr.io/ui';
import { AlertCircle, CheckCircle, Loader2, XCircle } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { Suspense } from 'react';
function CallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = React.useState<'processing' | 'success' | 'error'>('processing');
const [message, setMessage] = React.useState<string>('');
const [userInfo, setUserInfo] = React.useState<Record<string, unknown> | null>(null);
// Extract OAuth callback parameters
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
// Get stored OAuth configuration from session
const getStoredConfig = React.useCallback(() => {
const stored = sessionStorage.getItem('sonr_oauth_config');
if (stored) {
try {
return JSON.parse(stored);
} catch {
return null;
}
}
return null;
}, []);
// Initialize OAuth client
const oauthConfig = React.useMemo(() => {
const stored = getStoredConfig();
if (stored) {
return {
clientId: stored.clientId,
redirectUri: stored.redirectUri || `${window.location.origin}/oauth/callback`,
authorizationUrl: '/oauth2/authorize',
tokenUrl: '/oauth2/token',
userInfoUrl: '/oauth2/userinfo',
scopes: stored.scopes || ['openid', 'profile'],
};
}
// Fallback config for development
return {
clientId: 'dev_client_123',
redirectUri: `${window.location.origin}/oauth/callback`,
authorizationUrl: '/oauth2/authorize',
tokenUrl: '/oauth2/token',
userInfoUrl: '/oauth2/userinfo',
scopes: ['openid', 'profile'],
};
}, [getStoredConfig]);
const { handleCallback } = useSignInWithSonr(oauthConfig, {
onSuccess: (user, token) => {
setUserInfo(user);
setStatus('success');
setMessage('Authentication successful! Redirecting...');
// Store authentication data
localStorage.setItem('sonr_auth_user', JSON.stringify(user));
localStorage.setItem('sonr_auth_token', JSON.stringify(token));
// Get return URL from session storage
const returnUrl = sessionStorage.getItem('sonr_oauth_return_url');
sessionStorage.removeItem('sonr_oauth_return_url');
sessionStorage.removeItem('sonr_oauth_config');
// Redirect to return URL or dashboard
setTimeout(() => {
if (returnUrl) {
// If it's an external URL, use postMessage to communicate
if (returnUrl.startsWith('http')) {
window.opener?.postMessage(
{
type: 'sonr_auth_success',
user,
token,
},
new URL(returnUrl).origin
);
window.close();
} else {
router.push(returnUrl);
}
} else {
router.push('/dashboard');
}
}, 2000);
},
onError: (err) => {
setStatus('error');
setMessage(err.message || 'Authentication failed');
},
});
// Handle OAuth callback
React.useEffect(() => {
const processCallback = async () => {
// Check for OAuth errors first
if (error) {
setStatus('error');
setMessage(errorDescription || `OAuth error: ${error}`);
return;
}
// Validate state parameter
const storedState = sessionStorage.getItem('sonr_oauth_state');
if (state && storedState && state !== storedState) {
setStatus('error');
setMessage('Invalid state parameter. Possible CSRF attack.');
return;
}
// Process authorization code
if (code) {
try {
await handleCallback(window.location.href);
} catch (err) {
console.error('Callback processing error:', err);
setStatus('error');
setMessage(err instanceof Error ? err.message : 'Failed to process callback');
}
} else {
setStatus('error');
setMessage('No authorization code received');
}
};
processCallback();
}, [code, state, error, errorDescription, handleCallback]);
// Handle retry
const handleRetry = () => {
const returnUrl = sessionStorage.getItem('sonr_oauth_return_url') || '/dashboard';
router.push(`/login?return_url=${encodeURIComponent(returnUrl)}`);
};
// Handle close for popup mode
const handleClose = () => {
if (window.opener) {
window.opener.postMessage(
{
type: 'sonr_auth_cancelled',
},
'*'
);
window.close();
} else {
router.push('/');
}
};
return (
<div className="flex min-h-screen items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{status === 'processing' && (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Processing Authentication
</>
)}
{status === 'success' && (
<>
<CheckCircle className="h-5 w-5 text-green-500" />
Authentication Successful
</>
)}
{status === 'error' && (
<>
<XCircle className="h-5 w-5 text-destructive" />
Authentication Failed
</>
)}
</CardTitle>
<CardDescription>
{status === 'processing' && 'Please wait while we complete your authentication...'}
{status === 'success' && 'You have been successfully authenticated'}
{status === 'error' && 'There was a problem with your authentication'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Status message */}
{message && (
<Alert variant={status === 'error' ? 'destructive' : 'default'}>
<AlertCircle className="h-4 w-4" />
<AlertDescription>{message}</AlertDescription>
</Alert>
)}
{/* User info display on success */}
{status === 'success' && userInfo && (
<div className="space-y-2 p-4 rounded-lg bg-muted/50">
<p className="text-sm font-medium">Welcome back!</p>
<div className="space-y-1 text-sm text-muted-foreground">
{userInfo.name && <p>Name: {userInfo.name}</p>}
{userInfo.email && <p>Email: {userInfo.email}</p>}
{userInfo.did && <p className="font-mono text-xs break-all">DID: {userInfo.did}</p>}
</div>
</div>
)}
{/* Error details */}
{status === 'error' && error && (
<div className="space-y-1 p-3 rounded-lg bg-destructive/10 text-sm">
<p className="font-medium">Error Code: {error}</p>
{errorDescription && <p className="text-muted-foreground">{errorDescription}</p>}
</div>
)}
{/* Loading spinner */}
{status === 'processing' && (
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)}
{/* Action buttons */}
{status === 'error' && (
<div className="flex gap-2">
<Button variant="outline" onClick={handleClose} className="flex-1">
Cancel
</Button>
<Button onClick={handleRetry} className="flex-1">
Try Again
</Button>
</div>
)}
{/* Success redirect notice */}
{status === 'success' && (
<div className="text-center text-sm text-muted-foreground">
<p>Redirecting you to the application...</p>
<div className="mt-2">
<Loader2 className="h-4 w-4 animate-spin inline" />
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function CallbackPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<CallbackContent />
</Suspense>
);
}
+325
View File
@@ -0,0 +1,325 @@
'use client';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@sonr.io/ui';
import { Button } from '@sonr.io/ui';
import { Alert, AlertDescription } from '@sonr.io/ui';
import { Checkbox } from '@sonr.io/ui';
import { Label } from '@sonr.io/ui';
import { AlertCircle, Database, Info, Key, Shield, Wrench } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { Suspense } from 'react';
interface ConsentScope {
scope: string;
title: string;
description: string;
icon: React.ReactNode;
required: boolean;
capabilities?: string[];
}
function ConsentContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [selectedScopes, setSelectedScopes] = React.useState<Set<string>>(new Set());
const [rememberChoice, setRememberChoice] = React.useState(false);
// Extract OAuth parameters
const clientId = searchParams.get('client_id');
const redirectUri = searchParams.get('redirect_uri');
const requestedScopes = searchParams.get('scope')?.split(' ') || [];
const state = searchParams.get('state');
const authCode = searchParams.get('auth_code'); // Internal auth code for consent
// Define available scopes with UCAN capability mappings
const scopeDefinitions: ConsentScope[] = React.useMemo(
() => [
{
scope: 'openid',
title: 'Basic Identity',
description: 'Access to your Sonr DID and basic authentication info',
icon: <Shield className="h-5 w-5" />,
required: true,
capabilities: ['did:read'],
},
{
scope: 'profile',
title: 'Profile Information',
description: 'Read your name, picture, and public profile details',
icon: <Shield className="h-5 w-5" />,
required: false,
capabilities: ['profile:read'],
},
{
scope: 'vault:read',
title: 'Read Vault Data',
description: 'Read encrypted data stored in your personal vault',
icon: <Database className="h-5 w-5" />,
required: false,
capabilities: ['vault:read', 'vault:list'],
},
{
scope: 'vault:write',
title: 'Modify Vault Data',
description: 'Create, update, and organize data in your vault',
icon: <Database className="h-5 w-5" />,
required: false,
capabilities: ['vault:write', 'vault:create', 'vault:update'],
},
{
scope: 'vault:sign',
title: 'Sign Transactions',
description: 'Sign messages and transactions using your vault keys',
icon: <Key className="h-5 w-5" />,
required: false,
capabilities: ['vault:sign', 'tx:sign'],
},
{
scope: 'service:manage',
title: 'Manage Services',
description: 'Register and manage decentralized services on your behalf',
icon: <Wrench className="h-5 w-5" />,
required: false,
capabilities: ['service:create', 'service:update', 'service:delete'],
},
],
[]
);
// Filter to only requested scopes
const availableScopes = React.useMemo(() => {
return scopeDefinitions.filter((def) => requestedScopes.includes(def.scope));
}, [scopeDefinitions, requestedScopes]);
// Initialize selected scopes with required ones
React.useEffect(() => {
const required = new Set(availableScopes.filter((s) => s.required).map((s) => s.scope));
setSelectedScopes(required);
}, [availableScopes]);
// Validate request
React.useEffect(() => {
if (!clientId || !redirectUri || !authCode) {
setError('Invalid consent request. Missing required parameters.');
}
}, [clientId, redirectUri, authCode]);
// Handle scope toggle
const toggleScope = (scope: string, required: boolean) => {
if (required) return; // Can't toggle required scopes
setSelectedScopes((prev) => {
const next = new Set(prev);
if (next.has(scope)) {
next.delete(scope);
} else {
next.add(scope);
}
return next;
});
};
// Handle consent approval
const handleApprove = async () => {
setIsLoading(true);
setError(null);
try {
// Submit consent decision
const response = await fetch('/api/oauth/consent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('sonr_auth_token')}`,
},
body: JSON.stringify({
auth_code: authCode,
client_id: clientId,
approved_scopes: Array.from(selectedScopes),
remember: rememberChoice,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error_description || 'Consent submission failed');
}
const result = await response.json();
// Redirect back to authorization endpoint with consent token
const authUrl = new URL('/oauth/authorize', window.location.origin);
authUrl.searchParams.set('client_id', clientId!);
authUrl.searchParams.set('redirect_uri', redirectUri!);
authUrl.searchParams.set('scope', Array.from(selectedScopes).join(' '));
authUrl.searchParams.set('consent_token', result.consent_token);
if (state) {
authUrl.searchParams.set('state', state);
}
router.push(authUrl.pathname + authUrl.search);
} catch (err) {
setError(err instanceof Error ? err.message : 'Consent submission failed');
} finally {
setIsLoading(false);
}
};
// Handle consent denial
const handleDeny = () => {
const redirectUrl = new URL(redirectUri!);
redirectUrl.searchParams.set('error', 'consent_required');
redirectUrl.searchParams.set('error_description', 'User denied consent for requested scopes');
if (state) {
redirectUrl.searchParams.set('state', state);
}
window.location.href = redirectUrl.toString();
};
// Calculate total capabilities being granted
const totalCapabilities = React.useMemo(() => {
const caps = new Set<string>();
availableScopes.forEach((scope) => {
if (selectedScopes.has(scope.scope) && scope.capabilities) {
scope.capabilities.forEach((cap) => caps.add(cap));
}
});
return Array.from(caps);
}, [availableScopes, selectedScopes]);
if (error) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
Consent Error
</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
<CardFooter>
<Button variant="outline" onClick={() => window.history.back()}>
Go Back
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle>Review Permissions</CardTitle>
<CardDescription>Choose which permissions to grant this application</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Scope selection */}
<div className="space-y-3">
{availableScopes.map((scope) => (
<div
key={scope.scope}
className={`border rounded-lg p-4 transition-colors ${
selectedScopes.has(scope.scope)
? 'bg-primary/5 border-primary/20'
: 'bg-muted/30 border-muted-foreground/10'
}`}
>
<div className="flex items-start gap-3">
<Checkbox
id={scope.scope}
checked={selectedScopes.has(scope.scope)}
disabled={scope.required}
onCheckedChange={() => toggleScope(scope.scope, scope.required)}
className="mt-1"
/>
<div className="flex-1 space-y-1">
<Label htmlFor={scope.scope} className="flex items-center gap-2 cursor-pointer">
{scope.icon}
<span className="font-medium">{scope.title}</span>
{scope.required && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">
Required
</span>
)}
</Label>
<p className="text-sm text-muted-foreground">{scope.description}</p>
{scope.capabilities && scope.capabilities.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{scope.capabilities.map((cap) => (
<span key={cap} className="text-xs bg-muted px-2 py-0.5 rounded">
{cap}
</span>
))}
</div>
)}
</div>
</div>
</div>
))}
</div>
{/* UCAN capabilities summary */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong>UCAN Capabilities:</strong> This will create a delegation chain granting{' '}
{totalCapabilities.length} capabilities to the application.
</AlertDescription>
</Alert>
{/* Remember choice option */}
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
<Checkbox
id="remember"
checked={rememberChoice}
onCheckedChange={(checked) => setRememberChoice(checked as boolean)}
/>
<Label htmlFor="remember" className="text-sm cursor-pointer">
Remember my choice for this application
</Label>
</div>
{/* Privacy notice */}
<div className="text-xs text-muted-foreground p-3 rounded-lg bg-muted/30">
Your data remains encrypted in your vault. Applications can only access what you
explicitly permit through these capabilities.
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" onClick={handleDeny} disabled={isLoading} className="flex-1">
Deny
</Button>
<Button
onClick={handleApprove}
disabled={isLoading || selectedScopes.size === 0}
className="flex-1"
>
{isLoading
? 'Processing...'
: `Grant ${selectedScopes.size} Permission${selectedScopes.size !== 1 ? 's' : ''}`}
</Button>
</CardFooter>
</Card>
</div>
);
}
export default function ConsentPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ConsentContent />
</Suspense>
);
}
+179
View File
@@ -0,0 +1,179 @@
'use client';
import { useSession } from '@/hooks/useSession';
import { Button, SignInWithSonr } from '@sonr.io/ui';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function Home() {
const { isAuthenticated, isLoading } = useSession();
const router = useRouter();
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.push('/dashboard');
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<main className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-gray-50">
<div className="flex min-h-screen flex-col items-center justify-center p-6">
<div className="max-w-4xl mx-auto text-center">
{/* Logo and Title */}
<div className="mb-8">
<div className="mx-auto h-16 w-16 flex items-center justify-center rounded-full bg-blue-100 mb-6">
<svg
className="h-8 w-8 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Security shield"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<h1 className="text-5xl font-bold text-gray-900 mb-4">Highway</h1>
<p className="text-xl text-gray-600 mb-8">Sonr Authentication Gateway</p>
<p className="text-lg text-gray-500 max-w-2xl mx-auto">
Experience passwordless authentication with WebAuthn passkeys. Secure, simple, and
seamless access to your digital identity.
</p>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
<Button size="lg" onClick={() => router.push('/register')} className="px-8 py-3">
Create Account
</Button>
<Button
variant="secondary"
size="lg"
onClick={() => router.push('/login')}
className="px-8 py-3"
>
Sign In
</Button>
</div>
{/* OAuth Provider Section */}
<div className="border-t pt-8 mb-8">
<p className="text-sm text-gray-600 mb-4">Or use Sonr as an OAuth provider</p>
<SignInWithSonr
clientId="demo_client"
redirectUri={`${window.location.origin}/oauth/callback`}
scopes={['openid', 'profile', 'vault:read']}
onSuccess={(result) => {
console.log('OAuth success:', result);
router.push('/dashboard');
}}
onError={(error) => {
console.error('OAuth error:', error);
}}
/>
</div>
{/* Features */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
<div className="text-center">
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-green-100 mb-4">
<svg
className="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
role="img"
aria-label="Lock icon"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">No Passwords</h3>
<p className="text-gray-600">
Authenticate using your device's built-in security features like fingerprint or face
recognition.
</p>
</div>
<div className="text-center">
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100 mb-4">
<svg
className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
role="img"
aria-label="Lightning bolt"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Lightning Fast</h3>
<p className="text-gray-600">
Sign in instantly without typing passwords. One touch or glance is all it takes.
</p>
</div>
<div className="text-center">
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-purple-100 mb-4">
<svg
className="h-6 w-6 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
role="img"
aria-label="Shield check"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Secure by Design</h3>
<p className="text-gray-600">
Protected against phishing, breaches, and replay attacks with cryptographic
security.
</p>
</div>
</div>
{/* Technical Info */}
<div className="text-center text-sm text-gray-500">
<p>Powered by WebAuthn and FIDO2 standards</p>
<p className="mt-1">Compatible with modern browsers and platforms</p>
</div>
</div>
</div>
</main>
);
}
+162
View File
@@ -0,0 +1,162 @@
'use client';
import { useWebAuthn } from '@/hooks/useWebAuthn';
import { Button, ErrorAlert, Input } from '@sonr.io/ui';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
export default function RegisterPage() {
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [isSupported, setIsSupported] = useState<boolean | null>(null);
const { registerUser, isLoading, error, clearError } = useWebAuthn();
const router = useRouter();
// Check WebAuthn support on component mount
useState(() => {
const checkSupport = async () => {
if (typeof window !== 'undefined') {
const supported =
window.PublicKeyCredential &&
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable ===
'function';
setIsSupported(supported);
}
};
checkSupport();
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
if (!username.trim()) {
return;
}
try {
const success = await registerUser(username.trim(), displayName.trim() || undefined);
if (success) {
router.push('/dashboard');
}
} catch (_err) {
// Error is handled by the hook
}
};
if (isSupported === false) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
WebAuthn Not Supported
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Your browser doesn't support WebAuthn. Please use a modern browser like Chrome,
Firefox, Safari, or Edge.
</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
<svg
className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Account Creation Icon"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Register with a passkey for secure, passwordless authentication
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && <ErrorAlert message={error} onDismiss={clearError} />}
<div className="space-y-4">
<Input
label="Username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
autoComplete="username"
helpText="Choose a unique username for your account"
/>
<Input
label="Display Name (Optional)"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Enter your display name"
autoComplete="name"
helpText="This will be shown in your profile"
/>
</div>
<div>
<Button
type="submit"
size="lg"
isLoading={isLoading}
disabled={!username.trim() || isLoading}
className="w-full"
>
{isLoading ? 'Creating Account...' : 'Create Account with Passkey'}
</Button>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
Already have an account?{' '}
<a href="/login" className="font-medium text-blue-600 hover:text-blue-500">
Sign in here
</a>
</p>
</div>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gray-50 text-gray-500">What is a passkey?</span>
</div>
</div>
<div className="mt-4 text-sm text-gray-600 space-y-2">
<p>• No passwords to remember or type</p>
<p>• Uses your device's built-in security (fingerprint, face, PIN)</p>
<p> More secure than traditional passwords</p>
<p> Works across all your devices</p>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,186 @@
'use client';
import { ChevronDownIcon, ChevronRightIcon, LinkIcon } from '@heroicons/react/24/outline';
import { useState } from 'react';
interface DelegationNode {
id: string;
issuer: string;
audience: string;
capabilities: UCANCapability[];
expiresAt?: string;
issuedAt: string;
signature: string;
proofs: string[];
children?: DelegationNode[];
}
interface UCANCapability {
action: string;
resource: string;
caveats?: Record<string, any>;
}
interface DelegationChainViewerProps {
rootDelegation: DelegationNode;
className?: string;
}
export function DelegationChainViewer({
rootDelegation,
className = '',
}: DelegationChainViewerProps) {
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set([rootDelegation.id]));
const toggleNode = (nodeId: string) => {
setExpandedNodes((prev) => {
const next = new Set(prev);
if (next.has(nodeId)) {
next.delete(nodeId);
} else {
next.add(nodeId);
}
return next;
});
};
const formatDID = (did: string) => {
if (did.length > 20) {
return `${did.slice(0, 8)}...${did.slice(-8)}`;
}
return did;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const renderNode = (node: DelegationNode, depth = 0): JSX.Element => {
const isExpanded = expandedNodes.has(node.id);
const hasChildren = node.children && node.children.length > 0;
return (
<div key={node.id} className="relative">
{/* Connection line for non-root nodes */}
{depth > 0 && (
<div
className="absolute left-6 top-0 w-px bg-gray-300"
style={{ height: '24px', transform: 'translateY(-24px)' }}
/>
)}
<div className={`relative ${depth > 0 ? 'ml-8' : ''}`}>
{/* Node container */}
<div className="group relative bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
{/* Expand/collapse button */}
{hasChildren && (
<button
onClick={() => toggleNode(node.id)}
className="absolute -left-3 top-6 w-6 h-6 bg-white border border-gray-300 rounded-full flex items-center justify-center hover:bg-gray-50"
>
{isExpanded ? (
<ChevronDownIcon className="w-4 h-4 text-gray-600" />
) : (
<ChevronRightIcon className="w-4 h-4 text-gray-600" />
)}
</button>
)}
{/* Node content */}
<div className="space-y-3">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<LinkIcon className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium text-gray-900">
Delegation {depth > 0 ? `(Level ${depth})` : '(Root)'}
</span>
</div>
<p className="mt-1 text-xs text-gray-500">ID: {node.id.slice(0, 16)}...</p>
</div>
<span className="text-xs text-gray-500">{formatDate(node.issuedAt)}</span>
</div>
{/* Issuer and Audience */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-xs text-gray-500">Issuer:</span>
<p className="font-mono text-xs mt-1">{formatDID(node.issuer)}</p>
</div>
<div>
<span className="text-xs text-gray-500">Audience:</span>
<p className="font-mono text-xs mt-1">{formatDID(node.audience)}</p>
</div>
</div>
{/* Capabilities */}
<div>
<span className="text-xs text-gray-500">Capabilities:</span>
<div className="mt-1 flex flex-wrap gap-1">
{node.capabilities.map((cap, idx) => (
<span
key={idx}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
>
{cap.action}:{cap.resource}
</span>
))}
</div>
</div>
{/* Expiration */}
{node.expiresAt && (
<div className="text-xs">
<span className="text-gray-500">Expires:</span>
<span
className={`ml-2 ${new Date(node.expiresAt) < new Date() ? 'text-red-600' : 'text-gray-900'}`}
>
{formatDate(node.expiresAt)}
</span>
</div>
)}
{/* Signature preview */}
<div className="text-xs">
<span className="text-gray-500">Signature:</span>
<span className="ml-2 font-mono text-gray-600">
{node.signature.slice(0, 20)}...
</span>
</div>
</div>
</div>
{/* Children nodes */}
{hasChildren && isExpanded && (
<div className="relative mt-4 space-y-4">
{/* Vertical connection line */}
{node.children!.map(
(_, index) =>
index < node.children!.length - 1 && (
<div
key={`line-${index}`}
className="absolute left-6 top-6 w-px bg-gray-300"
style={{
height: `calc(100% - 24px)`,
transform: 'translateY(24px)',
}}
/>
)
)}
{node.children!.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
</div>
);
};
return <div className={`delegation-chain-viewer ${className}`}>{renderNode(rootDelegation)}</div>;
}
@@ -0,0 +1,241 @@
/**
* WebAuthn Registration Component with Email/Tel Support
*/
import React, { useState } from 'react';
import { useWebAuthn } from '../hooks/useWebAuthn';
interface RegistrationSuccessInfo {
did: string;
vaultId: string;
assertionMethods: string[];
}
export function WebAuthnRegistration() {
const { registerUser, isLoading, error, clearError } = useWebAuthn();
// Form state
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [assertionType, setAssertionType] = useState<'email' | 'tel'>('email');
const [assertionValue, setAssertionValue] = useState('');
const [createVault, setCreateVault] = useState(true);
// Success state
const [success, setSuccess] = useState(false);
const [registrationInfo, setRegistrationInfo] = useState<RegistrationSuccessInfo | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
setSuccess(false);
setRegistrationInfo(null);
try {
const result = await registerUser(
username,
displayName || username,
assertionType === 'email' ? assertionValue : undefined,
assertionType === 'tel' ? assertionValue : undefined,
createVault
);
if (result.success && result.did && result.vaultId) {
setSuccess(true);
setRegistrationInfo({
did: result.did,
vaultId: result.vaultId,
assertionMethods: [
`did:sonr:${username}`,
`did:${assertionType}:${assertionValue}`
]
});
}
} catch (err) {
console.error('Registration failed:', err);
}
};
const resetForm = () => {
setUsername('');
setDisplayName('');
setAssertionValue('');
setSuccess(false);
setRegistrationInfo(null);
clearError();
};
if (success && registrationInfo) {
return (
<div className="w-full max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
<div className="mb-6">
<h2 className="text-2xl font-bold text-green-600 mb-2">
Registration Successful!
</h2>
<p className="text-gray-600">
Your decentralized identity has been created successfully.
</p>
</div>
<div className="space-y-4 mb-6">
<div className="bg-gray-50 p-4 rounded">
<h3 className="font-semibold text-gray-700 mb-2">DID Document</h3>
<code className="block text-xs text-gray-600 break-all">
{registrationInfo.did}
</code>
</div>
<div className="bg-gray-50 p-4 rounded">
<h3 className="font-semibold text-gray-700 mb-2">Vault ID</h3>
<code className="block text-xs text-gray-600 break-all">
{registrationInfo.vaultId}
</code>
</div>
<div className="bg-gray-50 p-4 rounded">
<h3 className="font-semibold text-gray-700 mb-2">Assertion Methods</h3>
<ul className="space-y-1">
{registrationInfo.assertionMethods.map((method, index) => (
<li key={index} className="text-sm text-gray-600">
{method}
</li>
))}
</ul>
</div>
<div className="bg-blue-50 p-4 rounded">
<h3 className="font-semibold text-blue-700 mb-2">Authentication Method</h3>
<p className="text-sm text-blue-600">
WebAuthn credential successfully registered
</p>
</div>
<div className="bg-purple-50 p-4 rounded">
<h3 className="font-semibold text-purple-700 mb-2">UCAN Delegation</h3>
<p className="text-sm text-purple-600">
Origin token for wallet admin operations created
</p>
</div>
</div>
<button
onClick={resetForm}
className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Register Another Account
</button>
</div>
);
}
return (
<div className="w-full max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-6">Create Your Sonr Identity</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="alice"
/>
</div>
<div>
<label htmlFor="displayName" className="block text-sm font-medium text-gray-700 mb-1">
Display Name (optional)
</label>
<input
type="text"
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Alice Smith"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Assertion Method
</label>
<div className="flex space-x-4 mb-2">
<label className="flex items-center">
<input
type="radio"
value="email"
checked={assertionType === 'email'}
onChange={(e) => setAssertionType(e.target.value as 'email')}
className="mr-2"
/>
<span>Email</span>
</label>
<label className="flex items-center">
<input
type="radio"
value="tel"
checked={assertionType === 'tel'}
onChange={(e) => setAssertionType(e.target.value as 'tel')}
className="mr-2"
/>
<span>Phone</span>
</label>
</div>
<input
type={assertionType === 'email' ? 'email' : 'tel'}
value={assertionValue}
onChange={(e) => setAssertionValue(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={assertionType === 'email' ? 'alice@example.com' : '+1234567890'}
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="createVault"
checked={createVault}
onChange={(e) => setCreateVault(e.target.checked)}
className="mr-2"
/>
<label htmlFor="createVault" className="text-sm text-gray-700">
Create secure vault for encrypted data storage
</label>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? 'Creating Identity...' : 'Create Identity with WebAuthn'}
</button>
</form>
<div className="mt-6 p-4 bg-blue-50 rounded">
<h3 className="text-sm font-semibold text-blue-900 mb-2">What will be created:</h3>
<ul className="text-xs text-blue-700 space-y-1">
<li> W3C DID with Sonr address as controller</li>
<li> Two assertion methods (Sonr account + {assertionType === 'email' ? 'email' : 'phone'})</li>
<li> WebAuthn authentication method for passwordless login</li>
<li> UCAN delegation chain with validator proof</li>
{createVault && <li> Encrypted vault for secure data storage</li>}
</ul>
</div>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { useCallback, useEffect, useState } from 'react';
interface User {
id: string;
username: string;
displayName?: string;
createdAt: string;
}
interface SessionHookReturn {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
logout: () => Promise<void>;
checkSession: () => Promise<void>;
}
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
export function useSession(): SessionHookReturn {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isAuthenticated = user !== null;
const makeAuthenticatedRequest = useCallback(
async (endpoint: string, options: RequestInit = {}) => {
const sessionToken = localStorage.getItem('highway_session');
if (!sessionToken) {
throw new Error('No session token found');
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${sessionToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
if (response.status === 401) {
// Session expired or invalid
localStorage.removeItem('highway_session');
setUser(null);
throw new Error('Session expired');
}
const errorData = await response.json();
throw new Error(errorData.error || 'Request failed');
}
return response.json();
},
[]
);
const checkSession = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const sessionToken = localStorage.getItem('highway_session');
if (!sessionToken) {
setUser(null);
return;
}
// Validate session by getting user profile
const response = await makeAuthenticatedRequest('/auth/profile');
if (response.user) {
setUser(response.user);
} else {
setUser(null);
localStorage.removeItem('highway_session');
}
} catch (err) {
console.error('Session check error:', err);
setError(err instanceof Error ? err.message : 'Session check failed');
setUser(null);
localStorage.removeItem('highway_session');
} finally {
setIsLoading(false);
}
}, [makeAuthenticatedRequest]);
const logout = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
// Call logout endpoint to invalidate session on server
await makeAuthenticatedRequest('/auth/logout', {
method: 'POST',
});
} catch (err) {
console.error('Logout error:', err);
// Continue with local logout even if server call fails
} finally {
// Always clear local session
localStorage.removeItem('highway_session');
setUser(null);
setIsLoading(false);
}
}, [makeAuthenticatedRequest]);
// Check session on mount and when the component is focused
useEffect(() => {
checkSession();
const handleFocus = () => {
checkSession();
};
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'highway_session') {
if (e.newValue === null) {
setUser(null);
} else {
checkSession();
}
}
};
window.addEventListener('focus', handleFocus);
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('focus', handleFocus);
window.removeEventListener('storage', handleStorageChange);
};
}, [checkSession]);
return {
user,
isAuthenticated,
isLoading,
error,
logout,
checkSession,
};
}
+481
View File
@@ -0,0 +1,481 @@
import { WebAuthnClient } from '@sonr.io/sdk';
import { startAuthentication, startRegistration } from '@simplewebauthn/browser';
import { useCallback, useEffect, useState, useMemo } from 'react';
import {
OIDCClient,
type OIDCClientConfig,
type OIDCSession,
clearSession,
isSessionValid,
loadSession,
saveSession,
} from '../lib/oidc';
import { SIOPClient, type SIOPResponse, submitSIOPResponse } from '../lib/siop';
interface WebAuthnHookReturn {
isLoading: boolean;
error: string | null;
session: OIDCSession | null;
isAuthenticated: boolean;
registerUser: (
username: string,
displayName?: string,
email?: string,
tel?: string,
createVault?: boolean
) => Promise<WebAuthnRegistrationResult>;
authenticateUser: (username: string) => Promise<WebAuthnAuthenticationResult>;
handleSIOPRequest: (requestUrl: string) => Promise<SIOPResponse>;
submitSIOPResponse: (
response: SIOPResponse,
redirectUri: string,
responseMode?: 'form_post' | 'fragment' | 'query'
) => Promise<void>;
initializeOIDC: (config: Partial<OIDCClientConfig>) => Promise<string>;
handleOIDCCallback: (callbackUrl: string) => Promise<OIDCSession>;
refreshSession: () => Promise<OIDCSession | null>;
logout: () => void;
getCredentials: (username: string) => Promise<WebAuthnCredential[]>;
clearError: () => void;
}
interface WebAuthnRegistrationResult {
success: boolean;
userId?: string;
did?: string;
vaultId?: string;
sessionId?: string;
credential?: WebAuthnCredential;
}
interface WebAuthnAuthenticationResult {
success: boolean;
userId?: string;
did?: string;
vaultId?: string;
sessionId?: string;
user?: UserInfo;
}
interface WebAuthnCredential {
id: string;
rawId: string;
type: string;
publicKey: string;
counter: number;
createdAt: string;
}
interface UserInfo {
sub: string;
name?: string;
preferred_username?: string;
email?: string;
email_verified?: boolean;
did?: string;
vault_id?: string;
}
interface ApiResponse {
success?: boolean;
error?: string;
options?: unknown;
userId?: string;
sessionId?: string;
user?: UserInfo;
did?: string;
vaultId?: string;
credential?: WebAuthnCredential;
credentials?: WebAuthnCredential[];
}
// Use bridge endpoints at port 8080 instead of direct 8787
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
export function useWebAuthn(): WebAuthnHookReturn {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [session, setSession] = useState<OIDCSession | null>(null);
const [oidcClient, setOidcClient] = useState<OIDCClient | null>(null);
const [siopClient, setSiopClient] = useState<SIOPClient | null>(null);
// Initialize WebAuthn client from SDK
const webAuthnClient = useMemo(() => {
const apiUrl = process.env.NEXT_PUBLIC_CHAIN_API_URL || 'http://localhost:1317';
return new WebAuthnClient(apiUrl);
}, []);
// Initialize session state
useEffect(() => {
const savedSession = loadSession();
if (savedSession && isSessionValid(savedSession)) {
setSession(savedSession);
} else if (savedSession) {
clearSession();
}
}, []);
const clearError = useCallback(() => {
setError(null);
}, []);
const isAuthenticated = session !== null && isSessionValid(session);
const makeApiCall = useCallback(
async (endpoint: string, data?: unknown, method = 'POST'): Promise<ApiResponse> => {
const config: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
credentials: 'include',
};
if (data && method !== 'GET') {
config.body = JSON.stringify(data);
}
// Add session headers if available
if (session?.accessToken) {
config.headers = {
...config.headers,
Authorization: `Bearer ${session.accessToken}`,
};
}
const response = await fetch(`${BRIDGE_URL}${endpoint}`, config);
if (!response.ok) {
const errorData = await response.json().catch(() => ({
error: 'Request failed',
error_description: `HTTP ${response.status} ${response.statusText}`,
}));
throw new Error(errorData.error_description || errorData.error || 'Request failed');
}
return response.json();
},
[session]
);
const registerUser = useCallback(
async (
username: string,
displayName?: string,
email?: string,
tel?: string,
createVault = true
): Promise<WebAuthnRegistrationResult> => {
setIsLoading(true);
setError(null);
try {
// Use SDK WebAuthn client for registration with email/tel support
const result = await webAuthnClient.register({
username,
displayName,
email,
tel,
createVault,
});
if (result.success) {
// Fetch DID document to get full user info
let userInfo: UserInfo | undefined;
if (result.did) {
try {
const { document, metadata } = await webAuthnClient.getDIDDocument(result.did);
userInfo = {
sub: result.did,
name: displayName || username,
preferred_username: username,
email: email,
email_verified: !!email,
did: result.did,
vault_id: result.vaultId,
};
} catch (docError) {
console.warn('Failed to fetch DID document:', docError);
}
}
// Create OIDC session if we have user info
if (userInfo && result.ucanToken) {
const newSession: OIDCSession = {
accessToken: result.ucanToken,
tokenType: 'Bearer',
expiresAt: Date.now() + 3600 * 1000, // 1 hour default
userInfo,
};
saveSession(newSession);
setSession(newSession);
}
return {
success: true,
userId: result.did?.split(':').pop(),
did: result.did,
vaultId: result.vaultId,
sessionId: result.ucanToken,
credential: result.credential,
};
}
throw new Error(result.error || 'Registration failed');
} catch (err) {
console.error('WebAuthn registration error:', err);
setError(err instanceof Error ? err.message : 'Registration failed');
return {
success: false,
};
} finally {
setIsLoading(false);
}
},
[webAuthnClient]
);
const authenticateUser = useCallback(
async (username: string): Promise<WebAuthnAuthenticationResult> => {
setIsLoading(true);
setError(null);
try {
// Use SDK WebAuthn client for authentication
const result = await webAuthnClient.authenticate({
username,
});
if (result.success) {
// Fetch DID document to get full user info
let userInfo: UserInfo | undefined;
if (result.did) {
try {
const { document, metadata } = await webAuthnClient.getDIDDocument(result.did);
userInfo = {
sub: result.did,
name: username,
preferred_username: username,
did: result.did,
vault_id: result.vaultId,
};
} catch (docError) {
console.warn('Failed to fetch DID document:', docError);
}
}
// Create OIDC session
if (userInfo && result.sessionToken) {
const newSession: OIDCSession = {
accessToken: result.sessionToken,
tokenType: 'Bearer',
expiresAt: Date.now() + 3600 * 1000, // 1 hour default
userInfo,
};
saveSession(newSession);
setSession(newSession);
}
return {
success: true,
userId: result.did?.split(':').pop(),
did: result.did,
vaultId: result.vaultId,
sessionId: result.sessionToken,
user: userInfo,
};
}
throw new Error(result.error || 'Authentication failed');
} catch (err) {
console.error('WebAuthn authentication error:', err);
setError(err instanceof Error ? err.message : 'Authentication failed');
return {
success: false,
};
} finally {
setIsLoading(false);
}
},
[webAuthnClient]
);
// Get WebAuthn credentials for a user
const getCredentials = useCallback(
async (username: string): Promise<WebAuthnCredential[]> => {
setIsLoading(true);
setError(null);
try {
const response = await makeApiCall(`/webauthn/credentials/${username}`, undefined, 'GET');
return response.credentials || [];
} catch (err) {
console.error('Get credentials error:', err);
setError(err instanceof Error ? err.message : 'Failed to get credentials');
return [];
} finally {
setIsLoading(false);
}
},
[makeApiCall]
);
// Initialize OIDC client
const initializeOIDC = useCallback(async (config: Partial<OIDCClientConfig>): Promise<string> => {
setIsLoading(true);
setError(null);
try {
const defaultConfig: OIDCClientConfig = {
clientId: 'sonr-auth',
redirectUri: `${window.location.origin}/callback`,
scope: 'openid profile email',
responseType: 'code',
grantType: 'authorization_code',
providerUrl: window.location.origin,
usePKCE: true,
...config,
};
const client = new OIDCClient(defaultConfig);
await client.initialize();
setOidcClient(client);
const authUrl = await client.authorize();
return authUrl;
} catch (err) {
console.error('OIDC initialization error:', err);
setError(err instanceof Error ? err.message : 'OIDC initialization failed');
throw err;
} finally {
setIsLoading(false);
}
}, []);
// Handle OIDC callback
const handleOIDCCallback = useCallback(
async (callbackUrl: string): Promise<OIDCSession> => {
setIsLoading(true);
setError(null);
try {
if (!oidcClient) {
throw new Error('OIDC client not initialized');
}
const newSession = await oidcClient.handleCallback(callbackUrl);
setSession(newSession);
return newSession;
} catch (err) {
console.error('OIDC callback error:', err);
setError(err instanceof Error ? err.message : 'OIDC callback failed');
throw err;
} finally {
setIsLoading(false);
}
},
[oidcClient]
);
// Refresh OIDC session
const refreshSession = useCallback(async (): Promise<OIDCSession | null> => {
setIsLoading(true);
setError(null);
try {
if (!oidcClient) {
return null;
}
const refreshedSession = await oidcClient.refreshSession();
setSession(refreshedSession);
return refreshedSession;
} catch (err) {
console.error('Session refresh error:', err);
setError(err instanceof Error ? err.message : 'Session refresh failed');
setSession(null);
return null;
} finally {
setIsLoading(false);
}
}, [oidcClient]);
// Handle SIOP request
const handleSIOPRequest = useCallback(
async (requestUrl: string): Promise<SIOPResponse> => {
setIsLoading(true);
setError(null);
try {
if (!session?.userInfo?.did) {
throw new Error('No DID available for SIOP authentication');
}
// Initialize SIOP client if needed
let client = siopClient;
if (!client) {
client = new SIOPClient(session.userInfo.did);
await client.initialize();
setSiopClient(client);
}
const response = await client.handleSIOPRequest(requestUrl);
return response;
} catch (err) {
console.error('SIOP request error:', err);
setError(err instanceof Error ? err.message : 'SIOP request failed');
throw err;
} finally {
setIsLoading(false);
}
},
[session, siopClient]
);
// Submit SIOP response
const submitSIOPResponseCallback = useCallback(
async (
response: SIOPResponse,
redirectUri: string,
responseMode: 'form_post' | 'fragment' | 'query' = 'fragment'
): Promise<void> => {
try {
await submitSIOPResponse(response, redirectUri, responseMode);
} catch (err) {
console.error('SIOP response submission error:', err);
setError(err instanceof Error ? err.message : 'SIOP response submission failed');
throw err;
}
},
[]
);
// Logout and clear session
const logout = useCallback(() => {
clearSession();
setSession(null);
setOidcClient(null);
setSiopClient(null);
setError(null);
}, []);
return {
isLoading,
error,
session,
isAuthenticated,
registerUser,
authenticateUser,
handleSIOPRequest,
submitSIOPResponse: submitSIOPResponseCallback,
initializeOIDC,
handleOIDCCallback,
refreshSession,
logout,
getCredentials,
clearError,
};
}
+885
View File
@@ -0,0 +1,885 @@
/**
* OpenID Connect (OIDC) Utilities
*
* This module provides comprehensive OIDC client utilities for the Next.js auth application.
* It supports standard OIDC flows with PKCE, token validation, and session management.
*
* Features:
* - Authorization Code Flow with PKCE
* - Token validation and management
* - Discovery document fetching
* - JWT decoding and validation
* - Session management
* - CORS-enabled fetch wrapper
*/
// Utility function to generate random string for PKCE and nonces
export function generateRandomString(length: number): string {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const randomValues = new Uint8Array(length);
crypto.getRandomValues(randomValues);
return Array.from(randomValues, (byte) => charset[byte % charset.length]).join('');
}
// ============================================================================
// Types and Interfaces
// ============================================================================
/**
* OIDC Provider Configuration from Discovery Document
*/
export interface OIDCProviderConfig {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
jwks_uri: string;
registration_endpoint?: string;
scopes_supported: string[];
response_types_supported: string[];
response_modes_supported?: string[];
grant_types_supported: string[];
subject_types_supported: string[];
id_token_signing_alg_values_supported: string[];
token_endpoint_auth_methods_supported?: string[];
claims_supported?: string[];
code_challenge_methods_supported?: string[];
// SIOP specific fields
subject_syntax_types_supported?: string[];
id_token_types_supported?: string[];
request_object_signing_alg_values_supported?: string[];
}
/**
* OIDC Client Configuration
*/
export interface OIDCClientConfig {
clientId: string;
clientSecret?: string;
redirectUri: string;
scope: string;
responseType: string;
grantType: string;
providerUrl: string;
usePKCE: boolean;
additionalParams?: Record<string, string>;
}
/**
* PKCE Parameters for Authorization Code Flow
*/
export interface PKCEParams {
codeVerifier: string;
codeChallenge: string;
codeChallengeMethod: string;
}
/**
* Authorization Request Parameters
*/
export interface AuthorizationParams {
response_type: string;
client_id: string;
redirect_uri: string;
scope: string;
state: string;
nonce: string;
code_challenge?: string;
code_challenge_method?: string;
response_mode?: string;
prompt?: string;
max_age?: number;
ui_locales?: string;
claims?: string;
}
/**
* Token Request Parameters
*/
export interface TokenRequestParams {
grant_type: string;
code?: string;
redirect_uri?: string;
client_id: string;
client_secret?: string;
code_verifier?: string;
refresh_token?: string;
scope?: string;
}
/**
* OIDC Token Response
*/
export interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
id_token?: string;
scope?: string;
}
/**
* OIDC UserInfo Response
*/
export interface UserInfo {
sub: string;
name?: string;
preferred_username?: string;
email?: string;
email_verified?: boolean;
did?: string;
vault_id?: string;
updated_at?: number;
claims?: Record<string, unknown>;
}
/**
* JWT Header
*/
export interface JWTHeader {
alg: string;
typ: string;
kid?: string;
jku?: string;
x5u?: string;
}
/**
* JWT Payload (ID Token Claims)
*/
export interface JWTPayload {
iss: string;
sub: string;
aud: string | string[];
exp: number;
iat: number;
nbf?: number;
jti?: string;
auth_time?: number;
nonce?: string;
acr?: string;
amr?: string[];
azp?: string;
// Standard claims
name?: string;
preferred_username?: string;
email?: string;
email_verified?: boolean;
// Custom claims
did?: string;
vault_id?: string;
[key: string]: unknown;
}
/**
* OIDC Session Data
*/
export interface OIDCSession {
accessToken: string;
refreshToken?: string;
idToken?: string;
expiresAt: number;
userInfo?: UserInfo;
tokenType: string;
scope?: string;
}
/**
* OIDC Error Response
*/
export interface OIDCError {
error: string;
error_description?: string;
error_uri?: string;
state?: string;
}
// ============================================================================
// Constants and Configuration
// ============================================================================
const OIDC_DEFAULTS = {
SCOPE: 'openid profile email',
RESPONSE_TYPE: 'code',
GRANT_TYPE: 'authorization_code',
CODE_CHALLENGE_METHOD: 'S256',
TOKEN_TYPE: 'Bearer',
SESSION_STORAGE_KEY: 'oidc_session',
STATE_STORAGE_KEY: 'oidc_state',
NONCE_STORAGE_KEY: 'oidc_nonce',
PKCE_STORAGE_KEY: 'oidc_pkce',
} as const;
// ============================================================================
// CORS-Enabled Fetch Wrapper
// ============================================================================
/**
* CORS-enabled fetch wrapper for OIDC endpoints
*/
export async function oidcFetch(url: string, options: RequestInit = {}): Promise<Response> {
const defaultHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
const config: RequestInit = {
...options,
headers: {
...defaultHeaders,
...options.headers,
},
credentials: 'include',
mode: 'cors',
};
try {
const response = await fetch(url, config);
return response;
} catch (error) {
console.error('OIDC Fetch Error:', error);
throw new Error(`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// ============================================================================
// PKCE Utilities
// ============================================================================
/**
* Generate PKCE parameters for authorization code flow
*/
export function generatePKCE(): PKCEParams {
const codeVerifier = generateRandomString(128);
// Synchronous fallback using base64url encoding
const codeChallenge = btoa(codeVerifier)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return {
codeVerifier,
codeChallenge,
codeChallengeMethod: OIDC_DEFAULTS.CODE_CHALLENGE_METHOD,
};
}
/**
* Store PKCE parameters in session storage
*/
export function storePKCE(pkce: PKCEParams): void {
if (typeof window !== 'undefined') {
sessionStorage.setItem(OIDC_DEFAULTS.PKCE_STORAGE_KEY, JSON.stringify(pkce));
}
}
/**
* Retrieve and clear PKCE parameters from session storage
*/
export function retrieveAndClearPKCE(): PKCEParams | null {
if (typeof window === 'undefined') return null;
const stored = sessionStorage.getItem(OIDC_DEFAULTS.PKCE_STORAGE_KEY);
if (!stored) return null;
sessionStorage.removeItem(OIDC_DEFAULTS.PKCE_STORAGE_KEY);
try {
return JSON.parse(stored);
} catch {
return null;
}
}
// ============================================================================
// Discovery Document
// ============================================================================
/**
* Fetch OIDC discovery document from provider
*/
export async function fetchDiscoveryDocument(providerUrl: string): Promise<OIDCProviderConfig> {
const discoveryUrl = `${providerUrl}/.well-known/openid-configuration`;
const response = await oidcFetch(discoveryUrl, {
method: 'GET',
headers: {
'Cache-Control': 'max-age=3600',
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch discovery document: ${response.status} ${response.statusText}`
);
}
const config = (await response.json()) as OIDCProviderConfig;
// Validate required endpoints
if (!config.authorization_endpoint || !config.token_endpoint) {
throw new Error('Invalid discovery document: missing required endpoints');
}
return config;
}
// ============================================================================
// Authorization URL Builder
// ============================================================================
/**
* Build authorization URL with PKCE support
*/
export async function buildAuthorizationUrl(
config: OIDCClientConfig,
providerConfig?: OIDCProviderConfig
): Promise<{ url: string; state: string; nonce: string; pkce?: PKCEParams }> {
// Fetch discovery document if not provided
let discovery = providerConfig;
if (!discovery) {
discovery = await fetchDiscoveryDocument(config.providerUrl);
}
// Generate state and nonce
const state = generateRandomString(32);
const nonce = generateRandomString(32);
// Generate PKCE parameters if enabled
let pkce: PKCEParams | undefined;
if (config.usePKCE) {
pkce = generatePKCE();
storePKCE(pkce);
}
// Store state and nonce
if (typeof window !== 'undefined') {
sessionStorage.setItem(OIDC_DEFAULTS.STATE_STORAGE_KEY, state);
sessionStorage.setItem(OIDC_DEFAULTS.NONCE_STORAGE_KEY, nonce);
}
// Build authorization parameters
const authParams: AuthorizationParams = {
response_type: config.responseType,
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config.scope,
state,
nonce,
...config.additionalParams,
};
// Add PKCE parameters if enabled
if (pkce) {
authParams.code_challenge = pkce.codeChallenge;
authParams.code_challenge_method = pkce.codeChallengeMethod;
}
// Build query string
const params = new URLSearchParams();
Object.entries(authParams).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
params.append(key, value.toString());
}
});
const url = `${discovery.authorization_endpoint}?${params.toString()}`;
return { url, state, nonce, pkce };
}
// ============================================================================
// Token Exchange
// ============================================================================
/**
* Exchange authorization code for tokens
*/
export async function exchangeCodeForTokens(
code: string,
config: OIDCClientConfig,
providerConfig?: OIDCProviderConfig
): Promise<TokenResponse> {
// Fetch discovery document if not provided
let discovery = providerConfig;
if (!discovery) {
discovery = await fetchDiscoveryDocument(config.providerUrl);
}
// Retrieve PKCE parameters if used
const pkce = config.usePKCE ? retrieveAndClearPKCE() : undefined;
// Build token request parameters
const tokenParams: TokenRequestParams = {
grant_type: config.grantType,
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
};
// Add client secret if provided
if (config.clientSecret) {
tokenParams.client_secret = config.clientSecret;
}
// Add PKCE code verifier if used
if (pkce) {
tokenParams.code_verifier = pkce.codeVerifier;
}
// Prepare form data (OIDC spec requires form encoding)
const formData = new URLSearchParams();
Object.entries(tokenParams).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
formData.append(key, value);
}
});
const response = await oidcFetch(discovery.token_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({
error: 'token_error',
error_description: 'Token exchange failed',
}));
throw new Error(`Token exchange failed: ${errorData.error_description || errorData.error}`);
}
return response.json() as Promise<TokenResponse>;
}
/**
* Refresh access token using refresh token
*/
export async function refreshAccessToken(
refreshToken: string,
config: OIDCClientConfig,
providerConfig?: OIDCProviderConfig
): Promise<TokenResponse> {
// Fetch discovery document if not provided
let discovery = providerConfig;
if (!discovery) {
discovery = await fetchDiscoveryDocument(config.providerUrl);
}
const tokenParams: TokenRequestParams = {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: config.clientId,
scope: config.scope,
};
if (config.clientSecret) {
tokenParams.client_secret = config.clientSecret;
}
const formData = new URLSearchParams();
Object.entries(tokenParams).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
formData.append(key, value);
}
});
const response = await oidcFetch(discovery.token_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({
error: 'refresh_error',
error_description: 'Token refresh failed',
}));
throw new Error(`Token refresh failed: ${errorData.error_description || errorData.error}`);
}
return response.json() as Promise<TokenResponse>;
}
// ============================================================================
// UserInfo Fetching
// ============================================================================
/**
* Fetch user information using access token
*/
export async function fetchUserInfo(
accessToken: string,
config: OIDCClientConfig,
providerConfig?: OIDCProviderConfig
): Promise<UserInfo> {
// Fetch discovery document if not provided
let discovery = providerConfig;
if (!discovery) {
discovery = await fetchDiscoveryDocument(config.providerUrl);
}
const response = await oidcFetch(discovery.userinfo_endpoint, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({
error: 'userinfo_error',
error_description: 'UserInfo request failed',
}));
throw new Error(`UserInfo request failed: ${errorData.error_description || errorData.error}`);
}
return response.json() as Promise<UserInfo>;
}
// ============================================================================
// JWT Utilities
// ============================================================================
/**
* Decode JWT token without verification (for inspection only)
* WARNING: This does not validate the token signature - use validateJWT for production
*/
export function decodeJWT(token: string): {
header: JWTHeader;
payload: JWTPayload;
signature: string;
} {
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT format');
}
const [headerPart, payloadPart, signature] = parts;
if (!headerPart || !payloadPart || !signature) {
throw new Error('Invalid JWT format: missing parts');
}
try {
const header = JSON.parse(atob(headerPart.replace(/-/g, '+').replace(/_/g, '/'))) as JWTHeader;
const payload = JSON.parse(
atob(payloadPart.replace(/-/g, '+').replace(/_/g, '/'))
) as JWTPayload;
return { header, payload, signature };
} catch {
throw new Error('Failed to decode JWT: Invalid encoding');
}
}
/**
* Validate JWT token (basic validation without signature verification)
* For full validation, use a proper JWT library with JWKS support
*/
export function validateJWTClaims(
token: string,
expectedIssuer: string,
expectedAudience: string,
expectedNonce?: string
): JWTPayload {
const { payload } = decodeJWT(token);
// Validate issuer
if (payload.iss !== expectedIssuer) {
throw new Error(`Invalid issuer: expected ${expectedIssuer}, got ${payload.iss}`);
}
// Validate audience
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!audiences.includes(expectedAudience)) {
throw new Error(`Invalid audience: expected ${expectedAudience}, got ${audiences.join(', ')}`);
}
// Validate expiration
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
throw new Error('Token has expired');
}
// Validate not before
if (payload.nbf && payload.nbf > now) {
throw new Error('Token not yet valid');
}
// Validate nonce if provided
if (expectedNonce && payload.nonce !== expectedNonce) {
throw new Error('Invalid nonce');
}
return payload;
}
// ============================================================================
// Session Management
// ============================================================================
/**
* Save OIDC session to storage
*/
export function saveSession(session: OIDCSession): void {
if (typeof window !== 'undefined') {
localStorage.setItem(OIDC_DEFAULTS.SESSION_STORAGE_KEY, JSON.stringify(session));
}
}
/**
* Load OIDC session from storage
*/
export function loadSession(): OIDCSession | null {
if (typeof window === 'undefined') return null;
const stored = localStorage.getItem(OIDC_DEFAULTS.SESSION_STORAGE_KEY);
if (!stored) return null;
try {
const session = JSON.parse(stored) as OIDCSession;
// Check if session is expired
if (session.expiresAt && session.expiresAt < Date.now()) {
clearSession();
return null;
}
return session;
} catch {
clearSession();
return null;
}
}
/**
* Clear OIDC session from storage
*/
export function clearSession(): void {
if (typeof window !== 'undefined') {
localStorage.removeItem(OIDC_DEFAULTS.SESSION_STORAGE_KEY);
sessionStorage.removeItem(OIDC_DEFAULTS.STATE_STORAGE_KEY);
sessionStorage.removeItem(OIDC_DEFAULTS.NONCE_STORAGE_KEY);
sessionStorage.removeItem(OIDC_DEFAULTS.PKCE_STORAGE_KEY);
}
}
/**
* Check if current session is valid and not expired
*/
export function isSessionValid(session?: OIDCSession): boolean {
const currentSession = session || loadSession();
if (!currentSession) return false;
return currentSession.expiresAt > Date.now();
}
/**
* Get stored state parameter for validation
*/
export function getStoredState(): string | null {
if (typeof window === 'undefined') return null;
const state = sessionStorage.getItem(OIDC_DEFAULTS.STATE_STORAGE_KEY);
sessionStorage.removeItem(OIDC_DEFAULTS.STATE_STORAGE_KEY);
return state;
}
/**
* Get stored nonce parameter for validation
*/
export function getStoredNonce(): string | null {
if (typeof window === 'undefined') return null;
const nonce = sessionStorage.getItem(OIDC_DEFAULTS.NONCE_STORAGE_KEY);
sessionStorage.removeItem(OIDC_DEFAULTS.NONCE_STORAGE_KEY);
return nonce;
}
// ============================================================================
// High-Level OIDC Client
// ============================================================================
/**
* High-level OIDC Client class
*/
export class OIDCClient {
private config: OIDCClientConfig;
private providerConfig?: OIDCProviderConfig;
constructor(config: OIDCClientConfig) {
this.config = config;
}
/**
* Initialize the client by fetching provider configuration
*/
async initialize(): Promise<void> {
this.providerConfig = await fetchDiscoveryDocument(this.config.providerUrl);
}
/**
* Start authorization flow
*/
async authorize(): Promise<string> {
const { url } = await buildAuthorizationUrl(this.config, this.providerConfig);
return url;
}
/**
* Handle authorization callback
*/
async handleCallback(callbackUrl: string): Promise<OIDCSession> {
const url = new URL(callbackUrl);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
// Check for authorization errors
if (error) {
const errorDescription = url.searchParams.get('error_description');
throw new Error(
`Authorization error: ${error}${errorDescription ? ` - ${errorDescription}` : ''}`
);
}
// Validate required parameters
if (!code) {
throw new Error('Missing authorization code');
}
// Validate state parameter
const storedState = getStoredState();
if (!state || state !== storedState) {
throw new Error('Invalid state parameter');
}
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, this.config, this.providerConfig);
// Validate ID token if present
let userInfo: UserInfo | undefined;
if (tokens.id_token) {
const storedNonce = getStoredNonce();
const payload = validateJWTClaims(
tokens.id_token,
this.providerConfig?.issuer ?? '',
this.config.clientId,
storedNonce || undefined
);
// Extract user info from ID token
userInfo = {
sub: payload.sub,
name: payload.name,
preferred_username: payload.preferred_username,
email: payload.email,
email_verified: payload.email_verified,
did: payload.did,
vault_id: payload.vault_id,
};
} else {
// Fetch user info from UserInfo endpoint
userInfo = await fetchUserInfo(tokens.access_token, this.config, this.providerConfig);
}
// Create session
const session: OIDCSession = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
userInfo,
tokenType: tokens.token_type,
scope: tokens.scope,
};
// Save session
saveSession(session);
return session;
}
/**
* Refresh current session
*/
async refreshSession(): Promise<OIDCSession | null> {
const currentSession = loadSession();
if (!currentSession?.refreshToken) {
return null;
}
try {
const tokens = await refreshAccessToken(
currentSession.refreshToken,
this.config,
this.providerConfig
);
const newSession: OIDCSession = {
...currentSession,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || currentSession.refreshToken,
expiresAt: Date.now() + tokens.expires_in * 1000,
};
saveSession(newSession);
return newSession;
} catch (error) {
console.error('Failed to refresh session:', error);
clearSession();
return null;
}
}
/**
* Logout and clear session
*/
logout(): void {
clearSession();
}
/**
* Get current session
*/
getSession(): OIDCSession | null {
return loadSession();
}
/**
* Check if user is authenticated
*/
isAuthenticated(): boolean {
return isSessionValid();
}
}
// ============================================================================
// Error Handling Utilities
// ============================================================================
/**
* Check if error is an OIDC error response
*/
export function isOIDCError(error: unknown): error is OIDCError {
return (
typeof error === 'object' &&
error !== null &&
'error' in error &&
typeof (error as Record<string, unknown>).error === 'string'
);
}
/**
* Format OIDC error for display
*/
export function formatOIDCError(error: OIDCError): string {
return `${error.error}${error.error_description ? `: ${error.error_description}` : ''}`;
}
+995
View File
@@ -0,0 +1,995 @@
/**
* Self-Issued OpenID Provider (SIOP) v2 Utilities
*
* This module provides comprehensive SIOP v2 utilities for decentralized identity
* authentication using DIDs (Decentralized Identifiers) and Verifiable Presentations.
*
* Features:
* - SIOP request parsing and validation
* - Self-issued ID token generation
* - DID-based authentication
* - Verifiable Presentation (VP) token utilities
* - SIOP response builder
* - Dynamic client registration
* - DID document resolution
*/
import { type JWTHeader, type JWTPayload, decodeJWT, generateRandomString } from './oidc';
// ============================================================================
// Types and Interfaces
// ============================================================================
/**
* SIOP Request Parameters
*/
export interface SIOPRequest {
response_type: 'id_token';
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
nonce?: string;
response_mode?: 'form_post' | 'fragment' | 'query';
claims?: string | SIOPClaims;
registration?: string | SIOPClientMetadata;
request?: string; // JWT request object
request_uri?: string;
// SIOP specific parameters
subject_syntax_types_supported?: string[];
id_token_types_supported?: string[];
}
/**
* SIOP Claims Request
*/
export interface SIOPClaims {
id_token?: {
[key: string]: {
essential?: boolean;
value?: string;
values?: string[];
} | null;
};
userinfo?: {
[key: string]: {
essential?: boolean;
value?: string;
values?: string[];
} | null;
};
}
/**
* SIOP Client Metadata for Dynamic Registration
*/
export interface SIOPClientMetadata {
application_type?: string;
client_name?: string;
client_uri?: string;
logo_uri?: string;
contacts?: string[];
tos_uri?: string;
policy_uri?: string;
redirect_uris?: string[];
response_types?: string[];
grant_types?: string[];
subject_type?: 'public' | 'pairwise';
id_token_signed_response_alg?: string;
id_token_encrypted_response_alg?: string;
id_token_encrypted_response_enc?: string;
userinfo_signed_response_alg?: string;
userinfo_encrypted_response_alg?: string;
userinfo_encrypted_response_enc?: string;
request_object_signing_alg?: string;
request_object_encryption_alg?: string;
request_object_encryption_enc?: string;
// SIOP specific metadata
subject_syntax_types_supported?: string[];
id_token_types_supported?: string[];
vp_formats?: VPFormats;
}
/**
* Verifiable Presentation Formats
*/
export interface VPFormats {
jwt_vp?: {
alg?: string[];
};
jwt_vc?: {
alg?: string[];
};
ldp_vp?: {
proof_type?: string[];
};
ldp_vc?: {
proof_type?: string[];
};
}
/**
* SIOP Response
*/
export interface SIOPResponse {
id_token: string;
state?: string;
vp_token?: string; // Verifiable Presentation token
}
/**
* Self-Issued ID Token Payload
*/
export interface SelfIssuedIDToken extends JWTPayload {
iss: string; // 'https://self-issued.me/v2' or DID
sub: string; // DID or subject identifier
aud: string; // Client ID
sub_jwk?: JWK; // Subject's public key
did?: string; // DID of the subject
did_doc?: DIDDocument; // DID Document
// VP token reference
_vp_token?: {
presentation_submission?: PresentationSubmission;
};
}
/**
* JSON Web Key (JWK)
*/
export interface JWK {
kty: string; // Key type
use?: string; // Key use
key_ops?: string[]; // Key operations
alg?: string; // Algorithm
kid?: string; // Key ID
x5u?: string; // X.509 URL
x5c?: string[]; // X.509 certificate chain
x5t?: string; // X.509 thumbprint
'x5t#S256'?: string; // X.509 thumbprint (SHA-256)
// RSA keys
n?: string; // Modulus
e?: string; // Exponent
d?: string; // Private exponent
p?: string; // First prime factor
q?: string; // Second prime factor
dp?: string; // First factor CRT exponent
dq?: string; // Second factor CRT exponent
qi?: string; // First CRT coefficient
// EC keys
crv?: string; // Curve
x?: string; // X coordinate
y?: string; // Y coordinate
// Symmetric keys
k?: string; // Key value
}
/**
* DID Document (simplified)
*/
export interface DIDDocument {
'@context'?: string | string[];
id: string; // DID
alsoKnownAs?: string[];
controller?: string | string[];
verificationMethod?: VerificationMethod[];
authentication?: (string | VerificationMethod)[];
assertionMethod?: (string | VerificationMethod)[];
keyAgreement?: (string | VerificationMethod)[];
capabilityInvocation?: (string | VerificationMethod)[];
capabilityDelegation?: (string | VerificationMethod)[];
service?: ServiceEndpoint[];
}
/**
* Verification Method
*/
export interface VerificationMethod {
id: string;
type: string;
controller: string;
publicKeyJwk?: JWK;
publicKeyMultibase?: string;
publicKeyBase58?: string;
blockchainAccountId?: string;
}
/**
* Service Endpoint
*/
export interface ServiceEndpoint {
id: string;
type: string;
serviceEndpoint: string | object;
}
/**
* Verifiable Presentation
*/
export interface VerifiablePresentation {
'@context': string | string[];
type: string | string[];
verifiableCredential?: VerifiableCredential[];
holder?: string; // DID of the holder
proof?: Proof | Proof[];
// Presentation submission
presentation_submission?: PresentationSubmission;
}
/**
* Verifiable Credential
*/
export interface VerifiableCredential {
'@context': string | string[];
type: string | string[];
issuer: string | { id: string; [key: string]: unknown };
issuanceDate: string;
expirationDate?: string;
credentialSubject: CredentialSubject;
proof?: Proof | Proof[];
}
/**
* Credential Subject
*/
export interface CredentialSubject {
id?: string; // DID of the subject
[key: string]: unknown;
}
/**
* Cryptographic Proof
*/
export interface Proof {
type: string;
created: string;
verificationMethod: string;
proofPurpose: string;
challenge?: string;
domain?: string;
proofValue?: string;
jws?: string;
[key: string]: unknown;
}
/**
* Presentation Submission
*/
export interface PresentationSubmission {
id: string;
definition_id: string;
descriptor_map: InputDescriptor[];
}
/**
* Input Descriptor
*/
export interface InputDescriptor {
id: string;
format: string;
path: string;
path_nested?: InputDescriptor[];
}
/**
* VP Token Payload
*/
export interface VPTokenPayload extends JWTPayload {
iss: string; // DID of the presenter
vp: VerifiablePresentation;
}
// ============================================================================
// Constants
// ============================================================================
const SIOP_CONSTANTS = {
SELF_ISSUED_ISSUER_V2: 'https://self-issued.me/v2',
DEFAULT_RESPONSE_TYPE: 'id_token',
DEFAULT_SCOPE: 'openid',
SUBJECT_SYNTAX_TYPES: {
JWK_THUMBPRINT: 'urn:ietf:params:oauth:jwk-thumbprint',
DID: 'did',
},
ID_TOKEN_TYPES: {
SUBJECT_SIGNED: 'subject-signed_id_token',
ATTESTER_SIGNED: 'attester-signed_id_token',
},
} as const;
// ============================================================================
// SIOP Request Parsing and Validation
// ============================================================================
/**
* Parse SIOP request from URL parameters
*/
export function parseSIOPRequest(searchParams: URLSearchParams): SIOPRequest {
const request: SIOPRequest = {
response_type: (searchParams.get('response_type') as 'id_token') || 'id_token',
client_id: searchParams.get('client_id') || '',
redirect_uri: searchParams.get('redirect_uri') || '',
scope: searchParams.get('scope') || SIOP_CONSTANTS.DEFAULT_SCOPE,
state: searchParams.get('state') || undefined,
nonce: searchParams.get('nonce') || undefined,
response_mode:
(searchParams.get('response_mode') as 'form_post' | 'fragment' | 'query' | null) || undefined,
request: searchParams.get('request') || undefined,
request_uri: searchParams.get('request_uri') || undefined,
};
// Parse claims parameter
const claimsParam = searchParams.get('claims');
if (claimsParam) {
try {
request.claims = JSON.parse(claimsParam) as SIOPClaims;
} catch {
request.claims = claimsParam;
}
}
// Parse registration parameter
const registrationParam = searchParams.get('registration');
if (registrationParam) {
try {
request.registration = JSON.parse(registrationParam) as SIOPClientMetadata;
} catch {
request.registration = registrationParam;
}
}
return request;
}
/**
* Validate SIOP request parameters
*/
export function validateSIOPRequest(request: SIOPRequest): void {
// Validate required parameters
if (!request.client_id) {
throw new Error('Missing required parameter: client_id');
}
if (!request.redirect_uri) {
throw new Error('Missing required parameter: redirect_uri');
}
// Validate response_type
if (request.response_type !== 'id_token') {
throw new Error('Invalid response_type: only "id_token" is supported for SIOP');
}
// Validate redirect URI format
try {
new URL(request.redirect_uri);
} catch {
throw new Error('Invalid redirect_uri format');
}
// Validate scope contains 'openid'
if (!request.scope.includes('openid')) {
throw new Error('Scope must include "openid"');
}
// If request object is present, it should be validated separately
if (request.request && !request.request_uri) {
try {
decodeJWT(request.request);
} catch {
throw new Error('Invalid request object: not a valid JWT');
}
}
}
/**
* Resolve SIOP request object from JWT or URI
*/
export async function resolveSIOPRequestObject(request: SIOPRequest): Promise<SIOPRequest> {
if (request.request_uri) {
// Fetch request object from URI
try {
const response = await fetch(request.request_uri);
if (!response.ok) {
throw new Error(`Failed to fetch request object: ${response.status}`);
}
const requestObject = await response.text();
return parseJWTRequestObject(requestObject);
} catch (error) {
throw new Error(
`Failed to resolve request_uri: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
} else if (request.request) {
// Parse inline request object
return parseJWTRequestObject(request.request);
}
return request;
}
/**
* Parse JWT request object
*/
function parseJWTRequestObject(jwt: string): SIOPRequest {
const { payload } = decodeJWT(jwt);
return {
response_type: (payload.response_type as 'id_token') || 'id_token',
client_id: (payload.client_id as string) || '',
redirect_uri: (payload.redirect_uri as string) || '',
scope: (payload.scope as string) || SIOP_CONSTANTS.DEFAULT_SCOPE,
state: (payload.state as string) || undefined,
nonce: (payload.nonce as string) || undefined,
response_mode:
(payload.response_mode as 'form_post' | 'fragment' | 'query' | null) || undefined,
claims: (payload.claims as SIOPClaims) || undefined,
registration: (payload.registration as SIOPClientMetadata) || undefined,
};
}
// ============================================================================
// Self-Issued ID Token Generation
// ============================================================================
/**
* Generate self-issued ID token
*/
export async function generateSelfIssuedIDToken(
request: SIOPRequest,
did: string,
privateKey: CryptoKey,
additionalClaims?: Record<string, unknown>
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
// Create ID token payload
const payload: SelfIssuedIDToken = {
iss: SIOP_CONSTANTS.SELF_ISSUED_ISSUER_V2,
sub: did,
aud: request.client_id,
exp: now + 3600, // 1 hour
iat: now,
nbf: now,
jti: generateRandomString(16),
did,
...additionalClaims,
};
// Add nonce if present
if (request.nonce) {
payload.nonce = request.nonce;
}
// Create header
const header: JWTHeader = {
typ: 'JWT',
alg: 'ES256', // Assuming ES256, adjust based on key type
};
// Sign the token
return signJWT(header, payload, privateKey);
}
/**
* Generate subject JWK thumbprint
*/
export async function generateSubjectJWKThumbprint(publicKey: CryptoKey): Promise<string> {
// Export public key as JWK
const jwk = await crypto.subtle.exportKey('jwk', publicKey);
// Create canonical JWK for thumbprint
const canonicalJWK = {
kty: jwk.kty,
...(jwk.crv && { crv: jwk.crv }),
...(jwk.x && { x: jwk.x }),
...(jwk.y && { y: jwk.y }),
...(jwk.n && { n: jwk.n }),
...(jwk.e && { e: jwk.e }),
};
// Calculate SHA-256 thumbprint
const canonicalString = JSON.stringify(canonicalJWK);
const encoder = new TextEncoder();
const data = encoder.encode(canonicalString);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
// Base64url encode
return btoa(String.fromCharCode(...hashArray))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// ============================================================================
// DID Document Resolution
// ============================================================================
/**
* Resolve DID document (simplified implementation)
* In production, use a proper DID resolver library
*/
export async function resolveDIDDocument(did: string): Promise<DIDDocument> {
// This is a simplified implementation
// In practice, you would use a DID resolver that supports multiple DID methods
if (did.startsWith('did:web:')) {
return resolveWebDID(did);
}
if (did.startsWith('did:key:')) {
return resolveKeyDID(did);
}
if (did.startsWith('did:sonr:')) {
return resolveSonrDID(did);
}
throw new Error(`Unsupported DID method: ${did}`);
}
/**
* Resolve did:web DID
*/
async function resolveWebDID(did: string): Promise<DIDDocument> {
// Extract domain from did:web
const domain = did.replace('did:web:', '').replace(/:/g, '/');
const url = `https://${domain}/.well-known/did.json`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to resolve DID document: ${response.status}`);
}
return response.json() as Promise<DIDDocument>;
} catch (error) {
throw new Error(
`Failed to resolve did:web: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Resolve did:key DID (simplified)
*/
async function resolveKeyDID(did: string): Promise<DIDDocument> {
// This is a very simplified implementation
// In practice, you would properly decode the multibase key
const verificationMethod: VerificationMethod = {
id: `${did}#keys-1`,
type: 'Ed25519VerificationKey2020',
controller: did,
// In practice, decode the key from the DID
publicKeyMultibase: did.split(':')[2],
};
return {
id: did,
verificationMethod: [verificationMethod],
authentication: [verificationMethod.id],
assertionMethod: [verificationMethod.id],
};
}
/**
* Resolve did:sonr DID from blockchain
*/
async function resolveSonrDID(did: string): Promise<DIDDocument> {
// This would query the Sonr blockchain for the DID document
// For now, return a mock structure
const identifier = did.split(':')[2];
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
try {
const response = await fetch(`${apiUrl}/did/resolve/${identifier}`);
if (!response.ok) {
throw new Error(`Failed to resolve Sonr DID: ${response.status}`);
}
return response.json() as Promise<DIDDocument>;
} catch (error) {
throw new Error(
`Failed to resolve did:sonr: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// ============================================================================
// Verifiable Presentation Utilities
// ============================================================================
/**
* Create Verifiable Presentation token
*/
export async function createVPToken(
presentation: VerifiablePresentation,
holderDID: string,
privateKey: CryptoKey,
audience: string,
nonce?: string
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const payload: VPTokenPayload = {
iss: holderDID,
sub: holderDID,
aud: audience,
exp: now + 3600,
iat: now,
nbf: now,
jti: generateRandomString(16),
vp: presentation,
};
if (nonce) {
payload.nonce = nonce;
}
const header: JWTHeader = {
typ: 'JWT',
alg: 'ES256',
};
return signJWT(header, payload, privateKey);
}
/**
* Validate Verifiable Presentation
*/
export function validateVP(vp: VerifiablePresentation): void {
// Basic validation
if (!vp['@context'] || !vp.type) {
throw new Error('VP must have @context and type');
}
// Validate context
const contexts = Array.isArray(vp['@context']) ? vp['@context'] : [vp['@context']];
if (!contexts.includes('https://www.w3.org/2018/credentials/v1')) {
throw new Error('VP must include credentials v1 context');
}
// Validate type
const types = Array.isArray(vp.type) ? vp.type : [vp.type];
if (!types.includes('VerifiablePresentation')) {
throw new Error('VP must include VerifiablePresentation type');
}
// Validate credentials if present
if (vp.verifiableCredential) {
vp.verifiableCredential.forEach((vc, index) => {
try {
validateVC(vc);
} catch (error) {
throw new Error(
`Invalid credential at index ${index}: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
});
}
}
/**
* Validate Verifiable Credential
*/
export function validateVC(vc: VerifiableCredential): void {
if (!vc['@context'] || !vc.type || !vc.issuer || !vc.issuanceDate || !vc.credentialSubject) {
throw new Error(
'VC must have required fields: @context, type, issuer, issuanceDate, credentialSubject'
);
}
// Validate expiration
if (vc.expirationDate) {
const expirationDate = new Date(vc.expirationDate);
if (expirationDate < new Date()) {
throw new Error('Credential has expired');
}
}
// Additional validation would include signature verification
}
// ============================================================================
// SIOP Response Builder
// ============================================================================
/**
* Build SIOP response
*/
export async function buildSIOPResponse(
request: SIOPRequest,
did: string,
privateKey: CryptoKey,
vp?: VerifiablePresentation,
additionalClaims?: Record<string, unknown>
): Promise<SIOPResponse> {
// Generate self-issued ID token
const idToken = await generateSelfIssuedIDToken(request, did, privateKey, additionalClaims);
const response: SIOPResponse = {
id_token: idToken,
};
// Add state if present in request
if (request.state) {
response.state = request.state;
}
// Add VP token if VP is provided
if (vp) {
validateVP(vp);
response.vp_token = await createVPToken(vp, did, privateKey, request.client_id, request.nonce);
}
return response;
}
/**
* Submit SIOP response to client
*/
export async function submitSIOPResponse(
response: SIOPResponse,
redirectUri: string,
responseMode: 'form_post' | 'fragment' | 'query' = 'fragment'
): Promise<void> {
if (responseMode === 'form_post') {
// Submit via form post
const form = document.createElement('form');
form.method = 'POST';
form.action = redirectUri;
Object.entries(response).forEach(([key, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
} else {
// Submit via redirect with parameters
const url = new URL(redirectUri);
const params = responseMode === 'fragment' ? new URLSearchParams() : url.searchParams;
Object.entries(response).forEach(([key, value]) => {
params.append(key, value);
});
const finalUrl =
responseMode === 'fragment'
? `${url.origin}${url.pathname}#${params.toString()}`
: url.toString();
window.location.href = finalUrl;
}
}
// ============================================================================
// Dynamic Client Registration
// ============================================================================
/**
* Validate client metadata for dynamic registration
*/
export function validateClientMetadata(metadata: SIOPClientMetadata): void {
// Validate required fields for SIOP
if (!metadata.subject_type) {
metadata.subject_type = 'public';
}
// Validate redirect URIs
if (metadata.redirect_uris) {
metadata.redirect_uris.forEach((uri) => {
try {
new URL(uri);
} catch {
throw new Error(`Invalid redirect URI: ${uri}`);
}
});
}
// Validate response types
if (metadata.response_types && !metadata.response_types.includes('id_token')) {
throw new Error('SIOP clients must support "id_token" response type');
}
// Validate subject syntax types
if (metadata.subject_syntax_types_supported) {
const validTypes = Object.values(SIOP_CONSTANTS.SUBJECT_SYNTAX_TYPES);
const hasValidType = metadata.subject_syntax_types_supported.some((type) =>
validTypes.includes(
type as (typeof SIOP_CONSTANTS.SUBJECT_SYNTAX_TYPES)[keyof typeof SIOP_CONSTANTS.SUBJECT_SYNTAX_TYPES]
)
);
if (!hasValidType) {
throw new Error('Client must support at least one valid subject syntax type');
}
}
}
// ============================================================================
// JWT Signing Utilities
// ============================================================================
/**
* Sign JWT with private key
*/
async function signJWT(
header: JWTHeader,
payload: Record<string, unknown>,
privateKey: CryptoKey
): Promise<string> {
// Encode header and payload
const encodedHeader = base64URLEncode(new TextEncoder().encode(JSON.stringify(header)));
const encodedPayload = base64URLEncode(new TextEncoder().encode(JSON.stringify(payload)));
// Create signing input
const signingInput = `${encodedHeader}.${encodedPayload}`;
// Sign
const signature = await crypto.subtle.sign(
'ECDSA',
privateKey,
new TextEncoder().encode(signingInput)
);
// Encode signature
const encodedSignature = base64URLEncode(new Uint8Array(signature));
return `${signingInput}.${encodedSignature}`;
}
/**
* Base64URL encode utility
*/
function base64URLEncode(buffer: Uint8Array): string {
return btoa(String.fromCharCode(...Array.from(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// ============================================================================
// High-Level SIOP Client
// ============================================================================
/**
* SIOP Client for handling SIOP requests and responses
*/
export class SIOPClient {
private did: string;
private privateKey?: CryptoKey;
private didDocument?: DIDDocument;
constructor(did: string, privateKey?: CryptoKey) {
this.did = did;
this.privateKey = privateKey;
}
/**
* Initialize the client by resolving DID document
*/
async initialize(): Promise<void> {
this.didDocument = await resolveDIDDocument(this.did);
}
/**
* Handle incoming SIOP request
*/
async handleSIOPRequest(
requestUrl: string,
credentials?: VerifiableCredential[]
): Promise<SIOPResponse> {
const url = new URL(requestUrl);
let request = parseSIOPRequest(url.searchParams);
// Resolve request object if present
request = await resolveSIOPRequestObject(request);
// Validate request
validateSIOPRequest(request);
if (!this.privateKey) {
throw new Error('Private key required for signing');
}
// Create VP if credentials are provided
let vp: VerifiablePresentation | undefined;
if (credentials && credentials.length > 0) {
vp = {
'@context': ['https://www.w3.org/2018/credentials/v1'],
type: ['VerifiablePresentation'],
verifiableCredential: credentials,
holder: this.did,
};
}
// Build and return response
return buildSIOPResponse(request, this.did, this.privateKey, vp);
}
/**
* Submit SIOP response
*/
async submitResponse(
response: SIOPResponse,
redirectUri: string,
responseMode?: 'form_post' | 'fragment' | 'query'
): Promise<void> {
await submitSIOPResponse(response, redirectUri, responseMode);
}
/**
* Get DID document
*/
getDIDDocument(): DIDDocument | undefined {
return this.didDocument;
}
/**
* Set private key
*/
setPrivateKey(privateKey: CryptoKey): void {
this.privateKey = privateKey;
}
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Check if a request is a SIOP request
*/
export function isSIOPRequest(searchParams: URLSearchParams): boolean {
const responseType = searchParams.get('response_type');
const scope = searchParams.get('scope');
return responseType === 'id_token' && scope?.includes('openid') === true;
}
/**
* Extract DID from self-issued ID token
*/
export function extractDIDFromSelfIssuedToken(idToken: string): string | null {
try {
const { payload } = decodeJWT(idToken);
return (payload as SelfIssuedIDToken).did || null;
} catch {
return null;
}
}
/**
* Validate self-issued ID token structure
*/
export function validateSelfIssuedIDToken(
idToken: string,
expectedAudience: string
): SelfIssuedIDToken {
const { payload } = decodeJWT(idToken);
const token = payload as SelfIssuedIDToken;
// Validate issuer
if (token.iss !== SIOP_CONSTANTS.SELF_ISSUED_ISSUER_V2 && !token.did?.startsWith('did:')) {
throw new Error('Invalid issuer for self-issued ID token');
}
// Validate audience
if (token.aud !== expectedAudience) {
throw new Error('Invalid audience');
}
// Validate expiration
const now = Math.floor(Date.now() / 1000);
if (token.exp && token.exp < now) {
throw new Error('Token has expired');
}
// Validate not before
if (token.nbf && token.nbf > now) {
throw new Error('Token not yet valid');
}
return token;
}
+375
View File
@@ -0,0 +1,375 @@
/**
* UCAN delegation utilities for OAuth2 integration
*/
import { JWK, SignJWT, importJWK } from 'jose';
/**
* UCAN token structure
*/
export interface UCANToken {
iss: string; // Issuer DID
aud: string; // Audience DID
exp: number; // Expiration timestamp
nbf?: number; // Not before timestamp
att: UCANCapability[]; // Attenuations/capabilities
prf?: string[]; // Proof chain
fct?: any[]; // Facts
}
/**
* UCAN capability/attenuation
*/
export interface UCANCapability {
with: string; // Resource URI
can: string; // Action/capability
nb?: Record<string, any>; // Caveats/constraints
}
/**
* OAuth scope to UCAN capability mapping
*/
export interface ScopeMapping {
scope: string;
capabilities: UCANCapability[];
}
/**
* Default scope mappings
*/
export const DEFAULT_SCOPE_MAPPINGS: ScopeMapping[] = [
{
scope: 'openid',
capabilities: [{ with: 'did:*', can: 'identify' }],
},
{
scope: 'profile',
capabilities: [
{ with: 'profile:*', can: 'read' },
{ with: 'did:*', can: 'resolve' },
],
},
{
scope: 'vault:read',
capabilities: [
{ with: 'vault:*', can: 'read' },
{ with: 'vault:*', can: 'list' },
],
},
{
scope: 'vault:write',
capabilities: [
{ with: 'vault:*', can: 'write' },
{ with: 'vault:*', can: 'create' },
{ with: 'vault:*', can: 'update' },
{ with: 'vault:*', can: 'delete' },
],
},
{
scope: 'vault:sign',
capabilities: [
{ with: 'vault:*', can: 'sign' },
{ with: 'tx:*', can: 'sign' },
],
},
{
scope: 'service:manage',
capabilities: [
{ with: 'service:*', can: 'create' },
{ with: 'service:*', can: 'update' },
{ with: 'service:*', can: 'delete' },
{ with: 'service:*', can: 'list' },
],
},
];
/**
* Map OAuth scopes to UCAN capabilities
*/
export function scopesToCapabilities(scopes: string[]): UCANCapability[] {
const capabilities: UCANCapability[] = [];
for (const scope of scopes) {
const mapping = DEFAULT_SCOPE_MAPPINGS.find((m) => m.scope === scope);
if (mapping) {
capabilities.push(...mapping.capabilities);
} else {
// Handle custom scopes
capabilities.push({
with: `custom:${scope}`,
can: '*',
});
}
}
// Deduplicate capabilities
const unique = new Map<string, UCANCapability>();
for (const cap of capabilities) {
const key = `${cap.with}:${cap.can}`;
if (!unique.has(key)) {
unique.set(key, cap);
}
}
return Array.from(unique.values());
}
/**
* Parse UCAN token from JWT string
*/
export async function parseUCAN(token: string): Promise<UCANToken> {
// Decode JWT without verification (client-side)
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid UCAN token format');
}
const payload = JSON.parse(atob(parts[1]));
return {
iss: payload.iss,
aud: payload.aud,
exp: payload.exp,
nbf: payload.nbf,
att: payload.att || [],
prf: payload.prf || [],
fct: payload.fct || [],
};
}
/**
* Validate UCAN token capabilities
*/
export function validateCapabilities(
token: UCANToken,
requiredCapabilities: UCANCapability[]
): boolean {
for (const required of requiredCapabilities) {
const hasCapability = token.att.some((cap) => {
// Check if the capability matches
if (!matchResource(cap.with, required.with)) {
return false;
}
if (!matchAction(cap.can, required.can)) {
return false;
}
// Check caveats if present
if (required.nb) {
if (!cap.nb) return false;
for (const [key, value] of Object.entries(required.nb)) {
if (cap.nb[key] !== value) {
return false;
}
}
}
return true;
});
if (!hasCapability) {
return false;
}
}
return true;
}
/**
* Match resource patterns
*/
function matchResource(pattern: string, resource: string): boolean {
// Handle wildcards
if (pattern === '*' || resource === '*') {
return true;
}
// Handle prefix wildcards
if (pattern.endsWith('*')) {
const prefix = pattern.slice(0, -1);
return resource.startsWith(prefix);
}
// Exact match
return pattern === resource;
}
/**
* Match action patterns
*/
function matchAction(pattern: string, action: string): boolean {
// Handle wildcards
if (pattern === '*' || action === '*') {
return true;
}
// Exact match
return pattern === action;
}
/**
* Create a delegation chain
*/
export async function createDelegation(
issuerDID: string,
audienceDID: string,
capabilities: UCANCapability[],
expiresIn = 3600,
proofs?: string[]
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: issuerDID,
aud: audienceDID,
exp: now + expiresIn,
nbf: now,
att: capabilities,
prf: proofs || [],
};
// In production, this would be signed with the issuer's private key
// For client-side, we'll create an unsigned token
const header = { alg: 'none', typ: 'JWT', ucv: '0.9.0' };
const encodedHeader = btoa(JSON.stringify(header))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
const encodedPayload = btoa(JSON.stringify(payload))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return `${encodedHeader}.${encodedPayload}.`;
}
/**
* Verify UCAN delegation chain
*/
export async function verifyDelegationChain(token: string, rootDID: string): Promise<boolean> {
try {
const ucan = await parseUCAN(token);
// Check expiration
const now = Math.floor(Date.now() / 1000);
if (ucan.exp && ucan.exp < now) {
return false;
}
// Check not before
if (ucan.nbf && ucan.nbf > now) {
return false;
}
// Check if this is the root delegation
if (ucan.iss === rootDID) {
return true;
}
// Verify proof chain
if (!ucan.prf || ucan.prf.length === 0) {
return false;
}
// Recursively verify each proof
for (const proof of ucan.prf) {
const isValid = await verifyDelegationChain(proof, rootDID);
if (!isValid) {
return false;
}
// Verify that the proof delegates to this token's issuer
const proofUCAN = await parseUCAN(proof);
if (proofUCAN.aud !== ucan.iss) {
return false;
}
// Verify capabilities are attenuated
if (!isAttenuated(proofUCAN.att, ucan.att)) {
return false;
}
}
return true;
} catch {
return false;
}
}
/**
* Check if capabilities are properly attenuated
*/
function isAttenuated(parentCaps: UCANCapability[], childCaps: UCANCapability[]): boolean {
// Each child capability must be allowed by at least one parent capability
for (const childCap of childCaps) {
const isAllowed = parentCaps.some((parentCap) => {
// Check resource is same or more specific
if (!matchResource(parentCap.with, childCap.with)) {
return false;
}
// Check action is same or more specific
if (!matchAction(parentCap.can, childCap.can)) {
return false;
}
// Check caveats are same or more restrictive
if (childCap.nb) {
if (!parentCap.nb) {
// Child has caveats but parent doesn't - this is more restrictive, so OK
return true;
}
// All parent caveats must be present in child
for (const [key, value] of Object.entries(parentCap.nb)) {
if (childCap.nb[key] !== value) {
return false;
}
}
}
return true;
});
if (!isAllowed) {
return false;
}
}
return true;
}
/**
* Human-readable capability description
*/
export function describeCapability(cap: UCANCapability): string {
const resource = cap.with.replace(':', ' ').replace('*', 'all');
const action = cap.can.replace('*', 'all actions');
let description = `Can ${action} on ${resource}`;
if (cap.nb && Object.keys(cap.nb).length > 0) {
const caveats = Object.entries(cap.nb)
.map(([k, v]) => `${k}=${v}`)
.join(', ');
description += ` (with constraints: ${caveats})`;
}
return description;
}
/**
* Export utilities
*/
export const UCANUtils = {
scopesToCapabilities,
parseUCAN,
validateCapabilities,
createDelegation,
verifyDelegationChain,
describeCapability,
};
+415
View File
@@ -0,0 +1,415 @@
/**
* OAuth scope utilities for UCAN integration
*/
import type { UCANCapability } from './delegation';
/**
* OAuth scope definition
*/
export interface OAuthScope {
name: string;
title: string;
description: string;
icon?: string;
required?: boolean;
capabilities: UCANCapability[];
children?: string[]; // Hierarchical scopes
}
/**
* Scope registry
*/
export class ScopeRegistry {
private scopes: Map<string, OAuthScope> = new Map();
constructor() {
this.registerDefaultScopes();
}
/**
* Register default OAuth scopes
*/
private registerDefaultScopes() {
// OpenID Connect scopes
this.register({
name: 'openid',
title: 'OpenID',
description: 'Authenticate using your Sonr identity',
required: true,
capabilities: [{ with: 'did:*', can: 'identify' }],
});
this.register({
name: 'profile',
title: 'Profile',
description: 'Access your basic profile information',
capabilities: [
{ with: 'profile:*', can: 'read' },
{ with: 'did:*', can: 'resolve' },
],
});
this.register({
name: 'email',
title: 'Email',
description: 'Access your email address',
capabilities: [{ with: 'profile:email', can: 'read' }],
});
// Vault scopes
this.register({
name: 'vault:read',
title: 'Read Vault',
description: 'Read data from your encrypted vault',
capabilities: [
{ with: 'vault:*', can: 'read' },
{ with: 'vault:*', can: 'list' },
],
});
this.register({
name: 'vault:write',
title: 'Write Vault',
description: 'Create and modify data in your vault',
capabilities: [
{ with: 'vault:*', can: 'write' },
{ with: 'vault:*', can: 'create' },
{ with: 'vault:*', can: 'update' },
],
});
this.register({
name: 'vault:delete',
title: 'Delete Vault Data',
description: 'Delete data from your vault',
capabilities: [{ with: 'vault:*', can: 'delete' }],
});
this.register({
name: 'vault:sign',
title: 'Sign with Vault',
description: 'Sign messages and transactions using vault keys',
capabilities: [
{ with: 'vault:*', can: 'sign' },
{ with: 'tx:*', can: 'sign' },
],
});
// Hierarchical vault scope
this.register({
name: 'vault:admin',
title: 'Vault Admin',
description: 'Full administrative access to your vault',
capabilities: [{ with: 'vault:*', can: '*' }],
children: ['vault:read', 'vault:write', 'vault:delete', 'vault:sign'],
});
// Service management scopes
this.register({
name: 'service:read',
title: 'Read Services',
description: 'List and view your registered services',
capabilities: [
{ with: 'service:*', can: 'read' },
{ with: 'service:*', can: 'list' },
],
});
this.register({
name: 'service:write',
title: 'Manage Services',
description: 'Create and update service registrations',
capabilities: [
{ with: 'service:*', can: 'create' },
{ with: 'service:*', can: 'update' },
],
});
this.register({
name: 'service:delete',
title: 'Delete Services',
description: 'Remove service registrations',
capabilities: [{ with: 'service:*', can: 'delete' }],
});
this.register({
name: 'service:manage',
title: 'Service Admin',
description: 'Full control over service registrations',
capabilities: [{ with: 'service:*', can: '*' }],
children: ['service:read', 'service:write', 'service:delete'],
});
// DWN (Decentralized Web Node) scopes
this.register({
name: 'dwn:read',
title: 'Read DWN',
description: 'Read data from your Decentralized Web Node',
capabilities: [
{ with: 'dwn:*', can: 'read' },
{ with: 'dwn:*', can: 'query' },
],
});
this.register({
name: 'dwn:write',
title: 'Write DWN',
description: 'Store data in your Decentralized Web Node',
capabilities: [
{ with: 'dwn:*', can: 'write' },
{ with: 'dwn:*', can: 'create' },
],
});
// Offline access
this.register({
name: 'offline_access',
title: 'Offline Access',
description: "Access your account when you're not present",
capabilities: [{ with: 'token:*', can: 'refresh' }],
});
}
/**
* Register a new scope
*/
register(scope: OAuthScope): void {
this.scopes.set(scope.name, scope);
}
/**
* Get a scope by name
*/
get(name: string): OAuthScope | undefined {
return this.scopes.get(name);
}
/**
* Get all registered scopes
*/
getAll(): OAuthScope[] {
return Array.from(this.scopes.values());
}
/**
* Check if a scope exists
*/
has(name: string): boolean {
return this.scopes.has(name);
}
/**
* Validate requested scopes
*/
validate(requested: string[]): { valid: string[]; invalid: string[] } {
const valid: string[] = [];
const invalid: string[] = [];
for (const scope of requested) {
if (this.has(scope)) {
valid.push(scope);
} else {
invalid.push(scope);
}
}
return { valid, invalid };
}
/**
* Expand hierarchical scopes
*/
expand(scopes: string[]): string[] {
const expanded = new Set<string>();
for (const scope of scopes) {
expanded.add(scope);
const definition = this.get(scope);
if (definition?.children) {
for (const child of definition.children) {
expanded.add(child);
}
}
}
return Array.from(expanded);
}
/**
* Get capabilities for scopes
*/
getCapabilities(scopes: string[]): UCANCapability[] {
const capabilities: UCANCapability[] = [];
const seen = new Set<string>();
// Expand hierarchical scopes
const expanded = this.expand(scopes);
for (const scope of expanded) {
const definition = this.get(scope);
if (definition) {
for (const cap of definition.capabilities) {
const key = `${cap.with}:${cap.can}`;
if (!seen.has(key)) {
seen.add(key);
capabilities.push(cap);
}
}
}
}
return capabilities;
}
/**
* Get required scopes from a list
*/
getRequired(scopes: string[]): string[] {
return scopes.filter((scope) => {
const definition = this.get(scope);
return definition?.required === true;
});
}
/**
* Group scopes by category
*/
groupByCategory(scopes: string[]): Map<string, OAuthScope[]> {
const groups = new Map<string, OAuthScope[]>();
for (const scope of scopes) {
const definition = this.get(scope);
if (definition) {
const category = scope.split(':')[0] || 'general';
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category)!.push(definition);
}
}
return groups;
}
}
/**
* Default scope registry instance
*/
export const scopeRegistry = new ScopeRegistry();
/**
* Scope validation utilities
*/
export const ScopeValidator = {
/**
* Check if scopes are valid
*/
isValid(scopes: string[]): boolean {
const { invalid } = scopeRegistry.validate(scopes);
return invalid.length === 0;
},
/**
* Check if scope is hierarchical
*/
isHierarchical(scope: string): boolean {
const definition = scopeRegistry.get(scope);
return !!(definition?.children && definition.children.length > 0);
},
/**
* Check if one scope includes another
*/
includes(parent: string, child: string): boolean {
const definition = scopeRegistry.get(parent);
if (!definition?.children) return false;
return definition.children.includes(child);
},
/**
* Get minimal scope set (remove redundant child scopes)
*/
minimize(scopes: string[]): string[] {
const minimal = new Set<string>(scopes);
for (const scope of scopes) {
const definition = scopeRegistry.get(scope);
if (definition?.children) {
// Remove children if parent is present
for (const child of definition.children) {
minimal.delete(child);
}
}
}
return Array.from(minimal);
},
/**
* Check if scopes grant a specific capability
*/
hasCapability(scopes: string[], resource: string, action: string): boolean {
const capabilities = scopeRegistry.getCapabilities(scopes);
return capabilities.some((cap) => {
const resourceMatch =
cap.with === resource ||
cap.with === '*' ||
(cap.with.endsWith('*') && resource.startsWith(cap.with.slice(0, -1)));
const actionMatch = cap.can === action || cap.can === '*';
return resourceMatch && actionMatch;
});
},
};
/**
* Scope formatting utilities
*/
export const ScopeFormatter = {
/**
* Format scope for display
*/
format(scope: string): string {
const definition = scopeRegistry.get(scope);
return definition?.title || scope;
},
/**
* Format scope list
*/
formatList(scopes: string[]): string {
return scopes.map((s) => this.format(s)).join(', ');
},
/**
* Get scope description
*/
describe(scope: string): string {
const definition = scopeRegistry.get(scope);
return definition?.description || `Access to ${scope}`;
},
/**
* Get scope icon
*/
getIcon(scope: string): string | undefined {
const definition = scopeRegistry.get(scope);
return definition?.icon;
},
};
/**
* Export utilities
*/
export default {
registry: scopeRegistry,
validator: ScopeValidator,
formatter: ScopeFormatter,
};
+100
View File
@@ -0,0 +1,100 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./src/**/*.{js,ts,jsx,tsx,mdx}',
// Include @sonr.io/ui components
'../../packages/ui/src/**/*.{js,ts,jsx,tsx}',
],
prefix: '',
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
chart: {
1: 'hsl(var(--chart-1))',
2: 'hsl(var(--chart-2))',
3: 'hsl(var(--chart-3))',
4: 'hsl(var(--chart-4))',
5: 'hsl(var(--chart-5))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'fade-out': {
'0%': { opacity: '1' },
'100%': { opacity: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'fade-in': 'fade-in 0.5s ease-in-out',
'fade-out': 'fade-out 0.5s ease-in-out',
},
fontFamily: {
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
mono: ['var(--font-mono)', 'Consolas', 'monospace'],
},
},
},
plugins: [require('tailwindcss-animate')],
};
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+28
View File
@@ -0,0 +1,28 @@
main = ".open-next/worker.js"
name = "highway-auth"
compatibility_date = "2025-03-25"
compatibility_flags = ["nodejs_compat"]
[dev]
port = 3100
[assets]
directory = ".open-next/assets"
binding = "ASSETS"
# Development environment (default)
[env.development]
# Add any development-specific environment variables here
# Production environment
[env.production]
# Add any production-specific environment variables here
# Build configuration is handled separately by opennextjs-cloudflare
[build]
command = "npx opennextjs-cloudflare build"
cwd = "."
# Analytics and observability
[observability]
enabled = true