Skip to content

Commit 476f136

Browse files
committed
Add consumer mode
1 parent 796b946 commit 476f136

File tree

7 files changed

+201
-34
lines changed

7 files changed

+201
-34
lines changed

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ Table of Contents
2828
- [Organization Checks](organizations.md)
2929
- [API Keys](api_keys.md)
3030
- [Hosting SOAuth](hosting.md)
31+
- [Consumer Mode](consume.md)
3132
- [Developing SOAuth](developing.md)

docs/consume.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
Consumer Mode
2+
=============
3+
4+
To enable easy integration with other services, we provide a 'consumer' version of the
5+
server that can accept POST requests with access tokens to validate them. This allows
6+
for 'external server' authentication (with e.g.
7+
[nginx](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-subrequest-authentication/)).
8+
9+
To run the `soauth` server in consumer mode, you can use the same docker container as
10+
normal. However, you'll need to change the command that is run to:
11+
```
12+
soauth run consume
13+
```
14+
This server runs on port 8002 and does two main things:
15+
16+
1. Handles the callback loop with the main `soauth` server
17+
2. Hosts an endpoint, `/introspect`, that takes the cookies in the request and
18+
returns a 200 code if the tokens are good, and a 401 if they are bad.
19+
20+
Using with Nginx
21+
----------------
22+
23+
The most common use case for this kind of authentication is with another web service.
24+
Nginx, for instance, can proxy all requests via the auth server to check if they
25+
have valid tokens in them, configured as follows:
26+
```
27+
# 'Private' webpages hosted behind soauth.
28+
29+
# Reverse proxy to the 'consumer' service
30+
location /auth/ {
31+
proxy_pass http://soauth:8002/;
32+
}
33+
34+
# Handle the tokens.
35+
location = /auth/introspect {
36+
proxy_pass http://soauth:8002/introspect;
37+
proxy_method POST;
38+
}
39+
40+
# When we 401 in /private, we want to redirect to login
41+
location @error401 {
42+
return 302 https://identity.simonsobservatory.org/login/$YOUR_UUID;
43+
}
44+
45+
# The 'private' web pages hosted behind soauth
46+
location /private/ {
47+
root /private;
48+
# This sends each request via /auth/introspect to make sure they have valid cookies.
49+
auth_request /auth/introspect;
50+
proxy_intercept_errors on;
51+
error_page 401 = @error401;
52+
}
53+
54+
# We get redirected to the root of the auth after login.
55+
location = /auth {
56+
return 302 /private/;
57+
}
58+
```
59+
There are a few things here to note.
60+
61+
1. We host the `soauth` service at `/auth/*`.
62+
2. All requests to `/auth/introspect` are handled as POST requests (by default
63+
Nginx makes these requests as GET requests).
64+
3. We set up a redirect on a 401 error directly to the login page for `soauth`.
65+
4. We host as set of webpages at `/private/`, and add the `auth_request` directive.
66+
5. When users are redirected by the auth service to its root, we send them instead
67+
to the root of the private service.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "soauth"
7-
version = "0.7.2"
7+
version = "0.8.0"
88
requires-python = ">=3.11"
99
dependencies = [
1010
"pydantic",

soauth/scripts/cli.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ def main():
3131
register = sys.argv[1] == "register"
3232
dev = sys.argv[2] == "dev"
3333
prod = sys.argv[2] == "prod"
34+
consume = sys.argv[2] == "consume"
3435
except IndexError:
3536
print(
36-
"Only supported command is soauth run dev, soauth run prod, or soauth setup {username}"
37+
"Only supported command is soauth run dev, soauth run prod, soauth run consume, or soauth setup {username}"
3738
)
3839
exit(1)
3940

@@ -78,7 +79,8 @@ def main():
7879
background_process.start()
7980
time.sleep(1)
8081
uvicorn.run("soauth.app.app:app", host="0.0.0.0", port=8001)
81-
82+
if run and consume:
83+
uvicorn.run("soauth.toolkit.consumer:app", host="0.0.0.0", port=8002)
8284
if setup:
8385
from soauth.api.setup import initial_setup
8486
from soauth.config.settings import Settings

soauth/toolkit/consumer.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
A small web server that hosts only one endpoint: /introspect. This processes an
3+
identity token and checks whether the user has the correct grant to be
4+
authenticated against the service. We provide a courtesy '/login' endpoint
5+
that is a small HTML page with a button that directs users to the login page
6+
to
7+
"""
8+
9+
from fastapi import FastAPI, HTTPException, Request, Response
10+
from fastapi.responses import HTMLResponse
11+
from pydantic_settings import BaseSettings, SettingsConfigDict
12+
13+
from soauth.core.uuid import UUID
14+
from soauth.toolkit.fastapi import global_setup
15+
16+
17+
class ConsumerSettings(BaseSettings):
18+
app_base_url: str
19+
authentication_base_url: str
20+
app_id: UUID
21+
client_secret: str
22+
public_key: str
23+
key_pair_type: str
24+
25+
required_grant: str = "grant:you_must_choose_one"
26+
27+
model_config = SettingsConfigDict(env_prefix="SOAUTH_", env_file=".env")
28+
29+
30+
settings = ConsumerSettings()
31+
32+
app = global_setup(
33+
app=FastAPI(),
34+
app_base_url=settings.app_base_url,
35+
authentication_base_url=settings.authentication_base_url,
36+
app_id=settings.app_id,
37+
client_secret=settings.client_secret,
38+
public_key=settings.public_key,
39+
key_pair_type=settings.key_pair_type,
40+
handle_exceptions=False,
41+
use_refresh_token=False,
42+
)
43+
44+
45+
@app.get("/login")
46+
def login(request: Request):
47+
if request.user.is_authenticated:
48+
return HTMLResponse(
49+
content=f"<html><body><a href='{app.logout_url}'>Logout</a>"
50+
)
51+
else:
52+
return HTMLResponse(content=f"<html><body><a href='{app.login_url}'>Login</a>")
53+
54+
55+
@app.post("/introspect")
56+
def introspect(request: Request):
57+
if not request.user.is_authenticated:
58+
print("Not authenticated")
59+
raise HTTPException(401, "Not authenticated")
60+
61+
if settings.required_grant not in request.auth.scopes:
62+
print(settings.required_grant, "not in", request.auth.scopes)
63+
raise HTTPException(401, "Not authorized")
64+
return Response()

soauth/toolkit/fastapi.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,15 @@ async def secure_endpoint(user: AuthenticatedUserDependency):
5353
key_expired_handler,
5454
logout,
5555
on_auth_error,
56+
transform_auth_error,
5657
)
5758

5859

5960
class SOUserWithGrants(SOUser):
6061
grants: set[str] = Field(default_factory=set)
6162

6263

63-
def add_exception_handlers(app: FastAPI) -> FastAPI:
64+
def add_exception_handlers(app: FastAPI, allow_refresh: bool = True) -> FastAPI:
6465
"""
6566
Adds exception handlers for authentication. To use these, you must:
6667
@@ -73,6 +74,10 @@ def add_exception_handlers(app: FastAPI) -> FastAPI:
7374
7475
- Set `app.refresh_token_name` to change the cookie name for the refresh token
7576
- Set `app.access_token_name` to change the cookie name for the access token
77+
78+
If you do not wish to allow automatic refreshing of the keys by the application
79+
(e.g. you are running in 'consumer' mode where only 401 or 200s are allowed),
80+
you can set allow_refresh = False.
7681
"""
7782
app.add_exception_handler(KeyDecodeError, key_decode_handler)
7883
app.add_exception_handler(KeyExpiredError, key_expired_handler)
@@ -179,6 +184,8 @@ def global_setup(
179184
public_key: str,
180185
key_pair_type: str,
181186
add_middleware: bool = True,
187+
handle_exceptions: bool = True,
188+
use_refresh_token: bool = True,
182189
) -> FastAPI:
183190
"""
184191
Transform the app such that it is ready for authentication. Can either add middleware
@@ -207,6 +214,13 @@ def global_setup(
207214
This allows for the use of starlette's `@requries()` (against user grants), and
208215
access to `request.user` and `request.auth.scopes`. Alternatively, you can use
209216
the dependencies defined in this file.
217+
handle_exceptions: bool = True,
218+
If this is used, we automatically handle expired/broken credentials via the server
219+
itself (including using refresh tokens). If it is not, you are left on your own
220+
to handle the resulting 400-level errors.
221+
use_refresh_token: bool = True,
222+
Whether or not to set and use the refresh token cookie. Otherwise your users will
223+
need to round-trip to the identity server every time the access token expires.
210224
211225
Example
212226
-------
@@ -274,6 +288,7 @@ async def test(request: Request):
274288
app.authentication_url = authentication_base_url
275289
app.client_secret = client_secret
276290
app.app_id = app_id
291+
app.use_refresh_token = use_refresh_token
277292

278293
app.public_key = public_key.encode("utf-8")
279294
app.key_pair_type = key_pair_type
@@ -289,9 +304,11 @@ async def test(request: Request):
289304
app.add_middleware(
290305
AuthenticationMiddleware,
291306
backend=SOAuthCookieBackend(
292-
public_key=app.public_key, key_pair_type=app.key_pair_type
307+
public_key=app.public_key,
308+
key_pair_type=app.key_pair_type,
309+
use_refresh_token=use_refresh_token,
293310
),
294-
on_error=on_auth_error,
311+
on_error=on_auth_error if handle_exceptions else transform_auth_error,
295312
)
296313

297314
app.add_api_route(path="/logout", endpoint=logout)

0 commit comments

Comments
 (0)