airflow-file-auth-manager 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- airflow_file_auth_manager/__init__.py +37 -0
- airflow_file_auth_manager/cli.py +266 -0
- airflow_file_auth_manager/endpoints.py +174 -0
- airflow_file_auth_manager/file_auth_manager.py +413 -0
- airflow_file_auth_manager/password.py +94 -0
- airflow_file_auth_manager/policy.py +187 -0
- airflow_file_auth_manager/static/styles.css +89 -0
- airflow_file_auth_manager/templates/login.html +216 -0
- airflow_file_auth_manager/user.py +72 -0
- airflow_file_auth_manager/user_store.py +369 -0
- airflow_file_auth_manager-0.1.0.dist-info/METADATA +357 -0
- airflow_file_auth_manager-0.1.0.dist-info/RECORD +15 -0
- airflow_file_auth_manager-0.1.0.dist-info/WHEEL +4 -0
- airflow_file_auth_manager-0.1.0.dist-info/entry_points.txt +5 -0
- airflow_file_auth_manager-0.1.0.dist-info/licenses/LICENSE +176 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""File-based Auth Manager for Apache Airflow 3.x.
|
|
2
|
+
|
|
3
|
+
A lightweight YAML file-based authentication manager that supports:
|
|
4
|
+
- bcrypt password hashing
|
|
5
|
+
- Three-tier role system (admin, editor, viewer)
|
|
6
|
+
- JWT token-based session management
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from airflow_file_auth_manager.password import (
|
|
10
|
+
PasswordPolicyError,
|
|
11
|
+
hash_password,
|
|
12
|
+
validate_password,
|
|
13
|
+
verify_password,
|
|
14
|
+
)
|
|
15
|
+
from airflow_file_auth_manager.policy import FileAuthPolicy, Role
|
|
16
|
+
from airflow_file_auth_manager.user import FileUser
|
|
17
|
+
from airflow_file_auth_manager.user_store import UserStore
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
# FileAuthManager requires Airflow, so import lazily
|
|
22
|
+
try:
|
|
23
|
+
from airflow_file_auth_manager.file_auth_manager import FileAuthManager
|
|
24
|
+
except ImportError:
|
|
25
|
+
FileAuthManager = None # type: ignore[misc, assignment]
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"FileAuthManager",
|
|
29
|
+
"FileAuthPolicy",
|
|
30
|
+
"FileUser",
|
|
31
|
+
"PasswordPolicyError",
|
|
32
|
+
"Role",
|
|
33
|
+
"UserStore",
|
|
34
|
+
"hash_password",
|
|
35
|
+
"validate_password",
|
|
36
|
+
"verify_password",
|
|
37
|
+
]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""CLI commands for managing file auth users."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from airflow_file_auth_manager.password import hash_password
|
|
12
|
+
from airflow_file_auth_manager.user_store import UserStore
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def add_user(args: argparse.Namespace) -> None:
|
|
19
|
+
"""Add a new user to the users file."""
|
|
20
|
+
store = UserStore(args.file)
|
|
21
|
+
|
|
22
|
+
# Get password interactively if not provided
|
|
23
|
+
password = args.password
|
|
24
|
+
if not password:
|
|
25
|
+
password = getpass.getpass("Password: ")
|
|
26
|
+
confirm = getpass.getpass("Confirm password: ")
|
|
27
|
+
if password != confirm:
|
|
28
|
+
print("Error: Passwords do not match", file=sys.stderr)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
if not password:
|
|
32
|
+
print("Error: Password cannot be empty", file=sys.stderr)
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
user = store.add_user(
|
|
37
|
+
username=args.username,
|
|
38
|
+
password=password,
|
|
39
|
+
role=args.role,
|
|
40
|
+
email=args.email or "",
|
|
41
|
+
first_name=args.firstname or "",
|
|
42
|
+
last_name=args.lastname or "",
|
|
43
|
+
)
|
|
44
|
+
store.save()
|
|
45
|
+
print(f"User '{user.username}' added with role '{user.role}'")
|
|
46
|
+
except ValueError as e:
|
|
47
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def update_user(args: argparse.Namespace) -> None:
|
|
52
|
+
"""Update an existing user."""
|
|
53
|
+
store = UserStore(args.file)
|
|
54
|
+
|
|
55
|
+
# Get password interactively if flag is set
|
|
56
|
+
password = None
|
|
57
|
+
if args.password:
|
|
58
|
+
password = getpass.getpass("New password: ")
|
|
59
|
+
confirm = getpass.getpass("Confirm password: ")
|
|
60
|
+
if password != confirm:
|
|
61
|
+
print("Error: Passwords do not match", file=sys.stderr)
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
user = store.update_user(
|
|
66
|
+
username=args.username,
|
|
67
|
+
password=password,
|
|
68
|
+
role=args.role,
|
|
69
|
+
email=args.email,
|
|
70
|
+
first_name=args.firstname,
|
|
71
|
+
last_name=args.lastname,
|
|
72
|
+
active=args.active,
|
|
73
|
+
)
|
|
74
|
+
store.save()
|
|
75
|
+
print(f"User '{user.username}' updated")
|
|
76
|
+
except ValueError as e:
|
|
77
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def delete_user(args: argparse.Namespace) -> None:
|
|
82
|
+
"""Delete a user."""
|
|
83
|
+
store = UserStore(args.file)
|
|
84
|
+
|
|
85
|
+
if not args.yes:
|
|
86
|
+
confirm = input(f"Delete user '{args.username}'? [y/N]: ")
|
|
87
|
+
if confirm.lower() != "y":
|
|
88
|
+
print("Aborted")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
store.delete_user(args.username)
|
|
93
|
+
store.save()
|
|
94
|
+
print(f"User '{args.username}' deleted")
|
|
95
|
+
except ValueError as e:
|
|
96
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def list_users(args: argparse.Namespace) -> None:
|
|
101
|
+
"""List all users."""
|
|
102
|
+
store = UserStore(args.file)
|
|
103
|
+
users = store.get_all_users()
|
|
104
|
+
|
|
105
|
+
if not users:
|
|
106
|
+
print("No users found")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
# Table header
|
|
110
|
+
print(f"{'Username':<20} {'Role':<10} {'Email':<30} {'Active':<8}")
|
|
111
|
+
print("-" * 70)
|
|
112
|
+
|
|
113
|
+
for user in users:
|
|
114
|
+
active = "Yes" if user.is_active else "No"
|
|
115
|
+
print(f"{user.username:<20} {user.role:<10} {user.email:<30} {active:<8}")
|
|
116
|
+
|
|
117
|
+
print(f"\nTotal: {len(users)} user(s)")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def hash_password_cmd(args: argparse.Namespace) -> None:
|
|
121
|
+
"""Generate a password hash."""
|
|
122
|
+
password = args.password
|
|
123
|
+
if not password:
|
|
124
|
+
password = getpass.getpass("Password: ")
|
|
125
|
+
|
|
126
|
+
if not password:
|
|
127
|
+
print("Error: Password cannot be empty", file=sys.stderr)
|
|
128
|
+
sys.exit(1)
|
|
129
|
+
|
|
130
|
+
hashed = hash_password(password)
|
|
131
|
+
print(hashed)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def init_file(args: argparse.Namespace) -> None:
|
|
135
|
+
"""Initialize a new users file with an admin user."""
|
|
136
|
+
file_path = Path(args.file)
|
|
137
|
+
|
|
138
|
+
if file_path.exists() and not args.force:
|
|
139
|
+
print(f"Error: File already exists: {file_path}", file=sys.stderr)
|
|
140
|
+
print("Use --force to overwrite", file=sys.stderr)
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
# Get admin password
|
|
144
|
+
password = args.password
|
|
145
|
+
if not password:
|
|
146
|
+
password = getpass.getpass("Admin password: ")
|
|
147
|
+
confirm = getpass.getpass("Confirm password: ")
|
|
148
|
+
if password != confirm:
|
|
149
|
+
print("Error: Passwords do not match", file=sys.stderr)
|
|
150
|
+
sys.exit(1)
|
|
151
|
+
|
|
152
|
+
if not password:
|
|
153
|
+
print("Error: Password cannot be empty", file=sys.stderr)
|
|
154
|
+
sys.exit(1)
|
|
155
|
+
|
|
156
|
+
store = UserStore(file_path)
|
|
157
|
+
store.add_user(
|
|
158
|
+
username="admin",
|
|
159
|
+
password=password,
|
|
160
|
+
role="admin",
|
|
161
|
+
email=args.email or "",
|
|
162
|
+
)
|
|
163
|
+
store.save()
|
|
164
|
+
print(f"Created users file: {file_path}")
|
|
165
|
+
print("Admin user created with username 'admin'")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
169
|
+
"""Create the argument parser."""
|
|
170
|
+
parser = argparse.ArgumentParser(
|
|
171
|
+
prog="airflow file-auth",
|
|
172
|
+
description="Manage file-based authentication users",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
|
176
|
+
|
|
177
|
+
# add-user command
|
|
178
|
+
add_parser = subparsers.add_parser("add-user", help="Add a new user")
|
|
179
|
+
add_parser.add_argument("-f", "--file", required=True, help="Path to users YAML file")
|
|
180
|
+
add_parser.add_argument("-u", "--username", required=True, help="Username")
|
|
181
|
+
add_parser.add_argument("-p", "--password", help="Password (will prompt if not provided)")
|
|
182
|
+
add_parser.add_argument(
|
|
183
|
+
"-r", "--role",
|
|
184
|
+
required=True,
|
|
185
|
+
choices=["admin", "editor", "viewer"],
|
|
186
|
+
help="User role",
|
|
187
|
+
)
|
|
188
|
+
add_parser.add_argument("-e", "--email", help="Email address")
|
|
189
|
+
add_parser.add_argument("--firstname", help="First name")
|
|
190
|
+
add_parser.add_argument("--lastname", help="Last name")
|
|
191
|
+
add_parser.set_defaults(func=add_user)
|
|
192
|
+
|
|
193
|
+
# update-user command
|
|
194
|
+
update_parser = subparsers.add_parser("update-user", help="Update a user")
|
|
195
|
+
update_parser.add_argument("-f", "--file", required=True, help="Path to users YAML file")
|
|
196
|
+
update_parser.add_argument("-u", "--username", required=True, help="Username to update")
|
|
197
|
+
update_parser.add_argument("-p", "--password", action="store_true", help="Change password")
|
|
198
|
+
update_parser.add_argument(
|
|
199
|
+
"-r", "--role",
|
|
200
|
+
choices=["admin", "editor", "viewer"],
|
|
201
|
+
help="New role",
|
|
202
|
+
)
|
|
203
|
+
update_parser.add_argument("-e", "--email", help="New email")
|
|
204
|
+
update_parser.add_argument("--firstname", help="New first name")
|
|
205
|
+
update_parser.add_argument("--lastname", help="New last name")
|
|
206
|
+
update_parser.add_argument("--active", type=lambda x: x.lower() == "true", help="Set active status (true/false)")
|
|
207
|
+
update_parser.set_defaults(func=update_user)
|
|
208
|
+
|
|
209
|
+
# delete-user command
|
|
210
|
+
delete_parser = subparsers.add_parser("delete-user", help="Delete a user")
|
|
211
|
+
delete_parser.add_argument("-f", "--file", required=True, help="Path to users YAML file")
|
|
212
|
+
delete_parser.add_argument("-u", "--username", required=True, help="Username to delete")
|
|
213
|
+
delete_parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
|
|
214
|
+
delete_parser.set_defaults(func=delete_user)
|
|
215
|
+
|
|
216
|
+
# list-users command
|
|
217
|
+
list_parser = subparsers.add_parser("list-users", help="List all users")
|
|
218
|
+
list_parser.add_argument("-f", "--file", required=True, help="Path to users YAML file")
|
|
219
|
+
list_parser.set_defaults(func=list_users)
|
|
220
|
+
|
|
221
|
+
# hash-password command
|
|
222
|
+
hash_parser = subparsers.add_parser("hash-password", help="Generate bcrypt password hash")
|
|
223
|
+
hash_parser.add_argument("-p", "--password", help="Password to hash (will prompt if not provided)")
|
|
224
|
+
hash_parser.set_defaults(func=hash_password_cmd)
|
|
225
|
+
|
|
226
|
+
# init command
|
|
227
|
+
init_parser = subparsers.add_parser("init", help="Initialize users file with admin user")
|
|
228
|
+
init_parser.add_argument("-f", "--file", required=True, help="Path for new users YAML file")
|
|
229
|
+
init_parser.add_argument("-p", "--password", help="Admin password (will prompt if not provided)")
|
|
230
|
+
init_parser.add_argument("-e", "--email", help="Admin email")
|
|
231
|
+
init_parser.add_argument("--force", action="store_true", help="Overwrite existing file")
|
|
232
|
+
init_parser.set_defaults(func=init_file)
|
|
233
|
+
|
|
234
|
+
return parser
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def main(args: list[str] | None = None) -> None:
|
|
238
|
+
"""Main entry point for CLI."""
|
|
239
|
+
parser = create_parser()
|
|
240
|
+
parsed_args = parser.parse_args(args)
|
|
241
|
+
|
|
242
|
+
if not parsed_args.command:
|
|
243
|
+
parser.print_help()
|
|
244
|
+
sys.exit(1)
|
|
245
|
+
|
|
246
|
+
parsed_args.func(parsed_args)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# Airflow plugin for CLI integration (optional)
|
|
250
|
+
try:
|
|
251
|
+
from airflow.plugins_manager import AirflowPlugin
|
|
252
|
+
|
|
253
|
+
class FileAuthCLIPlugin(AirflowPlugin):
|
|
254
|
+
"""Airflow plugin to register file-auth CLI commands."""
|
|
255
|
+
|
|
256
|
+
name = "file_auth_cli"
|
|
257
|
+
# Note: Airflow 3.x CLI plugin registration differs from 2.x
|
|
258
|
+
# For now, the CLI can be used directly via `python -m airflow_file_auth_manager.cli`
|
|
259
|
+
|
|
260
|
+
except ImportError:
|
|
261
|
+
# Airflow not installed, CLI can still be used standalone
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
main()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""FastAPI endpoints for file-based authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Annotated
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, Form, Request, Response
|
|
10
|
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
11
|
+
from fastapi.staticfiles import StaticFiles
|
|
12
|
+
from jinja2 import Environment, FileSystemLoader
|
|
13
|
+
|
|
14
|
+
from airflow.configuration import conf
|
|
15
|
+
|
|
16
|
+
from airflow_file_auth_manager.password import verify_password
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from airflow_file_auth_manager.file_auth_manager import FileAuthManager
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Template and static file paths
|
|
24
|
+
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
25
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_auth_app(auth_manager: FileAuthManager) -> FastAPI:
|
|
29
|
+
"""Create FastAPI app with authentication endpoints.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
auth_manager: The FileAuthManager instance.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
FastAPI app with /login, /token, and /logout endpoints.
|
|
36
|
+
"""
|
|
37
|
+
app = FastAPI(
|
|
38
|
+
title="File Auth Manager",
|
|
39
|
+
description="YAML file-based authentication for Apache Airflow",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Setup Jinja2 templates
|
|
43
|
+
jinja_env = Environment(
|
|
44
|
+
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
|
45
|
+
autoescape=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Mount static files if directory exists
|
|
49
|
+
if STATIC_DIR.exists():
|
|
50
|
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
51
|
+
|
|
52
|
+
# Get JWT expiration from config
|
|
53
|
+
jwt_expiration = conf.getint("api_auth", "jwt_expiration_seconds", fallback=36000)
|
|
54
|
+
|
|
55
|
+
@app.get("/login", response_class=HTMLResponse)
|
|
56
|
+
async def login_page(request: Request, next: str | None = None, error: str | None = None) -> HTMLResponse:
|
|
57
|
+
"""Render the login page."""
|
|
58
|
+
template = jinja_env.get_template("login.html")
|
|
59
|
+
html = template.render(
|
|
60
|
+
next_url=next or "/",
|
|
61
|
+
error=error,
|
|
62
|
+
)
|
|
63
|
+
return HTMLResponse(content=html)
|
|
64
|
+
|
|
65
|
+
@app.post("/token")
|
|
66
|
+
async def create_token(
|
|
67
|
+
request: Request,
|
|
68
|
+
response: Response,
|
|
69
|
+
username: Annotated[str | None, Form()] = None,
|
|
70
|
+
password: Annotated[str | None, Form()] = None,
|
|
71
|
+
) -> Response:
|
|
72
|
+
"""Authenticate user and create JWT token.
|
|
73
|
+
|
|
74
|
+
Supports both form submission (browser) and JSON API requests.
|
|
75
|
+
|
|
76
|
+
Security features:
|
|
77
|
+
- Browser sessions: HttpOnly cookies (protected from XSS)
|
|
78
|
+
- API clients: Bearer tokens in response body (use Authorization header)
|
|
79
|
+
"""
|
|
80
|
+
# Handle JSON request
|
|
81
|
+
content_type = request.headers.get("content-type", "")
|
|
82
|
+
is_form_submission = "application/x-www-form-urlencoded" in content_type
|
|
83
|
+
|
|
84
|
+
if "application/json" in content_type:
|
|
85
|
+
try:
|
|
86
|
+
body = await request.json()
|
|
87
|
+
username = body.get("username")
|
|
88
|
+
password = body.get("password")
|
|
89
|
+
except Exception:
|
|
90
|
+
return JSONResponse(
|
|
91
|
+
status_code=400,
|
|
92
|
+
content={"error": "Invalid JSON body"},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Validate input
|
|
96
|
+
if not username or not password:
|
|
97
|
+
logger.warning("AUDIT: Login attempt with missing credentials")
|
|
98
|
+
if is_form_submission:
|
|
99
|
+
return RedirectResponse(
|
|
100
|
+
url="/auth/file/login?error=Username+and+password+required",
|
|
101
|
+
status_code=303,
|
|
102
|
+
)
|
|
103
|
+
return JSONResponse(
|
|
104
|
+
status_code=400,
|
|
105
|
+
content={"error": "Username and password required"},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Authenticate user
|
|
109
|
+
user = auth_manager.user_store.authenticate(username, password)
|
|
110
|
+
if not user:
|
|
111
|
+
logger.warning("AUDIT: Failed login attempt for user: %s (IP: %s)",
|
|
112
|
+
username, request.client.host if request.client else "unknown")
|
|
113
|
+
if is_form_submission:
|
|
114
|
+
return RedirectResponse(
|
|
115
|
+
url="/auth/file/login?error=Invalid+username+or+password",
|
|
116
|
+
status_code=303,
|
|
117
|
+
)
|
|
118
|
+
return JSONResponse(
|
|
119
|
+
status_code=401,
|
|
120
|
+
content={"error": "Invalid username or password"},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Create JWT token
|
|
124
|
+
from airflow.api_fastapi.auth.tokens import create_jwt_token
|
|
125
|
+
|
|
126
|
+
token_payload = auth_manager.serialize_user(user)
|
|
127
|
+
token = create_jwt_token(token_payload, expiration_seconds=jwt_expiration)
|
|
128
|
+
|
|
129
|
+
logger.info("AUDIT: User logged in: %s (IP: %s)",
|
|
130
|
+
username, request.client.host if request.client else "unknown")
|
|
131
|
+
|
|
132
|
+
# Form submission - set HttpOnly cookie and redirect
|
|
133
|
+
if is_form_submission:
|
|
134
|
+
form_data = await request.form()
|
|
135
|
+
next_url = form_data.get("next", "/")
|
|
136
|
+
|
|
137
|
+
# Detect if using HTTPS
|
|
138
|
+
is_secure = (
|
|
139
|
+
request.url.scheme == "https"
|
|
140
|
+
or request.headers.get("x-forwarded-proto") == "https"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
redirect_response = RedirectResponse(url=str(next_url), status_code=303)
|
|
144
|
+
redirect_response.set_cookie(
|
|
145
|
+
key="airflow_jwt",
|
|
146
|
+
value=token,
|
|
147
|
+
max_age=jwt_expiration,
|
|
148
|
+
httponly=True, # Protect from XSS attacks
|
|
149
|
+
secure=is_secure,
|
|
150
|
+
samesite="lax",
|
|
151
|
+
)
|
|
152
|
+
return redirect_response
|
|
153
|
+
|
|
154
|
+
# JSON API - return token in response body
|
|
155
|
+
# Client should use this token in Authorization header: "Bearer <token>"
|
|
156
|
+
return JSONResponse(
|
|
157
|
+
content={
|
|
158
|
+
"access_token": token,
|
|
159
|
+
"token_type": "Bearer",
|
|
160
|
+
"expires_in": jwt_expiration,
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@app.get("/logout")
|
|
165
|
+
async def logout(request: Request) -> RedirectResponse:
|
|
166
|
+
"""Log out user by clearing JWT cookie."""
|
|
167
|
+
logger.info("AUDIT: User logged out (IP: %s)",
|
|
168
|
+
request.client.host if request.client else "unknown")
|
|
169
|
+
|
|
170
|
+
redirect_response = RedirectResponse(url="/auth/file/login", status_code=303)
|
|
171
|
+
redirect_response.delete_cookie(key="airflow_jwt")
|
|
172
|
+
return redirect_response
|
|
173
|
+
|
|
174
|
+
return app
|