feat: google auth with secret manager and testing

This commit is contained in:
Moritz Graf 2026-01-03 09:14:44 +01:00
parent 636e194ea6
commit 754e495607
12 changed files with 1759 additions and 13 deletions

View File

@ -25,6 +25,14 @@ This document serves as the "Source of Truth" for the Haumdaucher website. It ou
- Managed via `vite-plugin-pwa`.
- Custom icons and standalone manifest for "Add to Home Screen" support.
## 🧪 Testing
- **Framework**: Vitest + HappyDOM.
- **Scope**: Lightweight sanity checks (e.g., verifying App mount).
- **Commands**:
- `npm test`: Run tests in watch mode.
- `npm test -- --run`: Run tests once (CI mode).
## 🕹 The Haumdaucher Game
- **Engine**: HTML5 Canvas rendering.
- **Controls**: Touch-responsive (horizontal drag) and Keyboard (Arrow Keys).

View File

@ -34,7 +34,13 @@ We use Terraform to manage Firebase Authentication and Firestore. To set this up
gcloud auth login
gcloud auth application-default login
```
4. **Verify**: Log in to the application. You should see the member banner.
4. **Configure Secrets**:
Run the helper script to create and populate the required secrets in Google Secret Manager:
```bash
./scripts/manage_secrets.py
```
*(You will need the Client ID and Secret from the GCP Console > APIs & Services > Credentials)*
5. **Verify**: Log in to the application. You should see the member banner.
## 🛠 Local Development Requirements

1532
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"setup:env": "./scripts/setup-local-env.sh",
"test": "vitest",
"build": "vite build",
"preview": "vite preview"
},
@ -15,12 +16,15 @@
"vuefire": "^3.2.2"
},
"devDependencies": {
"@types/node": "^16.11.1",
"@types/node": "^20.19.27",
"@vitejs/plugin-vue": "^4.5.2",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.4.0",
"happy-dom": "^20.0.11",
"typescript": "~5.2.2",
"vite": "^4.5.3",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.0.16",
"vue-tsc": "^1.8.8"
}
}
}

131
scripts/manage_secrets.py Executable file
View File

@ -0,0 +1,131 @@
#!/usr/bin/env python3
import subprocess
import argparse
import sys
import json
# Configuration
PROJECT_ID = "haumdaucher" # Could fetch from gcloud config, but hardcoding for project context
SECRETS = {
"haumdaucher-oauth-client-id": "Google OAuth Client ID",
"haumdaucher-oauth-client-secret": "Google OAuth Client Secret"
}
def run_command(command, check=True):
"""Runs a shell command and returns the output."""
try:
result = subprocess.run(
command,
shell=True,
check=check,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"❌ Error running command: {command}")
print(f" Stderr: {e.stderr}")
if check:
sys.exit(1)
return None
def check_secret_exists(secret_name):
"""Checks if a secret exists."""
cmd = f"gcloud secrets describe {secret_name} --project={PROJECT_ID} --format=json"
result = run_command(cmd, check=False)
return result is not None
def create_secret(secret_name):
"""Creates a new secret."""
print(f" Creating secret '{secret_name}'...")
cmd = f"gcloud secrets create {secret_name} --replication-policy=automatic --project={PROJECT_ID}"
run_command(cmd)
def add_secret_version(secret_name, payload):
"""Adds a new version to the secret."""
print(f" Adding new version to '{secret_name}'...")
# Passing payload via stdin to avoid shell history logging
process = subprocess.Popen(
f"gcloud secrets versions add {secret_name} --data-file=- --project={PROJECT_ID}",
shell=True,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
stdout, stderr = process.communicate(input=payload)
if process.returncode != 0:
print(f"❌ Failed to add version: {stderr}")
sys.exit(1)
print(" ✅ Version added.")
def cleanup_old_versions(secret_name):
"""Destroys old versions, keeping only the latest enabled one."""
print(f" Cleaning up old versions for '{secret_name}'...")
cmd = f"gcloud secrets versions list {secret_name} --project={PROJECT_ID} --limit=10 --format=json"
output = run_command(cmd)
if not output:
return
versions = json.loads(output)
# Sort by createTime desc (latest first)
# Filter for ENABLED versions usually, but we want to destroy everything except latest.
# We keep the one with state ENABLED that is most recent?
# Usually the just-added one is ENABLED.
if len(versions) <= 1:
print(" No old versions to cleanup.")
return
# Keep the latest one (index 0)
latest = versions[0]
to_destroy = versions[1:]
for v in to_destroy:
state = v.get('state')
if state == 'DESTROYED':
continue
version_id = v['name'].split('/')[-1]
print(f" destroying old version {version_id} ({state})...")
destroy_cmd = f"gcloud secrets versions destroy {version_id} --secret={secret_name} --project={PROJECT_ID} --quiet"
run_command(destroy_cmd)
def main():
print(f"🔐 Haumdaucher Secret Manager Tool")
print(f" Project: {PROJECT_ID}")
# Ensure API enabled
print("🔍 Checking Secret Manager API...")
run_command(f"gcloud services enable secretmanager.googleapis.com --project={PROJECT_ID}", check=False)
for secret_id, description in SECRETS.items():
print(f"\n👉 Secret: {secret_id} ({description})")
# Check existence
if not check_secret_exists(secret_id):
create_secret(secret_id)
# Prompt for value
print(f" Enter value for {description} (Input hidden): ")
# Use getpass logic via manual read to support all shells or just input() but masked?
# getpass in python is better.
import getpass
value = getpass.getpass(prompt=" > ")
if not value:
print(" ⚠️ Skipping update (empty input).")
continue
# Add version
add_secret_version(secret_id, value)
# Lifecycle: Destroy old
cleanup_old_versions(secret_id)
print("\n✅ All secrets updated successfully.")
if __name__ == "__main__":
main()

15
src/App.spec.ts Normal file
View File

@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import App from './App.vue'
describe('Smoke Test', () => {
it('mounts properly', () => {
const wrapper = mount(App)
expect(wrapper.exists()).toBe(true)
})
it('renders header text', () => {
const wrapper = mount(App)
expect(wrapper.text()).toContain('HAUMDAUCHER')
})
})

View File

@ -11,9 +11,15 @@ const emit = defineEmits(['update:theme', 'update:lang', 'open:game'])
const themes = ['classic', 'unicorn', 'luxury', 'win95', 'nat']
import { useAuth } from '../../composables/useAuth'
const { user, login, logout } = useAuth()
import { watch } from 'vue'
const { user, login, logout, error } = useAuth()
watch(user, (u) => {
console.log("👤 Header: User changed:", u ? u.email : "null")
})
</script>
<template>
<header class="fancy-glass header-top">
<div class="container top-content">
@ -21,6 +27,7 @@ const { user, login, logout } = useAuth()
<div class="controls">
<!-- Auth Control -->
<div class="auth-control">
<div v-if="error" class="auth-error" :title="error"></div>
<button v-if="!user" @click="login" class="login-btn">
Login
</button>
@ -261,4 +268,11 @@ button.active {
font-size: 0.7rem;
opacity: 0.7;
}
.auth-error {
color: #ff4444;
margin-right: 8px;
cursor: help;
font-size: 1.2rem;
}
</style>

View File

@ -7,9 +7,11 @@ import { doc, onSnapshot } from 'firebase/firestore'
const user = ref<User | null>(null)
const isAllowed = ref(false)
const isLoading = ref(true)
const error = ref<string | null>(null)
// Watch auth state
auth.onAuthStateChanged((u) => {
console.log("🔥 Auth State Changed:", u ? u.email : "Logged Out")
user.value = u
if (!u) {
isAllowed.value = false
@ -34,7 +36,7 @@ watchEffect((onCleanup) => {
}
}, (err) => {
console.error("Failed to check allowlist", err)
// Don't set isAllowed to false here automatically, maybe retain retry logic? for now default to false
error.value = "Failed to check allowlist: " + err.message
isLoading.value = false
})
@ -43,22 +45,26 @@ watchEffect((onCleanup) => {
export function useAuth() {
const login = async () => {
error.value = null
try {
const provider = new GoogleAuthProvider()
await signInWithPopup(auth, provider)
} catch (e) {
} catch (e: any) {
console.error("Login failed", e)
error.value = e.message
throw e
}
}
const logout = async () => {
error.value = null
try {
await signOut(auth)
user.value = null
isAllowed.value = false
} catch (e) {
} catch (e: any) {
console.error("Logout failed", e)
error.value = e.message
}
}
@ -66,6 +72,7 @@ export function useAuth() {
user,
isAllowed: computed(() => isAllowed.value),
isLoading: computed(() => isLoading.value),
error: computed(() => error.value),
login,
logout
}

View File

@ -70,6 +70,13 @@ resource "google_identity_platform_config" "default" {
provider = google-beta
project = var.project_id
# Authorized Domains for OAuth
authorized_domains = [
"localhost",
"${var.project_id}.firebaseapp.com",
"${var.project_id}.web.app",
]
# Enable Google Sign-In (and others if needed, but keeping it simple)
sign_in {
allow_duplicate_emails = false
@ -86,6 +93,18 @@ resource "google_identity_platform_config" "default" {
depends_on = [google_project_service.identitytoolkit]
}
# Enable Google Default Identity Provider
resource "google_identity_platform_default_supported_idp_config" "google" {
provider = google-beta
project = var.project_id
enabled = true
idp_id = "google.com"
client_id = data.google_secret_manager_secret_version.oauth_client_id.secret_data
client_secret = data.google_secret_manager_secret_version.oauth_client_secret.secret_data
depends_on = [google_project_service.identitytoolkit]
}
# NOTE: OAuth Client ID usually needs to be configured in console for Identity Platform
# or imported. Terraform support for *creating* the OAuth client for IAP/Identity is limited/complex.
# We will assume the default one created by Firebase is used or documented.

14
terraform/secrets.tf Normal file
View File

@ -0,0 +1,14 @@
# Fetch secrets from Secret Manager
# Expects these secrets to be created via scripts/manage_secrets.py
data "google_secret_manager_secret_version" "oauth_client_id" {
provider = google-beta
project = var.project_id
secret = "haumdaucher-oauth-client-id"
}
data "google_secret_manager_secret_version" "oauth_client_secret" {
provider = google-beta
project = var.project_id
secret = "haumdaucher-oauth-client-secret"
}

View File

@ -10,6 +10,7 @@ variable "region" {
default = "europe-west3"
}
variable "allowed_users" {
description = "List of email addresses allowed to access restricted features"
type = list(string)

View File

@ -1,3 +1,4 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
@ -36,5 +37,9 @@ export default defineConfig({
]
}
})
]
],
test: {
environment: 'happy-dom',
globals: true
}
})