WebAuthn/Passkey Implementation Guide
This document covers the WebAuthn (Web Authentication) and Passkey implementation in the PVPipe ms-auth service, providing modern passwordless authentication for web applications.
Table of Contents
- WebAuthn Overview
- Architecture Design
- Implementation Components
- API Endpoints
- Database Schema
- Security Considerations
- Client Integration
- Testing and Validation
- Deployment Guidelines
WebAuthn Overview
What is WebAuthn?
WebAuthn (Web Authentication API) is a W3C standard that enables strong, passwordless authentication using public-key cryptography. It allows users to authenticate using:
- Platform authenticators: Built-in biometrics (TouchID, FaceID, Windows Hello)
- External authenticators: Security keys (YubiKey, Titan Security Key)
- Passkeys: Synced credentials across devices via cloud providers
Benefits
- Security: Phishing-resistant, cryptographically secure
- User Experience: No passwords to remember or type
- Privacy: Biometric data never leaves the device
- Cross-platform: Works across different devices and browsers
Use Cases in PVPipe
- Primary Authentication: Replace username/password login
- Step-up Authentication: Additional verification for sensitive operations
- Cross-device Authentication: Seamless login across devices
- Recovery Authentication: Alternative when mobile biometric is unavailable
Architecture Design
WebAuthn Flow
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Web Browser │ │ ms-auth API │ │ Authenticator │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │ │
1. │ Begin Registration │ │
│──────────────────────►│ │
│ │ │
2. │◄──────────────────────│ PublicKeyCredential │
│ Options │ Creation │
│ │ │
3. │ navigator.credentials │ │
│ .create(options) │ │
│──────────────────────────────────────────────►│
│ │ │
4. │◄──────────────────────────────────────────────│
│ Attestation Response │
│ │ │
5. │ Complete Registration │ │
│──────────────────────►│ │
│ │ │
6. │◄──────────────────────│ Registration Success │
│ │ │Authentication Flow
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Web Browser │ │ ms-auth API │ │ Authenticator │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │ │
1. │ Begin Authentication │ │
│──────────────────────►│ │
│ │ │
2. │◄──────────────────────│ PublicKeyCredential │
│ Options │ Request │
│ │ │
3. │ navigator.credentials │ │
│ .get(options) │ │
│──────────────────────────────────────────────►│
│ │ │
4. │◄──────────────────────────────────────────────│
│ Assertion Response │
│ │ │
5. │ Complete Login │ │
│──────────────────────►│ │
│ │ │
6. │◄──────────────────────│ JWT Tokens │
│ │ │Implementation Components
WebAuthnService
Core service handling WebAuthn operations:
type WebAuthnService struct {
db *sql.DB
redis *redis.Client
auditSvc *AuditService
rpID string
rpName string
rpOrigin string
}
type WebAuthnCredential struct {
ID string `db:"id"`
UserID int64 `db:"user_id"`
CredentialID []byte `db:"credential_id"`
PublicKey []byte `db:"public_key"`
Algorithm int `db:"algorithm"`
SignCount uint32 `db:"sign_count"`
Transports []string `db:"transports"`
Name string `db:"name"`
CreatedAt time.Time `db:"created_at"`
LastUsedAt *time.Time `db:"last_used_at"`
}
// Registration methods
func (w *WebAuthnService) BeginRegistration(ctx context.Context, userID int64, username string, displayName string) (*protocol.CredentialCreation, error)
func (w *WebAuthnService) FinishRegistration(ctx context.Context, userID int64, credential *protocol.ParsedCredentialCreationData) (*WebAuthnCredential, error)
// Authentication methods
func (w *WebAuthnService) BeginAuthentication(ctx context.Context, userID int64) (*protocol.CredentialAssertion, error)
func (w *WebAuthnService) BeginAuthenticationByEmail(ctx context.Context, email string) (*protocol.CredentialAssertion, error)
func (w *WebAuthnService) FinishAuthentication(ctx context.Context, userID int64, credential *protocol.ParsedCredentialAssertionData) (*TokenResponse, error)Challenge Management
WebAuthn challenges are stored temporarily with security controls:
type WebAuthnChallenge struct {
ID string `json:"id"`
UserID int64 `json:"user_id"`
Challenge []byte `json:"challenge"`
Type string `json:"type"` // "registration" or "authentication"
Options interface{} `json:"options"`
ExpiresAt time.Time `json:"expires_at"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
}
func (w *WebAuthnService) storeChallengeSession(ctx context.Context, challenge *WebAuthnChallenge) error {
data, err := json.Marshal(challenge)
if err != nil {
return err
}
key := fmt.Sprintf("webauthn:challenge:%s", challenge.ID)
ttl := time.Until(challenge.ExpiresAt)
return w.redis.Set(ctx, key, data, ttl).Err()
}
func (w *WebAuthnService) getChallengeSession(ctx context.Context, challengeID string) (*WebAuthnChallenge, error) {
key := fmt.Sprintf("webauthn:challenge:%s", challengeID)
data, err := w.redis.Get(ctx, key).Result()
if err != nil {
return nil, err
}
var challenge WebAuthnChallenge
err = json.Unmarshal([]byte(data), &challenge)
return &challenge, err
}Credential Management
Managing WebAuthn credentials with proper security:
func (w *WebAuthnService) GetUserCredentials(ctx context.Context, userID int64) ([]WebAuthnCredential, error) {
query := `
SELECT id, user_id, credential_id, public_key, algorithm, sign_count,
transports, name, created_at, last_used_at
FROM webauthn_credentials
WHERE user_id = $1 AND is_active = true
ORDER BY last_used_at DESC NULLS LAST, created_at DESC
`
rows, err := w.db.QueryContext(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var credentials []WebAuthnCredential
for rows.Next() {
var cred WebAuthnCredential
var transports string
err := rows.Scan(
&cred.ID, &cred.UserID, &cred.CredentialID, &cred.PublicKey,
&cred.Algorithm, &cred.SignCount, &transports, &cred.Name,
&cred.CreatedAt, &cred.LastUsedAt,
)
if err != nil {
return nil, err
}
// Parse transports JSON
json.Unmarshal([]byte(transports), &cred.Transports)
credentials = append(credentials, cred)
}
return credentials, nil
}
func (w *WebAuthnService) UpdateSignCount(ctx context.Context, credentialID []byte, signCount uint32) error {
query := `
UPDATE webauthn_credentials
SET sign_count = $1, last_used_at = NOW(), updated_at = NOW()
WHERE credential_id = $2
`
_, err := w.db.ExecContext(ctx, query, signCount, credentialID)
return err
}Security Validation
Comprehensive validation for WebAuthn operations:
func (w *WebAuthnService) validateRegistrationRequest(req *RegistrationRequest) error {
// Validate credential name
if len(req.Name) == 0 || len(req.Name) > 100 {
return errors.New("credential name must be 1-100 characters")
}
// Validate user verification requirement
if req.UserVerification != "" &&
req.UserVerification != "required" &&
req.UserVerification != "preferred" &&
req.UserVerification != "discouraged" {
return errors.New("invalid user verification requirement")
}
// Validate authenticator selection
if req.AuthenticatorSelection != nil {
if req.AuthenticatorSelection.ResidentKey != "" &&
req.AuthenticatorSelection.ResidentKey != "required" &&
req.AuthenticatorSelection.ResidentKey != "preferred" &&
req.AuthenticatorSelection.ResidentKey != "discouraged" {
return errors.New("invalid resident key requirement")
}
}
return nil
}
func (w *WebAuthnService) validateOrigin(origin string) error {
if origin != w.rpOrigin {
return fmt.Errorf("invalid origin: expected %s, got %s", w.rpOrigin, origin)
}
return nil
}
func (w *WebAuthnService) validateRPID(rpID string) error {
if rpID != w.rpID {
return fmt.Errorf("invalid RP ID: expected %s, got %s", w.rpID, rpID)
}
return nil
}API Endpoints
Registration Endpoints
POST /api/v1/webauthn/register/begin
Initiates WebAuthn credential registration.
Request:
{
"name": "My Security Key",
"userVerification": "preferred",
"authenticatorSelection": {
"residentKey": "preferred",
"userVerification": "preferred",
"authenticatorAttachment": "platform"
}
}Response:
{
"data": {
"challengeId": "webauthn_challenge_uuid",
"publicKey": {
"challenge": "base64-challenge",
"rp": {
"name": "PVPipe",
"id": "pvpipe.com"
},
"user": {
"id": "base64-user-id",
"name": "user@example.com",
"displayName": "John Doe"
},
"pubKeyCredParams": [
{"type": "public-key", "alg": -7},
{"type": "public-key", "alg": -257}
],
"timeout": 300000,
"attestation": "none",
"authenticatorSelection": {
"residentKey": "preferred",
"userVerification": "preferred"
}
}
}
}POST /api/v1/webauthn/register/finish
Completes WebAuthn credential registration.
Request:
{
"challengeId": "webauthn_challenge_uuid",
"credential": {
"id": "credential-id",
"rawId": "base64-raw-id",
"type": "public-key",
"response": {
"attestationObject": "base64-attestation",
"clientDataJSON": "base64-client-data"
}
}
}Response:
{
"data": {
"success": true,
"credentialId": "credential-uuid",
"credential": {
"id": "credential-uuid",
"name": "My Security Key",
"algorithm": -7,
"transports": ["internal"],
"createdAt": "2025-08-02T15:30:00Z"
}
}
}Authentication Endpoints
POST /api/v1/webauthn/login/begin
Initiates WebAuthn authentication.
Request:
{
"email": "user@example.com",
"userVerification": "preferred"
}Response:
{
"data": {
"challengeId": "webauthn_login_challenge_uuid",
"publicKey": {
"challenge": "base64-challenge",
"timeout": 300000,
"rpId": "pvpipe.com",
"allowCredentials": [
{
"type": "public-key",
"id": "base64-credential-id",
"transports": ["internal"]
}
],
"userVerification": "preferred"
}
}
}POST /api/v1/webauthn/login/finish
Completes WebAuthn authentication.
Request:
{
"challengeId": "webauthn_login_challenge_uuid",
"credential": {
"id": "credential-id",
"rawId": "base64-raw-id",
"type": "public-key",
"response": {
"authenticatorData": "base64-auth-data",
"clientDataJSON": "base64-client-data",
"signature": "base64-signature"
}
}
}Response:
{
"data": {
"success": true,
"tokens": {
"accessToken": "jwt-access-token",
"refreshToken": "jwt-refresh-token",
"accessTokenExpiresAt": "2025-08-02T23:30:00Z",
"refreshTokenExpiresAt": "2025-08-05T15:30:00Z"
}
}
}Credential Management
GET /api/v1/webauthn/credentials
Lists user's WebAuthn credentials.
Response:
{
"data": {
"credentials": [
{
"id": "cred-uuid-1",
"name": "My Security Key",
"algorithm": -7,
"transports": ["usb", "nfc"],
"createdAt": "2025-08-01T10:00:00Z",
"lastUsedAt": "2025-08-02T14:30:00Z"
},
{
"id": "cred-uuid-2",
"name": "TouchID",
"algorithm": -7,
"transports": ["internal"],
"createdAt": "2025-07-15T09:15:00Z",
"lastUsedAt": "2025-08-02T08:45:00Z"
}
]
}
}DELETE /api/v1/webauthn/credentials/{id}
Removes a WebAuthn credential.
Response:
{
"data": {
"success": true,
"message": "Credential removed successfully"
}
}Utility Endpoints
POST /api/v1/auth/check-passkeys
Checks if a user has registered passkeys (public endpoint).
Request:
{
"email": "user@example.com"
}Response:
{
"hasPasskeys": true
}This endpoint is useful for frontend applications to determine whether to show passkey login options. It returns false for non-existent users to prevent user enumeration.
GET /api/v1/auth/passkey/prompt-status
Gets the passkey registration prompt status for the authenticated user.
Response:
{
"promptStatus": "pending",
"lastPrompted": null
}Possible status values:
pending: User hasn't been prompted yetaccepted: User accepted and registered a passkeydeclined: User explicitly declined registrationdismissed: User dismissed the prompt without deciding
PUT /api/v1/auth/passkey/prompt-status
Updates the passkey registration prompt status.
Request:
{
"status": "declined"
}Response:
{
"success": true,
"message": "Prompt status updated successfully"
}These utility endpoints enable better UX by:
- Checking passkey availability before attempting authentication
- Managing user prompts to avoid annoying repeated registration requests
- Tracking user engagement with passkey features
PUT /api/v1/webauthn/credentials/{id}
Updates credential name.
Request:
{
"name": "New Credential Name"
}Database Schema
WebAuthn Tables
webauthn_credentials
Stores WebAuthn credential information:
CREATE TABLE webauthn_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
algorithm INTEGER NOT NULL, -- COSE Algorithm Identifier
sign_count INTEGER NOT NULL DEFAULT 0,
transports TEXT[] NOT NULL DEFAULT '{}',
aaguid UUID,
name VARCHAR(100) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_webauthn_credentials_user_active ON webauthn_credentials(user_id, is_active);
CREATE INDEX idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id);
CREATE INDEX idx_webauthn_credentials_last_used ON webauthn_credentials(last_used_at DESC);webauthn_challenges
Temporary challenge storage (alternative to Redis):
CREATE TABLE webauthn_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
challenge BYTEA NOT NULL,
challenge_type VARCHAR(20) NOT NULL CHECK (challenge_type IN ('registration', 'authentication')),
options JSONB,
ip_address VARCHAR(45),
user_agent TEXT,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_webauthn_challenges_expires ON webauthn_challenges(expires_at);
CREATE INDEX idx_webauthn_challenges_user_type ON webauthn_challenges(user_id, challenge_type, created_at);
-- Cleanup expired challenges
CREATE OR REPLACE FUNCTION cleanup_expired_webauthn_challenges()
RETURNS void AS $
BEGIN
DELETE FROM webauthn_challenges WHERE expires_at < NOW();
END;
$ LANGUAGE plpgsql;
-- Schedule cleanup (adjust based on your job scheduler)
-- SELECT cron.schedule('cleanup-webauthn-challenges', '*/5 * * * *', 'SELECT cleanup_expired_webauthn_challenges();');webauthn_audit_logs
Audit trail for WebAuthn operations:
CREATE TABLE webauthn_audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
event_type VARCHAR(50) NOT NULL, -- 'registration', 'authentication', 'credential_delete'
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
credential_id BYTEA,
success BOOLEAN NOT NULL,
error_message TEXT,
ip_address VARCHAR(45),
user_agent TEXT,
details JSONB
);
CREATE INDEX idx_webauthn_audit_timestamp ON webauthn_audit_logs(timestamp DESC);
CREATE INDEX idx_webauthn_audit_user_event ON webauthn_audit_logs(user_id, event_type, timestamp);
CREATE INDEX idx_webauthn_audit_success ON webauthn_audit_logs(success, event_type, timestamp);Security Considerations
Origin Validation
Strict origin validation prevents phishing attacks:
func (w *WebAuthnService) validateOrigin(clientData *protocol.CollectedClientData) error {
expectedOrigin := w.rpOrigin
if clientData.Origin != expectedOrigin {
return fmt.Errorf("invalid origin: expected %s, got %s",
expectedOrigin, clientData.Origin)
}
return nil
}Challenge Uniqueness
Each challenge must be unique and time-limited:
func (w *WebAuthnService) generateChallenge() ([]byte, error) {
challenge := make([]byte, 32) // 256 bits
_, err := rand.Read(challenge)
if err != nil {
return nil, fmt.Errorf("failed to generate challenge: %w", err)
}
return challenge, nil
}Sign Counter Validation
Prevents credential cloning attacks:
func (w *WebAuthnService) validateSignCounter(credentialID []byte, newSignCount uint32) error {
var currentSignCount uint32
query := "SELECT sign_count FROM webauthn_credentials WHERE credential_id = $1"
err := w.db.QueryRow(query, credentialID).Scan(¤tSignCount)
if err != nil {
return err
}
// Sign counter should increase (or stay same for some authenticators)
if newSignCount < currentSignCount {
return errors.New("invalid sign counter: potential credential cloning detected")
}
return nil
}Rate Limiting
Protect against brute force attacks:
func (w *WebAuthnService) checkRateLimit(ctx context.Context, userID int64, operation string) error {
key := fmt.Sprintf("webauthn:rate_limit:%s:%d", operation, userID)
count, err := w.redis.Incr(ctx, key).Result()
if err != nil {
return err
}
// Set expiration on first attempt
if count == 1 {
w.redis.Expire(ctx, key, 15*time.Minute)
}
// Allow 5 attempts per 15 minutes
if count > 5 {
return errors.New("rate limit exceeded")
}
return nil
}Resident Key Security
Handle resident credentials carefully:
type AuthenticatorSelection struct {
ResidentKey string `json:"residentKey,omitempty"`
UserVerification string `json:"userVerification,omitempty"`
AuthenticatorAttachment string `json:"authenticatorAttachment,omitempty"`
}
func (w *WebAuthnService) buildAuthenticatorSelection(req *RegistrationRequest) *AuthenticatorSelection {
selection := &AuthenticatorSelection{
UserVerification: "preferred",
}
if req.AuthenticatorSelection != nil {
if req.AuthenticatorSelection.ResidentKey != "" {
selection.ResidentKey = req.AuthenticatorSelection.ResidentKey
}
if req.AuthenticatorSelection.UserVerification != "" {
selection.UserVerification = req.AuthenticatorSelection.UserVerification
}
if req.AuthenticatorSelection.AuthenticatorAttachment != "" {
selection.AuthenticatorAttachment = req.AuthenticatorSelection.AuthenticatorAttachment
}
}
return selection
}Client Integration
JavaScript Implementation
Registration Flow
class WebAuthnService {
async registerCredential(name, options = {}) {
try {
// 1. Begin registration
const beginResponse = await fetch('/api/v1/webauthn/register/begin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.accessToken}`
},
body: JSON.stringify({
name: name,
userVerification: options.userVerification || 'preferred',
authenticatorSelection: options.authenticatorSelection
})
});
const { data } = await beginResponse.json();
const challengeId = data.challengeId;
const creationOptions = data.publicKey;
// 2. Convert base64 values to ArrayBuffer
creationOptions.challenge = this.base64ToArrayBuffer(creationOptions.challenge);
creationOptions.user.id = this.base64ToArrayBuffer(creationOptions.user.id);
// 3. Create credential
const credential = await navigator.credentials.create({
publicKey: creationOptions
});
if (!credential) {
throw new Error('Failed to create credential');
}
// 4. Prepare credential for server
const credentialData = {
challengeId: challengeId,
credential: {
id: credential.id,
rawId: this.arrayBufferToBase64(credential.rawId),
type: credential.type,
response: {
attestationObject: this.arrayBufferToBase64(credential.response.attestationObject),
clientDataJSON: this.arrayBufferToBase64(credential.response.clientDataJSON)
}
}
};
// 5. Complete registration
const finishResponse = await fetch('/api/v1/webauthn/register/finish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.accessToken}`
},
body: JSON.stringify(credentialData)
});
const result = await finishResponse.json();
if (result.data.success) {
return result.data.credential;
} else {
throw new Error('Registration failed');
}
} catch (error) {
console.error('WebAuthn registration error:', error);
throw error;
}
}
async authenticateWithWebAuthn(email, options = {}) {
try {
// 1. Begin authentication
const beginResponse = await fetch('/api/v1/webauthn/login/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email,
userVerification: options.userVerification || 'preferred'
})
});
const { data } = await beginResponse.json();
const challengeId = data.challengeId;
const requestOptions = data.publicKey;
// 2. Convert base64 values
requestOptions.challenge = this.base64ToArrayBuffer(requestOptions.challenge);
if (requestOptions.allowCredentials) {
requestOptions.allowCredentials.forEach(cred => {
cred.id = this.base64ToArrayBuffer(cred.id);
});
}
// 3. Get assertion
const assertion = await navigator.credentials.get({
publicKey: requestOptions
});
if (!assertion) {
throw new Error('Authentication failed');
}
// 4. Prepare assertion for server
const assertionData = {
challengeId: challengeId,
credential: {
id: assertion.id,
rawId: this.arrayBufferToBase64(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: this.arrayBufferToBase64(assertion.response.authenticatorData),
clientDataJSON: this.arrayBufferToBase64(assertion.response.clientDataJSON),
signature: this.arrayBufferToBase64(assertion.response.signature),
userHandle: assertion.response.userHandle ?
this.arrayBufferToBase64(assertion.response.userHandle) : null
}
}
};
// 5. Complete authentication
const finishResponse = await fetch('/api/v1/webauthn/login/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assertionData)
});
const result = await finishResponse.json();
if (result.data.success) {
// Store tokens
this.storeTokens(result.data.tokens);
return result.data.tokens;
} else {
throw new Error('Authentication failed');
}
} catch (error) {
console.error('WebAuthn authentication error:', error);
throw error;
}
}
// Utility methods
base64ToArrayBuffer(base64) {
const binary = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
}Feature Detection
class WebAuthnSupport {
static isSupported() {
return !!(navigator.credentials &&
navigator.credentials.create &&
navigator.credentials.get &&
window.PublicKeyCredential);
}
static async isPlatformAuthenticatorAvailable() {
if (!this.isSupported()) return false;
try {
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch (e) {
return false;
}
}
static async isConditionalMediationSupported() {
if (!this.isSupported()) return false;
try {
return await PublicKeyCredential.isConditionalMediationAvailable();
} catch (e) {
return false;
}
}
static getSupportedFeatures() {
return {
webauthn: this.isSupported(),
conditionalMediation: null, // Will be set async
platformAuthenticator: null, // Will be set async
userVerification: this.isSupported()
};
}
}
// Initialize feature detection
WebAuthnSupport.getSupportedFeatures().then(async (features) => {
features.conditionalMediation = await WebAuthnSupport.isConditionalMediationSupported();
features.platformAuthenticator = await WebAuthnSupport.isPlatformAuthenticatorAvailable();
console.log('WebAuthn features:', features);
});React Component Example
import React, { useState, useEffect } from 'react';
import { WebAuthnService, WebAuthnSupport } from './webauthn-service';
function WebAuthnLogin({ onLogin }) {
const [isSupported, setIsSupported] = useState(false);
const [isPlatformAvailable, setIsPlatformAvailable] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [email, setEmail] = useState('');
useEffect(() => {
const checkSupport = async () => {
setIsSupported(WebAuthnSupport.isSupported());
setIsPlatformAvailable(await WebAuthnSupport.isPlatformAuthenticatorAvailable());
};
checkSupport();
}, []);
const handleWebAuthnLogin = async () => {
if (!email) {
alert('Please enter your email address');
return;
}
setIsLoading(true);
try {
const webauthnService = new WebAuthnService();
const tokens = await webauthnService.authenticateWithWebAuthn(email);
onLogin(tokens);
} catch (error) {
alert(`WebAuthn login failed: ${error.message}`);
} finally {
setIsLoading(false);
}
};
if (!isSupported) {
return <div>WebAuthn is not supported in this browser</div>;
}
return (
<div className="webauthn-login">
<h3>Passwordless Login</h3>
<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
/>
</div>
<button
onClick={handleWebAuthnLogin}
disabled={isLoading}
className="webauthn-button"
>
{isLoading ? 'Authenticating...' :
isPlatformAvailable ? 'Login with Touch/Face ID' : 'Login with Security Key'}
</button>
<div className="webauthn-help">
<small>
{isPlatformAvailable
? 'Use your device\'s built-in biometric authentication'
: 'Use your security key or other authenticator'
}
</small>
</div>
</div>
);
}
export default WebAuthnLogin;Testing and Validation
Unit Tests
func TestWebAuthnService_BeginRegistration(t *testing.T) {
service := setupTestWebAuthnService(t)
userID := int64(123)
username := "test@example.com"
displayName := "Test User"
options, err := service.BeginRegistration(context.Background(), userID, username, displayName)
assert.NoError(t, err)
assert.NotNil(t, options)
assert.Equal(t, "pvpipe.com", options.Response.RelyingParty.ID)
assert.Equal(t, "PVPipe", options.Response.RelyingParty.Name)
assert.Equal(t, username, options.Response.User.Name)
assert.Equal(t, displayName, options.Response.User.DisplayName)
assert.NotEmpty(t, options.Response.Challenge)
assert.Contains(t, options.Response.Parameters, protocol.CredentialParameter{Type: "public-key", Algorithm: -7})
}
func TestWebAuthnService_ValidateOrigin(t *testing.T) {
service := setupTestWebAuthnService(t)
tests := []struct {
name string
origin string
expectError bool
}{
{"Valid origin", "https://pvpipe.com", false},
{"Invalid origin", "https://evil.com", true},
{"HTTP origin", "http://pvpipe.com", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := service.validateOrigin(tt.origin)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}Integration Tests
// Browser automation tests using Playwright
const { test, expect } = require('@playwright/test');
test.describe('WebAuthn Integration', () => {
test('should complete registration flow', async ({ page, context }) => {
// Grant permissions for WebAuthn
await context.grantPermissions(['clipboard-read']);
// Navigate to registration page
await page.goto('/register');
// Fill in user details
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'testpassword123');
await page.click('button[type="submit"]');
// Wait for redirect to WebAuthn setup
await expect(page.locator('h2')).toContainText('Setup Passwordless Login');
// Click setup WebAuthn
await page.click('button:has-text("Setup Touch ID")');
// Handle WebAuthn credential creation
await page.evaluate(() => {
// Mock successful credential creation
const mockCredential = {
id: 'test-credential-id',
rawId: new Uint8Array([1, 2, 3, 4]),
type: 'public-key',
response: {
attestationObject: new Uint8Array([5, 6, 7, 8]),
clientDataJSON: new Uint8Array([9, 10, 11, 12])
}
};
// Override navigator.credentials.create
navigator.credentials.create = async () => mockCredential;
});
// Wait for success message
await expect(page.locator('.success-message')).toContainText('WebAuthn setup complete');
});
test('should complete login flow', async ({ page, context }) => {
// Setup existing credential
await setupTestCredential(page);
// Navigate to login
await page.goto('/login');
// Enter email
await page.fill('[name="email"]', 'test@example.com');
// Click WebAuthn login
await page.click('button:has-text("Login with Touch ID")');
// Mock WebAuthn authentication
await page.evaluate(() => {
const mockAssertion = {
id: 'test-credential-id',
rawId: new Uint8Array([1, 2, 3, 4]),
type: 'public-key',
response: {
authenticatorData: new Uint8Array([1, 2, 3, 4]),
clientDataJSON: new Uint8Array([5, 6, 7, 8]),
signature: new Uint8Array([9, 10, 11, 12])
}
};
navigator.credentials.get = async () => mockAssertion;
});
// Verify successful login
await expect(page.locator('.dashboard')).toBeVisible();
});
});Deployment Guidelines
Configuration
# Environment variables for WebAuthn
WEBAUTHN_ENABLED: "true"
WEBAUTHN_RP_ID: "pvpipe.com"
WEBAUTHN_RP_NAME: "PVPipe"
WEBAUTHN_RP_ORIGIN: "https://pvpipe.com"
WEBAUTHN_TIMEOUT: "300000" # 5 minutes in milliseconds
WEBAUTHN_REQUIRE_USER_VERIFICATION: "preferred"HTTPS Requirements
WebAuthn requires HTTPS in production:
server {
listen 443 ssl http2;
server_name pvpipe.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# Security headers for WebAuthn
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'";
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
location /api/v1/webauthn/ {
proxy_pass http://ms-auth-backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Database Migrations
-- Migration: Create WebAuthn tables
BEGIN;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- WebAuthn credentials table
CREATE TABLE webauthn_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
algorithm INTEGER NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
transports TEXT[] NOT NULL DEFAULT '{}',
aaguid UUID,
name VARCHAR(100) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMP WITH TIME ZONE
);
-- Indexes
CREATE INDEX idx_webauthn_credentials_user_active ON webauthn_credentials(user_id, is_active);
CREATE INDEX idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id);
CREATE INDEX idx_webauthn_credentials_last_used ON webauthn_credentials(last_used_at DESC);
-- Audit logs
CREATE TABLE webauthn_audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
event_type VARCHAR(50) NOT NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
credential_id BYTEA,
success BOOLEAN NOT NULL,
error_message TEXT,
ip_address VARCHAR(45),
user_agent TEXT,
details JSONB
);
CREATE INDEX idx_webauthn_audit_timestamp ON webauthn_audit_logs(timestamp DESC);
CREATE INDEX idx_webauthn_audit_user_event ON webauthn_audit_logs(user_id, event_type, timestamp);
CREATE INDEX idx_webauthn_audit_success ON webauthn_audit_logs(success, event_type, timestamp);
COMMIT;Monitoring and Metrics
// Prometheus metrics for WebAuthn
var (
webauthnRegistrations = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "webauthn_registrations_total",
Help: "Total number of WebAuthn registrations",
},
[]string{"success"},
)
webauthnAuthentications = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "webauthn_authentications_total",
Help: "Total number of WebAuthn authentications",
},
[]string{"success"},
)
webauthnDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "webauthn_operation_duration_seconds",
Help: "Duration of WebAuthn operations",
Buckets: prometheus.DefBuckets,
},
[]string{"operation"},
)
)This comprehensive WebAuthn implementation provides modern, secure, passwordless authentication that integrates seamlessly with the existing PVPipe authentication system while providing an enhanced user experience and improved security posture.