feat: google auth with secret manager and testing
This commit is contained in:
parent
636e194ea6
commit
754e495607
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue