Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 85 additions & 14 deletions openserverless/impl/auth/oidc_device_flow_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,7 @@ def start(self, requested_namespace=None):
verifier, challenge = self._create_pkce_pair()
response = self._http_client.post(
self._device_authorization_url(),
data={
"client_id": self._client_id(),
"scope": self._environ.get("OIDC_DEVICE_SCOPE", "openid email profile"),
"code_challenge": challenge,
"code_challenge_method": "S256",
},
data=self._device_authorization_form(challenge),
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
Expand All @@ -66,7 +61,7 @@ def start(self, requested_namespace=None):

if response.status_code >= 400:
return res_builder.build_error_message(
payload.get("error_description") or payload.get("error") or "Unable to start SSO login",
self._provider_error_message(payload, "Unable to start SSO login"),
502,
)

Expand Down Expand Up @@ -104,12 +99,8 @@ def poll(self, flow_id):
try:
response = self._http_client.post(
self._token_url(),
data={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"client_id": self._client_id(),
"device_code": flow["device_code"],
"code_verifier": flow["verifier"],
},
data=self._token_form(flow),
auth=self._client_auth(),
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
Expand All @@ -131,7 +122,7 @@ def poll(self, flow_id):
if response.status_code >= 400:
self._store.pop(flow_id, None)
return res_builder.build_error_message(
payload.get("error_description") or error or "SSO login failed",
self._provider_error_message(payload, "SSO login failed"),
401,
)

Expand All @@ -146,9 +137,89 @@ def poll(self, flow_id):
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"
Expand Down
61 changes: 56 additions & 5 deletions openserverless/rest/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,12 @@ def start_oidc_device_login():
schema:
$ref: '#/definitions/Message'
"""
return OidcDeviceFlowService().start(
requested_namespace=_requested_namespace_from_origin(
request.headers.get("Origin"),
request.host,
)
body = request.get_json(silent=True) or {}
requested_namespace = body.get("namespace") or _requested_namespace_from_origin(
request.headers.get("Origin"),
request.host,
)
return OidcDeviceFlowService().start(requested_namespace=requested_namespace)


@app.route('/system/api/v1/auth/oidc/device/poll', methods=['POST'])
Expand Down Expand Up @@ -265,3 +265,54 @@ def poll_oidc_device_login():
"""
body = request.get_json(silent=True) or {}
return OidcDeviceFlowService().poll(body.get("flow_id"))


@app.route('/system/api/v1/auth/oidc/password', methods=['POST'])
def oidc_password_login():
"""
Backend-managed OIDC password grant login
---
tags:
- Authentication Api
summary: Authenticate user with OIDC password grant
description: admin-api exchanges user credentials with the configured OIDC provider, validates the returned token and returns OpenServerless namespace data. User password and OIDC tokens are not returned.
operationId: oidcPasswordLogin
consumes:
- application/json
parameters:
- in: body
name: OidcPasswordLogin
required: true
schema:
type: object
properties:
username:
type: string
password:
type: string
namespace:
type: string
responses:
200:
description: Authentication successful. Returns OpenServerless user data.
schema:
$ref: '#/definitions/MessageData'
400:
description: Missing username or password.
schema:
$ref: '#/definitions/Message'
401:
description: SSO login failed.
schema:
$ref: '#/definitions/Message'
502:
description: admin-api could not authenticate with the configured OIDC provider.
schema:
$ref: '#/definitions/Message'
"""
body = request.get_json(silent=True) or {}
return OidcDeviceFlowService().password(
body.get("username"),
body.get("password"),
requested_namespace=body.get("namespace"),
)
Loading
Loading