mirror of
https://github.com/wahyd4/passkey-auth.git
synced 2026-08-08 20:15:44 +10:00
Merge branch 'main' into dependabot/go_modules/github.com/rs/cors-1.11.0
This commit is contained in:
@@ -12,18 +12,3 @@ A clear and concise description of what the problem is. Ex. I'm always frustrate
|
||||
|
||||
**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.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Configuration for chart-releaser
|
||||
# See https://github.com/helm/chart-releaser for more info
|
||||
|
||||
owner: wahyd4
|
||||
git-repo: passkey-auth
|
||||
charts-repo: https://wahyd4.github.io/passkey-auth
|
||||
target-branch: gh-pages
|
||||
package-path: .cr-release-packages
|
||||
index-path: .cr-index
|
||||
skip-existing: true
|
||||
push: true
|
||||
@@ -8,31 +8,3 @@ Brief description of what this PR does.
|
||||
- [ ] 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)
|
||||
|
||||
+26
-24
@@ -3,6 +3,8 @@ name: CI/CD
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
release:
|
||||
@@ -32,11 +34,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -55,17 +52,16 @@ jobs:
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
CGO_ENABLED=1 go build -v -o passkey-auth .
|
||||
CGO_ENABLED=0 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'
|
||||
if: github.event_name == 'push' || github.event_name == 'release' || startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -74,37 +70,48 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'release'
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name == 'push' || github.event_name == 'release' || startsWith(github.ref, 'refs/tags/v')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
passkey-auth
|
||||
ghcr.io/${{ github.repository }}
|
||||
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}}-
|
||||
type=sha,prefix={{branch}}-,enable=${{ github.ref_type == 'branch' }}
|
||||
type=sha,enable=${{ github.ref_type == 'tag' }}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Passkey Auth
|
||||
org.opencontainers.image.description=WebAuthn/Passkey authentication server with admin approval workflow
|
||||
org.opencontainers.image.licenses=Apache-2.0
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'release' || startsWith(github.ref, 'refs/tags/v') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
BUILDKIT_INLINE_CACHE=1
|
||||
GOCACHE=/root/.cache/go-build
|
||||
GOMODCACHE=/go/pkg/mod
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -117,11 +124,6 @@ jobs:
|
||||
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:
|
||||
@@ -135,6 +137,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Gosec Security Scanner
|
||||
uses: securecodewarrior/github-action-gosec@master
|
||||
uses: securego/gosec@master
|
||||
with:
|
||||
args: './...'
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
name: Release Helm Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
lint-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4.2.0
|
||||
with:
|
||||
version: v3.17.0
|
||||
|
||||
- uses: actions/setup-python@v5.3.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
check-latest: true
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@v2.7.0
|
||||
|
||||
- name: Run chart-testing (list-changed)
|
||||
id: list-changed
|
||||
run: |
|
||||
changed=$(ct list-changed --target-branch ${{ github.event.repository.default_branch }})
|
||||
if [[ -n "$changed" ]]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Run chart-testing (lint)
|
||||
if: steps.list-changed.outputs.changed == 'true'
|
||||
run: ct lint --target-branch ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Create kind cluster
|
||||
if: steps.list-changed.outputs.changed == 'true'
|
||||
uses: helm/kind-action@v1.12.0
|
||||
|
||||
- name: Run chart-testing (install)
|
||||
run: |
|
||||
# Install with test values
|
||||
ct install --target-branch ${{ github.event.repository.default_branch }} --chart-dirs helm \
|
||||
--helm-extra-set-args "--set config.webauthn.rpId=test.local --set config.auth.allowedEmails={test@example.com} --set secrets.sessionSecret=test-secret-for-ci-only --set ingress.enabled=false"
|
||||
|
||||
release:
|
||||
needs: lint-test
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
|
||||
- name: Create gh-pages branch if it doesn't exist
|
||||
run: |
|
||||
if ! git ls-remote --exit-code --heads origin gh-pages; then
|
||||
echo "Creating gh-pages branch"
|
||||
git checkout --orphan gh-pages
|
||||
git reset --hard
|
||||
git commit --allow-empty -m "Initial gh-pages commit"
|
||||
git push origin gh-pages
|
||||
git checkout main
|
||||
fi
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
with:
|
||||
version: '3.14.0'
|
||||
|
||||
- name: Add helm repos
|
||||
run: |
|
||||
helm repo add bitnami https://charts.bitnami.com/bitnami
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@v1.6.0
|
||||
with:
|
||||
charts_dir: helm
|
||||
config: .github/cr.yaml
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
+4
-1
@@ -5,7 +5,7 @@
|
||||
*.so
|
||||
*.dylib
|
||||
bin/
|
||||
passkey-auth
|
||||
./passkey-auth
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -56,3 +56,6 @@ k8s/*-secret.yaml
|
||||
|
||||
# Development files
|
||||
dev-*
|
||||
web/test.html
|
||||
|
||||
*.log
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# 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
@@ -1,129 +0,0 @@
|
||||
# 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! 🎉
|
||||
+24
-13
@@ -1,30 +1,38 @@
|
||||
# Build stage
|
||||
FROM golang:1.21-bullseye AS builder
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
# Install build dependencies including sqlite
|
||||
RUN apt-get update && apt-get install -y gcc libsqlite3-dev && rm -rf /var/lib/apt/lists/*
|
||||
# Install git for Go modules
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod files
|
||||
# Copy go mod files first for better layer caching
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
# Download dependencies (this will be cached if go.mod/go.sum don't change)
|
||||
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 .
|
||||
# Build the application with optimized flags and build cache
|
||||
# Remove -a flag to avoid rebuilding standard library
|
||||
# Remove unnecessary static linking flags since CGO is disabled
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o passkey-auth .
|
||||
|
||||
# Final stage
|
||||
FROM debian:bullseye-slim
|
||||
FROM alpine:latest
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y ca-certificates libsqlite3-0 && rm -rf /var/lib/apt/lists/*
|
||||
# Install ca-certificates for HTTPS (no sqlite library needed)
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
WORKDIR /root/
|
||||
# Create a non-root user with UID 1000
|
||||
RUN adduser -D -u 1000 -g 1000 -s /bin/sh appuser
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the binary from builder stage
|
||||
COPY --from=builder /app/passkey-auth .
|
||||
@@ -35,8 +43,11 @@ COPY --from=builder /app/web ./web/
|
||||
# Copy default config
|
||||
COPY --from=builder /app/config.yaml .
|
||||
|
||||
# Create directory for database
|
||||
RUN mkdir -p /data
|
||||
# Create directory for database and set ownership
|
||||
RUN mkdir -p /data && chown -R appuser:appuser /app /data
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
# GitHub Repository Setup Guide
|
||||
|
||||
This guide will help you create a GitHub repository and push your Passkey Auth project to GitHub.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Git installed and configured
|
||||
- GitHub account
|
||||
- SSH keys set up with GitHub (recommended) or HTTPS access
|
||||
|
||||
## Step-by-Step Instructions
|
||||
|
||||
### 1. Create GitHub Repository
|
||||
|
||||
1. Go to [GitHub](https://github.com) and sign in
|
||||
2. Click the "+" icon in the top right corner
|
||||
3. Select "New repository"
|
||||
4. Fill in the repository details:
|
||||
- **Repository name**: `passkey-auth` (or your preferred name)
|
||||
- **Description**: "WebAuthn (FIDO2) passkey authentication service for Kubernetes nginx ingress"
|
||||
- **Visibility**: Public (for open source) or Private
|
||||
- **DO NOT** initialize with README, .gitignore, or license (we already have these)
|
||||
5. Click "Create repository"
|
||||
|
||||
### 2. Push Your Code
|
||||
|
||||
#### Option A: Use the Setup Script (Recommended)
|
||||
```bash
|
||||
# Run the automated setup script
|
||||
./scripts/github-setup.sh
|
||||
```
|
||||
|
||||
#### Option B: Manual Setup
|
||||
```bash
|
||||
# Set your GitHub username and repository name
|
||||
GITHUB_USERNAME="your-username"
|
||||
REPO_NAME="passkey-auth"
|
||||
|
||||
# Add remote origin
|
||||
git remote add origin https://github.com/${GITHUB_USERNAME}/${REPO_NAME}.git
|
||||
|
||||
# Push to GitHub
|
||||
git branch -M main
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### 3. Configure Repository Settings
|
||||
|
||||
After pushing, configure your repository:
|
||||
|
||||
#### Basic Settings
|
||||
1. Go to your repository on GitHub
|
||||
2. Click "Settings" tab
|
||||
3. Add a description: "WebAuthn (FIDO2) passkey authentication service for Kubernetes nginx ingress"
|
||||
4. Add topics: `webauthn`, `fido2`, `passkey`, `kubernetes`, `authentication`, `golang`, `nginx`, `ingress`
|
||||
5. Add website URL if you have a demo
|
||||
|
||||
#### Branch Protection (Recommended)
|
||||
1. Go to Settings → Branches
|
||||
2. Click "Add rule"
|
||||
3. Branch name pattern: `main`
|
||||
4. Enable:
|
||||
- ✅ Require a pull request before merging
|
||||
- ✅ Require status checks to pass before merging
|
||||
- ✅ Require branches to be up to date before merging
|
||||
- ✅ Include administrators
|
||||
|
||||
#### GitHub Actions Secrets (For CI/CD)
|
||||
If you want to publish Docker images:
|
||||
|
||||
1. Go to Settings → Secrets and variables → Actions
|
||||
2. Add repository secrets:
|
||||
- `DOCKER_USERNAME`: Your Docker Hub username
|
||||
- `DOCKER_PASSWORD`: Your Docker Hub password or access token
|
||||
|
||||
### 4. Optional Enhancements
|
||||
|
||||
#### GitHub Pages (Documentation)
|
||||
1. Go to Settings → Pages
|
||||
2. Source: Deploy from a branch
|
||||
3. Branch: `main`
|
||||
4. Folder: `/docs` (create docs folder if needed)
|
||||
|
||||
#### Issue Templates
|
||||
The repository already includes:
|
||||
- Bug report template
|
||||
- Feature request template
|
||||
- Pull request template
|
||||
|
||||
#### Discussions
|
||||
1. Go to Settings → General
|
||||
2. Scroll to "Features"
|
||||
3. Enable "Discussions" for community Q&A
|
||||
|
||||
#### Security
|
||||
1. Go to Settings → Security
|
||||
2. Enable "Dependency graph"
|
||||
3. Enable "Dependabot alerts"
|
||||
4. Enable "Dependabot security updates"
|
||||
|
||||
## Repository Structure
|
||||
|
||||
Your repository now includes:
|
||||
|
||||
```
|
||||
passkey-auth/
|
||||
├── .github/ # GitHub templates and workflows
|
||||
│ ├── ISSUE_TEMPLATE/ # Bug and feature request templates
|
||||
│ ├── workflows/ # GitHub Actions CI/CD
|
||||
│ └── pull_request_template.md
|
||||
├── internal/ # Go application code
|
||||
├── k8s/ # Kubernetes manifests
|
||||
├── scripts/ # Build and deployment scripts
|
||||
├── web/ # Web interface
|
||||
├── CHANGELOG.md # Version history
|
||||
├── CONTRIBUTING.md # Contribution guidelines
|
||||
├── LICENSE # MIT License
|
||||
├── README.md # Main documentation
|
||||
├── Dockerfile # Docker build configuration
|
||||
└── Makefile # Development commands
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Star your repository** to bookmark it
|
||||
2. **Watch releases** to get notified of updates
|
||||
3. **Create your first release** when ready
|
||||
4. **Share with the community** on relevant platforms
|
||||
5. **Write blog posts** about your project
|
||||
6. **Submit to awesome lists** related to authentication or Kubernetes
|
||||
|
||||
## Promoting Your Open Source Project
|
||||
|
||||
- **Reddit**: Post in r/golang, r/kubernetes, r/selfhosted
|
||||
- **Hacker News**: Share when you have significant updates
|
||||
- **Dev.to**: Write technical blog posts
|
||||
- **Twitter/X**: Share updates and engage with the community
|
||||
- **Kubernetes Slack**: Share in relevant channels
|
||||
- **Awesome Lists**: Submit to awesome-go, awesome-kubernetes, etc.
|
||||
|
||||
## Support
|
||||
|
||||
If you need help with GitHub setup:
|
||||
- [GitHub Docs](https://docs.github.com)
|
||||
- [Git Handbook](https://guides.github.com/introduction/git-handbook/)
|
||||
- [GitHub Community](https://github.community)
|
||||
|
||||
Happy coding! 🚀
|
||||
@@ -1,122 +0,0 @@
|
||||
# 📋 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.
|
||||
@@ -1,21 +1,202 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Passkey Auth Contributors
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
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:
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
1. Definitions.
|
||||
|
||||
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.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 [Junwei Zhao]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
@@ -58,9 +58,9 @@ fmt: ## Format code
|
||||
@echo "🎨 Formatting code..."
|
||||
@go fmt ./...
|
||||
|
||||
lint: ## Run linter
|
||||
@echo "🔍 Running linter..."
|
||||
@golangci-lint run
|
||||
lint: ## Run linter with auto-fix (excluding test files)
|
||||
@echo "🔍 Running linter with auto-fix (excluding test files)..."
|
||||
@golangci-lint run --fix --tests=false
|
||||
|
||||
security: ## Run security scan
|
||||
@echo "🔒 Running security scan..."
|
||||
|
||||
-287
@@ -1,287 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,94 +1,67 @@
|
||||
# 🔐 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.
|
||||
A WebAuthn-based passkey authentication provider that integrates ingress controllers, currently support Kubernetes nginx Ingress controller. Provides secure, passwordless authentication using passkeys (FIDO2/WebAuthn) as an auth backend for nginx ingress.
|
||||
|
||||
## TLDR;
|
||||
|
||||
Log in Apps without typing password or going through 3rd Party Oauth!
|
||||
|
||||
I use it for signing into my home lab apps.
|
||||
|
||||
## 🎬 Demo
|
||||
|
||||
<a href="https://giphy.com/gifs/vBeSrnuYUl3u1AQho6">
|
||||
<img src="passkey-auth-screenshot.png" alt="Click to play demo - Passkey Auth Interface" width="200">
|
||||
</a>
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Pa2. **"User not found" during login**:
|
||||
- Ensure user is registered and approved (if required)
|
||||
- Check that email address matches exactly
|
||||
- **Passwordless Authentication**: Uses WebAuthn/FIDO2 passkeys for secure authentication
|
||||
- **Nginx Ingress Integration**: Works as auth backend using nginx `auth_request` directive
|
||||
- **User Management**: An simple Admin interface for managing users and approval status
|
||||
- **Kubernetes Native**: Designed for Kubernetes deployment with persistent storage
|
||||
|
||||
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
|
||||
## Security Benefits
|
||||
|
||||
## 🏗️ 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 │
|
||||
└──────────────────┘
|
||||
```
|
||||
- **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
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Kubernetes cluster with nginx ingress controller
|
||||
- Docker
|
||||
- kubectl configured to access your cluster
|
||||
|
||||
### 1. Clone and Build
|
||||
### Using Helm Chart (Recommended)
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd passkey-auth
|
||||
# Add the Helm repository
|
||||
helm repo add passkey-auth https://wahyd4.github.io/passkey-auth
|
||||
helm repo update
|
||||
|
||||
# Build the Docker image
|
||||
./scripts/build.sh
|
||||
# Install with your values
|
||||
helm upgrade --install my-passkey-auth -n home-apps -f my-values.yaml passkey-auth/passkey-auth
|
||||
```
|
||||
|
||||
### 2. Configure
|
||||
See the [Helm Chart README](helm/passkey-auth/README.md) for detailed configuration options.
|
||||
|
||||
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
|
||||
### Test with Docker
|
||||
|
||||
```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
|
||||
docker run --name passkey-auth -d -p 8080:8080 -e ADMIN_EMAIL="admin@example.com" ghcr.io/wahyd4/passkey-auth:main
|
||||
```
|
||||
|
||||
### 4. Configure Your App's Ingress
|
||||
### Local Development
|
||||
|
||||
Update your application's ingress to use passkey auth:
|
||||
```bash
|
||||
# Install dependencies and run locally
|
||||
go mod download
|
||||
go run main.go
|
||||
|
||||
# Access at http://localhost:8080
|
||||
```
|
||||
|
||||
|
||||
### Setup Your App's Ingress
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
@@ -96,14 +69,9 @@ 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"
|
||||
nginx.ingress.kubernetes.io/auth-url: "https://your-passkey-auth.com/auth"
|
||||
nginx.ingress.kubernetes.io/auth-signin: "https://your-passkey-auth.com/?redirect=https%3A%2F%2F$host$request_uri"
|
||||
spec:
|
||||
rules:
|
||||
- host: your-app.com
|
||||
@@ -118,129 +86,39 @@ spec:
|
||||
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:
|
||||
Navigate to `https:///your-passkey-auth.com` to access the admin interface with three tabs:
|
||||
- **Register User**: Register new users with passkeys
|
||||
- **Test Login**: Test authentication
|
||||
- **Manage Users**: View and manage all users
|
||||
- **Manage Users**: View and manage all users with `ADMIN_USER` email address
|
||||
|
||||
### 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:
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
# config.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
|
||||
Check [config.example.yaml](./config.example.yaml) for more details
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Local Development
|
||||
|
||||
1. **Install Go dependencies**:
|
||||
```bash
|
||||
# Install dependencies and run locally
|
||||
go mod download
|
||||
```
|
||||
|
||||
2. **Run locally**:
|
||||
```bash
|
||||
# Update config.yaml for local development
|
||||
go run main.go
|
||||
|
||||
# Access at http://localhost:8080
|
||||
```
|
||||
|
||||
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
|
||||
### Key API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
@@ -248,169 +126,11 @@ passkey-auth/
|
||||
| `/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) |
|
||||
| `/api/users` | GET/POST | List/create users |
|
||||
| `/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.
|
||||
Apache License 2.0
|
||||
|
||||
@@ -45,6 +45,14 @@ auth:
|
||||
# Set to false to allow automatic approval for trusted environments
|
||||
require_approval: true
|
||||
|
||||
# Cookie domain for session cookies
|
||||
# Leave empty for single domain (cookies only work on current domain)
|
||||
# Set to ".yourdomain.com" to share cookies across all subdomains
|
||||
# Examples:
|
||||
# - "" (empty) - cookies only work on the exact domain
|
||||
# - ".example.com" - cookies work on example.com and all subdomains
|
||||
cookie_domain: ""
|
||||
|
||||
# Email allowlist - list of email addresses allowed to register
|
||||
# Leave empty to allow any email address (not recommended for production)
|
||||
allowed_emails:
|
||||
@@ -53,6 +61,15 @@ auth:
|
||||
# - "user1@yourcompany.com"
|
||||
# - "user2@yourcompany.com"
|
||||
|
||||
# Admin email - user with this email will be auto-approved and can access admin panel
|
||||
# Can also be set via ADMIN_EMAIL environment variable
|
||||
admin_email: "" # Set this to your admin email address
|
||||
|
||||
# Default email to pre-fill in the login form
|
||||
# When set, this email will be automatically filled in the email input field
|
||||
# Leave empty to show an empty email field
|
||||
default_email: "" # Set this to pre-fill the login form with a default email
|
||||
|
||||
# Environment-specific overrides can be set via environment variables:
|
||||
# - PORT: Server port
|
||||
# - HOST: Server host
|
||||
@@ -60,3 +77,6 @@ auth:
|
||||
# - DATABASE_PATH: Database file path
|
||||
# - SESSION_SECRET: Session encryption secret
|
||||
# - ALLOWED_EMAILS: Comma-separated list of allowed emails
|
||||
# - ADMIN_EMAIL: Admin email address
|
||||
# - COOKIE_DOMAIN: Cookie domain for session cookies
|
||||
# - DEFAULT_EMAIL: Default email to pre-fill in login form
|
||||
|
||||
@@ -22,3 +22,5 @@ auth:
|
||||
allowed_emails:
|
||||
# - "admin@example.com"
|
||||
# - "user@example.com"
|
||||
# Admin email - user with this email will be auto-approved and can access admin panel
|
||||
admin_email: "" # Set this to your admin email or use ADMIN_EMAIL environment variable
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"passkey-auth/internal/cors"
|
||||
)
|
||||
|
||||
func TestWildcardCORSIntegration(t *testing.T) {
|
||||
// Create a simple test handler
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
// Create CORS middleware with wildcard support
|
||||
corsMiddleware := cors.WildcardCORS(cors.Config{
|
||||
AllowedOrigins: []string{"*.junv.cc", "https://static.example.com"},
|
||||
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
AllowCredentials: true,
|
||||
})
|
||||
|
||||
// Wrap the test handler
|
||||
handler := corsMiddleware(testHandler)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
origin string
|
||||
expectAllowed bool
|
||||
expectedOrigin string
|
||||
}{
|
||||
{
|
||||
name: "wildcard subdomain match",
|
||||
origin: "https://api.junv.cc",
|
||||
expectAllowed: true,
|
||||
expectedOrigin: "https://api.junv.cc",
|
||||
},
|
||||
{
|
||||
name: "wildcard base domain match",
|
||||
origin: "https://junv.cc",
|
||||
expectAllowed: true,
|
||||
expectedOrigin: "https://junv.cc",
|
||||
},
|
||||
{
|
||||
name: "static domain match",
|
||||
origin: "https://static.example.com",
|
||||
expectAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
origin: "https://evil.com",
|
||||
expectAllowed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a preflight OPTIONS request
|
||||
req := httptest.NewRequest("OPTIONS", "/", nil)
|
||||
req.Header.Set("Origin", tt.origin)
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
|
||||
// Record the response
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
// Check CORS headers
|
||||
allowOriginHeader := w.Header().Get("Access-Control-Allow-Origin")
|
||||
|
||||
if tt.expectAllowed {
|
||||
if allowOriginHeader == "" {
|
||||
t.Errorf("Expected Access-Control-Allow-Origin header, but got none")
|
||||
}
|
||||
|
||||
// For wildcard matches, should return the specific origin
|
||||
if tt.expectedOrigin != "" && allowOriginHeader != tt.expectedOrigin {
|
||||
t.Errorf("Expected Access-Control-Allow-Origin: %s, got: %s", tt.expectedOrigin, allowOriginHeader)
|
||||
}
|
||||
} else {
|
||||
if allowOriginHeader != "" {
|
||||
t.Errorf("Expected no Access-Control-Allow-Origin header, but got: %s", allowOriginHeader)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -9,9 +9,11 @@ services:
|
||||
- WEBAUTHN_RP_ID=localhost
|
||||
- DATABASE_PATH=/data/passkey-auth.db
|
||||
- SESSION_SECRET=dev-secret-change-in-production
|
||||
- ADMIN_EMAIL=admin@example.com
|
||||
- DEFAULT_EMAIL=admin@example.com
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./config.yaml:/root/config.yaml
|
||||
- ./config.yaml:/app/config.yaml
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
module passkey-auth
|
||||
|
||||
go 1.21
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/glebarez/go-sqlite v1.22.0
|
||||
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.11.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
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/golang-jwt/jwt/v5 v5.2.2 // 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/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
|
||||
golang.org/x/crypto v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
modernc.org/libc v1.37.6 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/sqlite v1.28.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
|
||||
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
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/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/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/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
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=
|
||||
@@ -21,12 +27,14 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
|
||||
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
|
||||
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
@@ -37,11 +45,12 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
|
||||
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/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
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=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.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=
|
||||
@@ -49,3 +58,11 @@ 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=
|
||||
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
|
||||
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
||||
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
apiVersion: v2
|
||||
name: passkey-auth
|
||||
description: A WebAuthn-based passkey authentication provider for Kubernetes Nginx Ingress
|
||||
type: application
|
||||
version: 0.1.1
|
||||
appVersion: "main"
|
||||
home: https://github.com/wahyd4/passkey-auth
|
||||
sources:
|
||||
- https://github.com/wahyd4/passkey-auth
|
||||
maintainers:
|
||||
- name: Junwei Zhao
|
||||
email: wahyd4@gmail.com
|
||||
keywords:
|
||||
- authentication
|
||||
- webauthn
|
||||
- passkey
|
||||
- nginx-ingress
|
||||
- security
|
||||
annotations:
|
||||
category: Security
|
||||
licenses: Apache-2.0
|
||||
@@ -0,0 +1,258 @@
|
||||
# Passkey Auth Helm Chart
|
||||
|
||||
A Helm chart for deploying Passkey Auth, a WebAuthn-based passkey authentication provider that integrates with Kubernetes Nginx Ingress controller.
|
||||
|
||||
## Overview
|
||||
|
||||
This chart deploys a secure, passwordless authentication service using WebAuthn/FIDO2 passkeys.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```bash
|
||||
helm repo add passkey-auth https://wahyd4.github.io/passkey-auth
|
||||
helm repo update
|
||||
helm upgrade --install my-passkey-auth -n home-apps -f my-values.yaml passkey-auth/passkey-auth
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Kubernetes 1.19+
|
||||
- Helm 3.0+
|
||||
- Nginx Ingress Controller
|
||||
- StorageClass for persistent volumes
|
||||
|
||||
## Installation
|
||||
|
||||
### Add Helm Repository
|
||||
|
||||
```bash
|
||||
helm repo add passkey-auth https://wahyd4.github.io/passkey-auth
|
||||
helm repo update
|
||||
```
|
||||
|
||||
|
||||
### Install from Local Chart
|
||||
|
||||
```bash
|
||||
git clone https://github.com/wahyd4/passkey-auth.git
|
||||
cd passkey-auth
|
||||
helm install my-passkey-auth ./helm/passkey-auth \
|
||||
--values ./helm/passkey-auth/values.yaml
|
||||
```
|
||||
|
||||
The command deploys Passkey Auth on the Kubernetes cluster with the default configuration. The [Parameters](#parameters) section lists the parameters that can be configured during installation.
|
||||
|
||||
> **Tip**: List all releases using `helm list`
|
||||
|
||||
## Configuration
|
||||
|
||||
### Core Configuration
|
||||
|
||||
The chart can be configured using the `values.yaml` file or by passing values via `--set` flags.
|
||||
|
||||
#### Required Configuration
|
||||
|
||||
```yaml
|
||||
config:
|
||||
webauthn:
|
||||
rpId: "auth.example.com" # Your authentication domain
|
||||
rpOrigins:
|
||||
- "https://auth.example.com" # Allowed origins for WebAuthn
|
||||
|
||||
cors:
|
||||
allowedOrigins:
|
||||
- "https://*.example.com" # CORS allowed origins
|
||||
|
||||
auth:
|
||||
cookieDomain: ".example.com" # Cookie domain for SSO
|
||||
allowedEmails:
|
||||
- "admin@example.com" # Allowed user emails
|
||||
|
||||
secrets:
|
||||
sessionSecret: "your-secure-random-secret" # Session signing secret
|
||||
|
||||
# OR use existing secret (recommended for production)
|
||||
secrets:
|
||||
existingSecret: "passkey-auth-secrets" # Reference to existing secret
|
||||
|
||||
ingress:
|
||||
hosts:
|
||||
- host: auth.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
```
|
||||
|
||||
## Setup Authentication for Your Services
|
||||
|
||||
Add these annotations to your ingress resources to protect them with passkey authentication:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: my-protected-app
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: "https://auth.example.com/auth"
|
||||
nginx.ingress.kubernetes.io/auth-signin: "https://auth.example.com/login?rd=$scheme://$http_host$request_uri"
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-Email"
|
||||
spec:
|
||||
# ... your ingress spec
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom Storage
|
||||
|
||||
```yaml
|
||||
persistence:
|
||||
enabled: true
|
||||
existingClaim: "my-existing-pvc"
|
||||
storageClass: "ssd-encrypted"
|
||||
size: 10Gi
|
||||
```
|
||||
|
||||
### External Secrets
|
||||
|
||||
For production deployments, it's recommended to create secrets manually and reference them:
|
||||
|
||||
```bash
|
||||
# Create the secret manually
|
||||
kubectl create secret generic passkey-auth-secrets \
|
||||
--from-literal=session-secret="$(openssl rand -base64 32)"
|
||||
|
||||
# Reference it in values.yaml
|
||||
secrets:
|
||||
existingSecret: "passkey-auth-secrets"
|
||||
```
|
||||
|
||||
Or use external secret management tools:
|
||||
|
||||
```yaml
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: external-secrets
|
||||
|
||||
secrets:
|
||||
existingSecret: "external-passkey-secrets" # Reference external secret
|
||||
```
|
||||
|
||||
|
||||
## Parameters
|
||||
|
||||
### Common parameters
|
||||
|
||||
| Name | Description | Value |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------- | ----- |
|
||||
| `nameOverride` | String to partially override passkey-auth.fullname template | `""` |
|
||||
| `fullnameOverride` | String to fully override passkey-auth.fullname template | `""` |
|
||||
| `commonLabels` | Add labels to all the deployed resources | `{}` |
|
||||
| `commonAnnotations` | Add annotations to all the deployed resources | `{}` |
|
||||
|
||||
### Passkey Auth parameters
|
||||
|
||||
| Name | Description | Value |
|
||||
| ----------------------------- | --------------------------------------------------------------- | ---------------------------------- |
|
||||
| `replicaCount` | Number of Passkey Auth replicas to deploy | `1` |
|
||||
| `image.repository` | Passkey Auth image repository | `ghcr.io/wahyd4/passkey-auth` |
|
||||
| `image.tag` | Passkey Auth image tag (immutable tags are recommended) | `main` |
|
||||
| `image.pullPolicy` | Passkey Auth image pull policy | `Always` |
|
||||
| `imagePullSecrets` | List of image pull secrets for private registries | `[]` |
|
||||
|
||||
### WebAuthn configuration
|
||||
|
||||
| Name | Description | Value |
|
||||
| --------------------------------- | --------------------------------------------------------------- | ------------------------------ |
|
||||
| `config.webauthn.rpDisplayName` | WebAuthn Relying Party display name | `Passkey Auth` |
|
||||
| `config.webauthn.rpId` | WebAuthn Relying Party ID (must match your domain) | `pass.example.com` |
|
||||
| `config.webauthn.rpOrigins` | Allowed origins for WebAuthn (array) | `["https://pass.example.com"]` |
|
||||
|
||||
### CORS configuration
|
||||
|
||||
| Name | Description | Value |
|
||||
| --------------------------------- | --------------------------------------------------------------- | -------------------------------- |
|
||||
| `config.cors.allowedOrigins` | CORS allowed origins (array) | `["https://*.example.com"]` |
|
||||
|
||||
### Authentication configuration
|
||||
|
||||
| Name | Description | Value |
|
||||
| --------------------------------- | --------------------------------------------------------------- | ------------------------------ |
|
||||
| `config.auth.requireApproval` | Require admin approval for new user registrations | `true` |
|
||||
| `config.auth.cookieDomain` | Cookie domain for SSO (e.g., .example.com) | `.example.com` |
|
||||
| `config.auth.allowedEmails` | List of allowed email addresses (array) | `["admin@example.com"]` |
|
||||
| `config.auth.allowedDomains` | List of allowed email domains (array) | `[]` |
|
||||
|
||||
### Service configuration
|
||||
|
||||
| Name | Description | Value |
|
||||
| -------------- | ----------------------------------- | ----------- |
|
||||
| `service.type` | Kubernetes service type | `ClusterIP` |
|
||||
| `service.port` | Kubernetes service port | `80` |
|
||||
|
||||
### Ingress configuration
|
||||
|
||||
| Name | Description | Value |
|
||||
| -------------------------- | --------------------------------------------------------------- | ------------------------------ |
|
||||
| `ingress.enabled` | Enable ingress controller resource | `true` |
|
||||
| `ingress.className` | IngressClass that will be used to implement the Ingress | `nginx` |
|
||||
| `ingress.annotations` | Additional annotations for the Ingress resource | `{}` |
|
||||
| `ingress.hosts[0].host` | Hostname for the ingress | `pass.example.com` |
|
||||
| `ingress.hosts[0].paths` | Paths for the ingress | `[{path: "/", pathType: "Prefix"}]` |
|
||||
| `ingress.tls` | TLS configuration for ingress | `[]` |
|
||||
|
||||
### Persistence configuration
|
||||
|
||||
| Name | Description | Value |
|
||||
| ----------------------------- | --------------------------------------------------------------- | ------------------ |
|
||||
| `persistence.enabled` | Enable persistent volume for data storage | `true` |
|
||||
| `persistence.storageClass` | Persistent Volume storage class | `""` |
|
||||
| `persistence.accessMode` | Persistent Volume access mode | `ReadWriteOnce` |
|
||||
| `persistence.size` | Persistent Volume size | `2Gi` |
|
||||
| `persistence.existingClaim` | Use existing persistent volume claim | `""` |
|
||||
|
||||
### Security configuration
|
||||
|
||||
| Name | Description | Value |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| `secrets.sessionSecret` | Session secret for signing cookies (only used if existingSecret is not set) | `""` |
|
||||
| `secrets.existingSecret` | Name of existing secret containing session-secret key | `""` |
|
||||
| `podSecurityContext.fsGroup` | Group ID for the pods | `1000` |
|
||||
| `securityContext.allowPrivilegeEscalation` | Allow privilege escalation for containers | `false` |
|
||||
| `securityContext.runAsNonRoot` | Run containers as non-root user | `true` |
|
||||
| `securityContext.runAsUser` | User ID for the containers | `1000` |
|
||||
| `securityContext.capabilities.drop` | Dropped capabilities | `["ALL"]` |
|
||||
|
||||
### Resource management
|
||||
|
||||
| Name | Description | Value |
|
||||
| ----------------------------- | --------------------------------------------------------------- | -------- |
|
||||
| `resources.limits.cpu` | CPU resource limits | `400m` |
|
||||
| `resources.limits.memory` | Memory resource limits | `512Mi` |
|
||||
| `resources.requests.cpu` | CPU resource requests | `100m` |
|
||||
| `resources.requests.memory` | Memory resource requests | `128Mi` |
|
||||
|
||||
### Autoscaling configuration
|
||||
|
||||
| Name | Description | Value |
|
||||
| -------------------------------------------------- | --------------------------------------------------------------- | ------- |
|
||||
| `autoscaling.enabled` | Enable Horizontal Pod Autoscaler | `false` |
|
||||
| `autoscaling.minReplicas` | Minimum number of replicas | `1` |
|
||||
| `autoscaling.maxReplicas` | Maximum number of replicas | `3` |
|
||||
| `autoscaling.targetCPUUtilizationPercentage` | Target CPU utilization percentage | `80` |
|
||||
| `autoscaling.targetMemoryUtilizationPercentage` | Target memory utilization percentage | `""` |
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Name | Description | Value |
|
||||
| ---------------- | --------------------------------------------------------------- | ------- |
|
||||
| `env.CONFIG_PATH` | Path to the configuration file | `/app/config.yaml` |
|
||||
| `env.ADMIN_EMAIL` | Admin email for auto-approval (optional) | `""` |
|
||||
| `env.DEFAULT_EMAIL` | Default email for initial setup (optional) | `""` |
|
||||
|
||||
### Other parameters
|
||||
|
||||
| Name | Description | Value |
|
||||
| ------------- | --------------------------------------------------------------- | ------- |
|
||||
| `nodeSelector` | Node labels for pod assignment | `{}` |
|
||||
| `tolerations` | Tolerations for pod assignment | `[]` |
|
||||
| `affinity` | Affinity for pod assignment | `{}` |
|
||||
@@ -0,0 +1,62 @@
|
||||
# Development values for passkey-auth
|
||||
# Use this for local development and testing
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/wahyd4/passkey-auth
|
||||
tag: "main"
|
||||
pullPolicy: Always
|
||||
|
||||
# Application configuration
|
||||
config:
|
||||
webauthn:
|
||||
rpDisplayName: "Dev Passkey Auth"
|
||||
rpId: "localhost"
|
||||
rpOrigins:
|
||||
- "http://localhost:8080"
|
||||
- "https://auth.dev.local"
|
||||
|
||||
cors:
|
||||
allowedOrigins:
|
||||
- "*" # Allow all origins in dev
|
||||
|
||||
auth:
|
||||
requireApproval: false # Auto-approve in dev
|
||||
cookieDomain: ".dev.local"
|
||||
allowedEmails: [] # Allow any email in dev
|
||||
|
||||
# Security configuration (dev only)
|
||||
secrets:
|
||||
sessionSecret: "dev-secret-not-for-production"
|
||||
|
||||
# Disable ingress for local development
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
# Minimal persistence for dev
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
|
||||
# Minimal resources for dev
|
||||
resources:
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
|
||||
# Disable autoscaling
|
||||
autoscaling:
|
||||
enabled: false
|
||||
|
||||
# Environment variables
|
||||
env:
|
||||
- name: DEFAULT_EMAIL
|
||||
value: "dev@example.com"
|
||||
- name: CONFIG_PATH
|
||||
value: "/app/config.yaml"
|
||||
- name: ADMIN_EMAIL
|
||||
value: "dev@example.com"
|
||||
@@ -0,0 +1,122 @@
|
||||
# Production values for passkey-auth
|
||||
# Use this as a template for production deployments
|
||||
|
||||
replicaCount: 2
|
||||
|
||||
image:
|
||||
repository: ghcr.io/wahyd4/passkey-auth
|
||||
tag: "v1.0.0" # Pin to specific version in production
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Application configuration
|
||||
config:
|
||||
webauthn:
|
||||
rpDisplayName: "My Company Passkey Auth"
|
||||
rpId: "auth.company.com" # CHANGE THIS
|
||||
rpOrigins:
|
||||
- "https://auth.company.com" # CHANGE THIS
|
||||
|
||||
cors:
|
||||
allowedOrigins:
|
||||
- "https://*.company.com" # CHANGE THIS
|
||||
- "https://app.company.com" # Add specific origins
|
||||
|
||||
auth:
|
||||
requireApproval: true
|
||||
cookieDomain: ".company.com" # CHANGE THIS
|
||||
allowedEmails:
|
||||
- "admin@company.com" # CHANGE THIS
|
||||
- "user@company.com" # Add allowed users
|
||||
|
||||
# Security configuration
|
||||
secrets:
|
||||
# Option 1: Create secret from values (less secure)
|
||||
# sessionSecret: "" # REQUIRED: Set this to a secure random string (32+ chars)
|
||||
|
||||
# Option 2: Use existing secret (recommended for production)
|
||||
# Create the secret manually: kubectl create secret generic passkey-auth-secrets --from-literal=session-secret="your-secret-here"
|
||||
existingSecret: "passkey-auth-secrets"
|
||||
|
||||
# Ingress configuration
|
||||
ingress:
|
||||
enabled: true
|
||||
className: "nginx"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
hosts:
|
||||
- host: auth.company.com # CHANGE THIS
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: auth-company-com-tls
|
||||
hosts:
|
||||
- auth.company.com # CHANGE THIS
|
||||
|
||||
# Persistence configuration
|
||||
persistence:
|
||||
enabled: true
|
||||
storageClass: "fast-ssd" # Use fast storage for production
|
||||
size: 5Gi
|
||||
accessMode: ReadWriteOnce
|
||||
|
||||
# Resource configuration
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
|
||||
# Autoscaling configuration
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 2
|
||||
maxReplicas: 5
|
||||
targetCPUUtilizationPercentage: 70
|
||||
|
||||
# Health checks
|
||||
healthCheck:
|
||||
enabled: true
|
||||
livenessProbe:
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# Security context
|
||||
podSecurityContext:
|
||||
fsGroup: 1000
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: false # SQLite needs write access
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
|
||||
# Node selection
|
||||
nodeSelector:
|
||||
kubernetes.io/os: linux
|
||||
|
||||
# Environment variables
|
||||
env:
|
||||
- name: DEFAULT_EMAIL
|
||||
value: "admin@company.com" # CHANGE THIS
|
||||
- name: CONFIG_PATH
|
||||
value: "/app/config.yaml"
|
||||
- name: ADMIN_EMAIL
|
||||
value: "admin@company.com" # CHANGE THIS
|
||||
@@ -0,0 +1,40 @@
|
||||
1. Get the application URL by running these commands:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
{{- range .paths }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- else if contains "NodePort" .Values.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "passkey-auth.fullname" . }})
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo http://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "passkey-auth.fullname" . }}'
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "passkey-auth.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "passkey-auth.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||
echo "Visit http://127.0.0.1:8080 to use your application"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||
{{- end }}
|
||||
|
||||
2. Configure your ingress for authentication by adding these annotations to your protected services:
|
||||
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: "http{{ if .Values.ingress.tls }}s{{ end }}://{{ (index .Values.ingress.hosts 0).host }}/auth"
|
||||
nginx.ingress.kubernetes.io/auth-signin: "http{{ if .Values.ingress.tls }}s{{ end }}://{{ (index .Values.ingress.hosts 0).host }}/login?rd=$scheme://$http_host$request_uri"
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-Email"
|
||||
|
||||
3. Visit the admin panel to manage users:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
http{{ if .Values.ingress.tls }}s{{ end }}://{{ (index .Values.ingress.hosts 0).host }}/
|
||||
{{- end }}
|
||||
|
||||
Important Security Notes:
|
||||
- Change the default session secret in values.yaml before deploying to production
|
||||
- Configure proper CORS origins for your domain
|
||||
- Set up proper TLS certificates
|
||||
- Review and configure allowed email addresses
|
||||
@@ -0,0 +1,91 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "passkey-auth.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "passkey-auth.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "passkey-auth.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "passkey-auth.labels" -}}
|
||||
helm.sh/chart: {{ include "passkey-auth.chart" . }}
|
||||
{{ include "passkey-auth.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "passkey-auth.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "passkey-auth.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "passkey-auth.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "passkey-auth.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the configmap
|
||||
*/}}
|
||||
{{- define "passkey-auth.configmapName" -}}
|
||||
{{- printf "%s-config" (include "passkey-auth.fullname" .) }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the secret
|
||||
*/}}
|
||||
{{- define "passkey-auth.secretName" -}}
|
||||
{{- if .Values.secrets.existingSecret }}
|
||||
{{- .Values.secrets.existingSecret }}
|
||||
{{- else }}
|
||||
{{- printf "%s-secrets" (include "passkey-auth.fullname" .) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the PVC
|
||||
*/}}
|
||||
{{- define "passkey-auth.pvcName" -}}
|
||||
{{- if .Values.persistence.existingClaim }}
|
||||
{{- .Values.persistence.existingClaim }}
|
||||
{{- else }}
|
||||
{{- printf "%s-pvc" (include "passkey-auth.fullname" .) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,38 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "passkey-auth.configmapName" . }}
|
||||
labels:
|
||||
{{- include "passkey-auth.labels" . | nindent 4 }}
|
||||
data:
|
||||
config.yaml: |
|
||||
server:
|
||||
port: {{ .Values.config.server.port | quote }}
|
||||
host: {{ .Values.config.server.host | quote }}
|
||||
|
||||
webauthn:
|
||||
rp_display_name: {{ .Values.config.webauthn.rpDisplayName | quote }}
|
||||
rp_id: {{ .Values.config.webauthn.rpId | quote }}
|
||||
rp_origins:
|
||||
{{- range .Values.config.webauthn.rpOrigins }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
|
||||
database:
|
||||
path: {{ .Values.config.database.path | quote }}
|
||||
|
||||
cors:
|
||||
allowed_origins:
|
||||
{{- range .Values.config.cors.allowedOrigins }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
|
||||
auth:
|
||||
require_approval: {{ .Values.config.auth.requireApproval }}
|
||||
cookie_domain: {{ .Values.config.auth.cookieDomain | quote }}
|
||||
{{- if .Values.config.auth.allowedEmails }}
|
||||
allowed_emails:
|
||||
{{- range .Values.config.auth.allowedEmails }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,121 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "passkey-auth.fullname" . }}
|
||||
labels:
|
||||
{{- include "passkey-auth.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
revisionHistoryLimit: 3
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "passkey-auth.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "passkey-auth.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "passkey-auth.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.targetPort }}
|
||||
protocol: TCP
|
||||
env:
|
||||
{{- range .Values.env }}
|
||||
- name: {{ .name }}
|
||||
value: {{ .value | quote }}
|
||||
{{- end }}
|
||||
- name: SESSION_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "passkey-auth.secretName" . }}
|
||||
key: session-secret
|
||||
{{- with .Values.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: "/app/config.yaml"
|
||||
subPath: config.yaml
|
||||
readOnly: true
|
||||
{{- if .Values.persistence.enabled }}
|
||||
- name: data
|
||||
mountPath: /data
|
||||
{{- end }}
|
||||
{{- with .Values.volumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.healthCheck.enabled }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.healthCheck.path }}
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.healthCheck.livenessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.healthCheck.livenessProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.healthCheck.livenessProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.healthCheck.livenessProbe.failureThreshold }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.healthCheck.path }}
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.healthCheck.readinessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.healthCheck.readinessProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.healthCheck.readinessProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.healthCheck.readinessProbe.failureThreshold }}
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.healthCheck.path }}
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.healthCheck.startupProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.healthCheck.startupProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.healthCheck.startupProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.healthCheck.startupProbe.failureThreshold }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: {{ include "passkey-auth.configmapName" . }}
|
||||
{{- if .Values.persistence.enabled }}
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "passkey-auth.pvcName" . }}
|
||||
{{- end }}
|
||||
{{- with .Values.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
restartPolicy: Always
|
||||
@@ -0,0 +1,32 @@
|
||||
{{- if .Values.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "passkey-auth.fullname" . }}
|
||||
labels:
|
||||
{{- include "passkey-auth.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "passkey-auth.fullname" . }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,55 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
{{- $fullName := include "passkey-auth.fullname" . -}}
|
||||
{{- $svcPort := .Values.service.port -}}
|
||||
{{- if and .Values.ingress.className (not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class")) }}
|
||||
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
|
||||
{{- end }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
labels:
|
||||
{{- include "passkey-auth.labels" . | nindent 4 }}
|
||||
{{- $annotations := (deepCopy (default (dict) .Values.ingress.annotations)) }}
|
||||
{{- if not (hasKey $annotations "nginx.ingress.kubernetes.io/upstream-vhost") }}
|
||||
{{- $_ := set $annotations "nginx.ingress.kubernetes.io/upstream-vhost" $.Values.config.webauthn.rpId }}
|
||||
{{- end }}
|
||||
annotations:
|
||||
{{- toYaml $annotations | nindent 4 }}
|
||||
spec:
|
||||
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
|
||||
pathType: {{ .pathType }}
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ $fullName }}
|
||||
port:
|
||||
number: {{ $svcPort }}
|
||||
{{- else }}
|
||||
serviceName: {{ $fullName }}
|
||||
servicePort: {{ $svcPort }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{- if .Values.persistence.enabled }}
|
||||
{{- if not .Values.persistence.existingClaim }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "passkey-auth.pvcName" . }}
|
||||
labels:
|
||||
{{- include "passkey-auth.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.persistence.accessMode }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.size }}
|
||||
{{- if .Values.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.persistence.storageClass }}
|
||||
{{- end }}
|
||||
volumeMode: Filesystem
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{- if not .Values.secrets.existingSecret }}
|
||||
{{- if not .Values.secrets.sessionSecret }}
|
||||
{{- fail "Either secrets.sessionSecret must be set or secrets.existingSecret must be specified" }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "passkey-auth.secretName" . }}
|
||||
labels:
|
||||
{{- include "passkey-auth.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
# Use openssl rand -base64 32 to generate a secure random string
|
||||
session-secret: {{ .Values.secrets.sessionSecret | b64enc | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "passkey-auth.fullname" . }}
|
||||
labels:
|
||||
{{- include "passkey-auth.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "passkey-auth.selectorLabels" . | nindent 4 }}
|
||||
@@ -0,0 +1,12 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "passkey-auth.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "passkey-auth.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,168 @@
|
||||
# Default values for passkey-auth.
|
||||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/wahyd4/passkey-auth
|
||||
pullPolicy: Always
|
||||
# Overrides the image tag whose default is the chart appVersion.
|
||||
tag: "main"
|
||||
|
||||
imagePullSecrets: []
|
||||
# - name: github-registry-secret
|
||||
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
create: false
|
||||
# Annotations to add to the service account
|
||||
annotations: {}
|
||||
# The name of the service account to use.
|
||||
# If not set and create is true, a name is generated using the fullname template
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
fsGroup: 1000
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
# readOnlyRootFilesystem: true
|
||||
# runAsNonRoot: true
|
||||
# runAsUser: 1000
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: "nginx"
|
||||
annotations:
|
||||
kubernetes.io/tls-acme: "true"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/proxy-set-headers: "passkey-auth-headers"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: "" # Will be set to config.webauthn.rpId
|
||||
nginx.ingress.kubernetes.io/proxy-redirect-from: "http://"
|
||||
nginx.ingress.kubernetes.io/proxy-redirect-to: "https://"
|
||||
hosts:
|
||||
- host: pass.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: passkey-auth-tls
|
||||
hosts:
|
||||
- pass.example.com
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 400m
|
||||
memory: 512Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 3
|
||||
targetCPUUtilizationPercentage: 80
|
||||
# targetMemoryUtilizationPercentage: 80
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
# Persistent storage for SQLite database
|
||||
persistence:
|
||||
enabled: true
|
||||
storageClass: "" # Use default storage class
|
||||
accessMode: ReadWriteOnce
|
||||
size: 2Gi
|
||||
# existingClaim: ""
|
||||
|
||||
# Health check configuration
|
||||
healthCheck:
|
||||
enabled: true
|
||||
path: /health
|
||||
livenessProbe:
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
startupProbe:
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 6
|
||||
|
||||
# Application configuration
|
||||
config:
|
||||
server:
|
||||
port: "8080"
|
||||
host: "0.0.0.0"
|
||||
|
||||
webauthn:
|
||||
rpDisplayName: "Passkey Auth"
|
||||
rpId: "pass.example.com"
|
||||
rpOrigins:
|
||||
- "https://pass.example.com"
|
||||
|
||||
database:
|
||||
path: "/data/passkey-auth.db"
|
||||
|
||||
cors:
|
||||
allowedOrigins:
|
||||
- "https://*.example.com"
|
||||
|
||||
auth:
|
||||
requireApproval: true
|
||||
cookieDomain: ".example.com"
|
||||
allowedEmails:
|
||||
- "admin@example.com"
|
||||
# adminEmail will be set via environment variable
|
||||
|
||||
# Environment variables
|
||||
env:
|
||||
- name: DEFAULT_EMAIL
|
||||
value: "admin@example.com"
|
||||
- name: CONFIG_PATH
|
||||
value: "/app/config.yaml"
|
||||
- name: ADMIN_EMAIL
|
||||
value: "admin@example.com"
|
||||
|
||||
# Secret environment variables
|
||||
secrets:
|
||||
# Session secret for cookie signing (only used if existingSecret is not set)
|
||||
sessionSecret: ""
|
||||
# Use an existing secret instead of creating one (recommended for production)
|
||||
# The secret should contain a key named "session-secret"
|
||||
existingSecret: ""
|
||||
|
||||
# Additional environment variables from existing secrets
|
||||
envFrom: []
|
||||
# - secretRef:
|
||||
# name: my-secret
|
||||
|
||||
# Additional volume mounts
|
||||
volumeMounts: []
|
||||
|
||||
# Additional volumes
|
||||
volumes: []
|
||||
@@ -1,8 +1,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
@@ -39,6 +40,9 @@ type AuthConfig struct {
|
||||
SessionSecret string `yaml:"session_secret"`
|
||||
RequireApproval bool `yaml:"require_approval"`
|
||||
AllowedEmails []string `yaml:"allowed_emails"`
|
||||
AdminEmail string `yaml:"admin_email"`
|
||||
CookieDomain string `yaml:"cookie_domain"`
|
||||
DefaultEmail string `yaml:"default_email"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@@ -47,6 +51,11 @@ func Load() (*Config, error) {
|
||||
configPath = "config.yaml"
|
||||
}
|
||||
|
||||
// Validate config path to prevent path traversal attacks
|
||||
if err := validateConfigPath(configPath); err != nil {
|
||||
return nil, fmt.Errorf("invalid config path: %w", err)
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
config := &Config{
|
||||
Server: ServerConfig{
|
||||
@@ -68,12 +77,14 @@ func Load() (*Config, error) {
|
||||
SessionSecret: "change-me-in-production",
|
||||
RequireApproval: true,
|
||||
AllowedEmails: []string{}, // Empty means no email restrictions
|
||||
CookieDomain: "", // Empty means no domain restriction (current domain only)
|
||||
},
|
||||
}
|
||||
|
||||
// Load from file if it exists
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
data, err := ioutil.ReadFile(configPath)
|
||||
// #nosec G304 - configPath is validated above to prevent path traversal
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -106,6 +117,15 @@ func Load() (*Config, error) {
|
||||
config.Auth.AllowedEmails[i] = strings.TrimSpace(email)
|
||||
}
|
||||
}
|
||||
if adminEmail := os.Getenv("ADMIN_EMAIL"); adminEmail != "" {
|
||||
config.Auth.AdminEmail = adminEmail
|
||||
}
|
||||
if cookieDomain := os.Getenv("COOKIE_DOMAIN"); cookieDomain != "" {
|
||||
config.Auth.CookieDomain = cookieDomain
|
||||
}
|
||||
if defaultEmail := os.Getenv("DEFAULT_EMAIL"); defaultEmail != "" {
|
||||
config.Auth.DefaultEmail = defaultEmail
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
@@ -127,3 +147,44 @@ func (c *Config) IsEmailAllowed(email string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsAdmin checks if an email address is the admin email
|
||||
func (c *Config) IsAdmin(email string) bool {
|
||||
return c.Auth.AdminEmail != "" && email == c.Auth.AdminEmail
|
||||
}
|
||||
|
||||
// validateConfigPath ensures the config path is safe and doesn't allow path traversal
|
||||
func validateConfigPath(path string) error {
|
||||
// Clean the path and check for path traversal attempts
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
// Don't allow paths that try to go up directories
|
||||
if strings.Contains(cleanPath, "..") {
|
||||
return fmt.Errorf("path traversal not allowed")
|
||||
}
|
||||
|
||||
// Only allow certain file extensions
|
||||
ext := filepath.Ext(cleanPath)
|
||||
if ext != ".yaml" && ext != ".yml" {
|
||||
return fmt.Errorf("only .yaml and .yml files are allowed")
|
||||
}
|
||||
|
||||
// Convert to absolute path to check if it's within allowed directories
|
||||
absPath, err := filepath.Abs(cleanPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
|
||||
// Get current working directory
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
// Only allow config files in current directory or its subdirectories
|
||||
if !strings.HasPrefix(absPath, wd) {
|
||||
return fmt.Errorf("config file must be in current directory or subdirectories")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package cors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/cors"
|
||||
)
|
||||
|
||||
// Config holds the CORS configuration with wildcard support
|
||||
type Config struct {
|
||||
AllowedOrigins []string
|
||||
AllowedMethods []string
|
||||
AllowedHeaders []string
|
||||
AllowCredentials bool
|
||||
}
|
||||
|
||||
// WildcardCORS creates a CORS handler with wildcard domain support
|
||||
func WildcardCORS(config Config) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
// Determine allowed origins for this request
|
||||
var allowedOrigins []string
|
||||
|
||||
// Separate wildcard and static origins
|
||||
var wildcardPatterns []string
|
||||
var staticOrigins []string
|
||||
|
||||
for _, configuredOrigin := range config.AllowedOrigins {
|
||||
if strings.Contains(configuredOrigin, "*") {
|
||||
wildcardPatterns = append(wildcardPatterns, configuredOrigin)
|
||||
} else {
|
||||
staticOrigins = append(staticOrigins, configuredOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if origin matches any wildcard pattern
|
||||
if len(wildcardPatterns) > 0 && origin != "" {
|
||||
wildcardMatcher := NewWildcardMatcher(wildcardPatterns)
|
||||
if wildcardMatcher.MatchOrigin(origin) {
|
||||
// For wildcard matches, allow the specific origin
|
||||
allowedOrigins = []string{origin}
|
||||
}
|
||||
}
|
||||
|
||||
// If no wildcard match, use static origins
|
||||
if len(allowedOrigins) == 0 {
|
||||
allowedOrigins = staticOrigins
|
||||
} else {
|
||||
// If we had a wildcard match, also include static origins
|
||||
allowedOrigins = append(allowedOrigins, staticOrigins...)
|
||||
}
|
||||
|
||||
// Create a new CORS instance for this request with the determined origins
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: allowedOrigins,
|
||||
AllowedMethods: config.AllowedMethods,
|
||||
AllowedHeaders: config.AllowedHeaders,
|
||||
AllowCredentials: config.AllowCredentials,
|
||||
})
|
||||
|
||||
// Use the rs/cors handler
|
||||
c.Handler(next).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package cors
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WildcardMatcher provides wildcard domain matching for CORS origins
|
||||
type WildcardMatcher struct {
|
||||
patterns []string
|
||||
}
|
||||
|
||||
// NewWildcardMatcher creates a new wildcard matcher with the given patterns
|
||||
func NewWildcardMatcher(patterns []string) *WildcardMatcher {
|
||||
return &WildcardMatcher{
|
||||
patterns: patterns,
|
||||
}
|
||||
}
|
||||
|
||||
// MatchOrigin checks if the given origin matches any of the wildcard patterns
|
||||
func (m *WildcardMatcher) MatchOrigin(origin string) bool {
|
||||
for _, pattern := range m.patterns {
|
||||
if m.matchPattern(origin, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchPattern checks if origin matches a specific pattern
|
||||
// Supports patterns like:
|
||||
// - "*.example.com" matches "api.example.com", "auth.example.com", etc.
|
||||
// - "*.*.example.com" matches "api.v1.example.com", etc.
|
||||
// - "example.com" matches exactly "example.com"
|
||||
func (m *WildcardMatcher) matchPattern(origin, pattern string) bool {
|
||||
// Remove protocol from origin if present
|
||||
origin = strings.TrimPrefix(origin, "https://")
|
||||
origin = strings.TrimPrefix(origin, "http://")
|
||||
|
||||
// Remove port if present
|
||||
if colonIndex := strings.LastIndex(origin, ":"); colonIndex != -1 && colonIndex > strings.LastIndex(origin, "]") {
|
||||
origin = origin[:colonIndex]
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if origin == pattern {
|
||||
return true
|
||||
}
|
||||
|
||||
// Wildcard match
|
||||
if strings.Contains(pattern, "*") {
|
||||
return m.wildcardMatch(origin, pattern)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// wildcardMatch performs wildcard matching
|
||||
func (m *WildcardMatcher) wildcardMatch(origin, pattern string) bool {
|
||||
// Handle simple case: *.domain.com
|
||||
if strings.HasPrefix(pattern, "*.") {
|
||||
suffix := pattern[2:] // Remove "*."
|
||||
|
||||
// Check if origin ends with the suffix and has at least one subdomain
|
||||
if strings.HasSuffix(origin, "."+suffix) {
|
||||
// Ensure there's a subdomain (not just the suffix itself)
|
||||
prefix := strings.TrimSuffix(origin, "."+suffix)
|
||||
// Make sure the prefix doesn't contain dots (single-level subdomain wildcard)
|
||||
// If you want multi-level subdomains, remove this check
|
||||
return !strings.Contains(prefix, ".")
|
||||
}
|
||||
|
||||
// Also check if origin exactly matches the suffix (without subdomain)
|
||||
return origin == suffix
|
||||
}
|
||||
|
||||
// For more complex patterns, we could implement more sophisticated matching
|
||||
// For now, handle the common *.domain.com case
|
||||
return false
|
||||
}
|
||||
|
||||
// GetAllowedOrigins returns the actual allowed origins for a request
|
||||
// This expands wildcard patterns based on the request origin
|
||||
func (m *WildcardMatcher) GetAllowedOrigins(requestOrigin string, staticOrigins []string) []string {
|
||||
allowedOrigins := make([]string, 0, len(staticOrigins))
|
||||
hasMatchingWildcard := false
|
||||
|
||||
// First pass: check if any wildcard matches
|
||||
for _, origin := range staticOrigins {
|
||||
if strings.Contains(origin, "*") {
|
||||
if m.matchPattern(requestOrigin, origin) {
|
||||
hasMatchingWildcard = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: build the result based on the logic
|
||||
for _, origin := range staticOrigins {
|
||||
if strings.Contains(origin, "*") {
|
||||
// This is a wildcard pattern
|
||||
if m.matchPattern(requestOrigin, origin) {
|
||||
// Add the actual request origin instead of the pattern
|
||||
allowedOrigins = append(allowedOrigins, requestOrigin)
|
||||
} else if !hasMatchingWildcard {
|
||||
// No wildcards match, so include this wildcard pattern as-is
|
||||
allowedOrigins = append(allowedOrigins, origin)
|
||||
}
|
||||
// If a wildcard matches but this one doesn't, skip it (don't add anything)
|
||||
} else {
|
||||
// This is a static origin, always add as-is
|
||||
allowedOrigins = append(allowedOrigins, origin)
|
||||
}
|
||||
}
|
||||
|
||||
return allowedOrigins
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package cors
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWildcardMatcher(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
patterns []string
|
||||
origin string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
patterns: []string{"example.com"},
|
||||
origin: "example.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "exact match with https",
|
||||
patterns: []string{"example.com"},
|
||||
origin: "https://example.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard subdomain match",
|
||||
patterns: []string{"*.junv.cc"},
|
||||
origin: "api.junv.cc",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard subdomain match with https",
|
||||
patterns: []string{"*.junv.cc"},
|
||||
origin: "https://auth.junv.cc",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard subdomain match with port",
|
||||
patterns: []string{"*.junv.cc"},
|
||||
origin: "https://dev.junv.cc:3000",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard base domain match",
|
||||
patterns: []string{"*.junv.cc"},
|
||||
origin: "junv.cc",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard no match - different domain",
|
||||
patterns: []string{"*.junv.cc"},
|
||||
origin: "api.example.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard no match - multi-level subdomain",
|
||||
patterns: []string{"*.junv.cc"},
|
||||
origin: "api.v1.junv.cc",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "multiple patterns - first match",
|
||||
patterns: []string{"*.junv.cc", "*.example.com"},
|
||||
origin: "api.junv.cc",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "multiple patterns - second match",
|
||||
patterns: []string{"*.junv.cc", "*.example.com"},
|
||||
origin: "api.example.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
patterns: []string{"*.junv.cc", "*.example.com"},
|
||||
origin: "api.other.com",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matcher := NewWildcardMatcher(tt.patterns)
|
||||
result := matcher.MatchOrigin(tt.origin)
|
||||
if result != tt.expected {
|
||||
t.Errorf("MatchOrigin() = %v, expected %v for origin %s with patterns %v",
|
||||
result, tt.expected, tt.origin, tt.patterns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllowedOrigins(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
patterns []string
|
||||
requestOrigin string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "wildcard match includes request origin",
|
||||
patterns: []string{"*.junv.cc", "https://static.com"},
|
||||
requestOrigin: "https://api.junv.cc",
|
||||
expected: []string{"https://api.junv.cc", "https://static.com"},
|
||||
},
|
||||
{
|
||||
name: "no wildcard match returns static origins",
|
||||
patterns: []string{"*.junv.cc", "https://static.com"},
|
||||
requestOrigin: "https://other.com",
|
||||
expected: []string{"*.junv.cc", "https://static.com"},
|
||||
},
|
||||
{
|
||||
name: "multiple wildcards, one matches",
|
||||
patterns: []string{"*.junv.cc", "*.example.com"},
|
||||
requestOrigin: "https://api.junv.cc",
|
||||
expected: []string{"https://api.junv.cc"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matcher := NewWildcardMatcher(tt.patterns)
|
||||
result := matcher.GetAllowedOrigins(tt.requestOrigin, tt.patterns)
|
||||
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("GetAllowedOrigins() returned %d origins, expected %d", len(result), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
for i, expected := range tt.expected {
|
||||
if result[i] != expected {
|
||||
t.Errorf("GetAllowedOrigins()[%d] = %v, expected %v", i, result[i], expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
_ "github.com/glebarez/go-sqlite"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
@@ -31,7 +31,7 @@ type Credential struct {
|
||||
}
|
||||
|
||||
func New(dbPath string) (*DB, error) {
|
||||
conn, err := sql.Open("sqlite3", dbPath+"?_fk=1")
|
||||
conn, err := sql.Open("sqlite", dbPath+"?_fk=1")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -98,6 +98,23 @@ func (db *DB) CreateUser(email, displayName string) (*User, error) {
|
||||
return db.GetUser(int(id))
|
||||
}
|
||||
|
||||
func (db *DB) CreateUserWithApproval(email, displayName string, approved bool) (*User, error) {
|
||||
result, err := db.conn.Exec(
|
||||
"INSERT INTO users (email, display_name, approved) VALUES (?, ?, ?)",
|
||||
email, displayName, approved,
|
||||
)
|
||||
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(
|
||||
|
||||
+207
-40
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -34,6 +35,7 @@ func New(db *database.DB, webAuthn *auth.WebAuthn, config *config.Config) *Handl
|
||||
HttpOnly: true,
|
||||
Secure: false, // Set to true in production with HTTPS
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Domain: config.Auth.CookieDomain, // Share cookies across subdomains if configured
|
||||
}
|
||||
|
||||
return &Handlers{
|
||||
@@ -47,12 +49,19 @@ func New(db *database.DB, webAuthn *auth.WebAuthn, config *config.Config) *Handl
|
||||
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})
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"error": message}); err != nil {
|
||||
// If we can't encode the error response, log it
|
||||
// Don't try to write another response as headers are already sent
|
||||
log.Printf("Failed to encode error response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) writeJSON(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
// If encoding fails, try to send a simple error response
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// BeginRegistration starts the passkey registration process
|
||||
@@ -85,16 +94,16 @@ func (h *Handlers) BeginRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
// Create a temporary WebAuthn user for registration without saving to DB yet
|
||||
isAdmin := h.config.IsAdmin(req.Email)
|
||||
tempUser := &database.User{
|
||||
Email: req.Email,
|
||||
DisplayName: req.DisplayName,
|
||||
Approved: isAdmin, // Auto-approve admins
|
||||
}
|
||||
|
||||
webAuthnUser := &auth.WebAuthnUser{}
|
||||
webAuthnUser.SetUser(user)
|
||||
webAuthnUser.SetUser(tempUser)
|
||||
|
||||
options, sessionData, err := h.webAuthn.BeginRegistration(webAuthnUser)
|
||||
if err != nil {
|
||||
@@ -103,11 +112,16 @@ func (h *Handlers) BeginRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Store session data
|
||||
// Store session data with user details for later creation
|
||||
session, _ := h.store.Get(r, "webauthn-session")
|
||||
session.Values["challenge"] = sessionData.Challenge
|
||||
session.Values["user_id"] = user.ID
|
||||
session.Save(r, w)
|
||||
session.Values["pending_email"] = req.Email
|
||||
session.Values["pending_display_name"] = req.DisplayName
|
||||
session.Values["pending_is_admin"] = isAdmin
|
||||
if err := session.Save(r, w); err != nil {
|
||||
h.writeError(w, "Failed to save session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Debug: log the options structure
|
||||
logrus.Debugf("WebAuthn options: %+v", options)
|
||||
@@ -123,15 +137,28 @@ func (h *Handlers) BeginRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := h.store.Get(r, "webauthn-session")
|
||||
|
||||
userID, ok := session.Values["user_id"].(int)
|
||||
// Get pending user data from session instead of user_id
|
||||
pendingEmail, ok := session.Values["pending_email"].(string)
|
||||
if !ok {
|
||||
h.writeError(w, "Invalid session", http.StatusBadRequest)
|
||||
h.writeError(w, "Invalid session - no pending registration", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pendingDisplayName, ok := session.Values["pending_display_name"].(string)
|
||||
if !ok {
|
||||
h.writeError(w, "Invalid session - missing display name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pendingIsAdmin, ok := session.Values["pending_is_admin"].(bool)
|
||||
if !ok {
|
||||
h.writeError(w, "Invalid session - missing admin flag", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
challenge, ok := session.Values["challenge"].(string)
|
||||
if !ok {
|
||||
h.writeError(w, "Invalid session", http.StatusBadRequest)
|
||||
h.writeError(w, "Invalid session - missing challenge", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,14 +173,15 @@ func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
// 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
|
||||
// Create temporary user for WebAuthn verification
|
||||
tempUser := &database.User{
|
||||
Email: pendingEmail,
|
||||
DisplayName: pendingDisplayName,
|
||||
Approved: pendingIsAdmin,
|
||||
}
|
||||
|
||||
webAuthnUser := &auth.WebAuthnUser{}
|
||||
webAuthnUser.SetUser(user)
|
||||
webAuthnUser.SetUser(tempUser)
|
||||
|
||||
sessionData := webauthn.SessionData{
|
||||
Challenge: challenge,
|
||||
@@ -161,7 +189,7 @@ func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Log the request details for debugging
|
||||
logrus.Debugf("Finishing registration for user: %s", user.Email)
|
||||
logrus.Debugf("Finishing registration for user: %s", pendingEmail)
|
||||
logrus.Debugf("Session challenge: %s", challenge)
|
||||
logrus.Debugf("Session user ID: %v", webAuthnUser.WebAuthnID())
|
||||
|
||||
@@ -172,17 +200,52 @@ func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only NOW create the user in the database after successful passkey registration
|
||||
user, err := h.db.CreateUserWithApproval(pendingEmail, pendingDisplayName, pendingIsAdmin)
|
||||
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 pendingIsAdmin {
|
||||
logrus.Infof("Admin user auto-approved: %s", pendingEmail)
|
||||
}
|
||||
|
||||
// Save credential to database
|
||||
if err := h.webAuthn.SaveCredential(user.ID, credential); err != nil {
|
||||
logrus.Errorf("Failed to save credential: %v", err)
|
||||
// If we can't save the credential, we should remove the user we just created
|
||||
if deleteErr := h.db.DeleteUser(user.ID); deleteErr != nil {
|
||||
logrus.Errorf("Failed to cleanup user after credential save failure: %v", deleteErr)
|
||||
}
|
||||
h.writeError(w, "Failed to save credential", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear session
|
||||
// Set authenticated session after successful registration
|
||||
authSession, _ := h.store.Get(r, "auth-session")
|
||||
authSession.Values["authenticated"] = true
|
||||
authSession.Values["user_id"] = user.ID
|
||||
authSession.Values["user_email"] = user.Email
|
||||
if err := authSession.Save(r, w); err != nil {
|
||||
h.writeError(w, "Failed to save auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear webauthn session
|
||||
session.Values["challenge"] = nil
|
||||
session.Values["user_id"] = nil
|
||||
session.Save(r, w)
|
||||
session.Values["pending_email"] = nil
|
||||
session.Values["pending_display_name"] = nil
|
||||
session.Values["pending_is_admin"] = nil
|
||||
if err := session.Save(r, w); err != nil {
|
||||
log.Printf("Failed to save session: %v", err)
|
||||
// Don't return error here as the main operation succeeded
|
||||
}
|
||||
|
||||
h.writeJSON(w, map[string]string{"status": "success"})
|
||||
}
|
||||
@@ -233,7 +296,10 @@ func (h *Handlers) BeginLogin(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := h.store.Get(r, "webauthn-session")
|
||||
session.Values["challenge"] = sessionData.Challenge
|
||||
session.Values["user_id"] = webAuthnUser.GetUser().ID
|
||||
session.Save(r, w)
|
||||
if err := session.Save(r, w); err != nil {
|
||||
h.writeError(w, "Failed to save session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeJSON(w, options)
|
||||
}
|
||||
@@ -288,12 +354,18 @@ func (h *Handlers) FinishLogin(w http.ResponseWriter, r *http.Request) {
|
||||
authSession.Values["authenticated"] = true
|
||||
authSession.Values["user_id"] = user.ID
|
||||
authSession.Values["user_email"] = user.Email
|
||||
authSession.Save(r, w)
|
||||
if err := authSession.Save(r, w); err != nil {
|
||||
h.writeError(w, "Failed to save auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear webauthn session
|
||||
session.Values["challenge"] = nil
|
||||
session.Values["user_id"] = nil
|
||||
session.Save(r, w)
|
||||
if err := session.Save(r, w); err != nil {
|
||||
log.Printf("Failed to save session: %v", err)
|
||||
// Don't return error here as the main operation succeeded
|
||||
}
|
||||
|
||||
h.writeJSON(w, map[string]interface{}{
|
||||
"status": "success",
|
||||
@@ -312,17 +384,34 @@ func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
session.Values["user_id"] = nil
|
||||
session.Values["user_email"] = nil
|
||||
session.Options.MaxAge = -1
|
||||
session.Save(r, w)
|
||||
if err := session.Save(r, w); err != nil {
|
||||
log.Printf("Failed to save session during logout: %v", err)
|
||||
// Don't return error here as logout should still succeed
|
||||
}
|
||||
|
||||
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")
|
||||
// Debug logging
|
||||
logrus.Debugf("AuthCheck request from %s", r.RemoteAddr)
|
||||
logrus.Debugf("AuthCheck headers: %+v", r.Header)
|
||||
logrus.Debugf("AuthCheck cookies: %+v", r.Cookies())
|
||||
|
||||
session, err := h.store.Get(r, "auth-session")
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to get auth session: %v", err)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
authenticated, ok := session.Values["authenticated"].(bool)
|
||||
logrus.Debugf("Session authenticated: %v, ok: %v", authenticated, ok)
|
||||
logrus.Debugf("Session values: %+v", session.Values)
|
||||
|
||||
if !ok || !authenticated {
|
||||
logrus.Debugf("User not authenticated, returning 401")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -335,14 +424,67 @@ func (h *Handlers) AuthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Auth-User", userEmail)
|
||||
}
|
||||
|
||||
logrus.Debugf("User authenticated, returning 200")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// GetAuthStatus returns the current authentication status
|
||||
func (h *Handlers) GetAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := h.store.Get(r, "auth-session")
|
||||
if err != nil {
|
||||
h.writeError(w, "Failed to get session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
userEmail, ok := session.Values["user_email"].(string)
|
||||
if !ok || userEmail == "" {
|
||||
h.writeError(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user details from database
|
||||
user, err := h.db.GetUserByEmail(userEmail)
|
||||
if err != nil {
|
||||
h.writeError(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is approved
|
||||
if !user.Approved {
|
||||
h.writeError(w, "User not approved", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"authenticated": true,
|
||||
"user": map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"display_name": user.DisplayName,
|
||||
"approved": user.Approved,
|
||||
"is_admin": h.config.IsAdmin(user.Email),
|
||||
},
|
||||
}
|
||||
|
||||
h.writeJSON(w, response)
|
||||
}
|
||||
|
||||
// GetConfig returns public configuration data
|
||||
func (h *Handlers) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
configData := map[string]interface{}{
|
||||
"default_email": h.config.Auth.DefaultEmail,
|
||||
}
|
||||
h.writeJSON(w, configData)
|
||||
}
|
||||
|
||||
// Admin endpoints
|
||||
|
||||
// ListUsers returns all users (admin endpoint)
|
||||
func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Add admin authentication check
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.db.ListUsers()
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to list users: %v", err)
|
||||
@@ -355,7 +497,10 @@ func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// CreateUser creates a new user (admin endpoint)
|
||||
func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Add admin authentication check
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
@@ -373,7 +518,7 @@ func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.db.CreateUser(req.Email, req.DisplayName)
|
||||
user, err := h.db.CreateUserWithApproval(req.Email, req.DisplayName, req.Approved)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
h.writeError(w, "User already exists", http.StatusConflict)
|
||||
@@ -384,18 +529,15 @@ func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
idStr, ok := vars["id"]
|
||||
if !ok {
|
||||
@@ -441,7 +583,10 @@ func (h *Handlers) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// DeleteUser deletes a user (admin endpoint)
|
||||
func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Add admin authentication check
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
idStr, ok := vars["id"]
|
||||
if !ok {
|
||||
@@ -463,3 +608,25 @@ func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
h.writeJSON(w, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *Handlers) isAdmin(r *http.Request) bool {
|
||||
session, err := h.store.Get(r, "auth-session")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
userEmail, ok := session.Values["user_email"].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return h.config.IsAdmin(userEmail)
|
||||
}
|
||||
|
||||
func (h *Handlers) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !h.isAdmin(r) {
|
||||
h.writeError(w, "Admin access required", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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
|
||||
@@ -1,44 +0,0 @@
|
||||
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
|
||||
@@ -1,4 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: passkey-auth
|
||||
@@ -5,13 +5,14 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rs/cors"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"passkey-auth/internal/auth"
|
||||
"passkey-auth/internal/config"
|
||||
"passkey-auth/internal/cors"
|
||||
"passkey-auth/internal/database"
|
||||
"passkey-auth/internal/handlers"
|
||||
)
|
||||
@@ -48,11 +49,13 @@ func main() {
|
||||
|
||||
// API routes
|
||||
api := router.PathPrefix("/api").Subrouter()
|
||||
api.HandleFunc("/config", h.GetConfig).Methods("GET")
|
||||
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("/auth/status", h.GetAuthStatus).Methods("GET")
|
||||
api.HandleFunc("/users", h.ListUsers).Methods("GET")
|
||||
api.HandleFunc("/users", h.CreateUser).Methods("POST")
|
||||
api.HandleFunc("/users/{id}", h.UpdateUser).Methods("PUT")
|
||||
@@ -64,21 +67,23 @@ func main() {
|
||||
// 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"})
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"status": "healthy"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}).Methods("GET")
|
||||
|
||||
// Static files for admin UI
|
||||
router.PathPrefix("/").Handler(http.FileServer(http.Dir("./web/"))).Methods("GET")
|
||||
|
||||
// Setup CORS
|
||||
c := cors.New(cors.Options{
|
||||
// Setup CORS with wildcard support
|
||||
corsHandler := cors.WildcardCORS(cors.Config{
|
||||
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
AllowCredentials: true,
|
||||
})
|
||||
|
||||
handler := c.Handler(router)
|
||||
handler := corsHandler(router)
|
||||
|
||||
// Start server
|
||||
port := os.Getenv("PORT")
|
||||
@@ -86,8 +91,17 @@ func main() {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
// Create server with proper timeouts to prevent resource exhaustion
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: handler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
logrus.Infof("Starting server on port %s", port)
|
||||
if err := http.ListenAndServe(":"+port, handler); err != nil {
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
log.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -16,7 +16,9 @@ func TestHealthEndpoint(t *testing.T) {
|
||||
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"}`))
|
||||
if _, err := w.Write([]byte(`{"status": "healthy"}`)); err != nil {
|
||||
t.Errorf("Failed to write response: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
Executable
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 189 KiB |
@@ -1,16 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# GitHub Repository Setup Script
|
||||
# Run this script after creating the repository on GitHub
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Passkey Auth - GitHub Repository Setup"
|
||||
echo "=========================================="
|
||||
|
||||
# Check if we're in a git repository
|
||||
if [ ! -d ".git" ]; then
|
||||
echo "❌ Error: Not in a git repository. Run this from the project root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for uncommitted changes
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo "⚠️ Warning: You have uncommitted changes."
|
||||
echo "Please commit or stash them before proceeding."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prompt for GitHub username and repository name
|
||||
read -p "Enter your GitHub username: " GITHUB_USERNAME
|
||||
read -p "Enter the repository name (default: passkey-auth): " REPO_NAME
|
||||
REPO_NAME=${REPO_NAME:-passkey-auth}
|
||||
|
||||
# Set the repository URL
|
||||
REPO_URL="https://github.com/${GITHUB_USERNAME}/${REPO_NAME}.git"
|
||||
|
||||
echo ""
|
||||
echo "📋 Repository Details:"
|
||||
echo " Username: ${GITHUB_USERNAME}"
|
||||
echo " Repository: ${REPO_NAME}"
|
||||
echo " URL: ${REPO_URL}"
|
||||
echo ""
|
||||
|
||||
# Confirm before proceeding
|
||||
read -p "Do you want to proceed? (y/N): " CONFIRM
|
||||
if [[ ! $CONFIRM =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "⚙️ Setting up remote repository..."
|
||||
|
||||
# Add remote origin
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
echo "📝 Updating existing origin remote..."
|
||||
git remote set-url origin "${REPO_URL}"
|
||||
else
|
||||
echo "📝 Adding origin remote..."
|
||||
git remote add origin "${REPO_URL}"
|
||||
fi
|
||||
|
||||
# Set upstream branch and push
|
||||
echo "📤 Pushing to GitHub..."
|
||||
git branch -M main
|
||||
git push -u origin main
|
||||
|
||||
echo ""
|
||||
echo "✅ Success! Your repository has been pushed to GitHub."
|
||||
echo ""
|
||||
echo "🔗 Repository URL: https://github.com/${GITHUB_USERNAME}/${REPO_NAME}"
|
||||
echo ""
|
||||
echo "📋 Next Steps:"
|
||||
echo " 1. Visit your repository on GitHub"
|
||||
echo " 2. Add repository description and topics"
|
||||
echo " 3. Configure branch protection rules (optional)"
|
||||
echo " 4. Set up GitHub Pages for documentation (optional)"
|
||||
echo " 5. Configure secrets for GitHub Actions:"
|
||||
echo " - DOCKER_USERNAME (for Docker Hub publishing)"
|
||||
echo " - DOCKER_PASSWORD (for Docker Hub publishing)"
|
||||
echo ""
|
||||
echo "🎉 Your open source project is now live!"
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/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
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Helm Chart Validation Script
|
||||
# This script validates the Helm chart before deployment
|
||||
|
||||
set -e
|
||||
|
||||
CHART_DIR="helm/passkey-auth"
|
||||
NAMESPACE="passkey-auth-test"
|
||||
|
||||
echo "🔍 Validating Passkey Auth Helm Chart..."
|
||||
|
||||
# Check if helm is installed
|
||||
if ! command -v helm &> /dev/null; then
|
||||
echo "❌ Helm is not installed. Please install Helm first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if kubectl is installed
|
||||
if ! command -v kubectl &> /dev/null; then
|
||||
echo "❌ kubectl is not installed. Please install kubectl first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Prerequisites check passed"
|
||||
|
||||
# Lint the chart
|
||||
echo "🔧 Linting Helm chart..."
|
||||
if helm lint $CHART_DIR; then
|
||||
echo "✅ Chart linting passed"
|
||||
else
|
||||
echo "❌ Chart linting failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Template the chart with test values
|
||||
echo "📝 Templating chart with test values..."
|
||||
helm template test-release $CHART_DIR \
|
||||
--set config.webauthn.rpId=test.example.com \
|
||||
--set config.webauthn.rpOrigins="{https://test.example.com}" \
|
||||
--set config.cors.allowedOrigins="{https://test.example.com}" \
|
||||
--set config.auth.cookieDomain=".example.com" \
|
||||
--set config.auth.allowedEmails="{admin@example.com}" \
|
||||
--set secrets.sessionSecret="test-secret-for-validation-only" \
|
||||
--set ingress.hosts[0].host=test.example.com \
|
||||
--set ingress.tls[0].hosts="{test.example.com}" \
|
||||
> /tmp/passkey-auth-template.yaml
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Chart templating passed"
|
||||
else
|
||||
echo "❌ Chart templating failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate Kubernetes manifests
|
||||
echo "🔍 Validating Kubernetes manifests..."
|
||||
if kubectl apply --dry-run=client -f /tmp/passkey-auth-template.yaml; then
|
||||
echo "✅ Kubernetes manifest validation passed"
|
||||
else
|
||||
echo "❌ Kubernetes manifest validation failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for required values
|
||||
echo "🔧 Checking for required configuration..."
|
||||
|
||||
REQUIRED_VALUES=(
|
||||
"config.webauthn.rpId"
|
||||
"config.webauthn.rpOrigins"
|
||||
"secrets.sessionSecret"
|
||||
)
|
||||
|
||||
for value in "${REQUIRED_VALUES[@]}"; do
|
||||
if helm template test-release $CHART_DIR --show-only templates/configmap.yaml | grep -q "REQUIRED"; then
|
||||
echo "❌ Required value not set: $value"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ Required values check passed"
|
||||
|
||||
# Test with production values
|
||||
if [ -f "$CHART_DIR/examples/values-production.yaml" ]; then
|
||||
echo "🏭 Testing with production values..."
|
||||
helm template test-release $CHART_DIR \
|
||||
-f $CHART_DIR/examples/values-production.yaml \
|
||||
--set secrets.sessionSecret="test-secret" \
|
||||
> /tmp/passkey-auth-production.yaml
|
||||
|
||||
if kubectl apply --dry-run=client -f /tmp/passkey-auth-production.yaml; then
|
||||
echo "✅ Production values validation passed"
|
||||
else
|
||||
echo "❌ Production values validation failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test with development values
|
||||
if [ -f "$CHART_DIR/examples/values-development.yaml" ]; then
|
||||
echo "🚀 Testing with development values..."
|
||||
helm template test-release $CHART_DIR \
|
||||
-f $CHART_DIR/examples/values-development.yaml \
|
||||
> /tmp/passkey-auth-development.yaml
|
||||
|
||||
if kubectl apply --dry-run=client -f /tmp/passkey-auth-development.yaml; then
|
||||
echo "✅ Development values validation passed"
|
||||
else
|
||||
echo "❌ Development values validation failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/passkey-auth-*.yaml
|
||||
|
||||
echo "🎉 All validations passed! The Helm chart is ready for deployment."
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Update values in values.yaml or use --set flags"
|
||||
echo "2. Install the chart: helm install my-passkey-auth $CHART_DIR"
|
||||
echo "3. Configure your ingress controllers to use the auth backend"
|
||||
echo ""
|
||||
echo "For production deployment, make sure to:"
|
||||
echo "• Set a secure session secret"
|
||||
echo "• Configure proper domains and origins"
|
||||
echo "• Set up TLS certificates"
|
||||
echo "• Configure allowed email addresses"
|
||||
+92
-651
@@ -3,699 +3,140 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Passkey Auth - Admin</title>
|
||||
<title>Passkey Auth</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;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
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;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
input[type="text"]:focus, input[type="email"]:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #007bff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</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 class="spinner"></div>
|
||||
<h2>🔐 Passkey Auth</h2>
|
||||
<p id="status">Checking authentication status...</p>
|
||||
</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');
|
||||
function updateStatus(message) {
|
||||
document.getElementById('status').textContent = message;
|
||||
}
|
||||
|
||||
console.log('Converting base64 to ArrayBuffer:', base64);
|
||||
function getRedirectUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
// Check for both 'redirect' and 'rd' parameters (nginx uses 'rd' by default)
|
||||
// Prefer 'redirect' over 'rd' if both are present
|
||||
const redirectParam = urlParams.get('redirect');
|
||||
const rdParam = urlParams.get('rd');
|
||||
|
||||
console.log('URL params:', {
|
||||
redirect: redirectParam,
|
||||
rd: rdParam,
|
||||
search: window.location.search
|
||||
});
|
||||
|
||||
return redirectParam || rdParam;
|
||||
}
|
||||
|
||||
function redirectToTarget(url) {
|
||||
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);
|
||||
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
updateStatus(`Redirecting to ${new URL(decodedUrl).hostname}...`);
|
||||
console.log('Redirecting to:', decodedUrl);
|
||||
// Small delay to show the message
|
||||
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();
|
||||
window.location.href = decodedUrl;
|
||||
}, 1000);
|
||||
return true;
|
||||
} 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>`;
|
||||
console.error('Error processing redirect URL:', error);
|
||||
updateStatus('Invalid redirect URL');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function approveUser(userId) {
|
||||
if (!confirm('Are you sure you want to approve this user?')) {
|
||||
function redirectToLogin() {
|
||||
const redirectUrl = getRedirectUrl();
|
||||
if (redirectUrl) {
|
||||
// Preserve the redirect parameter when going to login
|
||||
const loginUrl = `/login.html?redirect=${encodeURIComponent(redirectUrl)}`;
|
||||
updateStatus('Redirecting to login...');
|
||||
setTimeout(() => {
|
||||
window.location.href = loginUrl;
|
||||
}, 1000);
|
||||
} else {
|
||||
// No redirect parameter, just go to login
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAuthAndRedirect() {
|
||||
const redirectUrl = getRedirectUrl();
|
||||
|
||||
if (!redirectUrl) {
|
||||
updateStatus('No redirect URL provided');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login.html';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
approved: true
|
||||
})
|
||||
updateStatus('Checking authentication...');
|
||||
|
||||
const response = await fetch('/api/auth/status', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
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?')) {
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
if (userData.authenticated) {
|
||||
updateStatus(`Welcome back, ${userData.user.display_name}!`);
|
||||
redirectToTarget(redirectUrl);
|
||||
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();
|
||||
// Not authenticated, redirect to login with the target URL
|
||||
updateStatus('Authentication required...');
|
||||
redirectToLogin();
|
||||
|
||||
} catch (error) {
|
||||
showAlert(`Failed to delete user: ${error.message}`, 'error');
|
||||
console.error('Auth check failed:', error);
|
||||
updateStatus('Authentication check failed...');
|
||||
redirectToLogin();
|
||||
}
|
||||
}
|
||||
|
||||
// Load users when page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadUsers();
|
||||
});
|
||||
// Start the process when page loads
|
||||
document.addEventListener('DOMContentLoaded', checkAuthAndRedirect);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+959
@@ -0,0 +1,959 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Passkey Auth</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<style>
|
||||
/* Minimal custom styles - let PicoCSS handle most styling */
|
||||
.auth-toggle {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--pico-primary);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-approved {
|
||||
background-color: var(--pico-ins-color);
|
||||
color: var(--pico-ins-inverse);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background-color: var(--pico-del-color);
|
||||
color: var(--pico-del-inverse);
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Hide panels by default */
|
||||
.welcome-panel, .admin-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Better spacing for main header */
|
||||
body > main > header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Improve form layout */
|
||||
#authForm button[type="submit"] {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Make login container smaller */
|
||||
#authPanel article {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 576px) {
|
||||
.user-actions {
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<header>
|
||||
<hgroup>
|
||||
<h1>🔐 Passkey Auth</h1>
|
||||
<p id="headerSubtitle">Secure passwordless authentication</p>
|
||||
</hgroup>
|
||||
<nav>
|
||||
<ul>
|
||||
<li></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<details class="dropdown">
|
||||
<summary>Theme</summary>
|
||||
<ul>
|
||||
<li><a href="#" onclick="setTheme('auto')">Auto</a></li>
|
||||
<li><a href="#" onclick="setTheme('light')">Light</a></li>
|
||||
<li><a href="#" onclick="setTheme('dark')">Dark</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Authentication Section -->
|
||||
<section id="authPanel" class="auth-panel">
|
||||
<article>
|
||||
<form id="authForm">
|
||||
<label for="email">
|
||||
Email address
|
||||
<input type="email" id="email" name="email" placeholder="Enter your email address" required>
|
||||
</label>
|
||||
|
||||
<button type="submit" id="authSubmitBtn">
|
||||
Sign in with passkey
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<footer class="auth-toggle">
|
||||
<button type="button" class="link-btn" id="toggleModeBtn">
|
||||
Don't have an account? Create one
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Welcome Section -->
|
||||
<section id="welcomePanel" class="welcome-panel">
|
||||
<article>
|
||||
<header>
|
||||
<h2>Welcome back!</h2>
|
||||
<p>You're successfully authenticated</p>
|
||||
</header>
|
||||
|
||||
<div id="welcomeMessage"></div>
|
||||
|
||||
<footer>
|
||||
<button type="button" class="secondary" id="logoutBtn">
|
||||
Sign out
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Admin Panel -->
|
||||
<section id="adminPanel" class="admin-panel">
|
||||
<article>
|
||||
<header>
|
||||
<h3>User Management</h3>
|
||||
<p>Manage user accounts and permissions</p>
|
||||
</header>
|
||||
|
||||
<button onclick="loadUsers()" class="outline">
|
||||
Refresh users
|
||||
</button>
|
||||
|
||||
<div id="usersList" class="users-list">
|
||||
<article aria-busy="true">Loading users...</article>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Alerts Container -->
|
||||
<div id="alerts"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Theme management
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('pico-theme', theme);
|
||||
}
|
||||
|
||||
// Load saved theme or use auto
|
||||
function loadTheme() {
|
||||
const savedTheme = localStorage.getItem('pico-theme') || 'auto';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Utility function to extract error message from response
|
||||
function extractErrorMessage(responseText, defaultMessage = 'An error occurred') {
|
||||
let errorMessage = responseText;
|
||||
try {
|
||||
const errorJson = JSON.parse(responseText);
|
||||
if (errorJson.error) {
|
||||
errorMessage = errorJson.error;
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, check if it's already a plain text message
|
||||
if (responseText && responseText.trim() !== '') {
|
||||
errorMessage = responseText;
|
||||
} else {
|
||||
errorMessage = defaultMessage;
|
||||
}
|
||||
}
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
// Redirect handling for nginx auth_request
|
||||
function getRedirectUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
// Check for both 'redirect' and 'rd' parameters (nginx uses 'rd' by default)
|
||||
// Prefer 'redirect' over 'rd' if both are present
|
||||
const redirectParam = urlParams.get('redirect');
|
||||
const rdParam = urlParams.get('rd');
|
||||
|
||||
console.log('URL params:', {
|
||||
redirect: redirectParam,
|
||||
rd: rdParam,
|
||||
search: window.location.search
|
||||
});
|
||||
|
||||
return redirectParam || rdParam;
|
||||
}
|
||||
|
||||
// Extract email parameter from URL
|
||||
function getEmailFromUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const emailParam = urlParams.get('email');
|
||||
|
||||
return emailParam;
|
||||
}
|
||||
|
||||
// Pre-fill email input if email parameter exists or from config
|
||||
function prefillEmail() {
|
||||
const email = getEmailFromUrl();
|
||||
if (email) {
|
||||
const emailInput = document.getElementById('email');
|
||||
if (emailInput) {
|
||||
emailInput.value = decodeURIComponent(email);
|
||||
}
|
||||
} else {
|
||||
// If no email parameter, try to get default email from config
|
||||
loadDefaultEmail();
|
||||
}
|
||||
}
|
||||
|
||||
// Load default email from server configuration
|
||||
async function loadDefaultEmail() {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
if (response.ok) {
|
||||
const config = await response.json();
|
||||
if (config.default_email) {
|
||||
const emailInput = document.getElementById('email');
|
||||
if (emailInput && !emailInput.value) {
|
||||
emailInput.value = config.default_email;
|
||||
console.log('Pre-filled email from config:', config.default_email);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not load default email from config:', error);
|
||||
// Fail silently - this is not critical functionality
|
||||
}
|
||||
}
|
||||
|
||||
function redirectAfterAuth() {
|
||||
const redirectUrl = getRedirectUrl();
|
||||
console.log('Checking for redirect URL:', redirectUrl);
|
||||
if (redirectUrl) {
|
||||
try {
|
||||
// Decode the URL if it's encoded
|
||||
const decodedUrl = decodeURIComponent(redirectUrl);
|
||||
console.log('Decoded redirect URL:', decodedUrl);
|
||||
|
||||
// Validate the URL
|
||||
const url = new URL(decodedUrl);
|
||||
console.log('Parsed URL:', url.href);
|
||||
|
||||
// Show a brief message before redirecting
|
||||
showAlert(`Redirecting to ${url.hostname}...`, 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('Executing redirect to:', decodedUrl);
|
||||
window.location.href = decodedUrl;
|
||||
}, 1500);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error processing redirect URL:', error);
|
||||
showAlert('Invalid redirect URL', 'error');
|
||||
}
|
||||
} else {
|
||||
console.log('No redirect URL found');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// UI State Management
|
||||
let isSignUpMode = false;
|
||||
let currentUser = null;
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
// UI State Functions
|
||||
function showAuthPanel() {
|
||||
document.getElementById('authPanel').style.display = 'block';
|
||||
document.getElementById('welcomePanel').style.display = 'none';
|
||||
document.getElementById('adminPanel').style.display = 'none';
|
||||
document.getElementById('headerSubtitle').textContent = 'Secure passwordless authentication';
|
||||
}
|
||||
|
||||
function showWelcomePanel(user) {
|
||||
document.getElementById('authPanel').style.display = 'none';
|
||||
document.getElementById('welcomePanel').style.display = 'block';
|
||||
document.getElementById('headerSubtitle').textContent = 'Dashboard';
|
||||
|
||||
const welcomeMessage = document.getElementById('welcomeMessage');
|
||||
welcomeMessage.innerHTML = `
|
||||
<p>Hello, <strong>${user.display_name}</strong>! You're successfully authenticated and ready to go.</p>
|
||||
|
||||
<details>
|
||||
<summary>Account Information</summary>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Name</strong></td>
|
||||
<td>${user.display_name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Email</strong></td>
|
||||
<td>${user.email}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Role</strong></td>
|
||||
<td>${user.is_admin ? '<span class="status-badge status-approved">Administrator</span>' : '<span class="status-badge">User</span>'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
`;
|
||||
|
||||
// Show admin panel if user is admin
|
||||
if (user.is_admin) {
|
||||
document.getElementById('adminPanel').style.display = 'block';
|
||||
loadUsers();
|
||||
} else {
|
||||
document.getElementById('adminPanel').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSignUpMode() {
|
||||
isSignUpMode = !isSignUpMode;
|
||||
const authSubmitBtn = document.getElementById('authSubmitBtn');
|
||||
const toggleModeBtn = document.getElementById('toggleModeBtn');
|
||||
|
||||
if (isSignUpMode) {
|
||||
authSubmitBtn.textContent = 'Create account with passkey';
|
||||
toggleModeBtn.textContent = 'Already have an account? Sign in';
|
||||
} else {
|
||||
authSubmitBtn.textContent = 'Sign in with passkey';
|
||||
toggleModeBtn.textContent = "Don't have an account? Create one";
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
}).then(() => {
|
||||
currentUser = null;
|
||||
showAuthPanel();
|
||||
showAlert('You have been signed out successfully');
|
||||
// Clear email input
|
||||
document.getElementById('email').value = '';
|
||||
}).catch(error => {
|
||||
showAlert('Sign out failed: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
document.getElementById('toggleModeBtn').addEventListener('click', toggleSignUpMode);
|
||||
document.getElementById('logoutBtn').addEventListener('click', logout);
|
||||
|
||||
// Alert functions
|
||||
function showAlert(message, type = 'success') {
|
||||
const alertsContainer = document.getElementById('alerts');
|
||||
const alert = document.createElement('article');
|
||||
|
||||
// Use appropriate styling based on type
|
||||
if (type === 'success') {
|
||||
alert.style.borderLeftColor = 'var(--pico-ins-color)';
|
||||
alert.style.borderLeftWidth = '4px';
|
||||
alert.style.borderLeftStyle = 'solid';
|
||||
alert.innerHTML = `<strong>Success:</strong> ${message}`;
|
||||
} else {
|
||||
alert.style.borderLeftColor = 'var(--pico-del-color)';
|
||||
alert.style.borderLeftWidth = '4px';
|
||||
alert.style.borderLeftStyle = 'solid';
|
||||
alert.innerHTML = `<strong>Error:</strong> ${message}`;
|
||||
}
|
||||
|
||||
alertsContainer.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
alert.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Authentication status management
|
||||
async function checkAuthStatus() {
|
||||
try {
|
||||
console.log('Checking authentication status...');
|
||||
const response = await fetch('/api/auth/status', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
console.log('Auth status response:', response.status, response.statusText);
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
console.log('User is authenticated:', userData);
|
||||
currentUser = userData.user;
|
||||
showWelcomePanel(userData.user);
|
||||
return userData;
|
||||
} else {
|
||||
console.log('User is not authenticated');
|
||||
currentUser = null;
|
||||
showAuthPanel();
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Auth status check failed:', error);
|
||||
currentUser = null;
|
||||
showAuthPanel();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function showUsersTab() {
|
||||
console.log('showUsersTab called');
|
||||
const usersTab = document.getElementById('usersTab');
|
||||
if (usersTab) {
|
||||
usersTab.style.display = 'block';
|
||||
console.log('Users tab is now visible');
|
||||
} else {
|
||||
console.log('Could not find usersTab element');
|
||||
}
|
||||
}
|
||||
|
||||
function hideUsersTab() {
|
||||
console.log('hideUsersTab called');
|
||||
const usersTab = document.getElementById('usersTab');
|
||||
const usersContent = document.getElementById('users');
|
||||
|
||||
if (usersTab) {
|
||||
usersTab.style.display = 'none';
|
||||
console.log('Users tab is now hidden');
|
||||
}
|
||||
|
||||
// If users tab is currently active, switch to register tab
|
||||
if (usersContent && usersContent.classList.contains('active')) {
|
||||
console.log('Users tab was active, switching to register tab');
|
||||
showTab('register');
|
||||
}
|
||||
} // Unified Auth Form Handler
|
||||
document.getElementById('authForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const email = document.getElementById('email').value;
|
||||
const submitBtn = document.getElementById('authSubmitBtn');
|
||||
|
||||
// Disable submit button during processing
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.setAttribute('aria-busy', 'true');
|
||||
const originalText = submitBtn.textContent;
|
||||
submitBtn.textContent = isSignUpMode ?
|
||||
'Creating account...' :
|
||||
'Signing in...';
|
||||
|
||||
try {
|
||||
if (isSignUpMode) {
|
||||
await handleSignUp(email);
|
||||
} else {
|
||||
await handleSignIn(email);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert(`${isSignUpMode ? 'Account creation' : 'Sign in'} failed: ${error.message}`, 'error');
|
||||
} finally {
|
||||
// Re-enable submit button
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.removeAttribute('aria-busy');
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// Sign Up Handler
|
||||
async function handleSignUp(email) {
|
||||
console.log('Starting registration for email:', email);
|
||||
|
||||
// Use email as display name (extract name part before @)
|
||||
const displayName = email.split('@')[0];
|
||||
|
||||
// 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) {
|
||||
const errorText = await beginResponse.text();
|
||||
throw new Error(extractErrorMessage(errorText, 'Registration failed'));
|
||||
}
|
||||
|
||||
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(extractErrorMessage(errorText, 'Registration completion failed'));
|
||||
}
|
||||
|
||||
showAlert('Account created successfully! Welcome to the platform.');
|
||||
|
||||
// Check for redirect first, then fall back to checking auth status
|
||||
if (!redirectAfterAuth()) {
|
||||
// Check auth status to show welcome panel
|
||||
setTimeout(checkAuthStatus, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Sign In Handler
|
||||
async function handleSignIn(email) {
|
||||
// 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) {
|
||||
const errorText = await beginResponse.text();
|
||||
throw new Error(extractErrorMessage(errorText, 'Sign in failed'));
|
||||
}
|
||||
|
||||
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) {
|
||||
const errorText = await finishResponse.text();
|
||||
throw new Error(extractErrorMessage(errorText, 'Authentication failed'));
|
||||
}
|
||||
|
||||
const result = await finishResponse.json();
|
||||
showAlert(`Welcome back, ${result.user.display_name}!`);
|
||||
|
||||
// Check for redirect first, then fall back to checking auth status
|
||||
if (!redirectAfterAuth()) {
|
||||
// Check auth status to show welcome panel
|
||||
setTimeout(checkAuthStatus, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Users management
|
||||
async function loadUsers() {
|
||||
const usersList = document.getElementById('usersList');
|
||||
usersList.innerHTML = '<article aria-busy="true">Loading users...</article>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.status === 403) {
|
||||
// User is not admin, hide the tab
|
||||
hideUsersTab();
|
||||
showAlert('Access denied: Administrator privileges required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(extractErrorMessage(errorText, '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 => `
|
||||
<article>
|
||||
<header>
|
||||
<h4>${user.display_name}</h4>
|
||||
<p>${user.email}</p>
|
||||
</header>
|
||||
|
||||
<p><small>Created: ${new Date(user.created_at).toLocaleDateString()}</small></p>
|
||||
|
||||
<footer class="user-actions">
|
||||
<span class="status-badge ${user.approved ? 'status-approved' : 'status-pending'}">
|
||||
${user.approved ? 'Approved' : 'Pending Approval'}
|
||||
</span>
|
||||
${!user.approved ? `<button class="outline" onclick="approveUser(${user.id})">Approve User</button>` : ''}
|
||||
<button class="secondary" onclick="deleteUser(${user.id})">Delete User</button>
|
||||
</footer>
|
||||
</article>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
usersList.innerHTML = `<article><mark>Failed to load users: ${error.message}</mark></article>`;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(extractErrorMessage(errorText, 'Failed to approve user'));
|
||||
}
|
||||
|
||||
showAlert('User approved successfully');
|
||||
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? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(extractErrorMessage(errorText, 'Failed to delete user'));
|
||||
}
|
||||
|
||||
showAlert('User deleted successfully');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
showAlert(`Failed to delete user: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize page
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Load theme first
|
||||
loadTheme();
|
||||
|
||||
// Pre-fill email if provided in URL parameters
|
||||
prefillEmail();
|
||||
|
||||
// Check if there's a redirect URL and update header
|
||||
const redirectUrl = getRedirectUrl();
|
||||
if (redirectUrl) {
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(redirectUrl);
|
||||
const targetDomain = new URL(decodedUrl).hostname;
|
||||
document.getElementById('headerSubtitle').textContent = `Authenticating for ${targetDomain}`;
|
||||
} catch (error) {
|
||||
document.getElementById('headerSubtitle').textContent = 'Authenticating for protected resource';
|
||||
}
|
||||
}
|
||||
|
||||
// Check authentication status to determine initial UI state
|
||||
const user = await checkAuthStatus();
|
||||
|
||||
// If user is already authenticated and there's a redirect parameter, redirect immediately
|
||||
if (user && redirectUrl) {
|
||||
setTimeout(() => redirectAfterAuth(), 1000); // Give user a moment to see the message
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,142 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Passkey Auth</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #007bff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="spinner"></div>
|
||||
<h2>🔐 Passkey Auth</h2>
|
||||
<p id="status">Checking authentication status...</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateStatus(message) {
|
||||
document.getElementById('status').textContent = message;
|
||||
}
|
||||
|
||||
function getRedirectUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
// Check for both 'redirect' and 'rd' parameters (nginx uses 'rd' by default)
|
||||
// Prefer 'redirect' over 'rd' if both are present
|
||||
const redirectParam = urlParams.get('redirect');
|
||||
const rdParam = urlParams.get('rd');
|
||||
|
||||
console.log('URL params:', {
|
||||
redirect: redirectParam,
|
||||
rd: rdParam,
|
||||
search: window.location.search
|
||||
});
|
||||
|
||||
return redirectParam || rdParam;
|
||||
}
|
||||
|
||||
function redirectToTarget(url) {
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
updateStatus(`Redirecting to ${new URL(decodedUrl).hostname}...`);
|
||||
console.log('Redirecting to:', decodedUrl);
|
||||
// Small delay to show the message
|
||||
setTimeout(() => {
|
||||
window.location.href = decodedUrl;
|
||||
}, 1000);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error processing redirect URL:', error);
|
||||
updateStatus('Invalid redirect URL');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
const redirectUrl = getRedirectUrl();
|
||||
if (redirectUrl) {
|
||||
// Preserve the redirect parameter when going to login
|
||||
const loginUrl = `/login.html?redirect=${encodeURIComponent(redirectUrl)}`;
|
||||
updateStatus('Redirecting to login...');
|
||||
setTimeout(() => {
|
||||
window.location.href = loginUrl;
|
||||
}, 1000);
|
||||
} else {
|
||||
// No redirect parameter, just go to login
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAuthAndRedirect() {
|
||||
const redirectUrl = getRedirectUrl();
|
||||
|
||||
if (!redirectUrl) {
|
||||
updateStatus('No redirect URL provided');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login.html';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
updateStatus('Checking authentication...');
|
||||
|
||||
const response = await fetch('/api/auth/status', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
if (userData.authenticated) {
|
||||
updateStatus(`Welcome back, ${userData.user.display_name}!`);
|
||||
redirectToTarget(redirectUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Not authenticated, redirect to login with the target URL
|
||||
updateStatus('Authentication required...');
|
||||
redirectToLogin();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
updateStatus('Authentication check failed...');
|
||||
redirectToLogin();
|
||||
}
|
||||
}
|
||||
|
||||
// Start the process when page loads
|
||||
document.addEventListener('DOMContentLoaded', checkAuthAndRedirect);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user