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**
|
**Describe the solution you'd like**
|
||||||
A clear and concise description of what you want to happen.
|
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
|
- [ ] Documentation update
|
||||||
- [ ] Performance improvement
|
- [ ] Performance improvement
|
||||||
- [ ] Code refactoring
|
- [ ] 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)
|
|
||||||
|
|||||||
+29
-27
@@ -3,6 +3,8 @@ name: CI/CD
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, develop ]
|
branches: [ main, develop ]
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
release:
|
release:
|
||||||
@@ -14,7 +16,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -32,11 +34,6 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-go-
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y gcc libsqlite3-dev
|
|
||||||
|
|
||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
|
|
||||||
@@ -55,18 +52,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Build binary
|
- name: Build binary
|
||||||
run: |
|
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:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: test
|
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:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -74,37 +70,48 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to GitHub Container Registry
|
||||||
if: github.event_name == 'release'
|
if: github.event_name == 'push' || github.event_name == 'release' || startsWith(github.ref, 'refs/tags/v')
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
registry: ghcr.io
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata
|
- name: Extract metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
passkey-auth
|
ghcr.io/${{ github.repository }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
type=ref,event=pr
|
type=ref,event=pr
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
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
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
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 }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
build-args: |
|
||||||
|
BUILDKIT_INLINE_CACHE=1
|
||||||
|
GOCACHE=/root/.cache/go-build
|
||||||
|
GOMODCACHE=/go/pkg/mod
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -117,11 +124,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
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
|
- name: Run golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v3
|
uses: golangci/golangci-lint-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -135,6 +137,6 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Run Gosec Security Scanner
|
- name: Run Gosec Security Scanner
|
||||||
uses: securecodewarrior/github-action-gosec@master
|
uses: securego/gosec@master
|
||||||
with:
|
with:
|
||||||
args: './...'
|
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
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
bin/
|
bin/
|
||||||
passkey-auth
|
./passkey-auth
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
# Test binary, built with `go test -c`
|
||||||
*.test
|
*.test
|
||||||
@@ -56,3 +56,6 @@ k8s/*-secret.yaml
|
|||||||
|
|
||||||
# Development files
|
# Development files
|
||||||
dev-*
|
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
|
# Build stage
|
||||||
FROM golang:1.21-bullseye AS builder
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
# Install build dependencies including sqlite
|
# Install git for Go modules
|
||||||
RUN apt-get update && apt-get install -y gcc libsqlite3-dev && rm -rf /var/lib/apt/lists/*
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy go mod files
|
# Copy go mod files first for better layer caching
|
||||||
COPY go.mod go.sum ./
|
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
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the application (remove static linking for SQLite compatibility)
|
# Build the application with optimized flags and build cache
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -a -o passkey-auth .
|
# 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
|
# Final stage
|
||||||
FROM debian:bullseye-slim
|
FROM alpine:latest
|
||||||
|
|
||||||
# Install runtime dependencies
|
# Install ca-certificates for HTTPS (no sqlite library needed)
|
||||||
RUN apt-get update && apt-get install -y ca-certificates libsqlite3-0 && rm -rf /var/lib/apt/lists/*
|
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 the binary from builder stage
|
||||||
COPY --from=builder /app/passkey-auth .
|
COPY --from=builder /app/passkey-auth .
|
||||||
@@ -35,8 +43,11 @@ COPY --from=builder /app/web ./web/
|
|||||||
# Copy default config
|
# Copy default config
|
||||||
COPY --from=builder /app/config.yaml .
|
COPY --from=builder /app/config.yaml .
|
||||||
|
|
||||||
# Create directory for database
|
# Create directory for database and set ownership
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /data && chown -R appuser:appuser /app /data
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 8080
|
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
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
1. Definitions.
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
the copyright owner that is granting the License.
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
"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..."
|
@echo "🎨 Formatting code..."
|
||||||
@go fmt ./...
|
@go fmt ./...
|
||||||
|
|
||||||
lint: ## Run linter
|
lint: ## Run linter with auto-fix (excluding test files)
|
||||||
@echo "🔍 Running linter..."
|
@echo "🔍 Running linter with auto-fix (excluding test files)..."
|
||||||
@golangci-lint run
|
@golangci-lint run --fix --tests=false
|
||||||
|
|
||||||
security: ## Run security scan
|
security: ## Run security scan
|
||||||
@echo "🔒 Running 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
|
# 🔐 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
|
## ✨ Features
|
||||||
|
|
||||||
- **Pa2. **"User not found" during login**:
|
- **Passwordless Authentication**: Uses WebAuthn/FIDO2 passkeys for secure authentication
|
||||||
- Ensure user is registered and approved (if required)
|
- **Nginx Ingress Integration**: Works as auth backend using nginx `auth_request` directive
|
||||||
- Check that email address matches exactly
|
- **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
|
## Security Benefits
|
||||||
- **Email-Based Access Control**: Users are identified by email addresses with configurable allowlists
|
|
||||||
- **Nginx Ingress Integration**: Works as an auth backend using nginx `auth_request` directive
|
|
||||||
- **User Management**: Admin interface for managing users and their approval status
|
|
||||||
- **Access Control**: Configure allowed email addresses and approval requirements
|
|
||||||
- **Kubernetes Native**: Designed specifically for Kubernetes deployment
|
|
||||||
- **Persistent Storage**: Uses SQLite with persistent volumes for data storage
|
|
||||||
- **Modern UI**: Clean, responsive web interface for user registration and management
|
|
||||||
|
|
||||||
## 🏗️ Architecture
|
- **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
|
||||||
│ User Browser │ │ Nginx Ingress │ │ Your App │
|
- **Session security** - Secure cookie-based sessions
|
||||||
│ │ │ │ │ │
|
|
||||||
│ 1. Request │───▶│ 2. Auth Check │───▶│ 4. Serve App │
|
|
||||||
│ 4. Redirect │◀───│ 3. 401/302 │ │ │
|
|
||||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
|
||||||
│
|
|
||||||
│ auth_request
|
|
||||||
▼
|
|
||||||
┌──────────────────┐
|
|
||||||
│ Passkey Auth │
|
|
||||||
│ │
|
|
||||||
│ - WebAuthn │
|
|
||||||
│ - User Mgmt │
|
|
||||||
│ - Session Mgmt │
|
|
||||||
└──────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Using Helm Chart (Recommended)
|
||||||
|
|
||||||
- Kubernetes cluster with nginx ingress controller
|
|
||||||
- Docker
|
|
||||||
- kubectl configured to access your cluster
|
|
||||||
|
|
||||||
### 1. Clone and Build
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
# Add the Helm repository
|
||||||
cd passkey-auth
|
helm repo add passkey-auth https://wahyd4.github.io/passkey-auth
|
||||||
|
helm repo update
|
||||||
|
|
||||||
# Build the Docker image
|
# Install with your values
|
||||||
./scripts/build.sh
|
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:
|
### Test with Docker
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Update these values in the ConfigMap
|
|
||||||
webauthn:
|
|
||||||
rp_id: "your-domain.com" # Your domain
|
|
||||||
rp_origins:
|
|
||||||
- "https://your-domain.com" # Your domain with protocol
|
|
||||||
|
|
||||||
cors:
|
|
||||||
allowed_origins:
|
|
||||||
- "https://your-domain.com" # Your domain with protocol
|
|
||||||
|
|
||||||
auth:
|
|
||||||
session_secret: "your-secure-secret-key" # Generate a secure random string
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Deploy to Kubernetes
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Deploy the passkey auth service
|
docker run --name passkey-auth -d -p 8080:8080 -e ADMIN_EMAIL="admin@example.com" ghcr.io/wahyd4/passkey-auth:main
|
||||||
./scripts/deploy.sh
|
|
||||||
|
|
||||||
# Verify deployment
|
|
||||||
kubectl get pods -n passkey-auth
|
|
||||||
kubectl logs -f deployment/passkey-auth -n passkey-auth
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Configure Your App's Ingress
|
### 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
|
```yaml
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
@@ -96,14 +69,9 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: your-app-ingress
|
name: your-app-ingress
|
||||||
annotations:
|
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-url: "https://your-passkey-auth.com/auth"
|
||||||
nginx.ingress.kubernetes.io/auth-signin: "https://your-domain.com/auth"
|
nginx.ingress.kubernetes.io/auth-signin: "https://your-passkey-auth.com/?redirect=https%3A%2F%2F$host$request_uri"
|
||||||
|
|
||||||
# Pass user info to your app
|
|
||||||
nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-User-ID"
|
|
||||||
spec:
|
spec:
|
||||||
rules:
|
rules:
|
||||||
- host: your-app.com
|
- host: your-app.com
|
||||||
@@ -118,129 +86,39 @@ spec:
|
|||||||
number: 80
|
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
|
## 👥 User Management
|
||||||
|
|
||||||
### Access the Admin Interface
|
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 with `ADMIN_USER` email address
|
||||||
|
|
||||||
1. Navigate to `https://your-domain.com/auth` in your browser
|
### Configuration
|
||||||
2. You'll see the admin dashboard with three tabs:
|
|
||||||
- **Register User**: Register new users with passkeys
|
|
||||||
- **Test Login**: Test authentication
|
|
||||||
- **Manage Users**: View and manage all users
|
|
||||||
|
|
||||||
### User Registration Flow
|
|
||||||
|
|
||||||
1. **Register**: Admin enters email address and display name
|
|
||||||
2. **Email Validation**: System checks if email is in the allowlist (if configured)
|
|
||||||
3. **Passkey Creation**: Browser prompts for passkey creation (TouchID, Windows Hello, etc.)
|
|
||||||
4. **Approval**: If `require_approval` is enabled, admin must approve users manually
|
|
||||||
5. **Authentication**: Users can now authenticate with their passkeys
|
|
||||||
|
|
||||||
### User Approval Process
|
|
||||||
|
|
||||||
When `require_approval` is enabled in the configuration:
|
|
||||||
|
|
||||||
1. **New users register** but cannot authenticate until approved
|
|
||||||
2. **Admin reviews pending users** in the "Manage Users" tab
|
|
||||||
3. **Admin clicks "Approve"** next to pending users
|
|
||||||
4. **Users can now authenticate** with their passkeys
|
|
||||||
|
|
||||||
**To approve a user:**
|
|
||||||
1. Navigate to the admin interface at `https://your-domain.com/auth`
|
|
||||||
2. Click the "Manage Users" tab
|
|
||||||
3. Find users with "Pending" status
|
|
||||||
4. Click the "Approve" button next to the user
|
|
||||||
5. Confirm the approval in the dialog
|
|
||||||
|
|
||||||
**User Status Indicators:**
|
|
||||||
- 🟢 **Approved**: User can authenticate
|
|
||||||
- 🟡 **Pending**: User registered but needs approval
|
|
||||||
|
|
||||||
### Configuration Options
|
|
||||||
|
|
||||||
In `config.yaml` or environment variables:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
# config.yaml
|
||||||
auth:
|
auth:
|
||||||
require_approval: true # Require admin approval for new users
|
require_approval: true # Require admin approval for new users
|
||||||
session_secret: "secret" # Session encryption key
|
|
||||||
allowed_emails: # Email allowlist (empty = allow all)
|
allowed_emails: # Email allowlist (empty = allow all)
|
||||||
- "admin@company.com"
|
- "admin@company.com"
|
||||||
- "user@company.com"
|
- "user@company.com"
|
||||||
```
|
```
|
||||||
|
Check [config.example.yaml](./config.example.yaml) for more details
|
||||||
Environment variable overrides:
|
|
||||||
- `WEBAUTHN_RP_ID`: WebAuthn Relying Party ID (your domain)
|
|
||||||
- `SESSION_SECRET`: Session encryption secret
|
|
||||||
- `DATABASE_PATH`: SQLite database file path
|
|
||||||
- `PORT`: Server port (default: 8080)
|
|
||||||
- `ALLOWED_EMAILS`: Comma-separated list of allowed emails
|
|
||||||
|
|
||||||
## 🔧 Development
|
## 🔧 Development
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
|
|
||||||
1. **Install Go dependencies**:
|
|
||||||
```bash
|
```bash
|
||||||
|
# Install dependencies and run locally
|
||||||
go mod download
|
go mod download
|
||||||
```
|
|
||||||
|
|
||||||
2. **Run locally**:
|
|
||||||
```bash
|
|
||||||
# Update config.yaml for local development
|
|
||||||
go run main.go
|
go run main.go
|
||||||
|
|
||||||
|
# Access at http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Access the interface**:
|
### Key API Endpoints
|
||||||
```
|
|
||||||
http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
passkey-auth/
|
|
||||||
├── main.go # Application entry point
|
|
||||||
├── internal/
|
|
||||||
│ ├── auth/ # WebAuthn implementation
|
|
||||||
│ ├── config/ # Configuration management
|
|
||||||
│ ├── database/ # SQLite database layer
|
|
||||||
│ └── handlers/ # HTTP handlers
|
|
||||||
├── web/ # Static web files
|
|
||||||
├── k8s/ # Kubernetes manifests
|
|
||||||
├── scripts/ # Deployment scripts
|
|
||||||
└── config.yaml # Configuration file
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
@@ -248,169 +126,11 @@ passkey-auth/
|
|||||||
| `/api/register/finish` | POST | Complete passkey registration |
|
| `/api/register/finish` | POST | Complete passkey registration |
|
||||||
| `/api/login/begin` | POST | Start passkey authentication |
|
| `/api/login/begin` | POST | Start passkey authentication |
|
||||||
| `/api/login/finish` | POST | Complete passkey authentication |
|
| `/api/login/finish` | POST | Complete passkey authentication |
|
||||||
| `/api/logout` | POST | Logout user |
|
|
||||||
| `/auth` | GET | Nginx auth check endpoint |
|
| `/auth` | GET | Nginx auth check endpoint |
|
||||||
| `/api/users` | GET | List all users (admin) |
|
| `/api/users` | GET/POST | List/create users |
|
||||||
| `/api/users` | POST | Create new user (admin) |
|
|
||||||
| `/api/users/{id}` | PUT | Update user (approve/admin) |
|
|
||||||
| `/api/users/{id}` | DELETE | Delete user (admin) |
|
|
||||||
| `/health` | GET | Health check |
|
| `/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
|
## 📄 License
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
Apache License 2.0
|
||||||
|
|
||||||
## 🙏 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.
|
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ auth:
|
|||||||
# Set to false to allow automatic approval for trusted environments
|
# Set to false to allow automatic approval for trusted environments
|
||||||
require_approval: true
|
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
|
# Email allowlist - list of email addresses allowed to register
|
||||||
# Leave empty to allow any email address (not recommended for production)
|
# Leave empty to allow any email address (not recommended for production)
|
||||||
allowed_emails:
|
allowed_emails:
|
||||||
@@ -53,6 +61,15 @@ auth:
|
|||||||
# - "user1@yourcompany.com"
|
# - "user1@yourcompany.com"
|
||||||
# - "user2@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:
|
# Environment-specific overrides can be set via environment variables:
|
||||||
# - PORT: Server port
|
# - PORT: Server port
|
||||||
# - HOST: Server host
|
# - HOST: Server host
|
||||||
@@ -60,3 +77,6 @@ auth:
|
|||||||
# - DATABASE_PATH: Database file path
|
# - DATABASE_PATH: Database file path
|
||||||
# - SESSION_SECRET: Session encryption secret
|
# - SESSION_SECRET: Session encryption secret
|
||||||
# - ALLOWED_EMAILS: Comma-separated list of allowed emails
|
# - 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:
|
allowed_emails:
|
||||||
# - "admin@example.com"
|
# - "admin@example.com"
|
||||||
# - "user@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
|
- WEBAUTHN_RP_ID=localhost
|
||||||
- DATABASE_PATH=/data/passkey-auth.db
|
- DATABASE_PATH=/data/passkey-auth.db
|
||||||
- SESSION_SECRET=dev-secret-change-in-production
|
- SESSION_SECRET=dev-secret-change-in-production
|
||||||
|
- ADMIN_EMAIL=admin@example.com
|
||||||
|
- DEFAULT_EMAIL=admin@example.com
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./config.yaml:/root/config.yaml
|
- ./config.yaml:/app/config.yaml
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
module passkey-auth
|
module passkey-auth
|
||||||
|
|
||||||
go 1.21
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/glebarez/go-sqlite v1.22.0
|
||||||
github.com/go-webauthn/webauthn v0.10.2
|
github.com/go-webauthn/webauthn v0.10.2
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/sessions v1.2.2
|
github.com/gorilla/sessions v1.2.2
|
||||||
github.com/mattn/go-sqlite3 v1.14.18
|
|
||||||
github.com/rs/cors v1.11.0
|
github.com/rs/cors v1.11.0
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
|
||||||
github.com/go-webauthn/x v0.1.9 // 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/go-tpm v0.9.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // 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/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
golang.org/x/crypto v0.21.0 // indirect
|
golang.org/x/crypto v0.35.0 // indirect
|
||||||
golang.org/x/sys v0.18.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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
|
||||||
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
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 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
|
||||||
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
|
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 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
|
||||||
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
|
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.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
|
||||||
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
|
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 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 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/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 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
|
||||||
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
|
||||||
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
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/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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
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.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
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.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
@@ -39,6 +40,9 @@ type AuthConfig struct {
|
|||||||
SessionSecret string `yaml:"session_secret"`
|
SessionSecret string `yaml:"session_secret"`
|
||||||
RequireApproval bool `yaml:"require_approval"`
|
RequireApproval bool `yaml:"require_approval"`
|
||||||
AllowedEmails []string `yaml:"allowed_emails"`
|
AllowedEmails []string `yaml:"allowed_emails"`
|
||||||
|
AdminEmail string `yaml:"admin_email"`
|
||||||
|
CookieDomain string `yaml:"cookie_domain"`
|
||||||
|
DefaultEmail string `yaml:"default_email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -47,6 +51,11 @@ func Load() (*Config, error) {
|
|||||||
configPath = "config.yaml"
|
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
|
// Set defaults
|
||||||
config := &Config{
|
config := &Config{
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
@@ -68,12 +77,14 @@ func Load() (*Config, error) {
|
|||||||
SessionSecret: "change-me-in-production",
|
SessionSecret: "change-me-in-production",
|
||||||
RequireApproval: true,
|
RequireApproval: true,
|
||||||
AllowedEmails: []string{}, // Empty means no email restrictions
|
AllowedEmails: []string{}, // Empty means no email restrictions
|
||||||
|
CookieDomain: "", // Empty means no domain restriction (current domain only)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load from file if it exists
|
// Load from file if it exists
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -106,6 +117,15 @@ func Load() (*Config, error) {
|
|||||||
config.Auth.AllowedEmails[i] = strings.TrimSpace(email)
|
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
|
return config, nil
|
||||||
}
|
}
|
||||||
@@ -127,3 +147,44 @@ func (c *Config) IsEmailAllowed(email string) bool {
|
|||||||
|
|
||||||
return false
|
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"
|
"database/sql"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/glebarez/go-sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
@@ -31,7 +31,7 @@ type Credential struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(dbPath string) (*DB, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -98,6 +98,23 @@ func (db *DB) CreateUser(email, displayName string) (*User, error) {
|
|||||||
return db.GetUser(int(id))
|
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) {
|
func (db *DB) GetUser(id int) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
err := db.conn.QueryRow(
|
err := db.conn.QueryRow(
|
||||||
|
|||||||
+207
-40
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -34,6 +35,7 @@ func New(db *database.DB, webAuthn *auth.WebAuthn, config *config.Config) *Handl
|
|||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: false, // Set to true in production with HTTPS
|
Secure: false, // Set to true in production with HTTPS
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Domain: config.Auth.CookieDomain, // Share cookies across subdomains if configured
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Handlers{
|
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) {
|
func (h *Handlers) writeError(w http.ResponseWriter, message string, code int) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(code)
|
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{}) {
|
func (h *Handlers) writeJSON(w http.ResponseWriter, data interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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
|
// BeginRegistration starts the passkey registration process
|
||||||
@@ -85,16 +94,16 @@ func (h *Handlers) BeginRegistration(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new user
|
// Create a temporary WebAuthn user for registration without saving to DB yet
|
||||||
user, err := h.db.CreateUser(req.Email, req.DisplayName)
|
isAdmin := h.config.IsAdmin(req.Email)
|
||||||
if err != nil {
|
tempUser := &database.User{
|
||||||
logrus.Errorf("Failed to create user: %v", err)
|
Email: req.Email,
|
||||||
h.writeError(w, "Failed to create user", http.StatusInternalServerError)
|
DisplayName: req.DisplayName,
|
||||||
return
|
Approved: isAdmin, // Auto-approve admins
|
||||||
}
|
}
|
||||||
|
|
||||||
webAuthnUser := &auth.WebAuthnUser{}
|
webAuthnUser := &auth.WebAuthnUser{}
|
||||||
webAuthnUser.SetUser(user)
|
webAuthnUser.SetUser(tempUser)
|
||||||
|
|
||||||
options, sessionData, err := h.webAuthn.BeginRegistration(webAuthnUser)
|
options, sessionData, err := h.webAuthn.BeginRegistration(webAuthnUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,11 +112,16 @@ func (h *Handlers) BeginRegistration(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store session data
|
// Store session data with user details for later creation
|
||||||
session, _ := h.store.Get(r, "webauthn-session")
|
session, _ := h.store.Get(r, "webauthn-session")
|
||||||
session.Values["challenge"] = sessionData.Challenge
|
session.Values["challenge"] = sessionData.Challenge
|
||||||
session.Values["user_id"] = user.ID
|
session.Values["pending_email"] = req.Email
|
||||||
session.Save(r, w)
|
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
|
// Debug: log the options structure
|
||||||
logrus.Debugf("WebAuthn options: %+v", options)
|
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) {
|
func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
||||||
session, _ := h.store.Get(r, "webauthn-session")
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
challenge, ok := session.Values["challenge"].(string)
|
challenge, ok := session.Values["challenge"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.writeError(w, "Invalid session", http.StatusBadRequest)
|
h.writeError(w, "Invalid session - missing challenge", http.StatusBadRequest)
|
||||||
return
|
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
|
// Create a new reader from the body for the WebAuthn library
|
||||||
r.Body = io.NopCloser(strings.NewReader(string(body)))
|
r.Body = io.NopCloser(strings.NewReader(string(body)))
|
||||||
|
|
||||||
user, err := h.db.GetUser(userID)
|
// Create temporary user for WebAuthn verification
|
||||||
if err != nil {
|
tempUser := &database.User{
|
||||||
h.writeError(w, "User not found", http.StatusNotFound)
|
Email: pendingEmail,
|
||||||
return
|
DisplayName: pendingDisplayName,
|
||||||
|
Approved: pendingIsAdmin,
|
||||||
}
|
}
|
||||||
|
|
||||||
webAuthnUser := &auth.WebAuthnUser{}
|
webAuthnUser := &auth.WebAuthnUser{}
|
||||||
webAuthnUser.SetUser(user)
|
webAuthnUser.SetUser(tempUser)
|
||||||
|
|
||||||
sessionData := webauthn.SessionData{
|
sessionData := webauthn.SessionData{
|
||||||
Challenge: challenge,
|
Challenge: challenge,
|
||||||
@@ -161,7 +189,7 @@ func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log the request details for debugging
|
// 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 challenge: %s", challenge)
|
||||||
logrus.Debugf("Session user ID: %v", webAuthnUser.WebAuthnID())
|
logrus.Debugf("Session user ID: %v", webAuthnUser.WebAuthnID())
|
||||||
|
|
||||||
@@ -172,17 +200,52 @@ func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// Save credential to database
|
||||||
if err := h.webAuthn.SaveCredential(user.ID, credential); err != nil {
|
if err := h.webAuthn.SaveCredential(user.ID, credential); err != nil {
|
||||||
logrus.Errorf("Failed to save credential: %v", err)
|
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)
|
h.writeError(w, "Failed to save credential", http.StatusInternalServerError)
|
||||||
return
|
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["challenge"] = nil
|
||||||
session.Values["user_id"] = nil
|
session.Values["pending_email"] = nil
|
||||||
session.Save(r, w)
|
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"})
|
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, _ := h.store.Get(r, "webauthn-session")
|
||||||
session.Values["challenge"] = sessionData.Challenge
|
session.Values["challenge"] = sessionData.Challenge
|
||||||
session.Values["user_id"] = webAuthnUser.GetUser().ID
|
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)
|
h.writeJSON(w, options)
|
||||||
}
|
}
|
||||||
@@ -288,12 +354,18 @@ func (h *Handlers) FinishLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
authSession.Values["authenticated"] = true
|
authSession.Values["authenticated"] = true
|
||||||
authSession.Values["user_id"] = user.ID
|
authSession.Values["user_id"] = user.ID
|
||||||
authSession.Values["user_email"] = user.Email
|
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
|
// Clear webauthn session
|
||||||
session.Values["challenge"] = nil
|
session.Values["challenge"] = nil
|
||||||
session.Values["user_id"] = 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{}{
|
h.writeJSON(w, map[string]interface{}{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -312,17 +384,34 @@ func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
session.Values["user_id"] = nil
|
session.Values["user_id"] = nil
|
||||||
session.Values["user_email"] = nil
|
session.Values["user_email"] = nil
|
||||||
session.Options.MaxAge = -1
|
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"})
|
h.writeJSON(w, map[string]string{"status": "success"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthCheck implements the nginx auth_request protocol
|
// AuthCheck implements the nginx auth_request protocol
|
||||||
func (h *Handlers) AuthCheck(w http.ResponseWriter, r *http.Request) {
|
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)
|
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 {
|
if !ok || !authenticated {
|
||||||
|
logrus.Debugf("User not authenticated, returning 401")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -335,14 +424,67 @@ func (h *Handlers) AuthCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("X-Auth-User", userEmail)
|
w.Header().Set("X-Auth-User", userEmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logrus.Debugf("User authenticated, returning 200")
|
||||||
w.WriteHeader(http.StatusOK)
|
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
|
// Admin endpoints
|
||||||
|
|
||||||
// ListUsers returns all users (admin endpoint)
|
// ListUsers returns all users (admin endpoint)
|
||||||
func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) {
|
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()
|
users, err := h.db.ListUsers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorf("Failed to list users: %v", err)
|
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)
|
// CreateUser creates a new user (admin endpoint)
|
||||||
func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Add admin authentication check
|
if !h.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
@@ -373,7 +518,7 @@ func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 err != nil {
|
||||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||||
h.writeError(w, "User already exists", http.StatusConflict)
|
h.writeError(w, "User already exists", http.StatusConflict)
|
||||||
@@ -384,18 +529,15 @@ func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
h.writeJSON(w, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUser updates a user (admin endpoint)
|
// UpdateUser updates a user (admin endpoint)
|
||||||
func (h *Handlers) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
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)
|
vars := mux.Vars(r)
|
||||||
idStr, ok := vars["id"]
|
idStr, ok := vars["id"]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -441,7 +583,10 @@ func (h *Handlers) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// DeleteUser deletes a user (admin endpoint)
|
// DeleteUser deletes a user (admin endpoint)
|
||||||
func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
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)
|
vars := mux.Vars(r)
|
||||||
idStr, ok := vars["id"]
|
idStr, ok := vars["id"]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -463,3 +608,25 @@ func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
h.writeJSON(w, map[string]string{"status": "success"})
|
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"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/rs/cors"
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"passkey-auth/internal/auth"
|
"passkey-auth/internal/auth"
|
||||||
"passkey-auth/internal/config"
|
"passkey-auth/internal/config"
|
||||||
|
"passkey-auth/internal/cors"
|
||||||
"passkey-auth/internal/database"
|
"passkey-auth/internal/database"
|
||||||
"passkey-auth/internal/handlers"
|
"passkey-auth/internal/handlers"
|
||||||
)
|
)
|
||||||
@@ -48,11 +49,13 @@ func main() {
|
|||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
api := router.PathPrefix("/api").Subrouter()
|
api := router.PathPrefix("/api").Subrouter()
|
||||||
|
api.HandleFunc("/config", h.GetConfig).Methods("GET")
|
||||||
api.HandleFunc("/register/begin", h.BeginRegistration).Methods("POST")
|
api.HandleFunc("/register/begin", h.BeginRegistration).Methods("POST")
|
||||||
api.HandleFunc("/register/finish", h.FinishRegistration).Methods("POST")
|
api.HandleFunc("/register/finish", h.FinishRegistration).Methods("POST")
|
||||||
api.HandleFunc("/login/begin", h.BeginLogin).Methods("POST")
|
api.HandleFunc("/login/begin", h.BeginLogin).Methods("POST")
|
||||||
api.HandleFunc("/login/finish", h.FinishLogin).Methods("POST")
|
api.HandleFunc("/login/finish", h.FinishLogin).Methods("POST")
|
||||||
api.HandleFunc("/logout", h.Logout).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.ListUsers).Methods("GET")
|
||||||
api.HandleFunc("/users", h.CreateUser).Methods("POST")
|
api.HandleFunc("/users", h.CreateUser).Methods("POST")
|
||||||
api.HandleFunc("/users/{id}", h.UpdateUser).Methods("PUT")
|
api.HandleFunc("/users/{id}", h.UpdateUser).Methods("PUT")
|
||||||
@@ -64,21 +67,23 @@ func main() {
|
|||||||
// Health check
|
// Health check
|
||||||
router.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
router.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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")
|
}).Methods("GET")
|
||||||
|
|
||||||
// Static files for admin UI
|
// Static files for admin UI
|
||||||
router.PathPrefix("/").Handler(http.FileServer(http.Dir("./web/"))).Methods("GET")
|
router.PathPrefix("/").Handler(http.FileServer(http.Dir("./web/"))).Methods("GET")
|
||||||
|
|
||||||
// Setup CORS
|
// Setup CORS with wildcard support
|
||||||
c := cors.New(cors.Options{
|
corsHandler := cors.WildcardCORS(cors.Config{
|
||||||
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"*"},
|
AllowedHeaders: []string{"*"},
|
||||||
AllowCredentials: true,
|
AllowCredentials: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
handler := c.Handler(router)
|
handler := corsHandler(router)
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
@@ -86,8 +91,17 @@ func main() {
|
|||||||
port = "8080"
|
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)
|
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)
|
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) {
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
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)
|
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"
|
||||||
+91
-650
@@ -3,699 +3,140 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Passkey Auth - Admin</title>
|
<title>Passkey Auth</title>
|
||||||
<style>
|
<style>
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 2rem;
|
||||||
background: white;
|
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;
|
border-radius: 8px;
|
||||||
font-size: 1rem;
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
transition: border-color 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
.spinner {
|
||||||
input[type="text"]:focus, input[type="email"]:focus {
|
width: 40px;
|
||||||
outline: none;
|
height: 40px;
|
||||||
border-color: #667eea;
|
border: 4px solid #f3f3f3;
|
||||||
|
border-top: 4px solid #007bff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
}
|
}
|
||||||
|
@keyframes spin {
|
||||||
.btn {
|
0% { transform: rotate(0deg); }
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
100% { transform: rotate(360deg); }
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.75rem 2rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-list {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info h3 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info p {
|
|
||||||
color: #666;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-approved {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pending {
|
|
||||||
background: #fff3cd;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
border: 1px solid #c3e6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="spinner"></div>
|
||||||
<h1>🔐 Passkey Auth</h1>
|
<h2>🔐 Passkey Auth</h2>
|
||||||
<p>Admin Dashboard</p>
|
<p id="status">Checking authentication status...</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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Base64/Base64URL decoding functions for WebAuthn
|
function updateStatus(message) {
|
||||||
function base64ToArrayBuffer(base64) {
|
document.getElementById('status').textContent = message;
|
||||||
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 getRedirectUrl() {
|
||||||
function base64urlToArrayBuffer(base64url) {
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
return base64ToArrayBuffer(base64url);
|
// 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');
|
||||||
|
|
||||||
function arrayBufferToBase64url(buffer) {
|
console.log('URL params:', {
|
||||||
const bytes = new Uint8Array(buffer);
|
redirect: redirectParam,
|
||||||
let binary = '';
|
rd: rdParam,
|
||||||
for (let i = 0; i < bytes.byteLength; i++) {
|
search: window.location.search
|
||||||
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
|
return redirectParam || rdParam;
|
||||||
document.querySelectorAll('.tab').forEach(tab => {
|
}
|
||||||
tab.classList.remove('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show selected tab content
|
function redirectToTarget(url) {
|
||||||
document.getElementById(tabName).classList.add('active');
|
try {
|
||||||
|
const decodedUrl = decodeURIComponent(url);
|
||||||
// Add active class to clicked tab
|
updateStatus(`Redirecting to ${new URL(decodedUrl).hostname}...`);
|
||||||
event.target.classList.add('active');
|
console.log('Redirecting to:', decodedUrl);
|
||||||
|
// Small delay to show the message
|
||||||
// Load users when users tab is selected
|
setTimeout(() => {
|
||||||
if (tabName === 'users') {
|
window.location.href = decodedUrl;
|
||||||
loadUsers();
|
}, 1000);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing redirect URL:', error);
|
||||||
|
updateStatus('Invalid redirect URL');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alert functions
|
function redirectToLogin() {
|
||||||
function showAlert(message, type = 'success') {
|
const redirectUrl = getRedirectUrl();
|
||||||
const alertsContainer = document.getElementById('alerts');
|
if (redirectUrl) {
|
||||||
const alert = document.createElement('div');
|
// Preserve the redirect parameter when going to login
|
||||||
alert.className = `alert alert-${type}`;
|
const loginUrl = `/login.html?redirect=${encodeURIComponent(redirectUrl)}`;
|
||||||
alert.textContent = message;
|
updateStatus('Redirecting to login...');
|
||||||
alertsContainer.appendChild(alert);
|
setTimeout(() => {
|
||||||
|
window.location.href = loginUrl;
|
||||||
setTimeout(() => {
|
}, 1000);
|
||||||
alert.remove();
|
} else {
|
||||||
}, 5000);
|
// No redirect parameter, just go to login
|
||||||
} // Register functionality
|
window.location.href = '/login.html';
|
||||||
document.getElementById('registerForm').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const formData = new FormData(e.target);
|
|
||||||
const email = formData.get('email');
|
|
||||||
const displayName = formData.get('displayName');
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Starting registration for email:', email);
|
|
||||||
|
|
||||||
// Begin registration
|
|
||||||
const beginResponse = await fetch('/api/register/begin', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: email,
|
|
||||||
display_name: displayName
|
|
||||||
}),
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!beginResponse.ok) {
|
|
||||||
throw new Error(await beginResponse.text());
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = await beginResponse.json();
|
|
||||||
console.log('Raw options from server:', JSON.stringify(options, null, 2));
|
|
||||||
|
|
||||||
// Convert base64url encoded fields to ArrayBuffers
|
|
||||||
const webAuthnOptions = prepareWebAuthnOptions(options);
|
|
||||||
console.log('Final webAuthnOptions for navigator.credentials.create:', webAuthnOptions);
|
|
||||||
|
|
||||||
// Create credential
|
|
||||||
const credential = await navigator.credentials.create(webAuthnOptions);
|
|
||||||
console.log('Raw credential from navigator.credentials.create:', credential);
|
|
||||||
|
|
||||||
// Convert ArrayBuffers back to base64url for JSON
|
|
||||||
const credentialResponse = prepareWebAuthnResponse(credential);
|
|
||||||
console.log('Prepared credential response to send to server:', JSON.stringify(credentialResponse, null, 2));
|
|
||||||
|
|
||||||
// Finish registration
|
|
||||||
const finishResponse = await fetch('/api/register/finish', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(credentialResponse),
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Finish registration response status:', finishResponse.status);
|
|
||||||
if (!finishResponse.ok) {
|
|
||||||
const errorText = await finishResponse.text();
|
|
||||||
console.error('Finish registration error:', errorText);
|
|
||||||
throw new Error(errorText);
|
|
||||||
}
|
|
||||||
|
|
||||||
showAlert('User registered successfully!');
|
|
||||||
e.target.reset();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert(`Registration failed: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
}); // Login functionality
|
|
||||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const formData = new FormData(e.target);
|
|
||||||
const email = formData.get('email');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Begin login
|
|
||||||
const beginResponse = await fetch('/api/login/begin', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: email
|
|
||||||
}),
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!beginResponse.ok) {
|
|
||||||
throw new Error(await beginResponse.text());
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = await beginResponse.json();
|
|
||||||
console.log('Raw login options from server:', JSON.stringify(options, null, 2));
|
|
||||||
|
|
||||||
// Convert base64url encoded fields to ArrayBuffers
|
|
||||||
const webAuthnOptions = prepareWebAuthnOptions(options);
|
|
||||||
console.log('Final login webAuthnOptions for navigator.credentials.get:', webAuthnOptions);
|
|
||||||
|
|
||||||
// Get credential
|
|
||||||
const credential = await navigator.credentials.get(webAuthnOptions);
|
|
||||||
|
|
||||||
// Convert ArrayBuffers back to base64url for JSON
|
|
||||||
const credentialResponse = prepareWebAuthnResponse(credential);
|
|
||||||
|
|
||||||
// Finish login
|
|
||||||
const finishResponse = await fetch('/api/login/finish', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(credentialResponse),
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!finishResponse.ok) {
|
|
||||||
throw new Error(await finishResponse.text());
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await finishResponse.json();
|
|
||||||
showAlert(`Login successful! Welcome, ${result.user.display_name}`);
|
|
||||||
|
|
||||||
document.getElementById('loginStatus').innerHTML = `
|
|
||||||
<div class="alert alert-success">
|
|
||||||
<strong>Logged in as:</strong> ${result.user.display_name} (${result.user.email})
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} catch (error) {
|
|
||||||
showAlert(`Login failed: ${error.message}`, 'error');
|
|
||||||
document.getElementById('loginStatus').innerHTML = `
|
|
||||||
<div class="alert alert-error">
|
|
||||||
Login failed: ${error.message}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Users management
|
|
||||||
async function loadUsers() {
|
|
||||||
const usersList = document.getElementById('usersList');
|
|
||||||
usersList.innerHTML = '<div class="loading">Loading users...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/users');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to load users');
|
|
||||||
}
|
|
||||||
|
|
||||||
const users = await response.json();
|
|
||||||
|
|
||||||
if (users.length === 0) {
|
|
||||||
usersList.innerHTML = '<p>No users found.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
usersList.innerHTML = users.map(user => `
|
|
||||||
<div class="user-item">
|
|
||||||
<div class="user-info">
|
|
||||||
<h3>${user.display_name}</h3>
|
|
||||||
<p>${user.email} • Created: ${new Date(user.created_at).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
<div class="user-actions">
|
|
||||||
<span class="status-badge ${user.approved ? 'status-approved' : 'status-pending'}">
|
|
||||||
${user.approved ? 'Approved' : 'Pending'}
|
|
||||||
</span>
|
|
||||||
${!user.approved ? `<button class="btn" onclick="approveUser(${user.id})">Approve</button>` : ''}
|
|
||||||
<button class="btn btn-danger" onclick="deleteUser(${user.id})">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} catch (error) {
|
|
||||||
usersList.innerHTML = `<div class="alert alert-error">Failed to load users: ${error.message}</div>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function approveUser(userId) {
|
async function checkAuthAndRedirect() {
|
||||||
if (!confirm('Are you sure you want to approve this user?')) {
|
const redirectUrl = getRedirectUrl();
|
||||||
|
|
||||||
|
if (!redirectUrl) {
|
||||||
|
updateStatus('No redirect URL provided');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/login.html';
|
||||||
|
}, 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/users/${userId}`, {
|
updateStatus('Checking authentication...');
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
const response = await fetch('/api/auth/status', {
|
||||||
'Content-Type': 'application/json',
|
credentials: 'include'
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
approved: true
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (response.ok) {
|
||||||
throw new Error('Failed to approve user');
|
const userData = await response.json();
|
||||||
|
if (userData.authenticated) {
|
||||||
|
updateStatus(`Welcome back, ${userData.user.display_name}!`);
|
||||||
|
redirectToTarget(redirectUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showAlert('User approved successfully', 'success');
|
// Not authenticated, redirect to login with the target URL
|
||||||
loadUsers(); // Refresh the users list
|
updateStatus('Authentication required...');
|
||||||
|
redirectToLogin();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlert(`Failed to approve user: ${error.message}`, 'error');
|
console.error('Auth check failed:', error);
|
||||||
|
updateStatus('Authentication check failed...');
|
||||||
|
redirectToLogin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser(userId) {
|
// Start the process when page loads
|
||||||
if (!confirm('Are you sure you want to delete this user?')) {
|
document.addEventListener('DOMContentLoaded', checkAuthAndRedirect);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/users/${userId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to delete user');
|
|
||||||
}
|
|
||||||
|
|
||||||
showAlert('User deleted successfully!');
|
|
||||||
loadUsers();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert(`Failed to delete user: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load users when page loads
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
loadUsers();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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