# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
"""IAM Identity Center PKCE authentication.
Uses SSO OIDC directly:
1. RegisterClient -> obtain clientId/clientSecret
2. PKCE authorize via browser -> capture auth code on loopback
3. CreateToken (authorization_code grant) -> access + refresh tokens
4. CreateToken (refresh_token grant) -> silent refresh
Tokens cached in ~/.launch-with-aws/session.json (mode 0600).
"""
import base64
import hashlib
import html
import json
import os
import secrets
import signal
import stat
import subprocess
import sys
import tempfile
import threading
import time
from dataclasses import asdict
from http.server import BaseHTTPRequestHandler
from typing import Any, Optional, Tuple
from urllib.parse import parse_qs, urlencode, urlparse
from launch_config import (
AUTH_WAIT_POLL_INTERVAL_SECS,
CALLBACK_TIMEOUT_SECS,
CLIENT_NAME,
DEFAULT_TOKEN_LIFETIME_SECS,
ENV_SCOPES,
MAX_SESSION_LIFETIME_SECS,
SCOPES,
SESSION_DIR,
SESSION_FILE_NAME,
SSO_OIDC_REGION,
TOKEN_EXPIRY_BUFFER_SECS,
ClientCredentials,
StoredSession,
resolve_issuer_url,
)
__version__ = "0.1.0"
class SessionExpiredError(Exception):
"""Raised when the session cannot be refreshed non-interactively."""
pass
def _session_dir() -> str:
return os.path.expanduser(SESSION_DIR)
def _session_file() -> str:
return os.path.join(_session_dir(), SESSION_FILE_NAME)
def _create_sso_oidc_client(region: str) -> Any:
import boto3
from botocore.config import Config as BotoConfig
config = BotoConfig(user_agent_extra=f"md/awslabs#launch-with-aws#{__version__}")
return boto3.client("sso-oidc", region_name=region, config=config)
def _generate_pkce() -> Tuple[str, str]:
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode("ascii")
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return code_verifier, code_challenge
def _generate_state() -> str:
return secrets.token_hex(16)
# ── Session persistence ──────────────────────────────────────────────────
def load_session() -> Optional[StoredSession]:
try:
with open(_session_file()) as f:
return StoredSession.from_json(f.read())
except (OSError, ValueError, TypeError, KeyError):
return None
def save_session(session: StoredSession) -> None:
directory = _session_dir()
os.makedirs(directory, exist_ok=True)
os.chmod(directory, stat.S_IRWXU)
old_umask = os.umask(0o077)
try:
fd, tmp_path = tempfile.mkstemp(dir=directory, suffix=".tmp")
with os.fdopen(fd, "w") as f:
f.write(session.to_json(indent=2))
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
os.replace(tmp_path, _session_file())
finally:
os.umask(old_umask)
def has_valid_session() -> bool:
session = load_session()
return bool(session and session.token_expires_at > int(time.time()) + TOKEN_EXPIRY_BUFFER_SECS)
# ── Client registration ──────────────────────────────────────────────────
def _resolve_scopes() -> list[str]:
env_val = os.environ.get(ENV_SCOPES)
if env_val:
return [s.strip() for s in env_val.split(",") if s.strip()]
return list(SCOPES)
def _register_client() -> ClientCredentials:
sso_oidc = _create_sso_oidc_client(SSO_OIDC_REGION)
scopes = _resolve_scopes()
kwargs: dict[str, Any] = {
"clientName": CLIENT_NAME,
"clientType": "public",
"grantTypes": ["authorization_code", "refresh_token"],
"scopes": scopes,
"issuerUrl": resolve_issuer_url(),
"redirectUris": ["http://127.0.0.1"],
}
response = sso_oidc.register_client(**kwargs)
client_id = response.get("clientId")
client_secret = response.get("clientSecret")
if not client_id or not client_secret:
raise RuntimeError("Client registration response missing credentials")
# Cap the local session lifetime at MAX_SESSION_LIFETIME_SECS. A
# clientSecretExpiresAt of 0 means the IdP set no expiry; otherwise use the
# sooner of the IdP value and the cap.
expires_at_sec = response.get("clientSecretExpiresAt") or 0
now = int(time.time())
cap = now + MAX_SESSION_LIFETIME_SECS
client_expires_at = cap if expires_at_sec == 0 else min(int(expires_at_sec), cap)
return ClientCredentials(
client_id=client_id,
client_secret=client_secret,
client_expires_at=client_expires_at,
authorize_endpoint=f"https://oidc.{SSO_OIDC_REGION}.amazonaws.com/authorize",
token_endpoint=f"https://oidc.{SSO_OIDC_REGION}.amazonaws.com/token",
scopes=scopes,
)
# ── Token exchange ────────────────────────────────────────────────────────
def _exchange_code(
credentials: ClientCredentials,
code: str,
code_verifier: str,
redirect_uri: str,
) -> Tuple[str, str, int]:
sso_oidc = _create_sso_oidc_client(SSO_OIDC_REGION)
response = sso_oidc.create_token(
clientId=credentials.client_id,
clientSecret=credentials.client_secret,
grantType="authorization_code",
code=code,
codeVerifier=code_verifier,
redirectUri=redirect_uri,
)
access_token = response.get("accessToken")
refresh_token = response.get("refreshToken")
if not access_token or not refresh_token:
raise RuntimeError("Token exchange response missing tokens")
expires_in = response.get("expiresIn") or DEFAULT_TOKEN_LIFETIME_SECS
return access_token, refresh_token, int(time.time()) + expires_in
def _refresh_token(session: StoredSession) -> Tuple[str, str, int]:
sso_oidc = _create_sso_oidc_client(SSO_OIDC_REGION)
response = sso_oidc.create_token(
clientId=session.client_id,
clientSecret=session.client_secret,
grantType="refresh_token",
refreshToken=session.refresh_token,
)
access_token = response.get("accessToken")
if not access_token:
raise RuntimeError("Token refresh response missing tokens")
refresh_token = response.get("refreshToken") or session.refresh_token
expires_in = response.get("expiresIn") or DEFAULT_TOKEN_LIFETIME_SECS
return access_token, refresh_token, int(time.time()) + expires_in
# ── Loopback callback server ─────────────────────────────────────────────
class _OAuthState:
def __init__(self, expected_state: str) -> None:
self.expected_state = expected_state
self.auth_code: Optional[str] = None
self.error: Optional[str] = None
self.code_received = threading.Event()
class _CallbackHandler(BaseHTTPRequestHandler):
def _respond(self, status: int, message: str) -> None:
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
body = f'
{html.escape(message)}
'
self.wfile.write(body.encode("utf-8"))
def do_GET(self) -> None: # noqa: N802
state: _OAuthState = self.server.oauth_state # type: ignore[attr-defined]
params = parse_qs(urlparse(self.path).query)
error = params.get("error", [None])[0]
if error:
desc = params.get("error_description", [error])[0]
self._respond(400, f"Authorization failed: {desc}")
state.error = f"Authorization denied: {desc}"
state.code_received.set()
return
returned_state = params.get("state", [None])[0]
code = params.get("code", [None])[0]
if returned_state != state.expected_state:
if not code:
self.send_response(204)
self.end_headers()
return
self._respond(400, "State mismatch")
state.error = "OAuth state mismatch - possible CSRF"
state.code_received.set()
return
if not code:
self.send_response(204)
self.end_headers()
return
self._respond(200, "Authenticated - you can close this tab.")
state.auth_code = code
state.code_received.set()
def log_message(self, format: str, *args: object) -> None:
pass
# ── Public API ────────────────────────────────────────────────────────────
def get_access_token() -> str:
"""Return a valid Bearer access token (non-interactive).
Tries the cached token and silent refresh. If neither works, raises
SessionExpiredError so the caller can direct the user to auth-start.
"""
session = load_session()
now = int(time.time())
# 1. Valid cached access token.
if session and session.token_expires_at > now + TOKEN_EXPIRY_BUFFER_SECS:
return session.access_token
# 2. Try silent refresh.
if session and session.client_expires_at > now:
try:
access_token, refresh_tok, token_expires_at = _refresh_token(session)
updated = StoredSession(
**{
**asdict(session),
"access_token": access_token,
"refresh_token": refresh_tok,
"token_expires_at": token_expires_at,
}
)
save_session(updated)
return updated.access_token
except Exception:
pass
raise SessionExpiredError("Session expired or not authenticated. Run auth-start to sign in.")
def session_status() -> dict:
"""Report the local session state without triggering authentication.
Returns a dict describing whether a session exists, whether its access
token is currently usable, and how long until the access token and the
overall session registration expire (seconds, clamped at 0).
"""
session = load_session()
if not session:
return {"authenticated": False}
now = int(time.time())
token_valid = session.token_expires_at > now + TOKEN_EXPIRY_BUFFER_SECS
return {
"authenticated": token_valid,
"tokenExpiresInSecs": max(0, session.token_expires_at - now),
"sessionExpiresInSecs": max(0, session.client_expires_at - now),
"canRefresh": session.client_expires_at > now,
}
def sign_out() -> dict:
"""Sign out: delete the local session so the next call requires re-auth.
Best-effort revokes the refresh token, then removes the local session file.
Revocation failures do not block local deletion.
"""
session = load_session()
if session and session.refresh_token:
try:
sso_oidc = _create_sso_oidc_client(SSO_OIDC_REGION)
# revoke_token is not exposed on all endpoints; call it only when
# available.
revoke = getattr(sso_oidc, "revoke_token", None)
if callable(revoke):
revoke(
clientId=session.client_id,
clientSecret=session.client_secret,
token=session.refresh_token,
tokenTypeHint="refresh_token",
)
except Exception:
pass
removed = False
try:
os.unlink(_session_file())
removed = True
except FileNotFoundError:
pass
return {"signedOut": True, "sessionRemoved": removed}
# ── Non-blocking auth (auth-start / auth-wait) ──────────────────────────
def start_auth() -> dict:
"""Attempt auth non-blockingly. Returns immediately.
Returns:
- {authenticated: True, reusedCachedSession: True} if cached token valid
- {authenticated: True, reusedCachedSession: False} if silent refresh worked
- {authenticated: False, signInUrl: ..., pid: N, port: N} if interactive needed
"""
session = load_session()
now = int(time.time())
if session and session.token_expires_at > now + TOKEN_EXPIRY_BUFFER_SECS:
return {"authenticated": True, "reusedCachedSession": True}
if session and session.client_expires_at > now:
try:
access_token, refresh_tok, token_expires_at = _refresh_token(session)
updated = StoredSession(
**{
**asdict(session),
"access_token": access_token,
"refresh_token": refresh_tok,
"token_expires_at": token_expires_at,
}
)
save_session(updated)
return {"authenticated": True, "reusedCachedSession": False}
except Exception:
pass
credentials: ClientCredentials
if session and session.client_expires_at > now:
credentials = session
else:
credentials = _register_client()
code_verifier, code_challenge = _generate_pkce()
state_value = _generate_state()
server_script = os.path.join(os.path.dirname(__file__), "auth_callback_server.py")
# Configuration values are passed to the child over stdin as a JSON blob.
proc = subprocess.Popen(
[
sys.executable,
server_script,
"--state",
state_value,
"--timeout",
str(int(CALLBACK_TIMEOUT_SECS)),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.PIPE,
start_new_session=True,
)
# Write the config to the child over stdin, then close it so the child sees EOF.
assert proc.stdin is not None
proc.stdin.write(
json.dumps(
{
"client_id": credentials.client_id,
"client_secret": credentials.client_secret,
"client_expires_at": credentials.client_expires_at,
"scopes": credentials.scopes,
"code_verifier": code_verifier,
}
).encode()
)
proc.stdin.close()
# Child writes port to stdout once bound.
assert proc.stdout is not None
assert proc.stderr is not None
port_line = proc.stdout.readline().decode().strip()
proc.stdout.close()
if not port_line.isdigit():
proc.terminate()
err_output = proc.stderr.read().decode().strip()
proc.stderr.close()
detail = err_output or port_line or "no output"
raise RuntimeError(f"Callback server failed to start: {detail}")
proc.stderr.close()
port = int(port_line)
redirect_uri = f"http://127.0.0.1:{port}"
authorize_url = f"{credentials.authorize_endpoint}?" + urlencode(
{
"response_type": "code",
"client_id": credentials.client_id,
"redirect_uri": redirect_uri,
"state": state_value,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"scope": " ".join(credentials.scopes),
}
)
return {
"authenticated": False,
"signInUrl": authorize_url,
"pid": proc.pid,
"port": port,
}
def wait_for_auth(pid: int, timeout: Optional[float] = None) -> dict:
"""Block until the background callback server completes auth.
Args:
pid: PID of the background callback server (from start_auth).
timeout: Max seconds to wait (default: CALLBACK_TIMEOUT_SECS).
Returns:
{authenticated: True} on success.
Raises:
TimeoutError if timeout exceeded.
RuntimeError if the server exited without completing auth.
"""
timeout = timeout or CALLBACK_TIMEOUT_SECS
deadline = time.time() + timeout
while time.time() < deadline:
if has_valid_session():
return {"authenticated": True}
try:
os.kill(pid, 0)
except OSError:
if has_valid_session():
return {"authenticated": True}
raise RuntimeError(
"Auth callback server exited without completing authentication. "
"Run auth-start again."
)
time.sleep(AUTH_WAIT_POLL_INTERVAL_SECS)
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass
raise TimeoutError(f"Authentication timed out after {int(timeout)} seconds")