feat(auth): add OAuth2 client flow + token store for GCP client onboarding
This commit is contained in:
parent
5c1693994e
commit
63f8bd289c
1
auth/__init__.py
Normal file
1
auth/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# auth package — OAuth2 client flow for OSVauco
|
||||||
91
auth/oauth_flow.py
Normal file
91
auth/oauth_flow.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""
|
||||||
|
auth/oauth_flow.py — Google OAuth2 flow for klient-onboarding.
|
||||||
|
|
||||||
|
Klienten trykker /auth/login?client_id=X
|
||||||
|
→ redirect til Google OAuth2 consent screen
|
||||||
|
→ Google redirecter til /auth/callback?code=...&state=...
|
||||||
|
→ token lagres i Secret Manager under klient-ID
|
||||||
|
→ klienten redirectes til /static/billing-dashboard.html
|
||||||
|
|
||||||
|
Scopes (read-only):
|
||||||
|
- bigquery.readonly
|
||||||
|
- cloud-billing.readonly
|
||||||
|
|
||||||
|
Krever env-vars:
|
||||||
|
OAUTH_CLIENT_ID — fra GCP OAuth2 credentials
|
||||||
|
OAUTH_CLIENT_SECRET — fra GCP OAuth2 credentials
|
||||||
|
OAUTH_REDIRECT_URI — f.eks. https://osvauco-agent-....run.app/auth/callback
|
||||||
|
PROJECT_ID — GCP project for Secret Manager
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from google_auth_oauthlib.flow import Flow
|
||||||
|
|
||||||
|
SCOPES = [
|
||||||
|
"https://www.googleapis.com/auth/bigquery.readonly",
|
||||||
|
"https://www.googleapis.com/auth/cloud-billing.readonly",
|
||||||
|
"openid",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
]
|
||||||
|
|
||||||
|
_CLIENT_CONFIG = {
|
||||||
|
"web": {
|
||||||
|
"client_id": os.environ.get("OAUTH_CLIENT_ID", ""),
|
||||||
|
"client_secret": os.environ.get("OAUTH_CLIENT_SECRET", ""),
|
||||||
|
"redirect_uris": [os.environ.get("OAUTH_REDIRECT_URI", "http://localhost:8080/auth/callback")],
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# In-memory state store (nonce → client_id). For prod: bytt til Firestore/Redis.
|
||||||
|
_STATE_STORE: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_authorization_url(client_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Genererer Google OAuth2 autoriseringsURL for gitt client_id.
|
||||||
|
Returnerer URL klienten skal redirectes til.
|
||||||
|
"""
|
||||||
|
flow = Flow.from_client_config(_CLIENT_CONFIG, scopes=SCOPES)
|
||||||
|
flow.redirect_uri = os.environ.get(
|
||||||
|
"OAUTH_REDIRECT_URI", "http://localhost:8080/auth/callback"
|
||||||
|
)
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
_STATE_STORE[state] = client_id
|
||||||
|
auth_url, _ = flow.authorization_url(
|
||||||
|
access_type="offline",
|
||||||
|
include_granted_scopes="true",
|
||||||
|
state=state,
|
||||||
|
prompt="consent",
|
||||||
|
)
|
||||||
|
return auth_url
|
||||||
|
|
||||||
|
|
||||||
|
def exchange_code_for_token(code: str, state: str) -> tuple[str, dict]:
|
||||||
|
"""
|
||||||
|
Bytter OAuth2 code mot token.
|
||||||
|
Returnerer (client_id, token_dict).
|
||||||
|
Kaster ValueError hvis state er ukjent.
|
||||||
|
"""
|
||||||
|
client_id = _STATE_STORE.pop(state, None)
|
||||||
|
if client_id is None:
|
||||||
|
raise ValueError(f"Ukjent OAuth2 state: {state}")
|
||||||
|
|
||||||
|
flow = Flow.from_client_config(_CLIENT_CONFIG, scopes=SCOPES, state=state)
|
||||||
|
flow.redirect_uri = os.environ.get(
|
||||||
|
"OAUTH_REDIRECT_URI", "http://localhost:8080/auth/callback"
|
||||||
|
)
|
||||||
|
flow.fetch_token(code=code)
|
||||||
|
creds = flow.credentials
|
||||||
|
token_dict = {
|
||||||
|
"token": creds.token,
|
||||||
|
"refresh_token": creds.refresh_token,
|
||||||
|
"token_uri": creds.token_uri,
|
||||||
|
"client_id": creds.client_id,
|
||||||
|
"client_secret": creds.client_secret,
|
||||||
|
"scopes": list(creds.scopes or []),
|
||||||
|
}
|
||||||
|
return client_id, token_dict
|
||||||
76
auth/token_store.py
Normal file
76
auth/token_store.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""
|
||||||
|
auth/token_store.py — Lagre og hente OAuth2-tokens per klient i Secret Manager.
|
||||||
|
|
||||||
|
Hvert token lagres som en JSON-streng under secret-navn:
|
||||||
|
osvauco-client-token-{client_id}
|
||||||
|
|
||||||
|
Bruk:
|
||||||
|
from auth.token_store import save_token, load_credentials
|
||||||
|
|
||||||
|
save_token("klient-abc", token_dict)
|
||||||
|
creds = load_credentials("klient-abc") # google.oauth2.credentials.Credentials
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from google.cloud import secretmanager
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
|
||||||
|
PROJECT_ID = os.environ.get("PROJECT_ID", "propane-will-491900-m5")
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_name(client_id: str) -> str:
|
||||||
|
return f"osvauco-client-token-{client_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def save_token(client_id: str, token_dict: dict) -> None:
|
||||||
|
"""
|
||||||
|
Lagrer eller oppdaterer OAuth2-token for klient i Secret Manager.
|
||||||
|
Oppretter secret hvis den ikke finnes.
|
||||||
|
"""
|
||||||
|
client = secretmanager.SecretManagerServiceClient()
|
||||||
|
parent = f"projects/{PROJECT_ID}"
|
||||||
|
secret_id = _secret_name(client_id)
|
||||||
|
secret_path = f"{parent}/secrets/{secret_id}"
|
||||||
|
payload = json.dumps(token_dict).encode("utf-8")
|
||||||
|
|
||||||
|
# Opprett secret hvis den ikke finnes
|
||||||
|
try:
|
||||||
|
client.get_secret(name=secret_path)
|
||||||
|
except Exception:
|
||||||
|
client.create_secret(
|
||||||
|
request={
|
||||||
|
"parent": parent,
|
||||||
|
"secret_id": secret_id,
|
||||||
|
"secret": {"replication": {"automatic": {}}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legg til ny versjon (ny verdi)
|
||||||
|
client.add_secret_version(
|
||||||
|
request={"parent": secret_path, "payload": {"data": payload}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_credentials(client_id: str) -> Credentials:
|
||||||
|
"""
|
||||||
|
Henter siste token for klient fra Secret Manager.
|
||||||
|
Returnerer google.oauth2.credentials.Credentials klar til bruk.
|
||||||
|
Kaster KeyError hvis client_id ikke finnes.
|
||||||
|
"""
|
||||||
|
sm_client = secretmanager.SecretManagerServiceClient()
|
||||||
|
secret_path = f"projects/{PROJECT_ID}/secrets/{_secret_name(client_id)}/versions/latest"
|
||||||
|
try:
|
||||||
|
response = sm_client.access_secret_version(name=secret_path)
|
||||||
|
except Exception as exc:
|
||||||
|
raise KeyError(f"Ingen token funnet for klient '{client_id}': {exc}") from exc
|
||||||
|
|
||||||
|
token_dict = json.loads(response.payload.data.decode("utf-8"))
|
||||||
|
return Credentials(
|
||||||
|
token=token_dict.get("token"),
|
||||||
|
refresh_token=token_dict.get("refresh_token"),
|
||||||
|
token_uri=token_dict.get("token_uri", "https://oauth2.googleapis.com/token"),
|
||||||
|
client_id=token_dict.get("client_id"),
|
||||||
|
client_secret=token_dict.get("client_secret"),
|
||||||
|
scopes=token_dict.get("scopes"),
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user