Skip to content

Commit 584c7ab

Browse files
Add passthrough_nonce for some oidc endpoints and migration. nonce oidc check working for headscale/plane via browser
1 parent 1eb9af7 commit 584c7ab

File tree

7 files changed

+113
-18
lines changed

7 files changed

+113
-18
lines changed

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,12 @@ migrations. Here are the steps:
9797
3. Shut down all the services (``make clean``).
9898
4. Start up the db containers (``make init_dbs``).
9999
5. Run the existing migrations (``make migrate.upgrade``).
100-
6. Exec into a new migrations container:
101-
docker run -it --entrypoint=bash --network=authenticator_authenticator tapis/authenticator-migrations
102-
7. Once inside the container:
103-
$ flask db migrate
104-
$ flask db upgrade
105-
Note that the migrate step should create a new migration Python source file in /home/tapis/migrations/versions/
100+
6. Exec into a new migrations container:
101+
`docker run -it --entrypoint=bash --network=authenticator_authenticator tapis/authenticator-migrations`
102+
7. Once inside the container:
103+
1. `flask db migrate`
104+
2. `flask db upgrade`
105+
3. Note that the migrate step should create a new migration Python source file in /home/tapis/migrations/versions/
106106
Note also that the upgrade step (that applies the generated file) could fail if, for example, your changes include
107107
a new, non-nullable field. For such changes, you will need to make custom changes to the migration Python source
108108
file.
@@ -338,12 +338,12 @@ redirecting your user to the /oauth2/authorize URL and passing the following:
338338
```
339339
client_id=<your_client_id>
340340
redirect_uri=<your_redirect_uri>
341-
response_tyep=code
341+
response_type=code
342342
```
343343

