blob: c8b13d78d4ee107d3e9c419f3f38c9d7c6474976 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
import base64
import hashlib
import os
import secrets
import time
import requests
import openserverless.common.response_builder as res_builder
from openserverless.impl.auth.auth_service import AuthService
_DEVICE_FLOWS = {}
class OidcDeviceFlowService:
def __init__(
self,
environ=os.environ,
http_client=requests,
auth_service=None,
store=None,
now=None,
):
self._environ = environ
self._http_client = http_client
self._auth_service = auth_service if auth_service is not None else AuthService(environ=environ)
self._store = store if store is not None else _DEVICE_FLOWS
self._now = now if now is not None else time.time
def start(self, requested_namespace=None):
try:
verifier, challenge = self._create_pkce_pair()
response = self._http_client.post(
self._device_authorization_url(),
data=self._device_authorization_form(challenge),
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
payload = response.json()
except Exception:
return res_builder.build_error_message("Unable to start SSO login", 502)
if response.status_code >= 400:
return res_builder.build_error_message(
self._provider_error_message(payload, "Unable to start SSO login"),
502,
)
flow_id = secrets.token_urlsafe(32)
expires_in = int(payload.get("expires_in", 600))
self._cleanup_expired()
self._store[flow_id] = {
"device_code": payload["device_code"],
"verifier": verifier,
"expires_at": self._now() + expires_in,
"interval": int(payload.get("interval", 5)),
"requested_namespace": requested_namespace,
}
return res_builder.build_response_with_data(
{
"flow_id": flow_id,
"user_code": payload.get("user_code"),
"verification_uri": payload.get("verification_uri"),
"verification_uri_complete": payload.get("verification_uri_complete"),
"expires_in": expires_in,
"interval": int(payload.get("interval", 5)),
}
)
def poll(self, flow_id):
flow = self._store.get(flow_id)
if not flow:
return res_builder.build_error_message("Invalid SSO flow", 404)
if self._now() >= flow["expires_at"]:
self._store.pop(flow_id, None)
return res_builder.build_error_message("SSO login expired", 400)
try:
response = self._http_client.post(
self._token_url(),
data=self._token_form(flow),
auth=self._client_auth(),
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
payload = response.json()
except Exception:
return res_builder.build_error_message("Unable to poll SSO login", 502)
error = payload.get("error")
if error in ("authorization_pending", "slow_down"):
return res_builder.build_response_with_data(
{
"status": "pending",
"message": error,
"interval": flow["interval"],
},
202,
)
if response.status_code >= 400:
self._store.pop(flow_id, None)
return res_builder.build_error_message(
self._provider_error_message(payload, "SSO login failed"),
401,
)
access_token = payload.get("access_token")
if not access_token:
self._store.pop(flow_id, None)
return res_builder.build_error_message("SSO login did not return an access token", 401)
self._store.pop(flow_id, None)
return self._auth_service.login_oidc(
access_token,
expected_namespace=flow.get("requested_namespace"),
)
def password(self, username, password, requested_namespace=None):
if not username or not password:
return res_builder.build_error_message("Missing SSO username or password", 400)
try:
response = self._http_client.post(
self._token_url(),
data=self._password_token_form(username, password),
auth=self._client_auth(),
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
payload = response.json()
except Exception:
return res_builder.build_error_message("Unable to authenticate with SSO provider", 502)
if response.status_code >= 400:
return res_builder.build_error_message(
self._provider_error_message(
payload,
"SSO password login failed",
extra_sensitive=[password],
),
401,
)
access_token = payload.get("access_token")
if not access_token:
return res_builder.build_error_message("SSO password login did not return an access token", 401)
return self._auth_service.login_oidc(
access_token,
expected_namespace=requested_namespace,
)
def _client_id(self):
return self._environ.get("OIDC_CLIENT_ID") or self._environ.get("OIDC_AUDIENCE")
def _client_secret(self):
return (self._environ.get("OIDC_CLIENT_SECRET") or "").strip()
def _device_authorization_form(self, challenge):
form = {
"client_id": self._client_id(),
"scope": self._environ.get("OIDC_DEVICE_SCOPE", "openid email profile"),
"code_challenge": challenge,
"code_challenge_method": "S256",
}
client_secret = self._client_secret()
if client_secret:
form["client_secret"] = client_secret
return form
def _token_form(self, flow):
return {
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"client_id": self._client_id(),
"device_code": flow["device_code"],
"code_verifier": flow["verifier"],
}
def _password_token_form(self, username, password):
return {
"grant_type": "password",
"client_id": self._client_id(),
"username": username,
"password": password,
"scope": self._environ.get("OIDC_PASSWORD_SCOPE", "openid email profile"),
}
def _client_auth(self):
client_secret = self._client_secret()
if not client_secret:
return None
return (self._client_id(), client_secret)
def _provider_error_message(self, payload, fallback, extra_sensitive=None):
message = payload.get("error_description") or payload.get("error") or fallback
for sensitive in [self._client_secret()] + list(extra_sensitive or []):
if sensitive:
message = message.replace(sensitive, "<redacted>")
return message
def _device_authorization_url(self):
return self._environ.get("OIDC_DEVICE_AUTHORIZATION_URL") or (
f"{self._issuer_url()}/protocol/openid-connect/auth/device"
)
def _token_url(self):
return self._environ.get("OIDC_TOKEN_URL") or (
f"{self._issuer_url()}/protocol/openid-connect/token"
)
def _issuer_url(self):
issuer = self._environ.get("OIDC_ISSUER_URL")
if not issuer:
raise ValueError("missing OIDC_ISSUER_URL")
return issuer.rstrip("/")
def _cleanup_expired(self):
now = self._now()
expired = [flow_id for flow_id, flow in self._store.items() if now >= flow["expires_at"]]
for flow_id in expired:
self._store.pop(flow_id, None)
def _create_pkce_pair(self):
verifier = self._base64_url(secrets.token_bytes(32))
challenge = self._base64_url(hashlib.sha256(verifier.encode("ascii")).digest())
return verifier, challenge
def _base64_url(self, value):
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")