| # 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. | |
| # | |
| from openserverless import app | |
| from openserverless.impl.auth.auth_service import AuthService | |
| from openserverless.impl.auth.oidc_device_flow_service import OidcDeviceFlowService | |
| from openserverless.security.ow_authorize import ow_authorize | |
| from flask import request | |
| import openserverless.common.response_builder as res_builder | |
| from flasgger import swag_from | |
| from urllib.parse import urlparse | |
| def _extract_bearer_token(): | |
| authorization = request.headers.get("Authorization", "") | |
| if authorization.lower().startswith("bearer "): | |
| return authorization.split(" ", 1)[1].strip() | |
| body = request.get_json(silent=True) or {} | |
| return body.get("access_token") | |
| def _requested_namespace_from_origin(origin, api_host): | |
| if not origin: | |
| return None | |
| origin_host = urlparse(origin).hostname | |
| if not origin_host: | |
| return None | |
| api_hostname = (api_host or "").split(":", 1)[0] | |
| suffix = f".{api_hostname}" | |
| if origin_host == api_hostname or not origin_host.endswith(suffix): | |
| return None | |
| candidate = origin_host[: -len(suffix)] | |
| if "." in candidate or not candidate: | |
| return None | |
| return candidate | |
| @app.route('/system/api/v1/auth/<login>',methods=['PATCH']) | |
| @ow_authorize(pass_user_data=True) | |
| def password(login, **kwargs): | |
| """ | |
| Update User Password | |
| --- | |
| tags: | |
| - Authentication Api | |
| summary: Update user password | |
| description: Update the user password by patching the corresponding WhiskUser entry | |
| operationId: updateUserPassword | |
| security: | |
| - openwhiskBasicAuth: [] | |
| consumes: | |
| - application/json | |
| parameters: | |
| - in: path | |
| name: login | |
| description: The username requiring the password update | |
| required: true | |
| type: string | |
| - in: body | |
| name: PasswordUpdate | |
| description: Password update payload containing current and new password | |
| required: true | |
| schema: | |
| $ref: '#/definitions/LoginUpdateData' | |
| responses: | |
| 200: | |
| description: Password updated successfully | |
| schema: | |
| $ref: '#/definitions/MessageData' | |
| 400: | |
| description: Bad request. Missing required fields. | |
| schema: | |
| $ref: '#/definitions/Message' | |
| 401: | |
| description: Unauthorized. Invalid credentials or authorization token. | |
| schema: | |
| $ref: '#/definitions/Message' | |
| """ | |
| update_data = request.get_json() | |
| if 'ow-auth-user' in kwargs: | |
| authorized_data = kwargs['ow-auth-user'] | |
| if login not in authorized_data['login']: | |
| return res_builder.build_error_message(f"invalid AUTH token for user {login}", 401) | |
| auth_service = AuthService() | |
| return auth_service.update_password(login,update_data['password'],update_data['new_password']) | |
| @app.route('/system/api/v1/auth',methods=['POST']) | |
| def login(): | |
| """ | |
| User Authentication | |
| --- | |
| tags: | |
| - Authentication Api | |
| summary: Authenticate user with login credentials | |
| description: Perform user authentication using credentials stored in CouchDB metadata | |
| operationId: authenticateUser | |
| consumes: | |
| - application/json | |
| parameters: | |
| - in: body | |
| name: LoginCredentials | |
| description: User login credentials | |
| required: true | |
| schema: | |
| $ref: '#/definitions/LoginData' | |
| responses: | |
| 200: | |
| description: Authentication successful. Returns user data including environment variables and quota. | |
| schema: | |
| $ref: '#/definitions/MessageData' | |
| 400: | |
| description: Bad request. Missing login or password. | |
| schema: | |
| $ref: '#/definitions/Message' | |
| 401: | |
| description: Unauthorized. Invalid credentials. | |
| schema: | |
| $ref: '#/definitions/Message' | |
| """ | |
| login_data = request.get_json() | |
| auth_service = AuthService() | |
| return auth_service.login(login_data['login'], login_data['password']) | |
| @app.route('/system/api/v1/auth/oidc',methods=['POST']) | |
| def login_oidc(): | |
| """ | |
| User Authentication with OIDC | |
| --- | |
| tags: | |
| - Authentication Api | |
| summary: Authenticate user with an OIDC access token | |
| description: Validate a Keycloak OIDC token and map it to an existing OpenServerless namespace. | |
| operationId: authenticateUserOidc | |
| consumes: | |
| - application/json | |
| parameters: | |
| - in: header | |
| name: Authorization | |
| description: Bearer token issued by the configured OIDC provider | |
| required: false | |
| type: string | |
| - in: body | |
| name: OidcCredentials | |
| description: Optional JSON payload containing access_token when Authorization header is not used | |
| required: false | |
| schema: | |
| type: object | |
| properties: | |
| access_token: | |
| type: string | |
| responses: | |
| 200: | |
| description: Authentication successful. Returns OpenServerless user data. | |
| schema: | |
| $ref: '#/definitions/MessageData' | |
| 401: | |
| description: Unauthorized. Invalid or missing OIDC token. | |
| schema: | |
| $ref: '#/definitions/Message' | |
| 403: | |
| description: Forbidden. Token is valid but required group is missing. | |
| schema: | |
| $ref: '#/definitions/Message' | |
| 404: | |
| description: OIDC user is valid but no OpenServerless namespace is provisioned. | |
| schema: | |
| $ref: '#/definitions/Message' | |
| """ | |
| auth_service = AuthService() | |
| return auth_service.login_oidc(_extract_bearer_token()) | |
| @app.route('/system/api/v1/auth/oidc/device/start', methods=['POST']) | |
| def start_oidc_device_login(): | |
| """ | |
| Start backend-managed OIDC Device Authorization login | |
| --- | |
| tags: | |
| - Authentication Api | |
| summary: Start an SSO login flow managed by admin-api | |
| description: admin-api uses cluster SSO configuration to start OIDC device login and returns only an opaque flow id and user verification data. | |
| operationId: startOidcDeviceLogin | |
| responses: | |
| 200: | |
| description: SSO device login flow started. | |
| schema: | |
| type: object | |
| 502: | |
| description: admin-api could not start login with the configured OIDC provider. | |
| schema: | |
| $ref: '#/definitions/Message' | |
| """ | |
| 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']) | |
| def poll_oidc_device_login(): | |
| """ | |
| Poll backend-managed OIDC Device Authorization login | |
| --- | |
| tags: | |
| - Authentication Api | |
| summary: Poll an SSO login flow managed by admin-api | |
| description: admin-api exchanges device credentials with the configured OIDC provider, validates the token and returns OpenServerless namespace data. OIDC tokens are not returned to the browser. | |
| operationId: pollOidcDeviceLogin | |
| consumes: | |
| - application/json | |
| parameters: | |
| - in: body | |
| name: OidcDevicePoll | |
| required: true | |
| schema: | |
| type: object | |
| properties: | |
| flow_id: | |
| type: string | |
| responses: | |
| 200: | |
| description: Authentication successful. Returns OpenServerless user data. | |
| schema: | |
| $ref: '#/definitions/MessageData' | |
| 202: | |
| description: Login is still pending at the identity provider. | |
| schema: | |
| type: object | |
| 400: | |
| description: Login flow expired. | |
| schema: | |
| $ref: '#/definitions/Message' | |
| 401: | |
| description: SSO login failed. | |
| schema: | |
| $ref: '#/definitions/Message' | |
| 404: | |
| description: Unknown login flow or namespace not provisioned. | |
| schema: | |
| $ref: '#/definitions/Message' | |
| """ | |
| 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"), | |
| ) |