344344
For example:
345345
```
346-
1) GET http://localhost:5000/v3/oauth2/authorize?client_id=<client_id>&redirect_uri=<redirec_uri>&response_type=code
346+
1) GET http://localhost:5000/v3/oauth2/authorize?client_id=<client_id>&redirect_uri=<redirect_uri>&response_type=code
347347
348348
```
349349

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Migrations for the 2025 Q4 Authenticator release - version 2: adding nonce columns for complex oidc
2+
3+
Revision ID: 25Q4v2
4+
Revises: 25Q4
5+
"""
6+
from alembic import op
7+
import sqlalchemy as sa
8+
9+
10+
# revision identifiers, used by Alembic.
11+
revision = '25Q4v2'
12+
down_revision = '25Q4'
13+
branch_labels = None
14+
depends_on = None
15+
16+
17+
def upgrade():
18+
# ### commands auto generated by Alembic - please adjust! ###
19+
with op.batch_alter_table('authorization_codes', schema=None) as batch_op:
20+
batch_op.add_column(sa.Column('passthrough_nonce', sa.String(length=200), nullable=True))
21+
22+
with op.batch_alter_table('device_codes', schema=None) as batch_op:
23+
batch_op.add_column(sa.Column('passthrough_nonce', sa.String(length=200), nullable=True))
24+
# ### end Alembic commands ###
25+
26+
27+
def downgrade():
28+
# ### commands auto generated by Alembic - please adjust! ###
29+
with op.batch_alter_table('device_codes', schema=None) as batch_op:
30+
batch_op.drop_column('passthrough_nonce')
31+
32+
with op.batch_alter_table('authorization_codes', schema=None) as batch_op:
33+
batch_op.drop_column('passthrough_nonce')
34+
35+
# ### end Alembic commands ###

service/controllers.py

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1267,6 +1267,18 @@ def get(self):
12671267
# check if the grant type is supported by this tenant
12681268
config = tenant_configs_cache.get_config(tenant_id)
12691269
allowable_grant_types = json.loads(config.allowable_grant_types)
1270+
1271+
## in case of oidc we save nonce to session for use later
1272+
nonce = (
1273+
request.args.get("nonce")
1274+
or request.form.get("nonce")
1275+
or request.cookies.get("nonce")
1276+
or session.get("nonce")
1277+
)
1278+
session["nonce"] = nonce # Always write nonce to session, even if None or empty
1279+
if session["nonce"]:
1280+
logger.debug(f"inside of get auth - nonce derived: {nonce}, args: {request.args}, form: {request.form}, cookies: {request.cookies}")
1281+
12701282
mfa_config = json.loads(config.mfa_config)
12711283
user_code=request.args.get("user_code", None)
12721284

@@ -1382,6 +1394,7 @@ def get(self):
13821394
"client_state": client_state,
13831395
"device_login": session.get("device_login", None),
13841396
"user_code": user_code,
1397+
"nonce": nonce,
13851398
}
13861399

13871400
auto_approve = Users.query.filter_by(username=username, client_id=client_id).first()
@@ -1392,15 +1405,16 @@ def get(self):
13921405
logger.debug(f'Checking for auto approve ... ')
13931406
if auto_approve and not is_device_flow:
13941407
logger.debug(f'Found. Skipping authoriziation page.')
1395-
generate_authorization_code(tenant_id, username, client_id, client)
1408+
generate_authorization_code(tenant_id, username, client_id, client, nonce)
13961409
auto_redirect = handle_response_type(
13971410
response_type,
13981411
allowable_grant_types,
13991412
tenant_id,
14001413
username,
14011414
client_id,
14021415
client,
1403-
client_state
1416+
client_state,
1417+
nonce=nonce
14041418
)
14051419
return auto_redirect
14061420
logger.debug(f'Not found. Proceeding to authentication page')
@@ -1481,6 +1495,17 @@ def post(self):
14811495
if mfa_response:
14821496
return mfa_response
14831497

1498+
## in case of oidc we save nonce to session for use later
1499+
nonce = (
1500+
request.args.get("nonce")
1501+
or request.form.get("nonce")
1502+
or request.cookies.get("nonce")
1503+
or session.get("nonce")
1504+
)
1505+
session["nonce"] = nonce # Always write nonce to session, even if None or empty
1506+
if session["nonce"]:
1507+
logger.debug(f"inside of get auth - nonce derived: {nonce}, args: {request.args}, form: {request.form}, cookies: {request.cookies}")
1508+
14841509
# TODO - Move this to the handle_response_type function
14851510
if client_response_type == "device_code":
14861511
if "device_code" not in allowable_grant_types:
@@ -1580,7 +1605,8 @@ def post(self):
15801605
username,
15811606
client_id,
15821607
client,
1583-
state
1608+
state,
1609+
nonce=nonce
15841610
)
15851611

15861612

@@ -1823,13 +1849,15 @@ def _handle_tokens_request(request, oidc=False):
18231849
)
18241850
username = db_code.username
18251851
idp_id = db_code.tapis_idp_id
1852+
passthrough_nonce = getattr(db_code, "passthrough_nonce", None)
18261853
elif grant_type == "device_code":
18271854
username = db_code.username
18281855
ttl = db_code.access_token_ttl
18291856
idp_id = db_code.tapis_idp_id
1857+
passthrough_nonce = getattr(db_code, "passthrough_nonce", None)
18301858

18311859
logger.debug(
1832-
f"USERNAME: {username}; TTL: {ttl}; idp_id: {db_code.tapis_idp_id}"
1860+
f"device_code; USERNAME: {username}; TTL: {ttl}; idp_id: {db_code.tapis_idp_id}; passthrough_nonce: {passthrough_nonce}"
18331861
)
18341862

18351863
elif grant_type == "refresh_token":
@@ -1900,12 +1928,18 @@ def _handle_tokens_request(request, oidc=False):
19001928
if idp_id:
19011929
content["claims"]["tapis/idp_id"] = idp_id
19021930
if oidc:
1931+
logger.debug('Top of OIDC in handle_token_request - passthrough_nonce', passthrough_nonce)
19031932
if client_id:
19041933
# bookstack for example requires aud to match client id
19051934
content["claims"]["aud"] = client_id
19061935
content["claims"]["iat"] = int(time.time())
19071936
content["claims"]["extravar"] = username
19081937
content["claims"]["email"] = username
1938+
# Set passthrough_nonce from authorization code if available
1939+
if grant_type == "authorization_code" and passthrough_nonce:
1940+
content["claims"]["nonce"] = passthrough_nonce
1941+
else:
1942+
content["claims"]["nonce"] = ""
19091943

19101944
# only generate a refresh token when OAuth client is passed
19111945
if client_id and client_key:
@@ -2036,7 +2070,14 @@ def _handle_tokens_request(request, oidc=False):
20362070
raise errors.ResourceError(f"{msg}")
20372071

20382072
if oidc:
2039-
logger.info("Token endpoint with OIDC flag set.")
2073+
logger.info("inside of POST /v3/oauth2/tokens with OIDC flag set")
2074+
logger.debug(f"request headers: {request.headers}")
2075+
logger.debug(f"request form: {request.form}")
2076+
logger.debug(f"request json: {request.json}")
2077+
logger.debug(f"request data: {request.data}")
2078+
logger.debug(f"request args: {request.args}")
2079+
logger.debug(f"request content type: {request.content_type}")
2080+
logger.debug(f"request base url: {request.base_url}")
20402081
response_json = {
20412082
"access_token": result["access_token"]["access_token"],
20422083
"expires_in": result["access_token"]["expires_in"],
@@ -2057,6 +2098,15 @@ def post(self):
20572098

20582099
class OIDCTokensResource(Resource):
20592100
def post(self):
2101+
## print all of flask request to logs
2102+
logger.info("top of POST /v3/oauth2/tokens with OIDC flag set")
2103+
logger.debug(f"request headers: {request.headers}")
2104+
logger.debug(f"request form: {request.form}")
2105+
logger.debug(f"request json: {request.json}")
2106+
logger.debug(f"request data: {request.data}")
2107+
logger.debug(f"request args: {request.args}")
2108+
logger.debug(f"request content type: {request.content_type}")
2109+
logger.debug(f"request base url: {request.base_url}")
20602110
return _handle_tokens_request(request, oidc=True)
20612111

20622112

service/helpers.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
DEFAULT_DEVICE_CODE_TOKEN_TTL = 30
1313

1414

15-
def generate_authorization_code(tenant_id, username, client_id, client):
15+
def generate_authorization_code(tenant_id, username, client_id, client, nonce=None):
1616
"""
1717
Generates an authorization code and saves it to the database.
1818
"""
@@ -25,6 +25,7 @@ def generate_authorization_code(tenant_id, username, client_id, client):
2525
redirect_url=client.callback_url,
2626
code=AuthorizationCode.generate_code(),
2727
expiry_time=AuthorizationCode.compute_expiry(),
28+
passthrough_nonce=nonce,
2829
)
2930
try:
3031
db.session.add(authz_code)
@@ -39,7 +40,7 @@ def generate_authorization_code(tenant_id, username, client_id, client):
3940
return authz_code
4041

4142

42-
def handle_response_type(response_type, allowable_grant_types, tenant_id, username, client_id, client, state, **kwargs):
43+
def handle_response_type(response_type, allowable_grant_types, tenant_id, username, client_id, client, state, nonce=None, **kwargs):
4344
if response_type == "token":
4445
if "implicit" not in allowable_grant_types:
4546
raise errors.ResourceError(
@@ -91,8 +92,12 @@ def handle_response_type(response_type, allowable_grant_types, tenant_id, userna
9192
f"tenant. Allowable grant types: {allowable_grant_types}"
9293
)
9394

95+
# create the authorization code for the client and handle nonce if needed.
96+
if nonce:
97+
logger.debug(f"inside of handle response - nonce found: {nonce}")
98+
9499
authz_code = generate_authorization_code(
95-
tenant_id, username, client_id, client
100+
tenant_id, username, client_id, client, nonce=nonce
96101
)
97102

98103
# Redirect to the client's callback URL with the authorization code

service/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,8 @@ class AuthorizationCode(db.Model):
393393
# metadata about whether an MFA was performed and if so, how recently
394394
# currently not implemented
395395
tapis_mfa = db.Column(db.String(200), unique=False, nullable=True)
396+
# If /authorize is called with ?nonce=value, we store the value so we can return it on token to mitigate replay attacks.
397+
passthrough_nonce = db.Column(db.String(200), unique=False, nullable=True)
396398

397399
def __repr__(self):
398400
return f'{self.code}'
@@ -485,6 +487,8 @@ class DeviceCode(db.Model):
485487
# metadata about whether an MFA was performed and if so, how recently
486488
# currently not implemented
487489
tapis_mfa = db.Column(db.String(200), unique=False, nullable=True)
490+
# If /authorize is called with ?nonce=value, we store the value so we can return it on token to mitigate replay attacks.
491+
passthrough_nonce = db.Column(db.String(200), unique=False, nullable=True)
488492

489493
def __repr__(self):
490494
return f'{self.code}'

service/session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def clear_orig_client_data():
1313
session.pop("orig_client_redirect_uri", None)
1414
session.pop("orig_client_response_type", None)
1515
session.pop("orig_client_state", None)
16-
16+
session.pop("nonce", None)
1717

1818
def logout_from_webapp():
1919
"""

service/tests/basic_test.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1018,7 +1018,7 @@ def test_get_profile(client, tapis_jwt):
10181018
def test_authorization_code(client, init_db):
10191019
# simulate the authorization approval -
10201020
with client:
1021-
# use hte session_transaction to enable modification of the session object:
1021+
# use the session_transaction to enable modification of the session object:
10221022
# cf., https://flask.palletsprojects.com/en/1.1.x/testing/#accessing-and-modifying-sessions
10231023
with client.session_transaction() as sess:
10241024
sess["username"] = TEST_USERNAME
@@ -1035,6 +1035,7 @@ def test_authorization_code(client, init_db):
10351035
},
10361036
)
10371037
print(response)
1038+
print(f"DEBUG: got response string: {response.data.decode('utf-8')}")
10381039
assert response.status_code == 302
10391040
# note: response.data is a raw bytes object containing the full HTML returned from the page.
10401041
# try this if you want to debug ===> print(response.data)

0 commit comments

Comments
 (0)