Merge branch 'main' into dependabot/go_modules/github.com/rs/cors-1.11.0

This commit is contained in:
2025-08-06 23:21:34 +10:00
59 changed files with 3602 additions and 2317 deletions
-15
View File
@@ -12,18 +12,3 @@ A clear and concise description of what the problem is. Ex. I'm always frustrate
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Use case**
Describe the specific use case this feature would address.
**Implementation considerations**
- Would this be backwards compatible?
- Any security implications?
- Database schema changes needed?
- Configuration changes required?
**Additional context**
Add any other context, mockups, or examples about the feature request here.
+11
View File
@@ -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
-28
View File
@@ -8,31 +8,3 @@ Brief description of what this PR does.
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring
## Testing
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have tested the changes manually
## Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have updated the CHANGELOG.md if needed
## Security Considerations
- [ ] This change does not introduce any security vulnerabilities
- [ ] I have considered the security implications of this change
- [ ] Authentication/authorization is properly handled
## Breaking Changes
If this PR introduces breaking changes, please describe them here and update the major version.
## Screenshots/Demos
If applicable, add screenshots or demo videos.
## Related Issues
Closes #(issue_number)
Related to #(issue_number)
+26 -24
View File
@@ -3,6 +3,8 @@ name: CI/CD
on:
push:
branches: [ main, develop ]
tags:
- 'v*.*.*'
pull_request:
branches: [ main ]
release:
@@ -32,11 +34,6 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y gcc libsqlite3-dev
- name: Download dependencies
run: go mod download
@@ -55,17 +52,16 @@ jobs:
- name: Build binary
run: |
CGO_ENABLED=1 go build -v -o passkey-auth .
CGO_ENABLED=0 go build -v -o passkey-auth .
- name: Test binary
run: |
./passkey-auth --help || true # Some apps don't have --help
echo "Binary built successfully"
docker:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' || github.event_name == 'release'
if: github.event_name == 'push' || github.event_name == 'release' || startsWith(github.ref, 'refs/tags/v')
permissions:
contents: read
packages: write
steps:
- name: Checkout code
@@ -74,37 +70,48 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name == 'release'
- name: Log in to GitHub Container Registry
if: github.event_name == 'push' || github.event_name == 'release' || startsWith(github.ref, 'refs/tags/v')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
passkey-auth
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-
type=sha,prefix={{branch}}-,enable=${{ github.ref_type == 'branch' }}
type=sha,enable=${{ github.ref_type == 'tag' }}
type=raw,value=latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.title=Passkey Auth
org.opencontainers.image.description=WebAuthn/Passkey authentication server with admin approval workflow
org.opencontainers.image.licenses=Apache-2.0
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'release' }}
push: ${{ github.event_name == 'push' || github.event_name == 'release' || startsWith(github.ref, 'refs/tags/v') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILDKIT_INLINE_CACHE=1
GOCACHE=/root/.cache/go-build
GOMODCACHE=/go/pkg/mod
lint:
runs-on: ubuntu-latest
@@ -117,11 +124,6 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y gcc libsqlite3-dev
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v3
with:
@@ -135,6 +137,6 @@ jobs:
uses: actions/checkout@v4
- name: Run Gosec Security Scanner
uses: securecodewarrior/github-action-gosec@master
uses: securego/gosec@master
with:
args: './...'
+100
View File
@@ -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
View File
@@ -5,7 +5,7 @@
*.so
*.dylib
bin/
passkey-auth
./passkey-auth
# Test binary, built with `go test -c`
*.test
@@ -56,3 +56,6 @@ k8s/*-secret.yaml
# Development files
dev-*
web/test.html
*.log
-70
View File
@@ -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
View File
@@ -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
View File
@@ -1,30 +1,38 @@
# Build stage
FROM golang:1.21-bullseye AS builder
FROM golang:1.23-alpine AS builder
# Install build dependencies including sqlite
RUN apt-get update && apt-get install -y gcc libsqlite3-dev && rm -rf /var/lib/apt/lists/*
# Install git for Go modules
RUN apk add --no-cache git
WORKDIR /app
# Copy go mod files
# Copy go mod files first for better layer caching
COPY go.mod go.sum ./
# Download dependencies
# Download dependencies (this will be cached if go.mod/go.sum don't change)
RUN go mod download
# Copy source code
COPY . .
# Build the application (remove static linking for SQLite compatibility)
RUN CGO_ENABLED=1 GOOS=linux go build -a -o passkey-auth .
# Build the application with optimized flags and build cache
# Remove -a flag to avoid rebuilding standard library
# Remove unnecessary static linking flags since CGO is disabled
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o passkey-auth .
# Final stage
FROM debian:bullseye-slim
FROM alpine:latest
# Install runtime dependencies
RUN apt-get update && apt-get install -y ca-certificates libsqlite3-0 && rm -rf /var/lib/apt/lists/*
# Install ca-certificates for HTTPS (no sqlite library needed)
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Create a non-root user with UID 1000
RUN adduser -D -u 1000 -g 1000 -s /bin/sh appuser
# Set working directory
WORKDIR /app
# Copy the binary from builder stage
COPY --from=builder /app/passkey-auth .
@@ -35,8 +43,11 @@ COPY --from=builder /app/web ./web/
# Copy default config
COPY --from=builder /app/config.yaml .
# Create directory for database
RUN mkdir -p /data
# Create directory for database and set ownership
RUN mkdir -p /data && chown -R appuser:appuser /app /data
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 8080
-148
View File
@@ -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! 🚀
-122
View File
@@ -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.
+198 -17
View File
@@ -1,21 +1,202 @@
MIT License
Copyright (c) 2025 Passkey Auth Contributors
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Definitions.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 [Junwei Zhao]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+3 -3
View File
@@ -58,9 +58,9 @@ fmt: ## Format code
@echo "🎨 Formatting code..."
@go fmt ./...
lint: ## Run linter
@echo "🔍 Running linter..."
@golangci-lint run
lint: ## Run linter with auto-fix (excluding test files)
@echo "🔍 Running linter with auto-fix (excluding test files)..."
@golangci-lint run --fix --tests=false
security: ## Run security scan
@echo "🔒 Running security scan..."
-287
View File
@@ -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.
+56 -336
View File
@@ -1,94 +1,67 @@
# 🔐 Passkey Auth for Kubernetes Nginx Ingress
A WebAuthn-based passkey authentication provider that integrates seamlessly with Kubernetes nginx ingress controllers. This service provides secure, passwordless authentication using passkeys (FIDO2/WebAuthn) and acts as an auth backend for nginx ingress.
A WebAuthn-based passkey authentication provider that integrates ingress controllers, currently support Kubernetes nginx Ingress controller. Provides secure, passwordless authentication using passkeys (FIDO2/WebAuthn) as an auth backend for nginx ingress.
## TLDR;
Log in Apps without typing password or going through 3rd Party Oauth!
I use it for signing into my home lab apps.
## 🎬 Demo
<a href="https://giphy.com/gifs/vBeSrnuYUl3u1AQho6">
<img src="passkey-auth-screenshot.png" alt="Click to play demo - Passkey Auth Interface" width="200">
</a>
## ✨ Features
- **Pa2. **"User not found" during login**:
- Ensure user is registered and approved (if required)
- Check that email address matches exactly
- **Passwordless Authentication**: Uses WebAuthn/FIDO2 passkeys for secure authentication
- **Nginx Ingress Integration**: Works as auth backend using nginx `auth_request` directive
- **User Management**: An simple Admin interface for managing users and approval status
- **Kubernetes Native**: Designed for Kubernetes deployment with persistent storage
3. **WebAuthn errors**:dless Authentication**: Uses WebAuthn/FIDO2 passkeys for secure authentication
- **Email-Based Access Control**: Users are identified by email addresses with configurable allowlists
- **Nginx Ingress Integration**: Works as an auth backend using nginx `auth_request` directive
- **User Management**: Admin interface for managing users and their approval status
- **Access Control**: Configure allowed email addresses and approval requirements
- **Kubernetes Native**: Designed specifically for Kubernetes deployment
- **Persistent Storage**: Uses SQLite with persistent volumes for data storage
- **Modern UI**: Clean, responsive web interface for user registration and management
## Security Benefits
## 🏗️ Architecture
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ User Browser │ │ Nginx Ingress │ │ Your App │
│ │ │ │ │ │
│ 1. Request │───▶│ 2. Auth Check │───▶│ 4. Serve App │
│ 4. Redirect │◀───│ 3. 401/302 │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ auth_request
┌──────────────────┐
│ Passkey Auth │
│ │
│ - WebAuthn │
│ - User Mgmt │
│ - Session Mgmt │
└──────────────────┘
```
- **No passwords stored** - Only WebAuthn public keys
- **Email-based access control** - Restrict registration to specific domains/emails
- **Phishing resistant** - WebAuthn is tied to the domain
- **MFA built-in** - Passkeys require user presence and verification
- **Session security** - Secure cookie-based sessions
## 🚀 Quick Start
### Prerequisites
- Kubernetes cluster with nginx ingress controller
- Docker
- kubectl configured to access your cluster
### 1. Clone and Build
### Using Helm Chart (Recommended)
```bash
git clone <repository-url>
cd passkey-auth
# Add the Helm repository
helm repo add passkey-auth https://wahyd4.github.io/passkey-auth
helm repo update
# Build the Docker image
./scripts/build.sh
# Install with your values
helm upgrade --install my-passkey-auth -n home-apps -f my-values.yaml passkey-auth/passkey-auth
```
### 2. Configure
See the [Helm Chart README](helm/passkey-auth/README.md) for detailed configuration options.
Edit `k8s/deployment.yaml` to update:
```yaml
# Update these values in the ConfigMap
webauthn:
rp_id: "your-domain.com" # Your domain
rp_origins:
- "https://your-domain.com" # Your domain with protocol
cors:
allowed_origins:
- "https://your-domain.com" # Your domain with protocol
auth:
session_secret: "your-secure-secret-key" # Generate a secure random string
```
### 3. Deploy to Kubernetes
### Test with Docker
```bash
# Deploy the passkey auth service
./scripts/deploy.sh
# Verify deployment
kubectl get pods -n passkey-auth
kubectl logs -f deployment/passkey-auth -n passkey-auth
docker run --name passkey-auth -d -p 8080:8080 -e ADMIN_EMAIL="admin@example.com" ghcr.io/wahyd4/passkey-auth:main
```
### 4. Configure Your App's Ingress
### Local Development
Update your application's ingress to use passkey auth:
```bash
# Install dependencies and run locally
go mod download
go run main.go
# Access at http://localhost:8080
```
### Setup Your App's Ingress
```yaml
apiVersion: networking.k8s.io/v1
@@ -96,14 +69,9 @@ kind: Ingress
metadata:
name: your-app-ingress
annotations:
# Auth backend - points to passkey auth service
nginx.ingress.kubernetes.io/auth-url: "http://passkey-auth-service.passkey-auth.svc.cluster.local/auth"
# Redirect unauthorized users to login page
nginx.ingress.kubernetes.io/auth-signin: "https://your-domain.com/auth"
# Pass user info to your app
nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-User-ID"
nginx.ingress.kubernetes.io/auth-url: "https://your-passkey-auth.com/auth"
nginx.ingress.kubernetes.io/auth-signin: "https://your-passkey-auth.com/?redirect=https%3A%2F%2F$host$request_uri"
spec:
rules:
- host: your-app.com
@@ -118,129 +86,39 @@ spec:
number: 80
```
### 5. Create Ingress for Passkey Auth
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: passkey-auth-ingress
namespace: passkey-auth
spec:
rules:
- host: your-domain.com
http:
paths:
- path: /auth
pathType: Prefix
backend:
service:
name: passkey-auth-service
port:
number: 80
```
Apply the ingress:
```bash
kubectl apply -f k8s/ingress-example.yaml
```
## 👥 User Management
### Access the Admin Interface
1. Navigate to `https://your-domain.com/auth` in your browser
2. You'll see the admin dashboard with three tabs:
Navigate to `https:///your-passkey-auth.com` to access the admin interface with three tabs:
- **Register User**: Register new users with passkeys
- **Test Login**: Test authentication
- **Manage Users**: View and manage all users
- **Manage Users**: View and manage all users with `ADMIN_USER` email address
### User Registration Flow
1. **Register**: Admin enters email address and display name
2. **Email Validation**: System checks if email is in the allowlist (if configured)
3. **Passkey Creation**: Browser prompts for passkey creation (TouchID, Windows Hello, etc.)
4. **Approval**: If `require_approval` is enabled, admin must approve users manually
5. **Authentication**: Users can now authenticate with their passkeys
### User Approval Process
When `require_approval` is enabled in the configuration:
1. **New users register** but cannot authenticate until approved
2. **Admin reviews pending users** in the "Manage Users" tab
3. **Admin clicks "Approve"** next to pending users
4. **Users can now authenticate** with their passkeys
**To approve a user:**
1. Navigate to the admin interface at `https://your-domain.com/auth`
2. Click the "Manage Users" tab
3. Find users with "Pending" status
4. Click the "Approve" button next to the user
5. Confirm the approval in the dialog
**User Status Indicators:**
- 🟢 **Approved**: User can authenticate
- 🟡 **Pending**: User registered but needs approval
### Configuration Options
In `config.yaml` or environment variables:
### Configuration
```yaml
# config.yaml
auth:
require_approval: true # Require admin approval for new users
session_secret: "secret" # Session encryption key
allowed_emails: # Email allowlist (empty = allow all)
- "admin@company.com"
- "user@company.com"
```
Environment variable overrides:
- `WEBAUTHN_RP_ID`: WebAuthn Relying Party ID (your domain)
- `SESSION_SECRET`: Session encryption secret
- `DATABASE_PATH`: SQLite database file path
- `PORT`: Server port (default: 8080)
- `ALLOWED_EMAILS`: Comma-separated list of allowed emails
Check [config.example.yaml](./config.example.yaml) for more details
## 🔧 Development
### Local Development
1. **Install Go dependencies**:
```bash
# Install dependencies and run locally
go mod download
```
2. **Run locally**:
```bash
# Update config.yaml for local development
go run main.go
# Access at http://localhost:8080
```
3. **Access the interface**:
```
http://localhost:8080
```
### Project Structure
```
passkey-auth/
├── main.go # Application entry point
├── internal/
│ ├── auth/ # WebAuthn implementation
│ ├── config/ # Configuration management
│ ├── database/ # SQLite database layer
│ └── handlers/ # HTTP handlers
├── web/ # Static web files
├── k8s/ # Kubernetes manifests
├── scripts/ # Deployment scripts
└── config.yaml # Configuration file
```
### API Endpoints
### Key API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
@@ -248,169 +126,11 @@ passkey-auth/
| `/api/register/finish` | POST | Complete passkey registration |
| `/api/login/begin` | POST | Start passkey authentication |
| `/api/login/finish` | POST | Complete passkey authentication |
| `/api/logout` | POST | Logout user |
| `/auth` | GET | Nginx auth check endpoint |
| `/api/users` | GET | List all users (admin) |
| `/api/users` | POST | Create new user (admin) |
| `/api/users/{id}` | PUT | Update user (approve/admin) |
| `/api/users/{id}` | DELETE | Delete user (admin) |
| `/api/users` | GET/POST | List/create users |
| `/health` | GET | Health check |
## 🔒 Security Considerations
### Production Deployment
1. **HTTPS Only**: Always use HTTPS in production
2. **Secure Session Secret**: Use a strong, random session secret
3. **Domain Configuration**: Ensure `rp_id` matches your domain exactly
4. **Network Security**: Use Kubernetes network policies to restrict access
5. **Regular Backups**: Backup the SQLite database regularly
### Session Configuration
```yaml
# Secure session configuration for production
auth:
session_secret: "your-256-bit-random-key" # Use a proper secret manager
```
### Database Security
The SQLite database contains:
- User information (email addresses, display names)
- WebAuthn credentials (public keys, metadata)
- No passwords or private keys are stored
## 🔐 Email Access Control
### Allowlist Configuration
You can control who can register by configuring an email allowlist:
```yaml
# config.yaml
auth:
allowed_emails:
- "admin@yourcompany.com"
- "engineering@yourcompany.com"
- "support@yourcompany.com"
```
Or via environment variable:
```bash
export ALLOWED_EMAILS="admin@company.com,user1@company.com,user2@company.com"
```
### Access Control Options
1. **Open Registration** (allowed_emails is empty or not set):
- Any email address can register
- Suitable for internal/trusted environments
2. **Allowlist Mode** (allowed_emails configured):
- Only specified email addresses can register
- Recommended for production environments
3. **Combined with Approval**:
- Users must be in allowlist AND get admin approval
- Maximum security for sensitive applications
## 📊 Monitoring
### Health Checks
The service exposes a health endpoint at `/health`:
```bash
curl http://passkey-auth-service.passkey-auth.svc.cluster.local/health
```
### Logs
View application logs:
```bash
kubectl logs -f deployment/passkey-auth -n passkey-auth
```
### Metrics
For production, consider adding metrics collection:
- Authentication success/failure rates
- User registration rates
- Session duration statistics
## 🐛 Troubleshooting
### Common Issues
1. **Docker build failures with SQLite CGO errors**:
- The Dockerfile uses Debian-based images (golang:1.21-bullseye) instead of Alpine
- This resolves musl vs glibc compatibility issues with go-sqlite3
- If you encounter `pread64` or `pwrite64` errors, ensure you're using a glibc-based image
2. **WebAuthn encoding errors** (challenge not ArrayBuffer):
- The web interface includes base64url conversion functions
- Ensure you're using the included HTML file, not a custom frontend
- Binary WebAuthn data must be converted between base64url and ArrayBuffer
3. **"User not found" during login**:
- Ensure user is registered and approved (if required)
- Check that username matches exactly
4. **WebAuthn errors**:
- Verify `rp_id` matches your domain
- Ensure HTTPS is used (required for WebAuthn)
- Check browser support for WebAuthn
5. **Auth backend not working**:
- Verify ingress annotations are correct
- Check that the auth service is accessible from nginx
- Review nginx ingress controller logs
6. **Session issues**:
- Ensure session secret is consistent
- Check cookie settings (secure flag for HTTPS)
- Verify session storage is persistent
### Debug Commands
```bash
# Check pod status
kubectl get pods -n passkey-auth
# View logs
kubectl logs deployment/passkey-auth -n passkey-auth
# Check service
kubectl get svc -n passkey-auth
# Test auth endpoint
kubectl exec -it deployment/passkey-auth -n passkey-auth -- wget -O- http://localhost:8080/health
# Port forward for local testing
kubectl port-forward svc/passkey-auth-service 8080:80 -n passkey-auth
```
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
## 📄 License
This project is licensed under the MIT License - see the LICENSE file for details.
## 🙏 Acknowledgments
- [go-webauthn](https://github.com/go-webauthn/webauthn) - WebAuthn library for Go
- [Gorilla](https://github.com/gorilla) - HTTP utilities for Go
- WebAuthn/FIDO2 specifications
- Kubernetes and nginx ingress controller teams
---
For support or questions, please create an issue in the repository.
Apache License 2.0
+20
View File
@@ -45,6 +45,14 @@ auth:
# Set to false to allow automatic approval for trusted environments
require_approval: true
# Cookie domain for session cookies
# Leave empty for single domain (cookies only work on current domain)
# Set to ".yourdomain.com" to share cookies across all subdomains
# Examples:
# - "" (empty) - cookies only work on the exact domain
# - ".example.com" - cookies work on example.com and all subdomains
cookie_domain: ""
# Email allowlist - list of email addresses allowed to register
# Leave empty to allow any email address (not recommended for production)
allowed_emails:
@@ -53,6 +61,15 @@ auth:
# - "user1@yourcompany.com"
# - "user2@yourcompany.com"
# Admin email - user with this email will be auto-approved and can access admin panel
# Can also be set via ADMIN_EMAIL environment variable
admin_email: "" # Set this to your admin email address
# Default email to pre-fill in the login form
# When set, this email will be automatically filled in the email input field
# Leave empty to show an empty email field
default_email: "" # Set this to pre-fill the login form with a default email
# Environment-specific overrides can be set via environment variables:
# - PORT: Server port
# - HOST: Server host
@@ -60,3 +77,6 @@ auth:
# - DATABASE_PATH: Database file path
# - SESSION_SECRET: Session encryption secret
# - ALLOWED_EMAILS: Comma-separated list of allowed emails
# - ADMIN_EMAIL: Admin email address
# - COOKIE_DOMAIN: Cookie domain for session cookies
# - DEFAULT_EMAIL: Default email to pre-fill in login form
+2
View File
@@ -22,3 +22,5 @@ auth:
allowed_emails:
# - "admin@example.com"
# - "user@example.com"
# Admin email - user with this email will be auto-approved and can access admin panel
admin_email: "" # Set this to your admin email or use ADMIN_EMAIL environment variable
+89
View File
@@ -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
View File
@@ -9,9 +9,11 @@ services:
- WEBAUTHN_RP_ID=localhost
- DATABASE_PATH=/data/passkey-auth.db
- SESSION_SECRET=dev-secret-change-in-production
- ADMIN_EMAIL=admin@example.com
- DEFAULT_EMAIL=admin@example.com
volumes:
- ./data:/data
- ./config.yaml:/root/config.yaml
- ./config.yaml:/app/config.yaml
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
+12 -5
View File
@@ -1,26 +1,33 @@
module passkey-auth
go 1.21
go 1.23.0
require (
github.com/glebarez/go-sqlite v1.22.0
github.com/go-webauthn/webauthn v0.10.2
github.com/gorilla/mux v1.8.1
github.com/gorilla/sessions v1.2.2
github.com/mattn/go-sqlite3 v1.14.18
github.com/rs/cors v1.11.0
github.com/sirupsen/logrus v1.9.3
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
github.com/go-webauthn/x v0.1.9 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/go-tpm v0.9.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
modernc.org/libc v1.37.6 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.28.0 // indirect
)
+25 -8
View File
@@ -1,18 +1,24 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -21,12 +27,14 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
@@ -37,11 +45,12 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
@@ -49,3 +58,11 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
+21
View File
@@ -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
+258
View File
@@ -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
+40
View File
@@ -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
+91
View File
@@ -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 }}
+121
View File
@@ -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
+32
View File
@@ -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 }}
+55
View File
@@ -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 }}
+20
View File
@@ -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 }}
+15
View File
@@ -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 }}
+15
View File
@@ -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 }}
+168
View File
@@ -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: []
+63 -2
View File
@@ -1,8 +1,9 @@
package config
import (
"io/ioutil"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v2"
@@ -39,6 +40,9 @@ type AuthConfig struct {
SessionSecret string `yaml:"session_secret"`
RequireApproval bool `yaml:"require_approval"`
AllowedEmails []string `yaml:"allowed_emails"`
AdminEmail string `yaml:"admin_email"`
CookieDomain string `yaml:"cookie_domain"`
DefaultEmail string `yaml:"default_email"`
}
func Load() (*Config, error) {
@@ -47,6 +51,11 @@ func Load() (*Config, error) {
configPath = "config.yaml"
}
// Validate config path to prevent path traversal attacks
if err := validateConfigPath(configPath); err != nil {
return nil, fmt.Errorf("invalid config path: %w", err)
}
// Set defaults
config := &Config{
Server: ServerConfig{
@@ -68,12 +77,14 @@ func Load() (*Config, error) {
SessionSecret: "change-me-in-production",
RequireApproval: true,
AllowedEmails: []string{}, // Empty means no email restrictions
CookieDomain: "", // Empty means no domain restriction (current domain only)
},
}
// Load from file if it exists
if _, err := os.Stat(configPath); err == nil {
data, err := ioutil.ReadFile(configPath)
// #nosec G304 - configPath is validated above to prevent path traversal
data, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}
@@ -106,6 +117,15 @@ func Load() (*Config, error) {
config.Auth.AllowedEmails[i] = strings.TrimSpace(email)
}
}
if adminEmail := os.Getenv("ADMIN_EMAIL"); adminEmail != "" {
config.Auth.AdminEmail = adminEmail
}
if cookieDomain := os.Getenv("COOKIE_DOMAIN"); cookieDomain != "" {
config.Auth.CookieDomain = cookieDomain
}
if defaultEmail := os.Getenv("DEFAULT_EMAIL"); defaultEmail != "" {
config.Auth.DefaultEmail = defaultEmail
}
return config, nil
}
@@ -127,3 +147,44 @@ func (c *Config) IsEmailAllowed(email string) bool {
return false
}
// IsAdmin checks if an email address is the admin email
func (c *Config) IsAdmin(email string) bool {
return c.Auth.AdminEmail != "" && email == c.Auth.AdminEmail
}
// validateConfigPath ensures the config path is safe and doesn't allow path traversal
func validateConfigPath(path string) error {
// Clean the path and check for path traversal attempts
cleanPath := filepath.Clean(path)
// Don't allow paths that try to go up directories
if strings.Contains(cleanPath, "..") {
return fmt.Errorf("path traversal not allowed")
}
// Only allow certain file extensions
ext := filepath.Ext(cleanPath)
if ext != ".yaml" && ext != ".yml" {
return fmt.Errorf("only .yaml and .yml files are allowed")
}
// Convert to absolute path to check if it's within allowed directories
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
// Get current working directory
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
// Only allow config files in current directory or its subdirectories
if !strings.HasPrefix(absPath, wd) {
return fmt.Errorf("config file must be in current directory or subdirectories")
}
return nil
}
+68
View File
@@ -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)
})
}
}
+116
View File
@@ -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
}
+138
View File
@@ -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)
}
}
})
}
}
+19 -2
View File
@@ -4,7 +4,7 @@ import (
"database/sql"
"time"
_ "github.com/mattn/go-sqlite3"
_ "github.com/glebarez/go-sqlite"
)
type DB struct {
@@ -31,7 +31,7 @@ type Credential struct {
}
func New(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath+"?_fk=1")
conn, err := sql.Open("sqlite", dbPath+"?_fk=1")
if err != nil {
return nil, err
}
@@ -98,6 +98,23 @@ func (db *DB) CreateUser(email, displayName string) (*User, error) {
return db.GetUser(int(id))
}
func (db *DB) CreateUserWithApproval(email, displayName string, approved bool) (*User, error) {
result, err := db.conn.Exec(
"INSERT INTO users (email, display_name, approved) VALUES (?, ?, ?)",
email, displayName, approved,
)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return db.GetUser(int(id))
}
func (db *DB) GetUser(id int) (*User, error) {
var user User
err := db.conn.QueryRow(
+207 -40
View File
@@ -3,6 +3,7 @@ package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"strings"
@@ -34,6 +35,7 @@ func New(db *database.DB, webAuthn *auth.WebAuthn, config *config.Config) *Handl
HttpOnly: true,
Secure: false, // Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode,
Domain: config.Auth.CookieDomain, // Share cookies across subdomains if configured
}
return &Handlers{
@@ -47,12 +49,19 @@ func New(db *database.DB, webAuthn *auth.WebAuthn, config *config.Config) *Handl
func (h *Handlers) writeError(w http.ResponseWriter, message string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": message})
if err := json.NewEncoder(w).Encode(map[string]string{"error": message}); err != nil {
// If we can't encode the error response, log it
// Don't try to write another response as headers are already sent
log.Printf("Failed to encode error response: %v", err)
}
}
func (h *Handlers) writeJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
if err := json.NewEncoder(w).Encode(data); err != nil {
// If encoding fails, try to send a simple error response
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// BeginRegistration starts the passkey registration process
@@ -85,16 +94,16 @@ func (h *Handlers) BeginRegistration(w http.ResponseWriter, r *http.Request) {
return
}
// Create new user
user, err := h.db.CreateUser(req.Email, req.DisplayName)
if err != nil {
logrus.Errorf("Failed to create user: %v", err)
h.writeError(w, "Failed to create user", http.StatusInternalServerError)
return
// Create a temporary WebAuthn user for registration without saving to DB yet
isAdmin := h.config.IsAdmin(req.Email)
tempUser := &database.User{
Email: req.Email,
DisplayName: req.DisplayName,
Approved: isAdmin, // Auto-approve admins
}
webAuthnUser := &auth.WebAuthnUser{}
webAuthnUser.SetUser(user)
webAuthnUser.SetUser(tempUser)
options, sessionData, err := h.webAuthn.BeginRegistration(webAuthnUser)
if err != nil {
@@ -103,11 +112,16 @@ func (h *Handlers) BeginRegistration(w http.ResponseWriter, r *http.Request) {
return
}
// Store session data
// Store session data with user details for later creation
session, _ := h.store.Get(r, "webauthn-session")
session.Values["challenge"] = sessionData.Challenge
session.Values["user_id"] = user.ID
session.Save(r, w)
session.Values["pending_email"] = req.Email
session.Values["pending_display_name"] = req.DisplayName
session.Values["pending_is_admin"] = isAdmin
if err := session.Save(r, w); err != nil {
h.writeError(w, "Failed to save session", http.StatusInternalServerError)
return
}
// Debug: log the options structure
logrus.Debugf("WebAuthn options: %+v", options)
@@ -123,15 +137,28 @@ func (h *Handlers) BeginRegistration(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
session, _ := h.store.Get(r, "webauthn-session")
userID, ok := session.Values["user_id"].(int)
// Get pending user data from session instead of user_id
pendingEmail, ok := session.Values["pending_email"].(string)
if !ok {
h.writeError(w, "Invalid session", http.StatusBadRequest)
h.writeError(w, "Invalid session - no pending registration", http.StatusBadRequest)
return
}
pendingDisplayName, ok := session.Values["pending_display_name"].(string)
if !ok {
h.writeError(w, "Invalid session - missing display name", http.StatusBadRequest)
return
}
pendingIsAdmin, ok := session.Values["pending_is_admin"].(bool)
if !ok {
h.writeError(w, "Invalid session - missing admin flag", http.StatusBadRequest)
return
}
challenge, ok := session.Values["challenge"].(string)
if !ok {
h.writeError(w, "Invalid session", http.StatusBadRequest)
h.writeError(w, "Invalid session - missing challenge", http.StatusBadRequest)
return
}
@@ -146,14 +173,15 @@ func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
// Create a new reader from the body for the WebAuthn library
r.Body = io.NopCloser(strings.NewReader(string(body)))
user, err := h.db.GetUser(userID)
if err != nil {
h.writeError(w, "User not found", http.StatusNotFound)
return
// Create temporary user for WebAuthn verification
tempUser := &database.User{
Email: pendingEmail,
DisplayName: pendingDisplayName,
Approved: pendingIsAdmin,
}
webAuthnUser := &auth.WebAuthnUser{}
webAuthnUser.SetUser(user)
webAuthnUser.SetUser(tempUser)
sessionData := webauthn.SessionData{
Challenge: challenge,
@@ -161,7 +189,7 @@ func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
}
// Log the request details for debugging
logrus.Debugf("Finishing registration for user: %s", user.Email)
logrus.Debugf("Finishing registration for user: %s", pendingEmail)
logrus.Debugf("Session challenge: %s", challenge)
logrus.Debugf("Session user ID: %v", webAuthnUser.WebAuthnID())
@@ -172,17 +200,52 @@ func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
return
}
// Only NOW create the user in the database after successful passkey registration
user, err := h.db.CreateUserWithApproval(pendingEmail, pendingDisplayName, pendingIsAdmin)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
h.writeError(w, "User already exists", http.StatusConflict)
return
}
logrus.Errorf("Failed to create user: %v", err)
h.writeError(w, "Failed to create user", http.StatusInternalServerError)
return
}
if pendingIsAdmin {
logrus.Infof("Admin user auto-approved: %s", pendingEmail)
}
// Save credential to database
if err := h.webAuthn.SaveCredential(user.ID, credential); err != nil {
logrus.Errorf("Failed to save credential: %v", err)
// If we can't save the credential, we should remove the user we just created
if deleteErr := h.db.DeleteUser(user.ID); deleteErr != nil {
logrus.Errorf("Failed to cleanup user after credential save failure: %v", deleteErr)
}
h.writeError(w, "Failed to save credential", http.StatusInternalServerError)
return
}
// Clear session
// Set authenticated session after successful registration
authSession, _ := h.store.Get(r, "auth-session")
authSession.Values["authenticated"] = true
authSession.Values["user_id"] = user.ID
authSession.Values["user_email"] = user.Email
if err := authSession.Save(r, w); err != nil {
h.writeError(w, "Failed to save auth session", http.StatusInternalServerError)
return
}
// Clear webauthn session
session.Values["challenge"] = nil
session.Values["user_id"] = nil
session.Save(r, w)
session.Values["pending_email"] = nil
session.Values["pending_display_name"] = nil
session.Values["pending_is_admin"] = nil
if err := session.Save(r, w); err != nil {
log.Printf("Failed to save session: %v", err)
// Don't return error here as the main operation succeeded
}
h.writeJSON(w, map[string]string{"status": "success"})
}
@@ -233,7 +296,10 @@ func (h *Handlers) BeginLogin(w http.ResponseWriter, r *http.Request) {
session, _ := h.store.Get(r, "webauthn-session")
session.Values["challenge"] = sessionData.Challenge
session.Values["user_id"] = webAuthnUser.GetUser().ID
session.Save(r, w)
if err := session.Save(r, w); err != nil {
h.writeError(w, "Failed to save session", http.StatusInternalServerError)
return
}
h.writeJSON(w, options)
}
@@ -288,12 +354,18 @@ func (h *Handlers) FinishLogin(w http.ResponseWriter, r *http.Request) {
authSession.Values["authenticated"] = true
authSession.Values["user_id"] = user.ID
authSession.Values["user_email"] = user.Email
authSession.Save(r, w)
if err := authSession.Save(r, w); err != nil {
h.writeError(w, "Failed to save auth session", http.StatusInternalServerError)
return
}
// Clear webauthn session
session.Values["challenge"] = nil
session.Values["user_id"] = nil
session.Save(r, w)
if err := session.Save(r, w); err != nil {
log.Printf("Failed to save session: %v", err)
// Don't return error here as the main operation succeeded
}
h.writeJSON(w, map[string]interface{}{
"status": "success",
@@ -312,17 +384,34 @@ func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
session.Values["user_id"] = nil
session.Values["user_email"] = nil
session.Options.MaxAge = -1
session.Save(r, w)
if err := session.Save(r, w); err != nil {
log.Printf("Failed to save session during logout: %v", err)
// Don't return error here as logout should still succeed
}
h.writeJSON(w, map[string]string{"status": "success"})
}
// AuthCheck implements the nginx auth_request protocol
func (h *Handlers) AuthCheck(w http.ResponseWriter, r *http.Request) {
session, _ := h.store.Get(r, "auth-session")
// Debug logging
logrus.Debugf("AuthCheck request from %s", r.RemoteAddr)
logrus.Debugf("AuthCheck headers: %+v", r.Header)
logrus.Debugf("AuthCheck cookies: %+v", r.Cookies())
session, err := h.store.Get(r, "auth-session")
if err != nil {
logrus.Errorf("Failed to get auth session: %v", err)
w.WriteHeader(http.StatusUnauthorized)
return
}
authenticated, ok := session.Values["authenticated"].(bool)
logrus.Debugf("Session authenticated: %v, ok: %v", authenticated, ok)
logrus.Debugf("Session values: %+v", session.Values)
if !ok || !authenticated {
logrus.Debugf("User not authenticated, returning 401")
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -335,14 +424,67 @@ func (h *Handlers) AuthCheck(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Auth-User", userEmail)
}
logrus.Debugf("User authenticated, returning 200")
w.WriteHeader(http.StatusOK)
}
// GetAuthStatus returns the current authentication status
func (h *Handlers) GetAuthStatus(w http.ResponseWriter, r *http.Request) {
session, err := h.store.Get(r, "auth-session")
if err != nil {
h.writeError(w, "Failed to get session", http.StatusInternalServerError)
return
}
userEmail, ok := session.Values["user_email"].(string)
if !ok || userEmail == "" {
h.writeError(w, "Not authenticated", http.StatusUnauthorized)
return
}
// Get user details from database
user, err := h.db.GetUserByEmail(userEmail)
if err != nil {
h.writeError(w, "User not found", http.StatusNotFound)
return
}
// Check if user is approved
if !user.Approved {
h.writeError(w, "User not approved", http.StatusForbidden)
return
}
response := map[string]interface{}{
"authenticated": true,
"user": map[string]interface{}{
"id": user.ID,
"email": user.Email,
"display_name": user.DisplayName,
"approved": user.Approved,
"is_admin": h.config.IsAdmin(user.Email),
},
}
h.writeJSON(w, response)
}
// GetConfig returns public configuration data
func (h *Handlers) GetConfig(w http.ResponseWriter, r *http.Request) {
configData := map[string]interface{}{
"default_email": h.config.Auth.DefaultEmail,
}
h.writeJSON(w, configData)
}
// Admin endpoints
// ListUsers returns all users (admin endpoint)
func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) {
// TODO: Add admin authentication check
if !h.requireAdmin(w, r) {
return
}
users, err := h.db.ListUsers()
if err != nil {
logrus.Errorf("Failed to list users: %v", err)
@@ -355,7 +497,10 @@ func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) {
// CreateUser creates a new user (admin endpoint)
func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
// TODO: Add admin authentication check
if !h.requireAdmin(w, r) {
return
}
var req struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
@@ -373,7 +518,7 @@ func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
return
}
user, err := h.db.CreateUser(req.Email, req.DisplayName)
user, err := h.db.CreateUserWithApproval(req.Email, req.DisplayName, req.Approved)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
h.writeError(w, "User already exists", http.StatusConflict)
@@ -384,18 +529,15 @@ func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
return
}
if req.Approved {
if err := h.db.ApproveUser(user.ID); err != nil {
logrus.Errorf("Failed to approve user: %v", err)
}
}
h.writeJSON(w, user)
}
// UpdateUser updates a user (admin endpoint)
func (h *Handlers) UpdateUser(w http.ResponseWriter, r *http.Request) {
// TODO: Add admin authentication check
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
idStr, ok := vars["id"]
if !ok {
@@ -441,7 +583,10 @@ func (h *Handlers) UpdateUser(w http.ResponseWriter, r *http.Request) {
// DeleteUser deletes a user (admin endpoint)
func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
// TODO: Add admin authentication check
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
idStr, ok := vars["id"]
if !ok {
@@ -463,3 +608,25 @@ func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, map[string]string{"status": "success"})
}
func (h *Handlers) isAdmin(r *http.Request) bool {
session, err := h.store.Get(r, "auth-session")
if err != nil {
return false
}
userEmail, ok := session.Values["user_email"].(string)
if !ok {
return false
}
return h.config.IsAdmin(userEmail)
}
func (h *Handlers) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if !h.isAdmin(r) {
h.writeError(w, "Admin access required", http.StatusForbidden)
return false
}
return true
}
-118
View File
@@ -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
-44
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: passkey-auth
+20 -6
View File
@@ -5,13 +5,14 @@ import (
"log"
"net/http"
"os"
"time"
"github.com/gorilla/mux"
"github.com/rs/cors"
"github.com/sirupsen/logrus"
"passkey-auth/internal/auth"
"passkey-auth/internal/config"
"passkey-auth/internal/cors"
"passkey-auth/internal/database"
"passkey-auth/internal/handlers"
)
@@ -48,11 +49,13 @@ func main() {
// API routes
api := router.PathPrefix("/api").Subrouter()
api.HandleFunc("/config", h.GetConfig).Methods("GET")
api.HandleFunc("/register/begin", h.BeginRegistration).Methods("POST")
api.HandleFunc("/register/finish", h.FinishRegistration).Methods("POST")
api.HandleFunc("/login/begin", h.BeginLogin).Methods("POST")
api.HandleFunc("/login/finish", h.FinishLogin).Methods("POST")
api.HandleFunc("/logout", h.Logout).Methods("POST")
api.HandleFunc("/auth/status", h.GetAuthStatus).Methods("GET")
api.HandleFunc("/users", h.ListUsers).Methods("GET")
api.HandleFunc("/users", h.CreateUser).Methods("POST")
api.HandleFunc("/users/{id}", h.UpdateUser).Methods("PUT")
@@ -64,21 +67,23 @@ func main() {
// Health check
router.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
if err := json.NewEncoder(w).Encode(map[string]string{"status": "healthy"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}).Methods("GET")
// Static files for admin UI
router.PathPrefix("/").Handler(http.FileServer(http.Dir("./web/"))).Methods("GET")
// Setup CORS
c := cors.New(cors.Options{
// Setup CORS with wildcard support
corsHandler := cors.WildcardCORS(cors.Config{
AllowedOrigins: cfg.CORS.AllowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"*"},
AllowCredentials: true,
})
handler := c.Handler(router)
handler := corsHandler(router)
// Start server
port := os.Getenv("PORT")
@@ -86,8 +91,17 @@ func main() {
port = "8080"
}
// Create server with proper timeouts to prevent resource exhaustion
server := &http.Server{
Addr: ":" + port,
Handler: handler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
logrus.Infof("Starting server on port %s", port)
if err := http.ListenAndServe(":"+port, handler); err != nil {
if err := server.ListenAndServe(); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
+3 -1
View File
@@ -16,7 +16,9 @@ func TestHealthEndpoint(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "healthy"}`))
if _, err := w.Write([]byte(`{"status": "healthy"}`)); err != nil {
t.Errorf("Failed to write response: %v", err)
}
})
handler.ServeHTTP(rr, req)
Executable
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

-16
View File
@@ -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"
-33
View File
@@ -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"
-28
View File
@@ -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
-77
View File
@@ -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!"
-65
View File
@@ -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"
-19
View File
@@ -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
+128
View File
@@ -0,0 +1,128 @@
#!/bin/bash
# Helm Chart Validation Script
# This script validates the Helm chart before deployment
set -e
CHART_DIR="helm/passkey-auth"
NAMESPACE="passkey-auth-test"
echo "🔍 Validating Passkey Auth Helm Chart..."
# Check if helm is installed
if ! command -v helm &> /dev/null; then
echo "❌ Helm is not installed. Please install Helm first."
exit 1
fi
# Check if kubectl is installed
if ! command -v kubectl &> /dev/null; then
echo "❌ kubectl is not installed. Please install kubectl first."
exit 1
fi
echo "✅ Prerequisites check passed"
# Lint the chart
echo "🔧 Linting Helm chart..."
if helm lint $CHART_DIR; then
echo "✅ Chart linting passed"
else
echo "❌ Chart linting failed"
exit 1
fi
# Template the chart with test values
echo "📝 Templating chart with test values..."
helm template test-release $CHART_DIR \
--set config.webauthn.rpId=test.example.com \
--set config.webauthn.rpOrigins="{https://test.example.com}" \
--set config.cors.allowedOrigins="{https://test.example.com}" \
--set config.auth.cookieDomain=".example.com" \
--set config.auth.allowedEmails="{admin@example.com}" \
--set secrets.sessionSecret="test-secret-for-validation-only" \
--set ingress.hosts[0].host=test.example.com \
--set ingress.tls[0].hosts="{test.example.com}" \
> /tmp/passkey-auth-template.yaml
if [ $? -eq 0 ]; then
echo "✅ Chart templating passed"
else
echo "❌ Chart templating failed"
exit 1
fi
# Validate Kubernetes manifests
echo "🔍 Validating Kubernetes manifests..."
if kubectl apply --dry-run=client -f /tmp/passkey-auth-template.yaml; then
echo "✅ Kubernetes manifest validation passed"
else
echo "❌ Kubernetes manifest validation failed"
exit 1
fi
# Check for required values
echo "🔧 Checking for required configuration..."
REQUIRED_VALUES=(
"config.webauthn.rpId"
"config.webauthn.rpOrigins"
"secrets.sessionSecret"
)
for value in "${REQUIRED_VALUES[@]}"; do
if helm template test-release $CHART_DIR --show-only templates/configmap.yaml | grep -q "REQUIRED"; then
echo "❌ Required value not set: $value"
exit 1
fi
done
echo "✅ Required values check passed"
# Test with production values
if [ -f "$CHART_DIR/examples/values-production.yaml" ]; then
echo "🏭 Testing with production values..."
helm template test-release $CHART_DIR \
-f $CHART_DIR/examples/values-production.yaml \
--set secrets.sessionSecret="test-secret" \
> /tmp/passkey-auth-production.yaml
if kubectl apply --dry-run=client -f /tmp/passkey-auth-production.yaml; then
echo "✅ Production values validation passed"
else
echo "❌ Production values validation failed"
exit 1
fi
fi
# Test with development values
if [ -f "$CHART_DIR/examples/values-development.yaml" ]; then
echo "🚀 Testing with development values..."
helm template test-release $CHART_DIR \
-f $CHART_DIR/examples/values-development.yaml \
> /tmp/passkey-auth-development.yaml
if kubectl apply --dry-run=client -f /tmp/passkey-auth-development.yaml; then
echo "✅ Development values validation passed"
else
echo "❌ Development values validation failed"
exit 1
fi
fi
# Cleanup
rm -f /tmp/passkey-auth-*.yaml
echo "🎉 All validations passed! The Helm chart is ready for deployment."
echo ""
echo "Next steps:"
echo "1. Update values in values.yaml or use --set flags"
echo "2. Install the chart: helm install my-passkey-auth $CHART_DIR"
echo "3. Configure your ingress controllers to use the auth backend"
echo ""
echo "For production deployment, make sure to:"
echo "• Set a secure session secret"
echo "• Configure proper domains and origins"
echo "• Set up TLS certificates"
echo "• Configure allowed email addresses"
+92 -651
View File
@@ -3,699 +3,140 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passkey Auth - Admin</title>
<title>Passkey Auth</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
font-family: system-ui, -apple-system, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f8f9fa;
}
.container {
text-align: center;
max-width: 400px;
padding: 2rem;
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 2rem;
width: 100%;
max-width: 800px;
margin: 1rem;
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.header h1 {
color: #333;
font-size: 2rem;
margin-bottom: 0.5rem;
}
.header p {
color: #666;
font-size: 1rem;
}
.tabs {
display: flex;
margin-bottom: 2rem;
border-bottom: 1px solid #eee;
}
.tab {
background: none;
border: none;
padding: 1rem 2rem;
cursor: pointer;
font-size: 1rem;
color: #666;
border-bottom: 2px solid transparent;
transition: all 0.3s ease;
}
.tab.active {
color: #667eea;
border-bottom-color: #667eea;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 500;
}
input[type="text"], input[type="email"] {
width: 100%;
padding: 0.75rem;
border: 2px solid #eee;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s ease;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
input[type="text"]:focus, input[type="email"]:focus {
outline: none;
border-color: #667eea;
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
.btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: transform 0.2s ease;
}
.btn:hover {
transform: translateY(-2px);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-danger {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
}
.users-list {
margin-top: 2rem;
}
.user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: 1px solid #eee;
border-radius: 8px;
margin-bottom: 1rem;
}
.user-info {
flex-grow: 1;
}
.user-info h3 {
color: #333;
margin-bottom: 0.25rem;
}
.user-info p {
color: #666;
font-size: 0.9rem;
}
.user-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
margin-right: 1rem;
}
.status-approved {
background: #d4edda;
color: #155724;
}
.status-pending {
background: #fff3cd;
color: #856404;
}
.alert {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔐 Passkey Auth</h1>
<p>Admin Dashboard</p>
</div>
<div class="tabs">
<button class="tab active" onclick="showTab('register')">Register User</button>
<button class="tab" onclick="showTab('login')">Test Login</button>
<button class="tab" onclick="showTab('users')">Manage Users</button>
</div>
<!-- Register Tab -->
<div id="register" class="tab-content active">
<h2>Register New User</h2>
<form id="registerForm">
<div class="form-group">
<label for="email">Email Address:</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="displayName">Display Name:</label>
<input type="text" id="displayName" name="displayName" required>
</div>
<button type="submit" class="btn">Register with Passkey</button>
</form>
</div>
<!-- Login Tab -->
<div id="login" class="tab-content">
<h2>Test Login</h2>
<form id="loginForm">
<div class="form-group">
<label for="loginEmail">Email Address:</label>
<input type="email" id="loginEmail" name="email" required>
</div>
<button type="submit" class="btn">Login with Passkey</button>
</form>
<div id="loginStatus"></div>
</div>
<!-- Users Tab -->
<div id="users" class="tab-content">
<h2>Manage Users</h2>
<button class="btn" onclick="loadUsers()">Refresh Users</button>
<div id="usersList" class="users-list">
<div class="loading">Loading users...</div>
</div>
</div>
<div id="alerts"></div>
<div class="spinner"></div>
<h2>🔐 Passkey Auth</h2>
<p id="status">Checking authentication status...</p>
</div>
<script>
// Base64/Base64URL decoding functions for WebAuthn
function base64ToArrayBuffer(base64) {
if (!base64 || typeof base64 !== 'string') {
console.error('Invalid base64 input:', base64);
throw new Error('Invalid base64 input');
function updateStatus(message) {
document.getElementById('status').textContent = message;
}
console.log('Converting base64 to ArrayBuffer:', base64);
function getRedirectUrl() {
const urlParams = new URLSearchParams(window.location.search);
// Check for both 'redirect' and 'rd' parameters (nginx uses 'rd' by default)
// Prefer 'redirect' over 'rd' if both are present
const redirectParam = urlParams.get('redirect');
const rdParam = urlParams.get('rd');
console.log('URL params:', {
redirect: redirectParam,
rd: rdParam,
search: window.location.search
});
return redirectParam || rdParam;
}
function redirectToTarget(url) {
try {
// If it looks like base64url, convert to base64 first
let base64String = base64;
if (base64.includes('-') || base64.includes('_')) {
// This is base64url, convert to base64
base64String = base64.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if needed
const padding = base64String.length % 4;
if (padding) {
base64String += '='.repeat(4 - padding);
}
} else {
// This is standard base64, add padding if needed
const padding = base64String.length % 4;
if (padding) {
base64String += '='.repeat(4 - padding);
}
}
// Decode base64
const binary = atob(base64String);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
console.log('Converted to ArrayBuffer, length:', bytes.buffer.byteLength);
return bytes.buffer;
} catch (error) {
console.error('Error converting base64 to ArrayBuffer:', error);
throw error;
}
}
// For backwards compatibility, keep the old function name but make it handle both
function base64urlToArrayBuffer(base64url) {
return base64ToArrayBuffer(base64url);
}
function arrayBufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
} // Convert WebAuthn options from base64url to ArrayBuffer
function prepareWebAuthnOptions(options) {
console.log('prepareWebAuthnOptions called with:', options);
// Handle both registration and login options (both use publicKey wrapper)
if (options.publicKey) {
console.log('Processing WebAuthn options (has publicKey)');
const publicKey = { ...options.publicKey };
// Convert challenge (common to both registration and login)
if (publicKey.challenge) {
console.log('Original challenge:', publicKey.challenge, 'type:', typeof publicKey.challenge);
if (typeof publicKey.challenge === 'string') {
publicKey.challenge = base64ToArrayBuffer(publicKey.challenge);
console.log('Converted challenge to ArrayBuffer, byteLength:', publicKey.challenge.byteLength);
} else {
console.error('Challenge is not a string:', publicKey.challenge);
throw new Error('Challenge must be a base64 or base64url string');
}
}
// Convert user ID (registration only)
if (publicKey.user && publicKey.user.id) {
console.log('Original user ID:', publicKey.user.id, 'type:', typeof publicKey.user.id);
if (typeof publicKey.user.id === 'string') {
publicKey.user.id = base64ToArrayBuffer(publicKey.user.id);
console.log('Converted user ID to ArrayBuffer, byteLength:', publicKey.user.id.byteLength);
} else {
console.error('User ID is not a string:', publicKey.user.id);
throw new Error('User ID must be a base64 or base64url string');
}
}
// Convert excludeCredentials (registration)
if (publicKey.excludeCredentials) {
console.log('Converting excludeCredentials for registration');
publicKey.excludeCredentials = publicKey.excludeCredentials.map(cred => ({
...cred,
id: base64ToArrayBuffer(cred.id)
}));
}
// Convert allowCredentials (login/authentication)
if (publicKey.allowCredentials) {
console.log('Converting allowCredentials for login:', publicKey.allowCredentials);
publicKey.allowCredentials = publicKey.allowCredentials.map(cred => {
console.log('Converting credential ID:', cred.id, 'type:', typeof cred.id);
return {
...cred,
id: base64ToArrayBuffer(cred.id)
};
});
console.log('Converted allowCredentials:', publicKey.allowCredentials);
}
const result = { publicKey };
console.log('Returning prepared WebAuthn options:', result);
return result;
}
console.log('No publicKey property found, returning as-is');
return options;
}
// Convert WebAuthn response from ArrayBuffer to base64url
function prepareWebAuthnResponse(credential) {
console.log('prepareWebAuthnResponse input:', credential);
const response = {
id: '',
rawId: '',
type: credential.type || 'public-key',
response: {}
};
// Convert credential ID
if (credential.rawId) {
const credentialId = arrayBufferToBase64url(credential.rawId);
response.id = credentialId;
response.rawId = credentialId;
console.log('Converted credential ID:', credentialId);
} else if (credential.id) {
// Some browsers might provide id as string already
response.id = credential.id;
response.rawId = credential.id;
console.log('Using existing credential ID:', credential.id);
}
// Convert response data
if (credential.response) {
console.log('Processing credential.response:', credential.response);
if (credential.response.clientDataJSON) {
response.response.clientDataJSON = arrayBufferToBase64url(credential.response.clientDataJSON);
console.log('Converted clientDataJSON');
}
// For registration (attestationObject)
if (credential.response.attestationObject) {
response.response.attestationObject = arrayBufferToBase64url(credential.response.attestationObject);
console.log('Converted attestationObject');
}
// For authentication (authenticatorData, signature)
if (credential.response.authenticatorData) {
response.response.authenticatorData = arrayBufferToBase64url(credential.response.authenticatorData);
console.log('Converted authenticatorData');
}
if (credential.response.signature) {
response.response.signature = arrayBufferToBase64url(credential.response.signature);
console.log('Converted signature');
}
if (credential.response.userHandle) {
response.response.userHandle = arrayBufferToBase64url(credential.response.userHandle);
console.log('Converted userHandle');
}
}
console.log('Final prepared response:', response);
return response;
}
// Tab functionality
function showTab(tabName) {
// Hide all tab contents
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
// Remove active class from all tabs
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.remove('active');
});
// Show selected tab content
document.getElementById(tabName).classList.add('active');
// Add active class to clicked tab
event.target.classList.add('active');
// Load users when users tab is selected
if (tabName === 'users') {
loadUsers();
}
}
// Alert functions
function showAlert(message, type = 'success') {
const alertsContainer = document.getElementById('alerts');
const alert = document.createElement('div');
alert.className = `alert alert-${type}`;
alert.textContent = message;
alertsContainer.appendChild(alert);
const decodedUrl = decodeURIComponent(url);
updateStatus(`Redirecting to ${new URL(decodedUrl).hostname}...`);
console.log('Redirecting to:', decodedUrl);
// Small delay to show the message
setTimeout(() => {
alert.remove();
}, 5000);
} // Register functionality
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const email = formData.get('email');
const displayName = formData.get('displayName');
try {
console.log('Starting registration for email:', email);
// Begin registration
const beginResponse = await fetch('/api/register/begin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
display_name: displayName
}),
credentials: 'include'
});
if (!beginResponse.ok) {
throw new Error(await beginResponse.text());
}
const options = await beginResponse.json();
console.log('Raw options from server:', JSON.stringify(options, null, 2));
// Convert base64url encoded fields to ArrayBuffers
const webAuthnOptions = prepareWebAuthnOptions(options);
console.log('Final webAuthnOptions for navigator.credentials.create:', webAuthnOptions);
// Create credential
const credential = await navigator.credentials.create(webAuthnOptions);
console.log('Raw credential from navigator.credentials.create:', credential);
// Convert ArrayBuffers back to base64url for JSON
const credentialResponse = prepareWebAuthnResponse(credential);
console.log('Prepared credential response to send to server:', JSON.stringify(credentialResponse, null, 2));
// Finish registration
const finishResponse = await fetch('/api/register/finish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentialResponse),
credentials: 'include'
});
console.log('Finish registration response status:', finishResponse.status);
if (!finishResponse.ok) {
const errorText = await finishResponse.text();
console.error('Finish registration error:', errorText);
throw new Error(errorText);
}
showAlert('User registered successfully!');
e.target.reset();
window.location.href = decodedUrl;
}, 1000);
return true;
} catch (error) {
showAlert(`Registration failed: ${error.message}`, 'error');
}
}); // Login functionality
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const email = formData.get('email');
try {
// Begin login
const beginResponse = await fetch('/api/login/begin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email
}),
credentials: 'include'
});
if (!beginResponse.ok) {
throw new Error(await beginResponse.text());
}
const options = await beginResponse.json();
console.log('Raw login options from server:', JSON.stringify(options, null, 2));
// Convert base64url encoded fields to ArrayBuffers
const webAuthnOptions = prepareWebAuthnOptions(options);
console.log('Final login webAuthnOptions for navigator.credentials.get:', webAuthnOptions);
// Get credential
const credential = await navigator.credentials.get(webAuthnOptions);
// Convert ArrayBuffers back to base64url for JSON
const credentialResponse = prepareWebAuthnResponse(credential);
// Finish login
const finishResponse = await fetch('/api/login/finish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentialResponse),
credentials: 'include'
});
if (!finishResponse.ok) {
throw new Error(await finishResponse.text());
}
const result = await finishResponse.json();
showAlert(`Login successful! Welcome, ${result.user.display_name}`);
document.getElementById('loginStatus').innerHTML = `
<div class="alert alert-success">
<strong>Logged in as:</strong> ${result.user.display_name} (${result.user.email})
</div>
`;
} catch (error) {
showAlert(`Login failed: ${error.message}`, 'error');
document.getElementById('loginStatus').innerHTML = `
<div class="alert alert-error">
Login failed: ${error.message}
</div>
`;
}
});
// Users management
async function loadUsers() {
const usersList = document.getElementById('usersList');
usersList.innerHTML = '<div class="loading">Loading users...</div>';
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Failed to load users');
}
const users = await response.json();
if (users.length === 0) {
usersList.innerHTML = '<p>No users found.</p>';
return;
}
usersList.innerHTML = users.map(user => `
<div class="user-item">
<div class="user-info">
<h3>${user.display_name}</h3>
<p>${user.email} • Created: ${new Date(user.created_at).toLocaleDateString()}</p>
</div>
<div class="user-actions">
<span class="status-badge ${user.approved ? 'status-approved' : 'status-pending'}">
${user.approved ? 'Approved' : 'Pending'}
</span>
${!user.approved ? `<button class="btn" onclick="approveUser(${user.id})">Approve</button>` : ''}
<button class="btn btn-danger" onclick="deleteUser(${user.id})">Delete</button>
</div>
</div>
`).join('');
} catch (error) {
usersList.innerHTML = `<div class="alert alert-error">Failed to load users: ${error.message}</div>`;
console.error('Error processing redirect URL:', error);
updateStatus('Invalid redirect URL');
return false;
}
}
async function approveUser(userId) {
if (!confirm('Are you sure you want to approve this user?')) {
function redirectToLogin() {
const redirectUrl = getRedirectUrl();
if (redirectUrl) {
// Preserve the redirect parameter when going to login
const loginUrl = `/login.html?redirect=${encodeURIComponent(redirectUrl)}`;
updateStatus('Redirecting to login...');
setTimeout(() => {
window.location.href = loginUrl;
}, 1000);
} else {
// No redirect parameter, just go to login
window.location.href = '/login.html';
}
}
async function checkAuthAndRedirect() {
const redirectUrl = getRedirectUrl();
if (!redirectUrl) {
updateStatus('No redirect URL provided');
setTimeout(() => {
window.location.href = '/login.html';
}, 2000);
return;
}
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
approved: true
})
updateStatus('Checking authentication...');
const response = await fetch('/api/auth/status', {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to approve user');
}
showAlert('User approved successfully', 'success');
loadUsers(); // Refresh the users list
} catch (error) {
showAlert(`Failed to approve user: ${error.message}`, 'error');
}
}
async function deleteUser(userId) {
if (!confirm('Are you sure you want to delete this user?')) {
if (response.ok) {
const userData = await response.json();
if (userData.authenticated) {
updateStatus(`Welcome back, ${userData.user.display_name}!`);
redirectToTarget(redirectUrl);
return;
}
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete user');
}
showAlert('User deleted successfully!');
loadUsers();
// Not authenticated, redirect to login with the target URL
updateStatus('Authentication required...');
redirectToLogin();
} catch (error) {
showAlert(`Failed to delete user: ${error.message}`, 'error');
console.error('Auth check failed:', error);
updateStatus('Authentication check failed...');
redirectToLogin();
}
}
// Load users when page loads
document.addEventListener('DOMContentLoaded', () => {
loadUsers();
});
// Start the process when page loads
document.addEventListener('DOMContentLoaded', checkAuthAndRedirect);
</script>
</body>
</html>
+959
View File
@@ -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>
+142
View File
@@ -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>