Skip to content

Commit 325edd5

Browse files
steinitzutiangolo
authored andcommitted
✨ Add swagger UI OAuth2 redirect page for implicit/code auth flows in API docs (fastapi#198)
1 parent 08322ef commit 325edd5

File tree

5 files changed

+185
-5
lines changed

5 files changed

+185
-5
lines changed

fastapi/applications.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
from typing import Any, Callable, Dict, List, Optional, Type, Union
22

33
from fastapi import routing
4-
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
4+
from fastapi.openapi.docs import (
5+
get_redoc_html,
6+
get_swagger_ui_html,
7+
get_swagger_ui_oauth2_redirect_html,
8+
)
59
from fastapi.openapi.utils import get_openapi
610
from fastapi.params import Depends
711
from pydantic import BaseModel
@@ -36,6 +40,7 @@ def __init__(
3640
openapi_prefix: str = "",
3741
docs_url: Optional[str] = "/docs",
3842
redoc_url: Optional[str] = "/redoc",
43+
swagger_ui_oauth2_redirect_url: Optional[str] = "/docs/oauth2-redirect",
3944
**extra: Dict[str, Any],
4045
) -> None:
4146
self._debug = debug
@@ -52,6 +57,7 @@ def __init__(
5257
self.openapi_prefix = openapi_prefix.rstrip("/")
5358
self.docs_url = docs_url
5459
self.redoc_url = redoc_url
60+
self.swagger_ui_oauth2_redirect_url = swagger_ui_oauth2_redirect_url
5561
self.extra = extra
5662

5763
self.openapi_version = "3.0.2"
@@ -89,10 +95,23 @@ async def openapi(req: Request) -> JSONResponse:
8995

9096
async def swagger_ui_html(req: Request) -> HTMLResponse:
9197
return get_swagger_ui_html(
92-
openapi_url=openapi_url, title=self.title + " - Swagger UI"
98+
openapi_url=openapi_url,
99+
title=self.title + " - Swagger UI",
100+
oauth2_redirect_url=self.swagger_ui_oauth2_redirect_url,
93101
)
94102

95103
self.add_route(self.docs_url, swagger_ui_html, include_in_schema=False)
104+
105+
if self.swagger_ui_oauth2_redirect_url:
106+
107+
async def swagger_ui_redirect(req: Request) -> HTMLResponse:
108+
return get_swagger_ui_oauth2_redirect_html()
109+
110+
self.add_route(
111+
self.swagger_ui_oauth2_redirect_url,
112+
swagger_ui_redirect,
113+
include_in_schema=False,
114+
)
96115
if self.openapi_url and self.redoc_url:
97116

98117
async def redoc_html(req: Request) -> HTMLResponse:

fastapi/openapi/docs.py

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Optional
2+
13
from starlette.responses import HTMLResponse
24

35

@@ -8,7 +10,9 @@ def get_swagger_ui_html(
810
swagger_js_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui-bundle.js",
911
swagger_css_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.css",
1012
swagger_favicon_url: str = "https://fastapi.tiangolo.com/img/favicon.png",
13+
oauth2_redirect_url: Optional[str] = None,
1114
) -> HTMLResponse:
15+
1216
html = f"""
1317
<! doctype html>
1418
<html>
@@ -25,14 +29,19 @@ def get_swagger_ui_html(
2529
<script>
2630
const ui = SwaggerUIBundle({{
2731
url: '{openapi_url}',
32+
"""
33+
34+
if oauth2_redirect_url:
35+
html += f"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',"
36+
37+
html += """
2838
dom_id: '#swagger-ui',
2939
presets: [
3040
SwaggerUIBundle.presets.apis,
3141
SwaggerUIBundle.SwaggerUIStandalonePreset
3242
],
3343
layout: "BaseLayout"
34-
35-
}})
44+
})
3645
</script>
3746
</body>
3847
</html>
@@ -47,7 +56,6 @@ def get_redoc_html(
4756
redoc_js_url: str = "https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js",
4857
redoc_favicon_url: str = "https://fastapi.tiangolo.com/img/favicon.png",
4958
) -> HTMLResponse:
50-
5159
html = f"""
5260
<!DOCTYPE html>
5361
<html>
@@ -75,3 +83,76 @@ def get_redoc_html(
7583
</html>
7684
"""
7785
return HTMLResponse(html)
86+
87+
88+
def get_swagger_ui_oauth2_redirect_html() -> HTMLResponse:
89+
html = """
90+
<!doctype html>
91+
<html lang="en-US">
92+
<body onload="run()">
93+
</body>
94+
</html>
95+
<script>
96+
'use strict';
97+
function run () {
98+
var oauth2 = window.opener.swaggerUIRedirectOauth2;
99+
var sentState = oauth2.state;
100+
var redirectUrl = oauth2.redirectUrl;
101+
var isValid, qp, arr;
102+
103+
if (/code|token|error/.test(window.location.hash)) {
104+
qp = window.location.hash.substring(1);
105+
} else {
106+
qp = location.search.substring(1);
107+
}
108+
109+
arr = qp.split("&")
110+
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';})
111+
qp = qp ? JSON.parse('{' + arr.join() + '}',
112+
function (key, value) {
113+
return key === "" ? value : decodeURIComponent(value)
114+
}
115+
) : {}
116+
117+
isValid = qp.state === sentState
118+
119+
if ((
120+
oauth2.auth.schema.get("flow") === "accessCode"||
121+
oauth2.auth.schema.get("flow") === "authorizationCode"
122+
) && !oauth2.auth.code) {
123+
if (!isValid) {
124+
oauth2.errCb({
125+
authId: oauth2.auth.name,
126+
source: "auth",
127+
level: "warning",
128+
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
129+
});
130+
}
131+
132+
if (qp.code) {
133+
delete oauth2.state;
134+
oauth2.auth.code = qp.code;
135+
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
136+
} else {
137+
let oauthErrorMsg
138+
if (qp.error) {
139+
oauthErrorMsg = "["+qp.error+"]: " +
140+
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
141+
(qp.error_uri ? "More info: "+qp.error_uri : "");
142+
}
143+
144+
oauth2.errCb({
145+
authId: oauth2.auth.name,
146+
source: "auth",
147+
level: "error",
148+
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
149+
});
150+
}
151+
} else {
152+
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
153+
}
154+
window.close();
155+
}
156+
</script>
157+
"""
158+
return HTMLResponse(content=html)

tests/test_application.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,17 @@ def test_swagger_ui():
11311131
assert response.status_code == 200
11321132
assert response.headers["content-type"] == "text/html; charset=utf-8"
11331133
assert "swagger-ui-dist" in response.text
1134+
assert (
1135+
f"oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect'"
1136+
in response.text
1137+
)
1138+
1139+
1140+
def test_swagger_ui_oauth2_redirect():
1141+
response = client.get("/docs/oauth2-redirect")
1142+
assert response.status_code == 200
1143+
assert response.headers["content-type"] == "text/html; charset=utf-8"
1144+
assert "window.opener.swaggerUIRedirectOauth2" in response.text
11341145

11351146

11361147
def test_redoc():
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from fastapi import FastAPI
2+
from starlette.testclient import TestClient
3+
4+
swagger_ui_oauth2_redirect_url = "/docs/redirect"
5+
6+
app = FastAPI(swagger_ui_oauth2_redirect_url=swagger_ui_oauth2_redirect_url)
7+
8+
9+
@app.get("/items/")
10+
async def read_items():
11+
return {"id": "foo"}
12+
13+
14+
client = TestClient(app)
15+
16+
17+
def test_swagger_ui():
18+
response = client.get("/docs")
19+
assert response.status_code == 200
20+
assert response.headers["content-type"] == "text/html; charset=utf-8"
21+
assert "swagger-ui-dist" in response.text
22+
print(client.base_url)
23+
assert (
24+
f"oauth2RedirectUrl: window.location.origin + '{swagger_ui_oauth2_redirect_url}'"
25+
in response.text
26+
)
27+
28+
29+
def test_swagger_ui_oauth2_redirect():
30+
response = client.get(swagger_ui_oauth2_redirect_url)
31+
assert response.status_code == 200
32+
assert response.headers["content-type"] == "text/html; charset=utf-8"
33+
assert "window.opener.swaggerUIRedirectOauth2" in response.text
34+
35+
36+
def test_response():
37+
response = client.get("/items/")
38+
assert response.json() == {"id": "foo"}

tests/test_no_swagger_ui_redirect.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from fastapi import FastAPI
2+
from starlette.testclient import TestClient
3+
4+
app = FastAPI(swagger_ui_oauth2_redirect_url=None)
5+
6+
7+
@app.get("/items/")
8+
async def read_items():
9+
return {"id": "foo"}
10+
11+
12+
client = TestClient(app)
13+
14+
15+
def test_swagger_ui():
16+
response = client.get("/docs")
17+
assert response.status_code == 200
18+
assert response.headers["content-type"] == "text/html; charset=utf-8"
19+
assert "swagger-ui-dist" in response.text
20+
print(client.base_url)
21+
assert "oauth2RedirectUrl" not in response.text
22+
23+
24+
def test_swagger_ui_no_oauth2_redirect():
25+
response = client.get("/docs/oauth2-redirect")
26+
assert response.status_code == 404
27+
28+
29+
def test_response():
30+
response = client.get("/items/")
31+
assert response.json() == {"id": "foo"}

0 commit comments

Comments
 (0)