feat: initial commit - WebAuthn passkey authentication service

- Complete WebAuthn/FIDO2 authentication implementation
- SQLite database with user and credential management
- Email-based user identification with allowlist support
- Admin approval workflow for new users
- Session management with secure cookies
- Docker containerization with Debian base for SQLite compatibility
- Kubernetes deployment manifests with nginx ingress support
- Web-based admin interface for user management
- Comprehensive documentation and deployment guides
- Standard open source project structure with CI/CD
This commit is contained in:
2025-08-04 18:35:44 +10:00
commit 9427c36b8b
33 changed files with 3741 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
---
name: Bug Report
about: Create a report to help us improve
title: '[BUG] '
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. macOS, Linux, Windows]
- Go Version: [e.g. 1.21.0]
- Browser: [e.g. Chrome, Firefox, Safari]
- Docker Version: [if using Docker]
- Kubernetes Version: [if using Kubernetes]
**Configuration**
- Are you using email allowlist? [yes/no]
- Is admin approval enabled? [yes/no]
- Deployment method: [Docker, Kubernetes, direct binary]
**Logs**
If applicable, add logs to help diagnose the problem.
```
Paste relevant logs here
```
**Additional context**
Add any other context about the problem here.
+29
View File
@@ -0,0 +1,29 @@
---
name: Feature Request
about: Suggest an idea for this project
title: '[FEATURE] '
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Use case**
Describe the specific use case this feature would address.
**Implementation considerations**
- Would this be backwards compatible?
- Any security implications?
- Database schema changes needed?
- Configuration changes required?
**Additional context**
Add any other context, mockups, or examples about the feature request here.
+38
View File
@@ -0,0 +1,38 @@
## Description
Brief description of what this PR does.
## Type of Change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring
## Testing
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have tested the changes manually
## Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have updated the CHANGELOG.md if needed
## Security Considerations
- [ ] This change does not introduce any security vulnerabilities
- [ ] I have considered the security implications of this change
- [ ] Authentication/authorization is properly handled
## Breaking Changes
If this PR introduces breaking changes, please describe them here and update the major version.
## Screenshots/Demos
If applicable, add screenshots or demo videos.
## Related Issues
Closes #(issue_number)
Related to #(issue_number)
+140
View File
@@ -0,0 +1,140 @@
name: CI/CD
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
release:
types: [ published ]
env:
GO_VERSION: '1.21'
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- name: Cache Go modules
uses: actions/cache@v3
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y gcc libsqlite3-dev
- name: Download dependencies
run: go mod download
- name: Verify dependencies
run: go mod verify
- name: Run tests
run: go test -v -race -coverprofile=coverage.out ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.out
flags: unittests
name: codecov-umbrella
- name: Build binary
run: |
CGO_ENABLED=1 go build -v -o passkey-auth .
- name: Test binary
run: |
./passkey-auth --help || true # Some apps don't have --help
echo "Binary built successfully"
docker:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' || github.event_name == 'release'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name == 'release'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
passkey-auth
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'release' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y gcc libsqlite3-dev
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
args: --timeout=5m
security:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run Gosec Security Scanner
uses: securecodewarrior/github-action-gosec@master
with:
args: './...'
+58
View File
@@ -0,0 +1,58 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
bin/
passkey-auth
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# Database files
*.db
*.sqlite
*.sqlite3
# Configuration files with secrets
config-prod.yaml
.env
.env.local
# Data directory
data/
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Docker
.docker/
# Kubernetes secrets
k8s/*-secret.yaml
# Development files
dev-*
+70
View File
@@ -0,0 +1,70 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Initial release of Passkey Auth
- WebAuthn (FIDO2) authentication support
- SQLite database with user management
- Email-based user identification
- Admin approval workflow for new users
- Email allowlist support
- Session management with secure cookies
- Kubernetes deployment configuration
- nginx ingress auth backend support
- Docker containerization with Debian base
- Web-based admin interface
- User registration and login flows
- Real-time WebAuthn credential management
### Features
- **Authentication**: WebAuthn/FIDO2 passkey authentication
- **Database**: SQLite with user and credential storage
- **Admin Interface**: Web UI for user management and approval
- **Email Allowlist**: Restrict registration to specific email addresses
- **Session Management**: Secure session handling with configurable secrets
- **Docker Support**: Multi-stage builds with Debian base for SQLite compatibility
- **Kubernetes Ready**: Complete deployment manifests and ingress configuration
- **nginx Integration**: Auth backend for protecting upstream services
### Security
- WebAuthn challenge/response authentication
- Secure session cookie handling
- Admin approval workflow
- Email-based access control
- HTTPS-ready configuration
### Documentation
- Comprehensive README with setup instructions
- API endpoint documentation
- Kubernetes deployment guide
- Docker usage examples
- Troubleshooting guide
- Contributing guidelines
### Technical Details
- Go 1.21+ with CGO for SQLite
- Base64URL encoding/decoding for WebAuthn data
- CORS support for cross-origin requests
- Environment variable configuration
- Health check endpoints
- Structured logging
## [0.1.0] - 2025-08-04
### Added
- Initial project structure
- Core WebAuthn implementation
- SQLite database integration
- Basic web interface
- Docker containerization
- Kubernetes manifests
---
**Note**: This project follows semantic versioning. For upgrade instructions and breaking changes, see the README.md file.
+129
View File
@@ -0,0 +1,129 @@
# Contributing to Passkey Auth
Thank you for your interest in contributing to Passkey Auth! This document provides guidelines for contributing to the project.
## 🚀 Getting Started
1. Fork the repository
2. Clone your fork: `git clone https://github.com/yourusername/passkey-auth.git`
3. Create a feature branch: `git checkout -b feature/your-feature-name`
4. Make your changes
5. Test your changes
6. Commit and push to your fork
7. Create a Pull Request
## 🛠️ Development Setup
### Prerequisites
- Go 1.21 or later
- Docker (for containerized testing)
- Make (optional, for build scripts)
### Local Development
```bash
# Clone the repository
git clone https://github.com/yourusername/passkey-auth.git
cd passkey-auth
# Install dependencies
go mod download
# Run the application
go run .
# Run tests
go test ./...
```
### Docker Development
```bash
# Build Docker image
./scripts/build.sh
# Run with Docker
docker run -p 8080:8080 passkey-auth
```
## 📋 Guidelines
### Code Style
- Follow standard Go formatting (`gofmt`)
- Use meaningful variable and function names
- Add comments for exported functions and complex logic
- Keep functions focused and small
### Commit Messages
- Use clear, descriptive commit messages
- Start with a verb in present tense ("Add", "Fix", "Update")
- Include context for why the change was made
Example:
```
Add email allowlist support for user registration
- Allows administrators to restrict registration to specific email domains
- Configurable via config.yaml or environment variables
- Backwards compatible (empty list allows all emails)
```
### Testing
- Write tests for new features
- Ensure existing tests pass
- Test both success and error cases
- Include integration tests for new endpoints
### Documentation
- Update README.md for new features
- Add inline code comments
- Update API documentation if endpoints change
- Include examples in documentation
## 🐛 Bug Reports
When reporting bugs, please include:
- Go version
- Operating system
- Steps to reproduce
- Expected vs actual behavior
- Relevant logs or error messages
## 💡 Feature Requests
When requesting features:
- Describe the use case
- Explain why it would be valuable
- Consider backwards compatibility
- Provide examples if possible
## 🔐 Security
For security vulnerabilities:
- Do not open public issues
- Contact maintainers directly
- Provide detailed reproduction steps
- Allow time for fixes before disclosure
## 📜 Code of Conduct
- Be respectful and inclusive
- Welcome newcomers and help them learn
- Focus on constructive feedback
- Respect different viewpoints and experiences
## 🏷️ Release Process
1. Update version in relevant files
2. Update CHANGELOG.md
3. Create release PR
4. Tag release after merge
5. Build and publish Docker images
6. Create GitHub release with notes
## 📞 Getting Help
- Check existing issues and documentation first
- Open an issue for bugs or feature requests
- Join discussions in existing issues
- Ask questions in issues (we're happy to help!)
Thank you for contributing! 🎉
+45
View File
@@ -0,0 +1,45 @@
# Build stage
FROM golang:1.21-bullseye AS builder
# Install build dependencies including sqlite
RUN apt-get update && apt-get install -y gcc libsqlite3-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the application (remove static linking for SQLite compatibility)
RUN CGO_ENABLED=1 GOOS=linux go build -a -o passkey-auth .
# Final stage
FROM debian:bullseye-slim
# Install runtime dependencies
RUN apt-get update && apt-get install -y ca-certificates libsqlite3-0 && rm -rf /var/lib/apt/lists/*
WORKDIR /root/
# Copy the binary from builder stage
COPY --from=builder /app/passkey-auth .
# Copy web files
COPY --from=builder /app/web ./web/
# Copy default config
COPY --from=builder /app/config.yaml .
# Create directory for database
RUN mkdir -p /data
# Expose port
EXPOSE 8080
# Run the application
CMD ["./passkey-auth"]
+122
View File
@@ -0,0 +1,122 @@
# 📋 Implementation Summary
## What We've Built
**Complete Passkey Authentication System** with email-based access control
**SQLite Database** for persistent user storage
**Email Allowlist System** for controlling who can register
**Kubernetes Integration** with nginx ingress auth backend
**Modern Web UI** for user registration and management
**Production-Ready Deployment** with Docker and Kubernetes manifests
## Key Features Implemented
### 🔐 Email-Based Authentication
- Users are identified by email addresses (not usernames)
- Configurable email allowlist for access control
- Environment variable support for email configuration
### 📧 Email Access Control Options
1. **Open Mode**: Empty allowlist allows any email
2. **Restricted Mode**: Only allowlisted emails can register
3. **Combined Security**: Allowlist + manual approval
### 🗄️ Database Architecture (SQLite)
```sql
-- Users table
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT UNIQUE NOT NULL, -- Email as primary identifier
display_name TEXT NOT NULL,
approved BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Credentials table (WebAuthn keys)
CREATE TABLE credentials (
id BLOB PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
public_key BLOB NOT NULL,
attestation_type TEXT NOT NULL,
aaguid BLOB,
sign_count INTEGER DEFAULT 0,
clone_warning BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### ⚙️ Configuration Options
```yaml
auth:
session_secret: "your-secret-key"
require_approval: true # Admin approval required
allowed_emails: # Email allowlist
- "admin@company.com"
- "engineering@company.com"
```
Or via environment variables:
```bash
export ALLOWED_EMAILS="admin@company.com,user1@company.com,user2@company.com"
export SESSION_SECRET="your-secure-session-secret"
```
## Files Created/Modified
### Core Application
- `main.go` - Application entry point
- `internal/config/` - Configuration management with email allowlist
- `internal/database/` - SQLite database layer with email-based users
- `internal/auth/` - WebAuthn implementation
- `internal/handlers/` - HTTP handlers with email validation
### Web Interface
- `web/index.html` - Admin UI updated for email addresses
### Deployment
- `Dockerfile` - Container build
- `k8s/` - Kubernetes manifests
- `docker-compose.yml` - Local development
- `scripts/` - Build and deployment scripts
### Documentation
- `README.md` - Complete usage guide
- `PRODUCTION.md` - Production deployment guide
- `config.example.yaml` - Example configuration
## How It Works
1. **Email Validation**: When a user tries to register, the system checks if their email is in the allowlist (if configured)
2. **Database Storage**: User data is stored in SQLite with email as the unique identifier
3. **WebAuthn Integration**: Passkey credentials are linked to the user record
4. **Session Management**: Authentication sessions use email-based identification
5. **Nginx Integration**: Auth headers include user email for downstream applications
## Quick Start
```bash
# 1. Configure email allowlist
vim config.yaml # Add your allowed emails
# 2. Start the service
./scripts/dev.sh
# 3. Register users at http://localhost:8080
# Only emails in the allowlist can register
# 4. Deploy to Kubernetes
./scripts/build.sh
./scripts/deploy.sh
```
## Security Benefits
- **No passwords stored** - Only WebAuthn public keys
- **Email-based access control** - Restrict registration to specific domains/emails
- **Phishing resistant** - WebAuthn is tied to the domain
- **MFA built-in** - Passkeys require user presence and verification
- **Session security** - Secure cookie-based sessions
This implementation provides a complete, production-ready passkey authentication system with fine-grained email-based access control, perfect for enterprise environments where you need to restrict access to specific users.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Passkey Auth Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+67
View File
@@ -0,0 +1,67 @@
.PHONY: help build run dev test clean docker-build docker-run k8s-deploy k8s-undeploy
help: ## Show this help message
@echo 'Usage: make [target]'
@echo ''
@echo 'Targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
build: ## Build the Go binary
@echo "🔨 Building..."
@go build -o bin/passkey-auth .
run: build ## Run the application locally
@echo "🚀 Running..."
@./bin/passkey-auth
dev: ## Run in development mode with hot reload
@echo "🔧 Starting development server..."
@./scripts/dev.sh
test: ## Run tests
@echo "🧪 Running tests..."
@go test -v ./...
clean: ## Clean build artifacts
@echo "🧹 Cleaning..."
@rm -rf bin/
@rm -f *.db
docker-build: ## Build Docker image
@echo "🐳 Building Docker image..."
@docker build -t passkey-auth:latest .
docker-run: docker-build ## Run with Docker Compose
@echo "🐳 Starting with Docker Compose..."
@mkdir -p data
@docker-compose up -d
@echo "Access the application at http://localhost:8080"
docker-stop: ## Stop Docker Compose
@echo "🛑 Stopping Docker Compose..."
@docker-compose down
k8s-deploy: docker-build ## Deploy to Kubernetes
@echo "☸️ Deploying to Kubernetes..."
@./scripts/deploy.sh
k8s-undeploy: ## Remove from Kubernetes
@echo "☸️ Removing from Kubernetes..."
@./scripts/undeploy.sh
deps: ## Download dependencies
@echo "📦 Downloading dependencies..."
@go mod download
@go mod tidy
fmt: ## Format code
@echo "🎨 Formatting code..."
@go fmt ./...
lint: ## Run linter
@echo "🔍 Running linter..."
@golangci-lint run
security: ## Run security scan
@echo "🔒 Running security scan..."
@gosec ./...
+287
View File
@@ -0,0 +1,287 @@
# Production Deployment Guide
This guide covers deploying Passkey Auth in a production Kubernetes environment.
## Prerequisites
- Kubernetes cluster with nginx ingress controller
- Domain name with SSL certificate
- kubectl access to the cluster
- Docker registry access (optional, for custom builds)
## Step 1: Prepare Configuration
1. **Generate Session Secret**:
```bash
openssl rand -base64 32
```
2. **Update Kubernetes Configuration**:
Edit `k8s/deployment.yaml` and update the ConfigMap:
```yaml
data:
config.yaml: |
server:
port: "8080"
host: "0.0.0.0"
webauthn:
rp_display_name: "Your Company Auth"
rp_id: "auth.yourcompany.com" # Your auth domain
rp_origins:
- "https://auth.yourcompany.com" # Your auth URL
- "https://app.yourcompany.com" # Your app URLs
database:
path: "/data/passkey-auth.db"
cors:
allowed_origins:
- "https://auth.yourcompany.com"
- "https://app.yourcompany.com"
auth:
session_secret: "YOUR_GENERATED_SECRET_HERE"
require_approval: true
```
## Step 2: Deploy to Kubernetes
1. **Create Namespace**:
```bash
kubectl apply -f k8s/namespace.yaml
```
2. **Deploy Application**:
```bash
kubectl apply -f k8s/deployment.yaml
```
3. **Verify Deployment**:
```bash
kubectl get pods -n passkey-auth
kubectl logs -f deployment/passkey-auth -n passkey-auth
```
## Step 3: Configure SSL/TLS
Create an ingress with SSL termination:
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: passkey-auth-ingress
namespace: passkey-auth
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod" # If using cert-manager
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
tls:
- hosts:
- auth.yourcompany.com
secretName: passkey-auth-tls
rules:
- host: auth.yourcompany.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: passkey-auth-service
port:
number: 80
```
## Step 4: Configure Your Application Ingress
Update your application's ingress to use passkey auth:
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: your-app-ingress
annotations:
# Auth configuration
nginx.ingress.kubernetes.io/auth-url: "http://passkey-auth-service.passkey-auth.svc.cluster.local/auth"
nginx.ingress.kubernetes.io/auth-signin: "https://auth.yourcompany.com"
nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-User-ID"
# SSL configuration
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
tls:
- hosts:
- app.yourcompany.com
secretName: your-app-tls
rules:
- host: app.yourcompany.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: your-app-service
port:
number: 80
```
## Step 5: Set Up Monitoring (Optional)
### Health Checks
The service includes health checks at `/health`. Configure monitoring:
```yaml
# Add to your monitoring stack
- job_name: 'passkey-auth'
static_configs:
- targets: ['passkey-auth-service.passkey-auth.svc.cluster.local:80']
metrics_path: '/health'
```
### Log Aggregation
Configure log forwarding to your logging system:
```bash
kubectl logs -f deployment/passkey-auth -n passkey-auth | your-log-forwarder
```
## Step 6: Backup Strategy
### Database Backup
Set up regular backups of the SQLite database:
```bash
# Create a backup cronjob
apiVersion: batch/v1
kind: CronJob
metadata:
name: passkey-auth-backup
namespace: passkey-auth
spec:
schedule: "0 2 * * *" # Daily at 2 AM
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: alpine
command:
- /bin/sh
- -c
- |
cp /data/passkey-auth.db /backup/passkey-auth-$(date +%Y%m%d).db
# Upload to your backup storage
volumeMounts:
- name: data
mountPath: /data
- name: backup
mountPath: /backup
restartPolicy: OnFailure
volumes:
- name: data
persistentVolumeClaim:
claimName: passkey-auth-storage
- name: backup
# Configure your backup storage volume
```
## Security Checklist
- [ ] HTTPS enforced for all endpoints
- [ ] Session secret is randomly generated and secure
- [ ] CORS origins are specifically configured (not "*")
- [ ] WebAuthn RP ID matches your domain exactly
- [ ] Network policies restrict pod-to-pod communication
- [ ] Regular security updates applied
- [ ] Database backups are encrypted and tested
- [ ] Access logs are monitored
- [ ] Kubernetes RBAC is properly configured
## Troubleshooting
### Common Production Issues
1. **WebAuthn Registration Fails**:
- Verify RP ID matches domain exactly
- Ensure HTTPS is properly configured
- Check browser developer console for errors
2. **Auth Backend Returns 502**:
- Verify service is running: `kubectl get pods -n passkey-auth`
- Check service connectivity: `kubectl get svc -n passkey-auth`
- Review nginx ingress logs
3. **Session Issues**:
- Verify session secret is consistent across restarts
- Check cookie domain settings
- Ensure persistent storage is working
### Debug Commands
```bash
# Check service status
kubectl get all -n passkey-auth
# View logs
kubectl logs -f deployment/passkey-auth -n passkey-auth
# Test auth endpoint
kubectl exec -it deployment/passkey-auth -n passkey-auth -- wget -O- http://localhost:8080/health
# Check ingress
kubectl describe ingress -n passkey-auth
# Port forward for testing
kubectl port-forward svc/passkey-auth-service 8080:80 -n passkey-auth
```
## Scaling Considerations
### High Availability
For high availability, consider:
1. **Multiple Replicas**: Increase replica count in deployment
2. **Session Storage**: Use Redis for shared session storage
3. **Database**: Consider PostgreSQL for better concurrent access
4. **Load Balancing**: Ensure proper session affinity
### Performance Tuning
1. **Resource Limits**: Adjust based on usage patterns
2. **Database Optimization**: Regular VACUUM for SQLite
3. **Caching**: Add caching layer for frequently accessed data
## Updates and Maintenance
### Rolling Updates
```bash
# Update the image
kubectl set image deployment/passkey-auth passkey-auth=passkey-auth:v1.1.0 -n passkey-auth
# Monitor rollout
kubectl rollout status deployment/passkey-auth -n passkey-auth
# Rollback if needed
kubectl rollout undo deployment/passkey-auth -n passkey-auth
```
### Database Migrations
For schema changes, implement migration scripts and run them during maintenance windows.
---
This production guide ensures a secure, reliable deployment of Passkey Auth in your Kubernetes environment.
+416
View File
@@ -0,0 +1,416 @@
# 🔐 Passkey Auth for Kubernetes Nginx Ingress
A WebAuthn-based passkey authentication provider that integrates seamlessly with Kubernetes nginx ingress controllers. This service provides secure, passwordless authentication using passkeys (FIDO2/WebAuthn) and acts as an auth backend for nginx ingress.
## ✨ Features
- **Pa2. **"User not found" during login**:
- Ensure user is registered and approved (if required)
- Check that email address matches exactly
3. **WebAuthn errors**:dless Authentication**: Uses WebAuthn/FIDO2 passkeys for secure authentication
- **Email-Based Access Control**: Users are identified by email addresses with configurable allowlists
- **Nginx Ingress Integration**: Works as an auth backend using nginx `auth_request` directive
- **User Management**: Admin interface for managing users and their approval status
- **Access Control**: Configure allowed email addresses and approval requirements
- **Kubernetes Native**: Designed specifically for Kubernetes deployment
- **Persistent Storage**: Uses SQLite with persistent volumes for data storage
- **Modern UI**: Clean, responsive web interface for user registration and management
## 🏗️ Architecture
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ User Browser │ │ Nginx Ingress │ │ Your App │
│ │ │ │ │ │
│ 1. Request │───▶│ 2. Auth Check │───▶│ 4. Serve App │
│ 4. Redirect │◀───│ 3. 401/302 │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ auth_request
┌──────────────────┐
│ Passkey Auth │
│ │
│ - WebAuthn │
│ - User Mgmt │
│ - Session Mgmt │
└──────────────────┘
```
## 🚀 Quick Start
### Prerequisites
- Kubernetes cluster with nginx ingress controller
- Docker
- kubectl configured to access your cluster
### 1. Clone and Build
```bash
git clone <repository-url>
cd passkey-auth
# Build the Docker image
./scripts/build.sh
```
### 2. Configure
Edit `k8s/deployment.yaml` to update:
```yaml
# Update these values in the ConfigMap
webauthn:
rp_id: "your-domain.com" # Your domain
rp_origins:
- "https://your-domain.com" # Your domain with protocol
cors:
allowed_origins:
- "https://your-domain.com" # Your domain with protocol
auth:
session_secret: "your-secure-secret-key" # Generate a secure random string
```
### 3. Deploy to Kubernetes
```bash
# Deploy the passkey auth service
./scripts/deploy.sh
# Verify deployment
kubectl get pods -n passkey-auth
kubectl logs -f deployment/passkey-auth -n passkey-auth
```
### 4. Configure Your App's Ingress
Update your application's ingress to use passkey auth:
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: your-app-ingress
annotations:
# Auth backend - points to passkey auth service
nginx.ingress.kubernetes.io/auth-url: "http://passkey-auth-service.passkey-auth.svc.cluster.local/auth"
# Redirect unauthorized users to login page
nginx.ingress.kubernetes.io/auth-signin: "https://your-domain.com/auth"
# Pass user info to your app
nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-User-ID"
spec:
rules:
- host: your-app.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: your-app-service
port:
number: 80
```
### 5. Create Ingress for Passkey Auth
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: passkey-auth-ingress
namespace: passkey-auth
spec:
rules:
- host: your-domain.com
http:
paths:
- path: /auth
pathType: Prefix
backend:
service:
name: passkey-auth-service
port:
number: 80
```
Apply the ingress:
```bash
kubectl apply -f k8s/ingress-example.yaml
```
## 👥 User Management
### Access the Admin Interface
1. Navigate to `https://your-domain.com/auth` in your browser
2. You'll see the admin dashboard with three tabs:
- **Register User**: Register new users with passkeys
- **Test Login**: Test authentication
- **Manage Users**: View and manage all users
### User Registration Flow
1. **Register**: Admin enters email address and display name
2. **Email Validation**: System checks if email is in the allowlist (if configured)
3. **Passkey Creation**: Browser prompts for passkey creation (TouchID, Windows Hello, etc.)
4. **Approval**: If `require_approval` is enabled, admin must approve users manually
5. **Authentication**: Users can now authenticate with their passkeys
### User Approval Process
When `require_approval` is enabled in the configuration:
1. **New users register** but cannot authenticate until approved
2. **Admin reviews pending users** in the "Manage Users" tab
3. **Admin clicks "Approve"** next to pending users
4. **Users can now authenticate** with their passkeys
**To approve a user:**
1. Navigate to the admin interface at `https://your-domain.com/auth`
2. Click the "Manage Users" tab
3. Find users with "Pending" status
4. Click the "Approve" button next to the user
5. Confirm the approval in the dialog
**User Status Indicators:**
- 🟢 **Approved**: User can authenticate
- 🟡 **Pending**: User registered but needs approval
### Configuration Options
In `config.yaml` or environment variables:
```yaml
auth:
require_approval: true # Require admin approval for new users
session_secret: "secret" # Session encryption key
allowed_emails: # Email allowlist (empty = allow all)
- "admin@company.com"
- "user@company.com"
```
Environment variable overrides:
- `WEBAUTHN_RP_ID`: WebAuthn Relying Party ID (your domain)
- `SESSION_SECRET`: Session encryption secret
- `DATABASE_PATH`: SQLite database file path
- `PORT`: Server port (default: 8080)
- `ALLOWED_EMAILS`: Comma-separated list of allowed emails
## 🔧 Development
### Local Development
1. **Install Go dependencies**:
```bash
go mod download
```
2. **Run locally**:
```bash
# Update config.yaml for local development
go run main.go
```
3. **Access the interface**:
```
http://localhost:8080
```
### Project Structure
```
passkey-auth/
├── main.go # Application entry point
├── internal/
│ ├── auth/ # WebAuthn implementation
│ ├── config/ # Configuration management
│ ├── database/ # SQLite database layer
│ └── handlers/ # HTTP handlers
├── web/ # Static web files
├── k8s/ # Kubernetes manifests
├── scripts/ # Deployment scripts
└── config.yaml # Configuration file
```
### API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/register/begin` | POST | Start passkey registration |
| `/api/register/finish` | POST | Complete passkey registration |
| `/api/login/begin` | POST | Start passkey authentication |
| `/api/login/finish` | POST | Complete passkey authentication |
| `/api/logout` | POST | Logout user |
| `/auth` | GET | Nginx auth check endpoint |
| `/api/users` | GET | List all users (admin) |
| `/api/users` | POST | Create new user (admin) |
| `/api/users/{id}` | PUT | Update user (approve/admin) |
| `/api/users/{id}` | DELETE | Delete user (admin) |
| `/health` | GET | Health check |
## 🔒 Security Considerations
### Production Deployment
1. **HTTPS Only**: Always use HTTPS in production
2. **Secure Session Secret**: Use a strong, random session secret
3. **Domain Configuration**: Ensure `rp_id` matches your domain exactly
4. **Network Security**: Use Kubernetes network policies to restrict access
5. **Regular Backups**: Backup the SQLite database regularly
### Session Configuration
```yaml
# Secure session configuration for production
auth:
session_secret: "your-256-bit-random-key" # Use a proper secret manager
```
### Database Security
The SQLite database contains:
- User information (email addresses, display names)
- WebAuthn credentials (public keys, metadata)
- No passwords or private keys are stored
## 🔐 Email Access Control
### Allowlist Configuration
You can control who can register by configuring an email allowlist:
```yaml
# config.yaml
auth:
allowed_emails:
- "admin@yourcompany.com"
- "engineering@yourcompany.com"
- "support@yourcompany.com"
```
Or via environment variable:
```bash
export ALLOWED_EMAILS="admin@company.com,user1@company.com,user2@company.com"
```
### Access Control Options
1. **Open Registration** (allowed_emails is empty or not set):
- Any email address can register
- Suitable for internal/trusted environments
2. **Allowlist Mode** (allowed_emails configured):
- Only specified email addresses can register
- Recommended for production environments
3. **Combined with Approval**:
- Users must be in allowlist AND get admin approval
- Maximum security for sensitive applications
## 📊 Monitoring
### Health Checks
The service exposes a health endpoint at `/health`:
```bash
curl http://passkey-auth-service.passkey-auth.svc.cluster.local/health
```
### Logs
View application logs:
```bash
kubectl logs -f deployment/passkey-auth -n passkey-auth
```
### Metrics
For production, consider adding metrics collection:
- Authentication success/failure rates
- User registration rates
- Session duration statistics
## 🐛 Troubleshooting
### Common Issues
1. **Docker build failures with SQLite CGO errors**:
- The Dockerfile uses Debian-based images (golang:1.21-bullseye) instead of Alpine
- This resolves musl vs glibc compatibility issues with go-sqlite3
- If you encounter `pread64` or `pwrite64` errors, ensure you're using a glibc-based image
2. **WebAuthn encoding errors** (challenge not ArrayBuffer):
- The web interface includes base64url conversion functions
- Ensure you're using the included HTML file, not a custom frontend
- Binary WebAuthn data must be converted between base64url and ArrayBuffer
3. **"User not found" during login**:
- Ensure user is registered and approved (if required)
- Check that username matches exactly
4. **WebAuthn errors**:
- Verify `rp_id` matches your domain
- Ensure HTTPS is used (required for WebAuthn)
- Check browser support for WebAuthn
5. **Auth backend not working**:
- Verify ingress annotations are correct
- Check that the auth service is accessible from nginx
- Review nginx ingress controller logs
6. **Session issues**:
- Ensure session secret is consistent
- Check cookie settings (secure flag for HTTPS)
- Verify session storage is persistent
### Debug Commands
```bash
# Check pod status
kubectl get pods -n passkey-auth
# View logs
kubectl logs deployment/passkey-auth -n passkey-auth
# Check service
kubectl get svc -n passkey-auth
# Test auth endpoint
kubectl exec -it deployment/passkey-auth -n passkey-auth -- wget -O- http://localhost:8080/health
# Port forward for local testing
kubectl port-forward svc/passkey-auth-service 8080:80 -n passkey-auth
```
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
## 📄 License
This project is licensed under the MIT License - see the LICENSE file for details.
## 🙏 Acknowledgments
- [go-webauthn](https://github.com/go-webauthn/webauthn) - WebAuthn library for Go
- [Gorilla](https://github.com/gorilla) - HTTP utilities for Go
- WebAuthn/FIDO2 specifications
- Kubernetes and nginx ingress controller teams
---
For support or questions, please create an issue in the repository.
+62
View File
@@ -0,0 +1,62 @@
# Development Configuration
# Copy this file to config.yaml and modify for your environment
server:
port: "8080"
host: "0.0.0.0"
webauthn:
# Display name shown during passkey registration
rp_display_name: "Passkey Auth - Development"
# Must match your domain exactly (without protocol)
# For local development, use "localhost"
# For production, use your actual domain like "auth.example.com"
rp_id: "localhost"
# List of allowed origins (with protocol)
# Must include all URLs where users will access the auth interface
rp_origins:
- "http://localhost:8080"
# Add your production URLs:
# - "https://auth.example.com"
# - "https://example.com"
database:
# SQLite database file path
# In Kubernetes, this should be in a persistent volume
path: "passkey-auth.db"
cors:
# Allowed origins for CORS
# In production, be specific about allowed origins for security
allowed_origins:
- "*" # Only use "*" for development
# Production example:
# - "https://auth.example.com"
# - "https://admin.example.com"
auth:
# Session encryption secret - MUST be changed in production!
# Generate with: openssl rand -base64 32
session_secret: "change-me-in-production"
# Whether new users need admin approval before they can authenticate
# Set to false to allow automatic approval for trusted environments
require_approval: true
# Email allowlist - list of email addresses allowed to register
# Leave empty to allow any email address (not recommended for production)
allowed_emails:
# Example emails (uncomment and modify as needed):
# - "admin@yourcompany.com"
# - "user1@yourcompany.com"
# - "user2@yourcompany.com"
# Environment-specific overrides can be set via environment variables:
# - PORT: Server port
# - HOST: Server host
# - WEBAUTHN_RP_ID: WebAuthn Relying Party ID
# - DATABASE_PATH: Database file path
# - SESSION_SECRET: Session encryption secret
# - ALLOWED_EMAILS: Comma-separated list of allowed emails
+24
View File
@@ -0,0 +1,24 @@
server:
port: "8080"
host: "0.0.0.0"
webauthn:
rp_display_name: "Passkey Auth"
rp_id: "localhost"
rp_origins:
- "http://localhost:8080"
database:
path: "passkey-auth.db"
cors:
allowed_origins:
- "*"
auth:
session_secret: "change-me-in-production"
require_approval: true
# Email allowlist - leave empty to allow any email
allowed_emails:
# - "admin@example.com"
# - "user@example.com"
+20
View File
@@ -0,0 +1,20 @@
version: '3.8'
services:
passkey-auth:
build: .
ports:
- "8080:8080"
environment:
- WEBAUTHN_RP_ID=localhost
- DATABASE_PATH=/data/passkey-auth.db
- SESSION_SECRET=dev-secret-change-in-production
volumes:
- ./data:/data
- ./config.yaml:/root/config.yaml
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
+26
View File
@@ -0,0 +1,26 @@
module passkey-auth
go 1.21
require (
github.com/go-webauthn/webauthn v0.10.2
github.com/gorilla/mux v1.8.1
github.com/gorilla/sessions v1.2.2
github.com/mattn/go-sqlite3 v1.14.18
github.com/rs/cors v1.10.1
github.com/sirupsen/logrus v1.9.3
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
github.com/go-webauthn/x v0.1.9 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/go-tpm v0.9.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/sys v0.18.0 // indirect
)
+51
View File
@@ -0,0 +1,51 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+139
View File
@@ -0,0 +1,139 @@
package auth
import (
"net/http"
"passkey-auth/internal/config"
"passkey-auth/internal/database"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
type WebAuthn struct {
web *webauthn.WebAuthn
db *database.DB
}
// WebAuthnUser implements the webauthn.User interface
type WebAuthnUser struct {
user *database.User
credentials []*database.Credential
}
func (u *WebAuthnUser) SetUser(user *database.User) {
u.user = user
}
func (u *WebAuthnUser) GetUser() *database.User {
return u.user
}
func (u *WebAuthnUser) WebAuthnID() []byte {
return []byte(u.user.Email)
}
func (u *WebAuthnUser) WebAuthnName() string {
return u.user.Email
}
func (u *WebAuthnUser) WebAuthnDisplayName() string {
return u.user.DisplayName
}
func (u *WebAuthnUser) WebAuthnIcon() string {
return ""
}
func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
var creds []webauthn.Credential
for _, dbCred := range u.credentials {
creds = append(creds, webauthn.Credential{
ID: dbCred.ID,
PublicKey: dbCred.PublicKey,
AttestationType: dbCred.AttestationType,
Authenticator: webauthn.Authenticator{
AAGUID: dbCred.AAGUID,
SignCount: dbCred.SignCount,
CloneWarning: dbCred.CloneWarning,
},
})
}
return creds
}
func NewWebAuthn(cfg *config.Config) (*WebAuthn, error) {
wconfig := &webauthn.Config{
RPDisplayName: cfg.WebAuthn.RPDisplayName,
RPID: cfg.WebAuthn.RPID,
RPOrigins: cfg.WebAuthn.RPOrigins,
}
web, err := webauthn.New(wconfig)
if err != nil {
return nil, err
}
return &WebAuthn{
web: web,
}, nil
}
func (wa *WebAuthn) SetDB(db *database.DB) {
wa.db = db
}
func (wa *WebAuthn) GetUserByEmail(email string) (*WebAuthnUser, error) {
user, err := wa.db.GetUserByEmail(email)
if err != nil {
return nil, err
}
credentials, err := wa.db.GetCredentialsByUserID(user.ID)
if err != nil {
return nil, err
}
return &WebAuthnUser{
user: user,
credentials: credentials,
}, nil
}
func (wa *WebAuthn) BeginRegistration(user *WebAuthnUser) (*protocol.CredentialCreation, *webauthn.SessionData, error) {
creation, sessionData, err := wa.web.BeginRegistration(user)
return creation, sessionData, err
}
func (wa *WebAuthn) FinishRegistration(user *WebAuthnUser, sessionData webauthn.SessionData, response *http.Request) (*webauthn.Credential, error) {
credential, err := wa.web.FinishRegistration(user, sessionData, response)
return credential, err
}
func (wa *WebAuthn) BeginLogin(user *WebAuthnUser) (*protocol.CredentialAssertion, *webauthn.SessionData, error) {
assertion, sessionData, err := wa.web.BeginLogin(user)
return assertion, sessionData, err
}
func (wa *WebAuthn) FinishLogin(user *WebAuthnUser, sessionData webauthn.SessionData, response *http.Request) (*webauthn.Credential, error) {
credential, err := wa.web.FinishLogin(user, sessionData, response)
return credential, err
}
func (wa *WebAuthn) SaveCredential(userID int, cred *webauthn.Credential) error {
dbCred := &database.Credential{
ID: cred.ID,
UserID: userID,
PublicKey: cred.PublicKey,
AttestationType: cred.AttestationType,
AAGUID: cred.Authenticator.AAGUID,
SignCount: cred.Authenticator.SignCount,
CloneWarning: cred.Authenticator.CloneWarning,
}
return wa.db.SaveCredential(dbCred)
}
func (wa *WebAuthn) UpdateCredentialSignCount(credID []byte, signCount uint32) error {
return wa.db.UpdateCredentialSignCount(credID, signCount)
}
+129
View File
@@ -0,0 +1,129 @@
package config
import (
"io/ioutil"
"os"
"strings"
"gopkg.in/yaml.v2"
)
type Config struct {
Server ServerConfig `yaml:"server"`
WebAuthn WebAuthnConfig `yaml:"webauthn"`
Database DatabaseConfig `yaml:"database"`
CORS CORSConfig `yaml:"cors"`
Auth AuthConfig `yaml:"auth"`
}
type ServerConfig struct {
Port string `yaml:"port"`
Host string `yaml:"host"`
}
type WebAuthnConfig struct {
RPDisplayName string `yaml:"rp_display_name"`
RPID string `yaml:"rp_id"`
RPOrigins []string `yaml:"rp_origins"`
}
type DatabaseConfig struct {
Path string `yaml:"path"`
}
type CORSConfig struct {
AllowedOrigins []string `yaml:"allowed_origins"`
}
type AuthConfig struct {
SessionSecret string `yaml:"session_secret"`
RequireApproval bool `yaml:"require_approval"`
AllowedEmails []string `yaml:"allowed_emails"`
}
func Load() (*Config, error) {
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = "config.yaml"
}
// Set defaults
config := &Config{
Server: ServerConfig{
Port: "8080",
Host: "0.0.0.0",
},
WebAuthn: WebAuthnConfig{
RPDisplayName: "Passkey Auth",
RPID: "localhost",
RPOrigins: []string{"http://localhost:8080"},
},
Database: DatabaseConfig{
Path: "passkey-auth.db",
},
CORS: CORSConfig{
AllowedOrigins: []string{"*"},
},
Auth: AuthConfig{
SessionSecret: "change-me-in-production",
RequireApproval: true,
AllowedEmails: []string{}, // Empty means no email restrictions
},
}
// Load from file if it exists
if _, err := os.Stat(configPath); err == nil {
data, err := ioutil.ReadFile(configPath)
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(data, config); err != nil {
return nil, err
}
}
// Override with environment variables
if port := os.Getenv("PORT"); port != "" {
config.Server.Port = port
}
if host := os.Getenv("HOST"); host != "" {
config.Server.Host = host
}
if rpid := os.Getenv("WEBAUTHN_RP_ID"); rpid != "" {
config.WebAuthn.RPID = rpid
}
if dbPath := os.Getenv("DATABASE_PATH"); dbPath != "" {
config.Database.Path = dbPath
}
if secret := os.Getenv("SESSION_SECRET"); secret != "" {
config.Auth.SessionSecret = secret
}
if allowedEmails := os.Getenv("ALLOWED_EMAILS"); allowedEmails != "" {
config.Auth.AllowedEmails = strings.Split(allowedEmails, ",")
// Trim whitespace from emails
for i, email := range config.Auth.AllowedEmails {
config.Auth.AllowedEmails[i] = strings.TrimSpace(email)
}
}
return config, nil
}
// IsEmailAllowed checks if an email address is in the allowed list
// Returns true if the allowlist is empty (no restrictions) or if the email is in the list
func (c *Config) IsEmailAllowed(email string) bool {
// If no allowed emails specified, allow all
if len(c.Auth.AllowedEmails) == 0 {
return true
}
// Check if email is in the allowed list
for _, allowedEmail := range c.Auth.AllowedEmails {
if email == allowedEmail {
return true
}
}
return false
}
+202
View File
@@ -0,0 +1,202 @@
package database
import (
"database/sql"
"time"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
conn *sql.DB
}
type User struct {
ID int `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Approved bool `json:"approved"`
CreatedAt time.Time `json:"created_at"`
}
type Credential struct {
ID []byte `json:"id"`
UserID int `json:"user_id"`
PublicKey []byte `json:"public_key"`
AttestationType string `json:"attestation_type"`
AAGUID []byte `json:"aaguid"`
SignCount uint32 `json:"sign_count"`
CloneWarning bool `json:"clone_warning"`
CreatedAt time.Time `json:"created_at"`
}
func New(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath+"?_fk=1")
if err != nil {
return nil, err
}
db := &DB{conn: conn}
if err := db.migrate(); err != nil {
return nil, err
}
return db, nil
}
func (db *DB) Close() error {
return db.conn.Close()
}
func (db *DB) migrate() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
approved BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS credentials (
id BLOB PRIMARY KEY,
user_id INTEGER NOT NULL,
public_key BLOB NOT NULL,
attestation_type TEXT NOT NULL,
aaguid BLOB,
sign_count INTEGER DEFAULT 0,
clone_warning BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
`CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id)`,
}
for _, query := range queries {
if _, err := db.conn.Exec(query); err != nil {
return err
}
}
return nil
}
func (db *DB) CreateUser(email, displayName string) (*User, error) {
result, err := db.conn.Exec(
"INSERT INTO users (email, display_name) VALUES (?, ?)",
email, displayName,
)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return db.GetUser(int(id))
}
func (db *DB) GetUser(id int) (*User, error) {
var user User
err := db.conn.QueryRow(
"SELECT id, email, display_name, approved, created_at FROM users WHERE id = ?",
id,
).Scan(&user.ID, &user.Email, &user.DisplayName, &user.Approved, &user.CreatedAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) GetUserByEmail(email string) (*User, error) {
var user User
err := db.conn.QueryRow(
"SELECT id, email, display_name, approved, created_at FROM users WHERE email = ?",
email,
).Scan(&user.ID, &user.Email, &user.DisplayName, &user.Approved, &user.CreatedAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) ListUsers() ([]*User, error) {
rows, err := db.conn.Query(
"SELECT id, email, display_name, approved, created_at FROM users ORDER BY created_at DESC",
)
if err != nil {
return nil, err
}
defer rows.Close()
var users []*User
for rows.Next() {
var user User
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.Approved, &user.CreatedAt); err != nil {
return nil, err
}
users = append(users, &user)
}
return users, nil
}
func (db *DB) ApproveUser(id int) error {
_, err := db.conn.Exec("UPDATE users SET approved = TRUE WHERE id = ?", id)
return err
}
func (db *DB) DeleteUser(id int) error {
_, err := db.conn.Exec("DELETE FROM users WHERE id = ?", id)
return err
}
func (db *DB) SaveCredential(cred *Credential) error {
_, err := db.conn.Exec(
`INSERT INTO credentials (id, user_id, public_key, attestation_type, aaguid, sign_count, clone_warning)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
cred.ID, cred.UserID, cred.PublicKey, cred.AttestationType,
cred.AAGUID, cred.SignCount, cred.CloneWarning,
)
return err
}
func (db *DB) GetCredentialsByUserID(userID int) ([]*Credential, error) {
rows, err := db.conn.Query(
`SELECT id, user_id, public_key, attestation_type, aaguid, sign_count, clone_warning, created_at
FROM credentials WHERE user_id = ?`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var credentials []*Credential
for rows.Next() {
var cred Credential
if err := rows.Scan(
&cred.ID, &cred.UserID, &cred.PublicKey, &cred.AttestationType,
&cred.AAGUID, &cred.SignCount, &cred.CloneWarning, &cred.CreatedAt,
); err != nil {
return nil, err
}
credentials = append(credentials, &cred)
}
return credentials, nil
}
func (db *DB) UpdateCredentialSignCount(credID []byte, signCount uint32) error {
_, err := db.conn.Exec(
"UPDATE credentials SET sign_count = ? WHERE id = ?",
signCount, credID,
)
return err
}
+465
View File
@@ -0,0 +1,465 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/sirupsen/logrus"
"passkey-auth/internal/auth"
"passkey-auth/internal/config"
"passkey-auth/internal/database"
)
type Handlers struct {
db *database.DB
webAuthn *auth.WebAuthn
config *config.Config
store *sessions.CookieStore
}
func New(db *database.DB, webAuthn *auth.WebAuthn, config *config.Config) *Handlers {
webAuthn.SetDB(db)
store := sessions.NewCookieStore([]byte(config.Auth.SessionSecret))
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7, // 7 days
HttpOnly: true,
Secure: false, // Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode,
}
return &Handlers{
db: db,
webAuthn: webAuthn,
config: config,
store: store,
}
}
func (h *Handlers) writeError(w http.ResponseWriter, message string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func (h *Handlers) writeJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
// BeginRegistration starts the passkey registration process
func (h *Handlers) BeginRegistration(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" || req.DisplayName == "" {
h.writeError(w, "Email and display name are required", http.StatusBadRequest)
return
}
// Check if email is allowed
if !h.config.IsEmailAllowed(req.Email) {
h.writeError(w, "Email address not allowed", http.StatusForbidden)
return
}
// Check if user already exists
existingUser, err := h.db.GetUserByEmail(req.Email)
if err == nil && existingUser != nil {
h.writeError(w, "User already exists", http.StatusConflict)
return
}
// Create new user
user, err := h.db.CreateUser(req.Email, req.DisplayName)
if err != nil {
logrus.Errorf("Failed to create user: %v", err)
h.writeError(w, "Failed to create user", http.StatusInternalServerError)
return
}
webAuthnUser := &auth.WebAuthnUser{}
webAuthnUser.SetUser(user)
options, sessionData, err := h.webAuthn.BeginRegistration(webAuthnUser)
if err != nil {
logrus.Errorf("Failed to begin registration: %v", err)
h.writeError(w, "Failed to begin registration", http.StatusInternalServerError)
return
}
// Store session data
session, _ := h.store.Get(r, "webauthn-session")
session.Values["challenge"] = sessionData.Challenge
session.Values["user_id"] = user.ID
session.Save(r, w)
// Debug: log the options structure
logrus.Debugf("WebAuthn options: %+v", options)
logrus.Debugf("Challenge type: %T", options.Response.Challenge)
logrus.Debugf("Challenge value: %v", options.Response.Challenge)
logrus.Debugf("User ID type: %T", options.Response.User.ID)
logrus.Debugf("User ID value: %v", options.Response.User.ID)
h.writeJSON(w, options)
}
// FinishRegistration completes the passkey registration process
func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
session, _ := h.store.Get(r, "webauthn-session")
userID, ok := session.Values["user_id"].(int)
if !ok {
h.writeError(w, "Invalid session", http.StatusBadRequest)
return
}
challenge, ok := session.Values["challenge"].(string)
if !ok {
h.writeError(w, "Invalid session", http.StatusBadRequest)
return
}
// Read and log the request body for debugging
body, err := io.ReadAll(r.Body)
if err != nil {
h.writeError(w, "Failed to read request body", http.StatusBadRequest)
return
}
logrus.Debugf("Received credential response body: %s", string(body))
// Create a new reader from the body for the WebAuthn library
r.Body = io.NopCloser(strings.NewReader(string(body)))
user, err := h.db.GetUser(userID)
if err != nil {
h.writeError(w, "User not found", http.StatusNotFound)
return
}
webAuthnUser := &auth.WebAuthnUser{}
webAuthnUser.SetUser(user)
sessionData := webauthn.SessionData{
Challenge: challenge,
UserID: webAuthnUser.WebAuthnID(),
}
// Log the request details for debugging
logrus.Debugf("Finishing registration for user: %s", user.Email)
logrus.Debugf("Session challenge: %s", challenge)
logrus.Debugf("Session user ID: %v", webAuthnUser.WebAuthnID())
credential, err := h.webAuthn.FinishRegistration(webAuthnUser, sessionData, r)
if err != nil {
logrus.Errorf("Failed to finish registration: %v", err)
h.writeError(w, "Failed to finish registration", http.StatusInternalServerError)
return
}
// Save credential to database
if err := h.webAuthn.SaveCredential(user.ID, credential); err != nil {
logrus.Errorf("Failed to save credential: %v", err)
h.writeError(w, "Failed to save credential", http.StatusInternalServerError)
return
}
// Clear session
session.Values["challenge"] = nil
session.Values["user_id"] = nil
session.Save(r, w)
h.writeJSON(w, map[string]string{"status": "success"})
}
// BeginLogin starts the passkey authentication process
func (h *Handlers) BeginLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, "Invalid request body", http.StatusBadRequest)
return
}
webAuthnUser, err := h.webAuthn.GetUserByEmail(req.Email)
if err != nil {
h.writeError(w, "User not found", http.StatusNotFound)
return
}
// Check if user is approved (if required)
if h.config.Auth.RequireApproval && !webAuthnUser.GetUser().Approved {
h.writeError(w, "User not approved", http.StatusForbidden)
return
}
options, sessionData, err := h.webAuthn.BeginLogin(webAuthnUser)
if err != nil {
logrus.Errorf("Failed to begin login: %v", err)
h.writeError(w, "Failed to begin login", http.StatusInternalServerError)
return
}
// Debug: log the login options structure
logrus.Debugf("Login options: %+v", options)
logrus.Debugf("Login challenge type: %T", options.Response.Challenge)
logrus.Debugf("Login challenge value: %v", options.Response.Challenge)
if len(options.Response.AllowedCredentials) > 0 {
logrus.Debugf("AllowedCredentials count: %d", len(options.Response.AllowedCredentials))
for i, cred := range options.Response.AllowedCredentials {
logrus.Debugf("Credential %d ID type: %T", i, cred.CredentialID)
logrus.Debugf("Credential %d ID value: %v", i, cred.CredentialID)
}
}
// Store session data
session, _ := h.store.Get(r, "webauthn-session")
session.Values["challenge"] = sessionData.Challenge
session.Values["user_id"] = webAuthnUser.GetUser().ID
session.Save(r, w)
h.writeJSON(w, options)
}
// FinishLogin completes the passkey authentication process
func (h *Handlers) FinishLogin(w http.ResponseWriter, r *http.Request) {
session, _ := h.store.Get(r, "webauthn-session")
userID, ok := session.Values["user_id"].(int)
if !ok {
h.writeError(w, "Invalid session", http.StatusBadRequest)
return
}
challenge, ok := session.Values["challenge"].(string)
if !ok {
h.writeError(w, "Invalid session", http.StatusBadRequest)
return
}
user, err := h.db.GetUser(userID)
if err != nil {
h.writeError(w, "User not found", http.StatusNotFound)
return
}
webAuthnUser, err := h.webAuthn.GetUserByEmail(user.Email)
if err != nil {
h.writeError(w, "User not found", http.StatusNotFound)
return
}
sessionData := webauthn.SessionData{
Challenge: challenge,
UserID: webAuthnUser.WebAuthnID(),
}
credential, err := h.webAuthn.FinishLogin(webAuthnUser, sessionData, r)
if err != nil {
logrus.Errorf("Failed to finish login: %v", err)
h.writeError(w, "Authentication failed", http.StatusUnauthorized)
return
}
// Update credential sign count
if err := h.webAuthn.UpdateCredentialSignCount(credential.ID, credential.Authenticator.SignCount); err != nil {
logrus.Errorf("Failed to update sign count: %v", err)
}
// Set authenticated session
authSession, _ := h.store.Get(r, "auth-session")
authSession.Values["authenticated"] = true
authSession.Values["user_id"] = user.ID
authSession.Values["user_email"] = user.Email
authSession.Save(r, w)
// Clear webauthn session
session.Values["challenge"] = nil
session.Values["user_id"] = nil
session.Save(r, w)
h.writeJSON(w, map[string]interface{}{
"status": "success",
"user": map[string]interface{}{
"id": user.ID,
"email": user.Email,
"display_name": user.DisplayName,
},
})
}
// Logout clears the authentication session
func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
session, _ := h.store.Get(r, "auth-session")
session.Values["authenticated"] = false
session.Values["user_id"] = nil
session.Values["user_email"] = nil
session.Options.MaxAge = -1
session.Save(r, w)
h.writeJSON(w, map[string]string{"status": "success"})
}
// AuthCheck implements the nginx auth_request protocol
func (h *Handlers) AuthCheck(w http.ResponseWriter, r *http.Request) {
session, _ := h.store.Get(r, "auth-session")
authenticated, ok := session.Values["authenticated"].(bool)
if !ok || !authenticated {
w.WriteHeader(http.StatusUnauthorized)
return
}
// Optional: Add user info to response headers
if userID, ok := session.Values["user_id"].(int); ok {
w.Header().Set("X-Auth-User-ID", strconv.Itoa(userID))
}
if userEmail, ok := session.Values["user_email"].(string); ok {
w.Header().Set("X-Auth-User", userEmail)
}
w.WriteHeader(http.StatusOK)
}
// Admin endpoints
// ListUsers returns all users (admin endpoint)
func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) {
// TODO: Add admin authentication check
users, err := h.db.ListUsers()
if err != nil {
logrus.Errorf("Failed to list users: %v", err)
h.writeError(w, "Failed to list users", http.StatusInternalServerError)
return
}
h.writeJSON(w, users)
}
// CreateUser creates a new user (admin endpoint)
func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
// TODO: Add admin authentication check
var req struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Approved bool `json:"approved"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, "Invalid request body", http.StatusBadRequest)
return
}
// Check if email is allowed
if !h.config.IsEmailAllowed(req.Email) {
h.writeError(w, "Email address not allowed", http.StatusForbidden)
return
}
user, err := h.db.CreateUser(req.Email, req.DisplayName)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
h.writeError(w, "User already exists", http.StatusConflict)
return
}
logrus.Errorf("Failed to create user: %v", err)
h.writeError(w, "Failed to create user", http.StatusInternalServerError)
return
}
if req.Approved {
if err := h.db.ApproveUser(user.ID); err != nil {
logrus.Errorf("Failed to approve user: %v", err)
}
}
h.writeJSON(w, user)
}
// UpdateUser updates a user (admin endpoint)
func (h *Handlers) UpdateUser(w http.ResponseWriter, r *http.Request) {
// TODO: Add admin authentication check
vars := mux.Vars(r)
idStr, ok := vars["id"]
if !ok {
h.writeError(w, "User ID required", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(idStr)
if err != nil {
h.writeError(w, "Invalid user ID", http.StatusBadRequest)
return
}
var req struct {
Approved *bool `json:"approved"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, "Invalid request body", http.StatusBadRequest)
return
}
// Get existing user
user, err := h.db.GetUser(id)
if err != nil {
h.writeError(w, "User not found", http.StatusNotFound)
return
}
// Update approval status if provided
if req.Approved != nil && *req.Approved {
if err := h.db.ApproveUser(id); err != nil {
logrus.Errorf("Failed to approve user: %v", err)
h.writeError(w, "Failed to approve user", http.StatusInternalServerError)
return
}
user.Approved = true
logrus.Infof("User approved: %s", user.Email)
}
h.writeJSON(w, user)
}
// DeleteUser deletes a user (admin endpoint)
func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
// TODO: Add admin authentication check
vars := mux.Vars(r)
idStr, ok := vars["id"]
if !ok {
h.writeError(w, "User ID required", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(idStr)
if err != nil {
h.writeError(w, "Invalid user ID", http.StatusBadRequest)
return
}
if err := h.db.DeleteUser(id); err != nil {
logrus.Errorf("Failed to delete user: %v", err)
h.writeError(w, "Failed to delete user", http.StatusInternalServerError)
return
}
h.writeJSON(w, map[string]string{"status": "success"})
}
+118
View File
@@ -0,0 +1,118 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: passkey-auth-config
namespace: passkey-auth
data:
config.yaml: |
server:
port: "8080"
host: "0.0.0.0"
webauthn:
rp_display_name: "Passkey Auth"
rp_id: "your-domain.com"
rp_origins:
- "https://your-domain.com"
database:
path: "/data/passkey-auth.db"
cors:
allowed_origins:
- "https://your-domain.com"
auth:
session_secret: "your-session-secret-change-me"
require_approval: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: passkey-auth-storage
namespace: passkey-auth
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: passkey-auth
namespace: passkey-auth
labels:
app: passkey-auth
spec:
replicas: 1
selector:
matchLabels:
app: passkey-auth
template:
metadata:
labels:
app: passkey-auth
spec:
containers:
- name: passkey-auth
image: passkey-auth:latest
ports:
- containerPort: 8080
env:
- name: CONFIG_PATH
value: "/config/config.yaml"
- name: DATABASE_PATH
value: "/data/passkey-auth.db"
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: data
mountPath: /data
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "100m"
volumes:
- name: config
configMap:
name: passkey-auth-config
- name: data
persistentVolumeClaim:
claimName: passkey-auth-storage
---
apiVersion: v1
kind: Service
metadata:
name: passkey-auth-service
namespace: passkey-auth
labels:
app: passkey-auth
spec:
selector:
app: passkey-auth
ports:
- port: 80
targetPort: 8080
protocol: TCP
type: ClusterIP
+44
View File
@@ -0,0 +1,44 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: example-app-ingress
namespace: default
annotations:
nginx.ingress.kubernetes.io/auth-url: "http://passkey-auth-service.passkey-auth.svc.cluster.local/auth"
nginx.ingress.kubernetes.io/auth-signin: "https://your-domain.com/auth"
nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-User-ID"
spec:
ingressClassName: nginx
rules:
- host: your-app.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: your-app-service
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: passkey-auth-ingress
namespace: passkey-auth
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: your-domain.com
http:
paths:
- path: /auth
pathType: Prefix
backend:
service:
name: passkey-auth-service
port:
number: 80
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: passkey-auth
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
"github.com/rs/cors"
"github.com/sirupsen/logrus"
"passkey-auth/internal/auth"
"passkey-auth/internal/config"
"passkey-auth/internal/database"
"passkey-auth/internal/handlers"
)
func main() {
// Setup logging
logrus.SetFormatter(&logrus.JSONFormatter{})
logrus.SetLevel(logrus.DebugLevel)
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Initialize database
db, err := database.New(cfg.Database.Path)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer db.Close()
// Initialize WebAuthn
webAuthn, err := auth.NewWebAuthn(cfg)
if err != nil {
log.Fatalf("Failed to initialize WebAuthn: %v", err)
}
// Initialize handlers
h := handlers.New(db, webAuthn, cfg)
// Setup routes
router := mux.NewRouter()
// API routes
api := router.PathPrefix("/api").Subrouter()
api.HandleFunc("/register/begin", h.BeginRegistration).Methods("POST")
api.HandleFunc("/register/finish", h.FinishRegistration).Methods("POST")
api.HandleFunc("/login/begin", h.BeginLogin).Methods("POST")
api.HandleFunc("/login/finish", h.FinishLogin).Methods("POST")
api.HandleFunc("/logout", h.Logout).Methods("POST")
api.HandleFunc("/users", h.ListUsers).Methods("GET")
api.HandleFunc("/users", h.CreateUser).Methods("POST")
api.HandleFunc("/users/{id}", h.UpdateUser).Methods("PUT")
api.HandleFunc("/users/{id}", h.DeleteUser).Methods("DELETE")
// Nginx auth backend endpoint
router.HandleFunc("/auth", h.AuthCheck).Methods("GET", "HEAD")
// Health check
router.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
}).Methods("GET")
// Static files for admin UI
router.PathPrefix("/").Handler(http.FileServer(http.Dir("./web/"))).Methods("GET")
// Setup CORS
c := cors.New(cors.Options{
AllowedOrigins: cfg.CORS.AllowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"*"},
AllowCredentials: true,
})
handler := c.Handler(router)
// Start server
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
logrus.Infof("Starting server on port %s", port)
if err := http.ListenAndServe(":"+port, handler); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthEndpoint(t *testing.T) {
req, err := http.NewRequest("GET", "/health", nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "healthy"}`))
})
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
expected := `{"status": "healthy"}`
if rr.Body.String() != expected {
t.Errorf("handler returned unexpected body: got %v want %v",
rr.Body.String(), expected)
}
}
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
set -e
echo "🚀 Building Passkey Auth..."
# Build Docker image
echo "Building Docker image..."
docker build -t passkey-auth:latest .
echo "✅ Build complete!"
echo ""
echo "Next steps:"
echo "1. Update k8s/deployment.yaml with your domain and session secret"
echo "2. Run ./scripts/deploy.sh to deploy to Kubernetes"
echo "3. Configure your nginx ingress to use passkey auth"
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
set -e
echo "🚀 Deploying Passkey Auth to Kubernetes..."
# Check if kubectl is available
if ! command -v kubectl &> /dev/null; then
echo "❌ kubectl is not installed or not in PATH"
exit 1
fi
# Create namespace
echo "Creating namespace..."
kubectl apply -f k8s/namespace.yaml
# Deploy the application
echo "Deploying application..."
kubectl apply -f k8s/deployment.yaml
# Wait for deployment to be ready
echo "Waiting for deployment to be ready..."
kubectl wait --for=condition=available --timeout=300s deployment/passkey-auth -n passkey-auth
echo "✅ Deployment complete!"
echo ""
echo "To check the status:"
echo " kubectl get pods -n passkey-auth"
echo ""
echo "To view logs:"
echo " kubectl logs -f deployment/passkey-auth -n passkey-auth"
echo ""
echo "To configure nginx ingress, see k8s/ingress-example.yaml"
Executable
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
set -e
echo "🔧 Starting Passkey Auth in development mode..."
# Check if Go is installed
if ! command -v go &> /dev/null; then
echo "❌ Go is not installed"
exit 1
fi
# Install dependencies
echo "Installing dependencies..."
go mod download
# Set development environment variables
export PORT=8080
export WEBAUTHN_RP_ID=localhost
export DATABASE_PATH=./dev-passkey-auth.db
export SESSION_SECRET=dev-secret-not-for-production
echo "🚀 Starting server..."
echo "Access the admin interface at: http://localhost:8080"
echo "Press Ctrl+C to stop"
# Run the application
go run main.go
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
set -e
echo "🧪 Testing Passkey Auth Build..."
# Clean previous builds
rm -f bin/passkey-auth
# Test build
echo "Building application..."
go build -o bin/passkey-auth .
if [ -f "bin/passkey-auth" ]; then
echo "✅ Build successful!"
echo "📦 Binary size: $(du -h bin/passkey-auth | cut -f1)"
else
echo "❌ Build failed!"
exit 1
fi
# Test basic functionality
echo ""
echo "🔍 Testing basic configuration..."
# Create test config
cat > test-config.yaml << EOF
server:
port: "8080"
host: "localhost"
webauthn:
rp_display_name: "Test Auth"
rp_id: "localhost"
rp_origins:
- "http://localhost:8080"
database:
path: "test.db"
cors:
allowed_origins:
- "*"
auth:
session_secret: "test-secret"
require_approval: false
allowed_emails:
- "test@example.com"
- "admin@example.com"
EOF
echo "✅ Test configuration created"
# Clean up
rm -f test-config.yaml test.db
echo ""
echo "🎉 All tests passed!"
echo ""
echo "To run the application:"
echo " ./bin/passkey-auth"
echo ""
echo "To run in development mode:"
echo " ./scripts/dev.sh"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -e
echo "🧹 Undeploying Passkey Auth from Kubernetes..."
# Delete the application
echo "Deleting application..."
kubectl delete -f k8s/deployment.yaml --ignore-not-found=true
# Delete namespace (this will also delete PVC - data will be lost!)
read -p "⚠️ This will delete all data. Are you sure? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
kubectl delete -f k8s/namespace.yaml --ignore-not-found=true
echo "✅ Undeployment complete!"
else
echo "❌ Cancelled"
fi
+701
View File
@@ -0,0 +1,701 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passkey Auth - Admin</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 2rem;
width: 100%;
max-width: 800px;
margin: 1rem;
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.header h1 {
color: #333;
font-size: 2rem;
margin-bottom: 0.5rem;
}
.header p {
color: #666;
font-size: 1rem;
}
.tabs {
display: flex;
margin-bottom: 2rem;
border-bottom: 1px solid #eee;
}
.tab {
background: none;
border: none;
padding: 1rem 2rem;
cursor: pointer;
font-size: 1rem;
color: #666;
border-bottom: 2px solid transparent;
transition: all 0.3s ease;
}
.tab.active {
color: #667eea;
border-bottom-color: #667eea;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 500;
}
input[type="text"], input[type="email"] {
width: 100%;
padding: 0.75rem;
border: 2px solid #eee;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
input[type="text"]:focus, input[type="email"]:focus {
outline: none;
border-color: #667eea;
}
.btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: transform 0.2s ease;
}
.btn:hover {
transform: translateY(-2px);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-danger {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
}
.users-list {
margin-top: 2rem;
}
.user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: 1px solid #eee;
border-radius: 8px;
margin-bottom: 1rem;
}
.user-info {
flex-grow: 1;
}
.user-info h3 {
color: #333;
margin-bottom: 0.25rem;
}
.user-info p {
color: #666;
font-size: 0.9rem;
}
.user-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
margin-right: 1rem;
}
.status-approved {
background: #d4edda;
color: #155724;
}
.status-pending {
background: #fff3cd;
color: #856404;
}
.alert {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔐 Passkey Auth</h1>
<p>Admin Dashboard</p>
</div>
<div class="tabs">
<button class="tab active" onclick="showTab('register')">Register User</button>
<button class="tab" onclick="showTab('login')">Test Login</button>
<button class="tab" onclick="showTab('users')">Manage Users</button>
</div>
<!-- Register Tab -->
<div id="register" class="tab-content active">
<h2>Register New User</h2>
<form id="registerForm">
<div class="form-group">
<label for="email">Email Address:</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="displayName">Display Name:</label>
<input type="text" id="displayName" name="displayName" required>
</div>
<button type="submit" class="btn">Register with Passkey</button>
</form>
</div>
<!-- Login Tab -->
<div id="login" class="tab-content">
<h2>Test Login</h2>
<form id="loginForm">
<div class="form-group">
<label for="loginEmail">Email Address:</label>
<input type="email" id="loginEmail" name="email" required>
</div>
<button type="submit" class="btn">Login with Passkey</button>
</form>
<div id="loginStatus"></div>
</div>
<!-- Users Tab -->
<div id="users" class="tab-content">
<h2>Manage Users</h2>
<button class="btn" onclick="loadUsers()">Refresh Users</button>
<div id="usersList" class="users-list">
<div class="loading">Loading users...</div>
</div>
</div>
<div id="alerts"></div>
</div>
<script>
// Base64/Base64URL decoding functions for WebAuthn
function base64ToArrayBuffer(base64) {
if (!base64 || typeof base64 !== 'string') {
console.error('Invalid base64 input:', base64);
throw new Error('Invalid base64 input');
}
console.log('Converting base64 to ArrayBuffer:', base64);
try {
// If it looks like base64url, convert to base64 first
let base64String = base64;
if (base64.includes('-') || base64.includes('_')) {
// This is base64url, convert to base64
base64String = base64.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if needed
const padding = base64String.length % 4;
if (padding) {
base64String += '='.repeat(4 - padding);
}
} else {
// This is standard base64, add padding if needed
const padding = base64String.length % 4;
if (padding) {
base64String += '='.repeat(4 - padding);
}
}
// Decode base64
const binary = atob(base64String);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
console.log('Converted to ArrayBuffer, length:', bytes.buffer.byteLength);
return bytes.buffer;
} catch (error) {
console.error('Error converting base64 to ArrayBuffer:', error);
throw error;
}
}
// For backwards compatibility, keep the old function name but make it handle both
function base64urlToArrayBuffer(base64url) {
return base64ToArrayBuffer(base64url);
}
function arrayBufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
} // Convert WebAuthn options from base64url to ArrayBuffer
function prepareWebAuthnOptions(options) {
console.log('prepareWebAuthnOptions called with:', options);
// Handle both registration and login options (both use publicKey wrapper)
if (options.publicKey) {
console.log('Processing WebAuthn options (has publicKey)');
const publicKey = { ...options.publicKey };
// Convert challenge (common to both registration and login)
if (publicKey.challenge) {
console.log('Original challenge:', publicKey.challenge, 'type:', typeof publicKey.challenge);
if (typeof publicKey.challenge === 'string') {
publicKey.challenge = base64ToArrayBuffer(publicKey.challenge);
console.log('Converted challenge to ArrayBuffer, byteLength:', publicKey.challenge.byteLength);
} else {
console.error('Challenge is not a string:', publicKey.challenge);
throw new Error('Challenge must be a base64 or base64url string');
}
}
// Convert user ID (registration only)
if (publicKey.user && publicKey.user.id) {
console.log('Original user ID:', publicKey.user.id, 'type:', typeof publicKey.user.id);
if (typeof publicKey.user.id === 'string') {
publicKey.user.id = base64ToArrayBuffer(publicKey.user.id);
console.log('Converted user ID to ArrayBuffer, byteLength:', publicKey.user.id.byteLength);
} else {
console.error('User ID is not a string:', publicKey.user.id);
throw new Error('User ID must be a base64 or base64url string');
}
}
// Convert excludeCredentials (registration)
if (publicKey.excludeCredentials) {
console.log('Converting excludeCredentials for registration');
publicKey.excludeCredentials = publicKey.excludeCredentials.map(cred => ({
...cred,
id: base64ToArrayBuffer(cred.id)
}));
}
// Convert allowCredentials (login/authentication)
if (publicKey.allowCredentials) {
console.log('Converting allowCredentials for login:', publicKey.allowCredentials);
publicKey.allowCredentials = publicKey.allowCredentials.map(cred => {
console.log('Converting credential ID:', cred.id, 'type:', typeof cred.id);
return {
...cred,
id: base64ToArrayBuffer(cred.id)
};
});
console.log('Converted allowCredentials:', publicKey.allowCredentials);
}
const result = { publicKey };
console.log('Returning prepared WebAuthn options:', result);
return result;
}
console.log('No publicKey property found, returning as-is');
return options;
}
// Convert WebAuthn response from ArrayBuffer to base64url
function prepareWebAuthnResponse(credential) {
console.log('prepareWebAuthnResponse input:', credential);
const response = {
id: '',
rawId: '',
type: credential.type || 'public-key',
response: {}
};
// Convert credential ID
if (credential.rawId) {
const credentialId = arrayBufferToBase64url(credential.rawId);
response.id = credentialId;
response.rawId = credentialId;
console.log('Converted credential ID:', credentialId);
} else if (credential.id) {
// Some browsers might provide id as string already
response.id = credential.id;
response.rawId = credential.id;
console.log('Using existing credential ID:', credential.id);
}
// Convert response data
if (credential.response) {
console.log('Processing credential.response:', credential.response);
if (credential.response.clientDataJSON) {
response.response.clientDataJSON = arrayBufferToBase64url(credential.response.clientDataJSON);
console.log('Converted clientDataJSON');
}
// For registration (attestationObject)
if (credential.response.attestationObject) {
response.response.attestationObject = arrayBufferToBase64url(credential.response.attestationObject);
console.log('Converted attestationObject');
}
// For authentication (authenticatorData, signature)
if (credential.response.authenticatorData) {
response.response.authenticatorData = arrayBufferToBase64url(credential.response.authenticatorData);
console.log('Converted authenticatorData');
}
if (credential.response.signature) {
response.response.signature = arrayBufferToBase64url(credential.response.signature);
console.log('Converted signature');
}
if (credential.response.userHandle) {
response.response.userHandle = arrayBufferToBase64url(credential.response.userHandle);
console.log('Converted userHandle');
}
}
console.log('Final prepared response:', response);
return response;
}
// Tab functionality
function showTab(tabName) {
// Hide all tab contents
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
// Remove active class from all tabs
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.remove('active');
});
// Show selected tab content
document.getElementById(tabName).classList.add('active');
// Add active class to clicked tab
event.target.classList.add('active');
// Load users when users tab is selected
if (tabName === 'users') {
loadUsers();
}
}
// Alert functions
function showAlert(message, type = 'success') {
const alertsContainer = document.getElementById('alerts');
const alert = document.createElement('div');
alert.className = `alert alert-${type}`;
alert.textContent = message;
alertsContainer.appendChild(alert);
setTimeout(() => {
alert.remove();
}, 5000);
} // Register functionality
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const email = formData.get('email');
const displayName = formData.get('displayName');
try {
console.log('Starting registration for email:', email);
// Begin registration
const beginResponse = await fetch('/api/register/begin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
display_name: displayName
}),
credentials: 'include'
});
if (!beginResponse.ok) {
throw new Error(await beginResponse.text());
}
const options = await beginResponse.json();
console.log('Raw options from server:', JSON.stringify(options, null, 2));
// Convert base64url encoded fields to ArrayBuffers
const webAuthnOptions = prepareWebAuthnOptions(options);
console.log('Final webAuthnOptions for navigator.credentials.create:', webAuthnOptions);
// Create credential
const credential = await navigator.credentials.create(webAuthnOptions);
console.log('Raw credential from navigator.credentials.create:', credential);
// Convert ArrayBuffers back to base64url for JSON
const credentialResponse = prepareWebAuthnResponse(credential);
console.log('Prepared credential response to send to server:', JSON.stringify(credentialResponse, null, 2));
// Finish registration
const finishResponse = await fetch('/api/register/finish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentialResponse),
credentials: 'include'
});
console.log('Finish registration response status:', finishResponse.status);
if (!finishResponse.ok) {
const errorText = await finishResponse.text();
console.error('Finish registration error:', errorText);
throw new Error(errorText);
}
showAlert('User registered successfully!');
e.target.reset();
} catch (error) {
showAlert(`Registration failed: ${error.message}`, 'error');
}
}); // Login functionality
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const email = formData.get('email');
try {
// Begin login
const beginResponse = await fetch('/api/login/begin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email
}),
credentials: 'include'
});
if (!beginResponse.ok) {
throw new Error(await beginResponse.text());
}
const options = await beginResponse.json();
console.log('Raw login options from server:', JSON.stringify(options, null, 2));
// Convert base64url encoded fields to ArrayBuffers
const webAuthnOptions = prepareWebAuthnOptions(options);
console.log('Final login webAuthnOptions for navigator.credentials.get:', webAuthnOptions);
// Get credential
const credential = await navigator.credentials.get(webAuthnOptions);
// Convert ArrayBuffers back to base64url for JSON
const credentialResponse = prepareWebAuthnResponse(credential);
// Finish login
const finishResponse = await fetch('/api/login/finish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentialResponse),
credentials: 'include'
});
if (!finishResponse.ok) {
throw new Error(await finishResponse.text());
}
const result = await finishResponse.json();
showAlert(`Login successful! Welcome, ${result.user.display_name}`);
document.getElementById('loginStatus').innerHTML = `
<div class="alert alert-success">
<strong>Logged in as:</strong> ${result.user.display_name} (${result.user.email})
</div>
`;
} catch (error) {
showAlert(`Login failed: ${error.message}`, 'error');
document.getElementById('loginStatus').innerHTML = `
<div class="alert alert-error">
Login failed: ${error.message}
</div>
`;
}
});
// Users management
async function loadUsers() {
const usersList = document.getElementById('usersList');
usersList.innerHTML = '<div class="loading">Loading users...</div>';
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Failed to load users');
}
const users = await response.json();
if (users.length === 0) {
usersList.innerHTML = '<p>No users found.</p>';
return;
}
usersList.innerHTML = users.map(user => `
<div class="user-item">
<div class="user-info">
<h3>${user.display_name}</h3>
<p>${user.email} • Created: ${new Date(user.created_at).toLocaleDateString()}</p>
</div>
<div class="user-actions">
<span class="status-badge ${user.approved ? 'status-approved' : 'status-pending'}">
${user.approved ? 'Approved' : 'Pending'}
</span>
${!user.approved ? `<button class="btn" onclick="approveUser(${user.id})">Approve</button>` : ''}
<button class="btn btn-danger" onclick="deleteUser(${user.id})">Delete</button>
</div>
</div>
`).join('');
} catch (error) {
usersList.innerHTML = `<div class="alert alert-error">Failed to load users: ${error.message}</div>`;
}
}
async function approveUser(userId) {
if (!confirm('Are you sure you want to approve this user?')) {
return;
}
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
approved: true
})
});
if (!response.ok) {
throw new Error('Failed to approve user');
}
showAlert('User approved successfully', 'success');
loadUsers(); // Refresh the users list
} catch (error) {
showAlert(`Failed to approve user: ${error.message}`, 'error');
}
}
async function deleteUser(userId) {
if (!confirm('Are you sure you want to delete this user?')) {
return;
}
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete user');
}
showAlert('User deleted successfully!');
loadUsers();
} catch (error) {
showAlert(`Failed to delete user: ${error.message}`, 'error');
}
}
// Load users when page loads
document.addEventListener('DOMContentLoaded', () => {
loadUsers();
});
</script>
</body>
</html>