Reorder config imports so that instance config is dead last - and can override everything else.

Updated docker-compose for running a demo.
run_pyl fixes
This commit is contained in:
Dan 2022-12-01 14:12:25 -05:00
parent 8a21450ff7
commit 186727e371
11 changed files with 123 additions and 105 deletions

View File

@ -6,46 +6,46 @@ services:
platform: linux/amd64 platform: linux/amd64
cap_add: cap_add:
- SYS_NICE - SYS_NICE
restart: "${SPIFFWORKFLOW_BACKEND_DATABASE_DOCKER_RESTART_POLICY:-no}" restart: "no"
environment: environment:
- MYSQL_DATABASE=${SPIFFWORKFLOW_BACKEND_DATABASE_NAME:-spiffworkflow_backend_development} - MYSQL_DATABASE=spiffworkflow_backend_development
- MYSQL_ROOT_PASSWORD=${SPIFFWORKFLOW_BACKEND_MYSQL_ROOT_DATABASE:-my-secret-pw} - MYSQL_ROOT_PASSWORD=my-secret-pw
- MYSQL_TCP_PORT=7003 - MYSQL_TCP_PORT=7003
ports: ports:
- "7003" - "7003"
volumes:
- spiffworkflow_backend:/var/lib/mysql
healthcheck: healthcheck:
test: mysql --user=root --password=${SPIFFWORKFLOW_BACKEND_MYSQL_ROOT_DATABASE:-my-secret-pw} -e 'select 1' ${SPIFFWORKFLOW_BACKEND_DATABASE_NAME:-spiffworkflow_backend_development} test: mysql --user=root --password=my-secret-pw -e 'select 1' spiffworkflow_backend_development
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
spiffworkflow-backend: spiffworkflow-backend:
container_name: spiffworkflow-backend container_name: spiffworkflow-backend
image: ghcr.io/sartography/spiffworkflow-backend image: ghcr.io/sartography/spiffworkflow-backend:latest
depends_on: depends_on:
spiffworkflow-db: spiffworkflow-db:
condition: service_healthy condition: service_healthy
environment: environment:
- APPLICATION_ROOT=/ - APPLICATION_ROOT=/
- SPIFFWORKFLOW_BACKEND_ENV=${SPIFFWORKFLOW_BACKEND_ENV:-development} - SPIFFWORKFLOW_BACKEND_ENV=development
- FLASK_DEBUG=0 - FLASK_DEBUG=0
- FLASK_SESSION_SECRET_KEY=${FLASK_SESSION_SECRET_KEY:-super_secret_key} - FLASK_SESSION_SECRET_KEY=super_secret_key
- OPEN_ID_SERVER_URL=${OPEN_ID_SERVER_URL:-http://spiffworkflow-openid:7002} - OPEN_ID_SERVER_URL=http://localhost:7000/openid
- SPIFFWORKFLOW_FRONTEND_URL=${SPIFFWORKFLOW_FRONTEND_URL:-http://spiffworkflow-frontend:7001} - SPIFFWORKFLOW_FRONTEND_URL=http://localhost:7001
- SPIFFWORKFLOW_BACKEND_URL=${SPIFFWORKFLOW_BACKEND_URL:-http://spiffworkflow-backend:7000} - SPIFFWORKFLOW_BACKEND_URL=http://localhost:7000
- SPIFFWORKFLOW_BACKEND_PORT=7000 - SPIFFWORKFLOW_BACKEND_PORT=7000
- SPIFFWORKFLOW_BACKEND_UPGRADE_DB=true - SPIFFWORKFLOW_BACKEND_UPGRADE_DB=true
- SPIFFWORKFLOW_BACKEND_DATABASE_URI=mysql+mysqlconnector://root:${SPIFFWORKFLOW_BACKEND_MYSQL_ROOT_DATABASE:-my-secret-pw}@spiffworkflow-db:7003/${SPIFFWORKFLOW_BACKEND_DATABASE_NAME:-spiffworkflow_backend_development} - SPIFFWORKFLOW_BACKEND_DATABASE_URI=mysql+mysqlconnector://root:my-secret-pw@spiffworkflow-db:7003/spiffworkflow_backend_development
- BPMN_SPEC_ABSOLUTE_DIR=/app/process_models - BPMN_SPEC_ABSOLUTE_DIR=/app/process_models
- SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA=${SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA:-false} - SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA=false
- SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=${SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME:-acceptance_tests.yml} - SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=demo.yml
- RUN_BACKGROUND_SCHEDULER=true - RUN_BACKGROUND_SCHEDULER=true
- OPEN_ID_CLIENT_ID=spiffworkflow-backend
- OPEN_ID_CLIENT_SECRET_KEY=my_open_id_secret_key
ports: ports:
- "7000:7000" - "7000:7000"
volumes: volumes:
- ${BPMN_SPEC_ABSOLUTE_DIR:-./../sample-process-models}:/app/process_models - ./process_models:/app/process_models
- ./log:/app/log - ./log:/app/log
healthcheck: healthcheck:
test: curl localhost:7000/v1.0/status --fail test: curl localhost:7000/v1.0/status --fail

View File

@ -19,7 +19,9 @@ from werkzeug.exceptions import NotFound
import spiffworkflow_backend.load_database_models # noqa: F401 import spiffworkflow_backend.load_database_models # noqa: F401
from spiffworkflow_backend.config import setup_config from spiffworkflow_backend.config import setup_config
from spiffworkflow_backend.routes.admin_blueprint.admin_blueprint import admin_blueprint from spiffworkflow_backend.routes.admin_blueprint.admin_blueprint import admin_blueprint
from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import openid_blueprint from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import (
openid_blueprint,
)
from spiffworkflow_backend.routes.process_api_blueprint import process_api_blueprint from spiffworkflow_backend.routes.process_api_blueprint import process_api_blueprint
from spiffworkflow_backend.routes.user import verify_token from spiffworkflow_backend.routes.user import verify_token
from spiffworkflow_backend.routes.user_blueprint import user_blueprint from spiffworkflow_backend.routes.user_blueprint import user_blueprint

View File

@ -52,12 +52,6 @@ def setup_config(app: Flask) -> None:
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config.from_object("spiffworkflow_backend.config.default") app.config.from_object("spiffworkflow_backend.config.default")
# This allows config/testing.py or instance/config.py to override the default config
if "ENV_IDENTIFIER" in app.config and app.config["ENV_IDENTIFIER"] == "testing":
app.config.from_pyfile("config/testing.py", silent=True)
else:
app.config.from_pyfile(f"{app.instance_path}/config.py", silent=True)
env_config_prefix = "spiffworkflow_backend.config." env_config_prefix = "spiffworkflow_backend.config."
env_config_module = env_config_prefix + app.config["ENV_IDENTIFIER"] env_config_module = env_config_prefix + app.config["ENV_IDENTIFIER"]
try: try:
@ -73,6 +67,12 @@ def setup_config(app: Flask) -> None:
f"Cannot find config module: {env_config_module}" f"Cannot find config module: {env_config_module}"
) from exception ) from exception
# This allows config/testing.py or instance/config.py to override the default config
if "ENV_IDENTIFIER" in app.config and app.config["ENV_IDENTIFIER"] == "testing":
app.config.from_pyfile("config/testing.py", silent=True)
else:
app.config.from_pyfile(f"{app.instance_path}/config.py", silent=True)
setup_database_uri(app) setup_database_uri(app)
setup_logger(app) setup_logger(app)

View File

@ -30,7 +30,11 @@ CONNECTOR_PROXY_URL = environ.get(
GIT_COMMIT_ON_SAVE = environ.get("GIT_COMMIT_ON_SAVE", default="false") == "true" GIT_COMMIT_ON_SAVE = environ.get("GIT_COMMIT_ON_SAVE", default="false") == "true"
# Open ID server # Open ID server
OPEN_ID_SERVER_URL = environ.get("OPEN_ID_SERVER_URL", default="http://localhost:7002/realms/spiffworkflow") OPEN_ID_SERVER_URL = environ.get(
"OPEN_ID_SERVER_URL", default="http://localhost:7002/realms/spiffworkflow"
)
# Replace above line with this to use the built-in Open ID Server.
# OPEN_ID_SERVER_URL = environ.get("OPEN_ID_SERVER_URL", default="http://localhost:7000/openid")
OPEN_ID_CLIENT_ID = environ.get("OPEN_ID_CLIENT_ID", default="spiffworkflow-backend") OPEN_ID_CLIENT_ID = environ.get("OPEN_ID_CLIENT_ID", default="spiffworkflow-backend")
OPEN_ID_CLIENT_SECRET_KEY = environ.get( OPEN_ID_CLIENT_SECRET_KEY = environ.get(
"OPEN_ID_CLIENT_SECRET_KEY", default="JXeQExm0JhQPLumgHtIIqf52bDalHz0q" "OPEN_ID_CLIENT_SECRET_KEY", default="JXeQExm0JhQPLumgHtIIqf52bDalHz0q"

View File

@ -30,7 +30,8 @@ class UserModel(SpiffworkflowBaseDBModel):
__table_args__ = (db.UniqueConstraint("service", "service_id", name="service_key"),) __table_args__ = (db.UniqueConstraint("service", "service_id", name="service_key"),)
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(255), nullable=False, unique=False) # server and service id must be unique, not username. # server and service id must be unique, not username.
username = db.Column(db.String(255), nullable=False, unique=False)
uid = db.Column(db.String(50), unique=True) uid = db.Column(db.String(50), unique=True)
service = db.Column(db.String(50), nullable=False, unique=False) service = db.Column(db.String(50), nullable=False, unique=False)
service_id = db.Column(db.String(255), nullable=False, unique=False) service_id = db.Column(db.String(255), nullable=False, unique=False)

View File

@ -9,7 +9,12 @@ from urllib.parse import urlencode
import jwt import jwt
import yaml import yaml
from flask import Blueprint, render_template, request, current_app, redirect, url_for from flask import Blueprint
from flask import current_app
from flask import redirect
from flask import render_template
from flask import request
from flask import url_for
openid_blueprint = Blueprint( openid_blueprint = Blueprint(
"openid", __name__, template_folder="templates", static_folder="static" "openid", __name__, template_folder="templates", static_folder="static"
@ -21,8 +26,8 @@ MY_SECRET_CODE = ":this_is_not_secure_do_not_use_in_production"
@openid_blueprint.route("/.well-known/openid-configuration", methods=["GET"]) @openid_blueprint.route("/.well-known/openid-configuration", methods=["GET"])
def well_known(): def well_known():
"""OpenID Discovery endpoint -- as these urls can be very different from system to system, """OpenID Discovery endpoint -- as these urls can be very different from system to system,
this is just a small subset.""" this is just a small subset."""
host_url = request.host_url.strip('/') host_url = request.host_url.strip("/")
return { return {
"issuer": f"{host_url}/openid", "issuer": f"{host_url}/openid",
"authorization_endpoint": f"{host_url}{url_for('openid.auth')}", "authorization_endpoint": f"{host_url}{url_for('openid.auth')}",
@ -34,82 +39,90 @@ def well_known():
@openid_blueprint.route("/auth", methods=["GET"]) @openid_blueprint.route("/auth", methods=["GET"])
def auth(): def auth():
"""Accepts a series of parameters""" """Accepts a series of parameters"""
return render_template('login.html', return render_template(
state=request.args.get('state'), "login.html",
response_type=request.args.get('response_type'), state=request.args.get("state"),
client_id=request.args.get('client_id'), response_type=request.args.get("response_type"),
scope=request.args.get('scope'), client_id=request.args.get("client_id"),
redirect_uri=request.args.get('redirect_uri'), scope=request.args.get("scope"),
error_message=request.args.get('error_message', '')) redirect_uri=request.args.get("redirect_uri"),
error_message=request.args.get("error_message", ""),
)
@openid_blueprint.route("/form_submit", methods=["POST"]) @openid_blueprint.route("/form_submit", methods=["POST"])
def form_submit(): def form_submit():
users = get_users() users = get_users()
if request.values['Uname'] in users and request.values['Pass'] == users[request.values['Uname']]["password"]: if (
request.values["Uname"] in users
and request.values["Pass"] == users[request.values["Uname"]]["password"]
):
# Redirect back to the end user with some detailed information # Redirect back to the end user with some detailed information
state = request.values.get('state') state = request.values.get("state")
data = { data = {
"state": state, "state": state,
"code": request.values['Uname'] + MY_SECRET_CODE, "code": request.values["Uname"] + MY_SECRET_CODE,
"session_state": "" "session_state": "",
} }
url = request.values.get('redirect_uri') + "?" + urlencode(data) url = request.values.get("redirect_uri") + "?" + urlencode(data)
return redirect(url) return redirect(url)
else: else:
return render_template('login.html', return render_template(
state=request.values.get('state'), "login.html",
response_type=request.values.get('response_type'), state=request.values.get("state"),
client_id=request.values.get('client_id'), response_type=request.values.get("response_type"),
scope=request.values.get('scope'), client_id=request.values.get("client_id"),
redirect_uri=request.values.get('redirect_uri'), scope=request.values.get("scope"),
error_message="Login failed. Please try again.") redirect_uri=request.values.get("redirect_uri"),
error_message="Login failed. Please try again.",
)
@openid_blueprint.route("/token", methods=["POST"]) @openid_blueprint.route("/token", methods=["POST"])
def token(): def token():
"""Url that will return a valid token, given the super secret sauce""" """Url that will return a valid token, given the super secret sauce"""
grant_type = request.values.get('grant_type') request.values.get("grant_type")
code = request.values.get('code') code = request.values.get("code")
redirect_uri = request.values.get('redirect_uri') request.values.get("redirect_uri")
"""We just stuffed the user name on the front of the code, so grab it.""" """We just stuffed the user name on the front of the code, so grab it."""
user_name, secret_hash = code.split(":") user_name, secret_hash = code.split(":")
user_details = get_users()[user_name] user_details = get_users()[user_name]
"""Get authentication from headers.""" """Get authentication from headers."""
authorization = request.headers.get('Authorization') authorization = request.headers.get("Authorization")
authorization = authorization[6:] # Remove "Basic" authorization = authorization[6:] # Remove "Basic"
authorization = base64.b64decode(authorization).decode('utf-8') authorization = base64.b64decode(authorization).decode("utf-8")
client_id, client_secret = authorization.split(":") client_id, client_secret = authorization.split(":")
base_url = request.host_url + "openid" base_url = request.host_url + "openid"
access_token = user_name + ":" + "always_good_demo_access_token" access_token = user_name + ":" + "always_good_demo_access_token"
refresh_token = user_name + ":" + "always_good_demo_refresh_token" refresh_token = user_name + ":" + "always_good_demo_refresh_token"
id_token = jwt.encode({ id_token = jwt.encode(
"iss": base_url, {
"aud": [client_id, "account"], "iss": base_url,
"iat": time.time(), "aud": [client_id, "account"],
"exp": time.time() + 86400, # Expire after a day. "iat": time.time(),
"sub": user_name, "exp": time.time() + 86400, # Expire after a day.
"preferred_username": user_details.get('preferred_username', user_name) "sub": user_name,
}, "preferred_username": user_details.get("preferred_username", user_name),
},
client_secret, client_secret,
algorithm="HS256", algorithm="HS256",
) )
response = { response = {
"access_token": id_token, "access_token": id_token,
"id_token": id_token, "id_token": id_token,
"refresh_token": id_token "refresh_token": id_token,
} }
return response return response
@openid_blueprint.route("/end_session", methods=["GET"]) @openid_blueprint.route("/end_session", methods=["GET"])
def end_session(): def end_session():
redirect_url = request.args.get('post_logout_redirect_uri') redirect_url = request.args.get("post_logout_redirect_uri")
id_token_hint = request.args.get('id_token_hint') request.args.get("id_token_hint")
return redirect(redirect_url) return redirect(redirect_url)

View File

@ -15,12 +15,9 @@ from flask import request
from flask_bpmn.api.api_error import ApiError from flask_bpmn.api.api_error import ApiError
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
from spiffworkflow_backend.models.group import GroupModel
from spiffworkflow_backend.models.permission_assignment import PermissionAssignmentModel
from spiffworkflow_backend.models.principal import PrincipalModel
from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.authentication_service import ( from spiffworkflow_backend.services.authentication_service import (
AuthenticationService, AuthenticationProviderTypes, AuthenticationService,
) )
from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.user_service import UserService from spiffworkflow_backend.services.user_service import UserService
@ -91,7 +88,7 @@ def verify_token(
if auth_token and "error" not in auth_token: if auth_token and "error" not in auth_token:
# We have the user, but this code is a bit convoluted, and will later demand # We have the user, but this code is a bit convoluted, and will later demand
# a user_info object so it can look up the user. Sorry to leave this crap here. # a user_info object so it can look up the user. Sorry to leave this crap here.
user_info = {"sub": user.service_id } user_info = {"sub": user.service_id}
else: else:
raise ae raise ae
else: else:
@ -200,16 +197,18 @@ def login(redirect_url: str = "/") -> Response:
) )
return redirect(login_redirect_url) return redirect(login_redirect_url)
def parse_id_token(token: str) -> dict: def parse_id_token(token: str) -> dict:
parts = token.split(".") parts = token.split(".")
if len(parts) != 3: if len(parts) != 3:
raise Exception("Incorrect id token format") raise Exception("Incorrect id token format")
payload = parts[1] payload = parts[1]
padded = payload + '=' * (4 - len(payload) % 4) padded = payload + "=" * (4 - len(payload) % 4)
decoded = base64.b64decode(padded) decoded = base64.b64decode(padded)
return json.loads(decoded) return json.loads(decoded)
def login_return(code: str, state: str, session_state: str) -> Optional[Response]: def login_return(code: str, state: str, session_state: str) -> Optional[Response]:
"""Login_return.""" """Login_return."""
state_dict = ast.literal_eval(base64.b64decode(state).decode("utf-8")) state_dict = ast.literal_eval(base64.b64decode(state).decode("utf-8"))

View File

@ -3,7 +3,6 @@ import base64
import enum import enum
import json import json
import time import time
import typing
from typing import Optional from typing import Optional
import jwt import jwt
@ -26,7 +25,10 @@ class AuthenticationProviderTypes(enum.Enum):
class AuthenticationService: class AuthenticationService:
"""AuthenticationService.""" """AuthenticationService."""
ENDPOINT_CACHE = {} # We only need to find the openid endpoints once, then we can cache them.
ENDPOINT_CACHE = (
{}
) # We only need to find the openid endpoints once, then we can cache them.
@staticmethod @staticmethod
def client_id(): def client_id():
@ -40,7 +42,6 @@ class AuthenticationService:
def secret_key(): def secret_key():
return current_app.config["OPEN_ID_CLIENT_SECRET_KEY"] return current_app.config["OPEN_ID_CLIENT_SECRET_KEY"]
@classmethod @classmethod
def open_id_endpoint_for_name(cls, name: str) -> None: def open_id_endpoint_for_name(cls, name: str) -> None:
"""All openid systems provide a mapping of static names to the full path of that endpoint.""" """All openid systems provide a mapping of static names to the full path of that endpoint."""

View File

@ -6,9 +6,10 @@ from typing import Union
import jwt import jwt
import yaml import yaml
from flask import current_app, scaffold from flask import current_app
from flask import g from flask import g
from flask import request from flask import request
from flask import scaffold
from flask_bpmn.api.api_error import ApiError from flask_bpmn.api.api_error import ApiError
from flask_bpmn.models.db import db from flask_bpmn.models.db import db
from SpiffWorkflow.task import Task as SpiffTask # type: ignore from SpiffWorkflow.task import Task as SpiffTask # type: ignore
@ -127,7 +128,6 @@ class AuthorizationService:
db.session.add(user_group_assignemnt) db.session.add(user_group_assignemnt)
db.session.commit() db.session.commit()
@classmethod @classmethod
def import_permissions_from_yaml_file( def import_permissions_from_yaml_file(
cls, raise_if_missing_user: bool = False cls, raise_if_missing_user: bool = False
@ -448,7 +448,6 @@ class AuthorizationService:
email=email, email=email,
) )
# this may eventually get too slow. # this may eventually get too slow.
# when it does, be careful about backgrounding, because # when it does, be careful about backgrounding, because
# the user will immediately need permissions to use the site. # the user will immediately need permissions to use the site.

View File

@ -1,59 +1,58 @@
"""Test_authentication.""" """Test_authentication."""
import ast
import base64
from flask import Flask from flask import Flask
from flask.testing import FlaskClient from flask.testing import FlaskClient
from tests.spiffworkflow_backend.helpers.base_test import BaseTest from tests.spiffworkflow_backend.helpers.base_test import BaseTest
from spiffworkflow_backend.services.authentication_service import (
AuthenticationService,
)
class TestFaskOpenId(BaseTest): class TestFaskOpenId(BaseTest):
"""An integrated Open ID that responds to openID requests """An integrated Open ID that responds to openID requests
by referencing a build in YAML file. Useful for by referencing a build in YAML file. Useful for
local development, testing, demos etc...""" local development, testing, demos etc..."""
def test_discovery_of_endpoints(self, def test_discovery_of_endpoints(
app: Flask, self,
client: FlaskClient, app: Flask,
with_db_and_bpmn_file_cleanup: None,) -> None: client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
) -> None:
response = client.get("/openid/.well-known/openid-configuration") response = client.get("/openid/.well-known/openid-configuration")
discovered_urls = response.json discovered_urls = response.json
assert "http://localhost/openid" == discovered_urls["issuer"] assert "http://localhost/openid" == discovered_urls["issuer"]
assert "http://localhost/openid/auth" == discovered_urls["authorization_endpoint"] assert (
"http://localhost/openid/auth" == discovered_urls["authorization_endpoint"]
)
assert "http://localhost/openid/token" == discovered_urls["token_endpoint"] assert "http://localhost/openid/token" == discovered_urls["token_endpoint"]
def test_get_login_page(self, def test_get_login_page(
app: Flask, self,
client: FlaskClient, app: Flask,
with_db_and_bpmn_file_cleanup: None,) -> None: client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
) -> None:
# It should be possible to get to a login page # It should be possible to get to a login page
data = { data = {"state": {"bubblegum": 1, "daydream": 2}}
"state": {"bubblegum":1, "daydream":2}
}
response = client.get("/openid/auth", query_string=data) response = client.get("/openid/auth", query_string=data)
assert b"<h2>Login to SpiffWorkflow</h2>" in response.data assert b"<h2>Login to SpiffWorkflow</h2>" in response.data
assert b"bubblegum" in response.data assert b"bubblegum" in response.data
def test_get_token(self, def test_get_token(
self,
app: Flask, app: Flask,
client: FlaskClient, client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,) -> None: with_db_and_bpmn_file_cleanup: None,
) -> None:
code = "c3BpZmZ3b3JrZmxvdy1iYWNrZW5kOkpYZVFFeG0wSmhRUEx1bWdIdElJcWY1MmJEYWxIejBx" code = (
"c3BpZmZ3b3JrZmxvdy1iYWNrZW5kOkpYZVFFeG0wSmhRUEx1bWdIdElJcWY1MmJEYWxIejBx"
)
headers = { headers = {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {code}", "Authorization": f"Basic {code}",
} }
data = { data = {
"grant_type": 'authorization_code', "grant_type": "authorization_code",
"code": code, "code": code,
"redirect_url": 'http://localhost:7000/v1.0/login_return' "redirect_url": "http://localhost:7000/v1.0/login_return",
} }
response = client.post("/openid/token", data=data, headers=headers) response = client.post("/openid/token", data=data, headers=headers)
assert response assert response