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`.
|
- Managed via `vite-plugin-pwa`.
|
||||||
- Custom icons and standalone manifest for "Add to Home Screen" support.
|
- 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
|
## 🕹 The Haumdaucher Game
|
||||||
- **Engine**: HTML5 Canvas rendering.
|
- **Engine**: HTML5 Canvas rendering.
|
||||||
- **Controls**: Touch-responsive (horizontal drag) and Keyboard (Arrow Keys).
|
- **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 login
|
||||||
gcloud auth application-default 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
|
## 🛠 Local Development Requirements
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,6 +6,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"setup:env": "./scripts/setup-local-env.sh",
|
"setup:env": "./scripts/setup-local-env.sh",
|
||||||
|
"test": "vitest",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|
@ -15,12 +16,15 @@
|
||||||
"vuefire": "^3.2.2"
|
"vuefire": "^3.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^16.11.1",
|
"@types/node": "^20.19.27",
|
||||||
"@vitejs/plugin-vue": "^4.5.2",
|
"@vitejs/plugin-vue": "^4.5.2",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.4.0",
|
"@vue/tsconfig": "^0.4.0",
|
||||||
|
"happy-dom": "^20.0.11",
|
||||||
"typescript": "~5.2.2",
|
"typescript": "~5.2.2",
|
||||||
"vite": "^4.5.3",
|
"vite": "^4.5.3",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"vitest": "^4.0.16",
|
||||||
"vue-tsc": "^1.8.8"
|
"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']
|
const themes = ['classic', 'unicorn', 'luxury', 'win95', 'nat']
|
||||||
|
|
||||||
import { useAuth } from '../../composables/useAuth'
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="fancy-glass header-top">
|
<header class="fancy-glass header-top">
|
||||||
<div class="container top-content">
|
<div class="container top-content">
|
||||||
|
|
@ -21,6 +27,7 @@ const { user, login, logout } = useAuth()
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<!-- Auth Control -->
|
<!-- Auth Control -->
|
||||||
<div class="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">
|
<button v-if="!user" @click="login" class="login-btn">
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -261,4 +268,11 @@ button.active {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-error {
|
||||||
|
color: #ff4444;
|
||||||
|
margin-right: 8px;
|
||||||
|
cursor: help;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ import { doc, onSnapshot } from 'firebase/firestore'
|
||||||
const user = ref<User | null>(null)
|
const user = ref<User | null>(null)
|
||||||
const isAllowed = ref(false)
|
const isAllowed = ref(false)
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
// Watch auth state
|
// Watch auth state
|
||||||
auth.onAuthStateChanged((u) => {
|
auth.onAuthStateChanged((u) => {
|
||||||
|
console.log("🔥 Auth State Changed:", u ? u.email : "Logged Out")
|
||||||
user.value = u
|
user.value = u
|
||||||
if (!u) {
|
if (!u) {
|
||||||
isAllowed.value = false
|
isAllowed.value = false
|
||||||
|
|
@ -34,7 +36,7 @@ watchEffect((onCleanup) => {
|
||||||
}
|
}
|
||||||
}, (err) => {
|
}, (err) => {
|
||||||
console.error("Failed to check allowlist", 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
|
isLoading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -43,22 +45,26 @@ watchEffect((onCleanup) => {
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const provider = new GoogleAuthProvider()
|
const provider = new GoogleAuthProvider()
|
||||||
await signInWithPopup(auth, provider)
|
await signInWithPopup(auth, provider)
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error("Login failed", e)
|
console.error("Login failed", e)
|
||||||
|
error.value = e.message
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
|
error.value = null
|
||||||
try {
|
try {
|
||||||
await signOut(auth)
|
await signOut(auth)
|
||||||
user.value = null
|
user.value = null
|
||||||
isAllowed.value = false
|
isAllowed.value = false
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error("Logout failed", e)
|
console.error("Logout failed", e)
|
||||||
|
error.value = e.message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,6 +72,7 @@ export function useAuth() {
|
||||||
user,
|
user,
|
||||||
isAllowed: computed(() => isAllowed.value),
|
isAllowed: computed(() => isAllowed.value),
|
||||||
isLoading: computed(() => isLoading.value),
|
isLoading: computed(() => isLoading.value),
|
||||||
|
error: computed(() => error.value),
|
||||||
login,
|
login,
|
||||||
logout
|
logout
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,13 @@ resource "google_identity_platform_config" "default" {
|
||||||
provider = google-beta
|
provider = google-beta
|
||||||
project = var.project_id
|
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)
|
# Enable Google Sign-In (and others if needed, but keeping it simple)
|
||||||
sign_in {
|
sign_in {
|
||||||
allow_duplicate_emails = false
|
allow_duplicate_emails = false
|
||||||
|
|
@ -86,6 +93,18 @@ resource "google_identity_platform_config" "default" {
|
||||||
depends_on = [google_project_service.identitytoolkit]
|
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
|
# 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.
|
# 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.
|
# 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"
|
default = "europe-west3"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
variable "allowed_users" {
|
variable "allowed_users" {
|
||||||
description = "List of email addresses allowed to access restricted features"
|
description = "List of email addresses allowed to access restricted features"
|
||||||
type = list(string)
|
type = list(string)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
@ -36,5 +37,9 @@ export default defineConfig({
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]
|
],
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
globals: true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue