Skip to content

Commit

Permalink
[Issue #3103] Pass client assertion jwt to token endpoint (#3190)
Browse files Browse the repository at this point in the history
## Summary
Fixes #3103

### Time to review: __10 mins__

## Changes proposed
Changed the way we call the OAuth token endpoint to use private_key_jwt

Added docs for how to setup Login.gov certs

## Context for reviewers
For the first pass, I setup the call to the token endpoint to use
client_id, but actual Login.gov uses private_key_jwt for this instead
(which includes the client ID). Luckily it seems our local mock is fine
with that and can take in this as well. It doesn't do any validation on
the key itself, so any private key is fine.

Effectively, this form of "auth" is just passing the client ID + a hash
that could have only been created by our private key, and login.gov is
configured to have our public key.

## Additional information
For testing this, I actually connected it to our dev login.gov app which
I setup alongside it. It works! Also verified exactly what we need to
set for many of the env vars to get everything happy.

You get directed first to:

![Screenshot 2024-12-11 at 4 14
39 PM](https://github.com/user-attachments/assets/25474a57-afb8-42dd-8c74-599f4ac3edab)

And then back to our API and then the final endpoint with a token that
works locally.

---------

Co-authored-by: nava-platform-bot <[email protected]>
  • Loading branch information
chouinar and nava-platform-bot authored Dec 13, 2024
1 parent 41be850 commit e9c710d
Show file tree
Hide file tree
Showing 11 changed files with 111 additions and 15 deletions.
30 changes: 30 additions & 0 deletions OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,33 @@ out the instance count. Effectively using the instance count scaling might requi
When scaling openSearch, consider which attribute changes will trigger blue/green deploys, versus which attributes
can be edited in place. [You can find that information here](https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-configuration-changes.html). Requiring blue/green changes for the average configuration change is a
notable constraint of OpenSearch, relative to ECS and the Database.

# Yearly Rotations

We manage several secret values that need to be rotated yearly.

## Login.gov Certificates

*These certificates were last updated in December 2024*

We need to manage a public certificate with login.gov for [private_jwt_auth](https://developers.login.gov/oidc/token/#client_assertion) in each of our environments.

To generate a certificate run:
```shell
openssl req -nodes -x509 -days 365 -newkey rsa:2048 -keyout private.pem -out public.crt -subj "/C=US/ST=Washington DC/L=Washington DC/O=Nava PBC/OU=Engineering/CN=Simpler Grants.gov/[email protected]"
```

Navigate to the [login.gov service provider page](https://dashboard.int.identitysandbox.gov/service_providers)
and for each application edit it, and upload the public.crt file. Leave any prior cert files alone until we have
switched the API to using the new one.

Go to SSM parameter store and change the value that maps to the `LOGIN_GOV_CLIENT_ASSERTION_PRIVATE_KEY` value
for the given environment to be the value from the `private.pem` key you generated.

After the next deployment in an environment, we should be using the new keys, and can cleanup the old certificate.

### Prod Login.gov

Prod login.gov does not update immediately, and you must [request a deployment](https://developers.login.gov/production/#changes-to-production-applications) to get a certificate rotated.

For Prod, assume it will take at least two weeks from creating the certificate, before it is available for the API, and until it is, do not change the API's configured key.
4 changes: 4 additions & 0 deletions api/bin/setup-env-override-file.sh
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ main() {
API_JWT_PRIVATE_KEY="$PRIVATE_KEY"
API_JWT_PUBLIC_KEY="$PUBLIC_KEY"
# The local mock doesn't check the key used
# for token auth so just re-use the other private key
LOGIN_GOV_CLIENT_ASSERTION_PRIVATE_KEY="$PRIVATE_KEY"
EOF


Expand Down
1 change: 1 addition & 0 deletions api/local.env
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ LOGIN_FINAL_DESTINATION=http://localhost:8080/v1/users/login/result
# which can be created by running `make setup-env-override-file`
API_JWT_PRIVATE_KEY=
API_JWT_PUBLIC_KEY=
LOGIN_GOV_CLIENT_ASSERTION_PRIVATE_KEY=

ENABLE_AUTH_ENDPOINT=TRUE

Expand Down
2 changes: 1 addition & 1 deletion api/openapi.generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1698,7 +1698,7 @@ components:
email:
type: string
description: The email address returned from Oauth2 provider
example: js@gmail.com
example: user@example.com
external_user_type:
description: The Oauth2 provider through which a user was authenticated
example: !!python/object/apply:src.constants.lookup_constants.ExternalUserType
Expand Down
5 changes: 2 additions & 3 deletions api/src/adapters/oauth/login_gov/login_gov_oauth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,8 @@ def get_token(self, request: OauthTokenRequest) -> OauthTokenResponse:
body = {
"code": request.code,
"grant_type": request.grant_type,
# TODO https://github.com/HHS/simpler-grants-gov/issues/3103
# when we support client assertion, we need to not add the client_id
"client_id": self.config.client_id,
"client_assertion": request.client_assertion,
"client_assertion_type": request.client_assertion_type,
}

response = self._request("POST", self.config.login_gov_token_endpoint, data=body)
Expand Down
7 changes: 3 additions & 4 deletions api/src/adapters/oauth/oauth_client_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@ class OauthTokenRequest:
"""https://developers.login.gov/oidc/token/#request-parameters"""

code: str
grant_type: str = "authorization_code"
client_assertion: str

# TODO: https://github.com/HHS/simpler-grants-gov/issues/3103
# client_assertion: str | None = None
# client_assertion_type: str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer
grant_type: str = "authorization_code"
client_assertion_type: str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"


class OauthTokenResponse(BaseModel):
Expand Down
2 changes: 1 addition & 1 deletion api/src/api/users/user_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class UserSchema(Schema):
email = fields.String(
metadata={
"description": "The email address returned from Oauth2 provider",
"example": "js@gmail.com",
"example": "user@example.com",
}
)
external_user_type = fields.Enum(
Expand Down
32 changes: 32 additions & 0 deletions api/src/auth/login_gov_jwt_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import urllib
import uuid
from datetime import timedelta

import flask
import jwt
Expand All @@ -10,6 +11,7 @@
from src.adapters import db
from src.auth.auth_errors import JwtValidationError
from src.db.models.user_models import LoginGovState
from src.util import datetime_util
from src.util.env_config import PydanticBaseEnvConfig

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -44,6 +46,12 @@ class LoginGovConfig(PydanticBaseEnvConfig):
# for now we'll always send them to the same place (a frontend page)
login_final_destination: str = Field(alias="LOGIN_FINAL_DESTINATION")

# The private key we gave login.gov for private_key_jwt validation in the token endpoint
# See: https://developers.login.gov/oidc/token/#client_assertion
login_gov_client_assertion_private_key: str = Field(
alias="LOGIN_GOV_CLIENT_ASSERTION_PRIVATE_KEY"
)


# Initialize a config at startup
_config: LoginGovConfig | None = None
Expand Down Expand Up @@ -138,6 +146,29 @@ def get_login_gov_redirect_uri(db_session: db.Session, config: LoginGovConfig |
return f"{config.login_gov_auth_endpoint}?{encoded_params}"


def get_login_gov_client_assertion(config: LoginGovConfig | None = None) -> str:
"""Generate a client assertion token for login.gov auth"""
if config is None:
config = get_config()

# Docs recommend a 5 minute expiration time
current_time = datetime_util.utcnow()
expiration_time = current_time + timedelta(minutes=5)

# See: https://developers.login.gov/oidc/token/#client_assertion
client_assertion_payload = {
"iss": config.client_id,
"sub": config.client_id,
"aud": config.login_gov_token_endpoint,
"jti": str(uuid.uuid4()),
"exp": expiration_time,
}

return jwt.encode(
client_assertion_payload, config.login_gov_client_assertion_private_key, algorithm="RS256"
)


def get_final_redirect_uri(
message: str,
token: str | None = None,
Expand Down Expand Up @@ -170,6 +201,7 @@ def validate_token(token: str, config: LoginGovConfig | None = None) -> LoginGov

# TODO - this iteration approach won't be necessary if the JWT we get
# from login.gov does actually set the KID in the header
# TODO - turns out login.gov DOES return a KID, can adjust this.
# Iterate over the public keys we have and check each
# to determine if we have a valid key.
for public_key in config.public_keys:
Expand Down
10 changes: 7 additions & 3 deletions api/src/services/users/login_gov_callback_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from src.api.route_utils import raise_flask_error
from src.auth.api_jwt_auth import create_jwt_for_user
from src.auth.auth_errors import JwtValidationError
from src.auth.login_gov_jwt_auth import validate_token
from src.auth.login_gov_jwt_auth import get_login_gov_client_assertion, validate_token
from src.constants.lookup_constants import ExternalUserType
from src.db.models.user_models import LinkExternalUser, LoginGovState, User
from src.util.string_utils import is_valid_uuid
Expand Down Expand Up @@ -77,9 +77,13 @@ def handle_login_gov_callback(query_data: dict, db_session: db.Session) -> Login

# call the token endpoint (make a client)
# https://developers.login.gov/oidc/token/
# TODO: Creating a JWT with the key we gave login.gov
client = get_login_gov_client()
response = client.get_token(OauthTokenRequest(code=callback_params.code))
response = client.get_token(
OauthTokenRequest(
code=callback_params.code, client_assertion=get_login_gov_client_assertion()
)
)

# If this request failed, we'll assume we're the issue and 500
# TODO - need to test with actual login.gov if there could be other scenarios
# the mock always returns something as long as the request is well-formatted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def test_get_token(monkeypatch):
)

client = LoginGovOauthClient(LoginGovConfig())
resp = client.get_token(OauthTokenRequest(code="abc123"))
resp = client.get_token(OauthTokenRequest(code="abc123", client_assertion="fake_token"))

assert resp.id_token == "abc123"
assert resp.access_token == "xyz456"
Expand All @@ -43,7 +43,7 @@ def test_get_token_error(monkeypatch):
)

client = LoginGovOauthClient(LoginGovConfig())
resp = client.get_token(OauthTokenRequest(code="abc123"))
resp = client.get_token(OauthTokenRequest(code="abc123", client_assertion="fake_token"))

assert resp.id_token == ""
assert resp.access_token == ""
Expand Down
29 changes: 28 additions & 1 deletion api/tests/src/auth/test_login_gov_jwt_auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from calendar import timegm
from datetime import datetime, timedelta, timezone

import freezegun
import jwt
import pytest

Expand All @@ -11,14 +13,15 @@


@pytest.fixture
def login_gov_config(public_rsa_key):
def login_gov_config(public_rsa_key, private_rsa_key):
# Note this isn't session scoped so it gets remade
# for every test in the event of changes to it
return LoginGovConfig(
LOGIN_GOV_PUBLIC_KEYS=[public_rsa_key],
LOGIN_GOV_JWK_ENDPOINT="not_used",
LOGIN_GOV_ENDPOINT=DEFAULT_ISSUER,
LOGIN_GOV_CLIENT_ID=DEFAULT_CLIENT_ID,
LOGIN_GOV_CLIENT_ASSERTION_PRIVATE_KEY=private_rsa_key,
)


Expand Down Expand Up @@ -186,3 +189,27 @@ def override_method(config):
monkeypatch.setattr(login_gov_jwt_auth, "_refresh_keys", override_method)

validate_token(token, login_gov_config)


@freezegun.freeze_time("2024-11-14 12:00:00", tz_offset=0)
def test_get_login_gov_client_assertion(login_gov_config, public_rsa_key):
client_assertion = login_gov_jwt_auth.get_login_gov_client_assertion(login_gov_config)

# Turn the jwt back into a dict
# Validate with the public key
decoded_jwt = jwt.decode(
client_assertion,
key=public_rsa_key,
algorithms=["RS256"],
issuer=login_gov_config.client_id,
audience=login_gov_config.login_gov_token_endpoint,
)

assert decoded_jwt["iss"] == login_gov_config.client_id
assert decoded_jwt["sub"] == login_gov_config.client_id
assert decoded_jwt["aud"] == login_gov_config.login_gov_token_endpoint
assert decoded_jwt["jti"] is not None
# exp is 5 minutes from "now"
assert decoded_jwt["exp"] == timegm(
datetime.fromisoformat("2024-11-14 12:05:00+00:00").utctimetuple()
)

0 comments on commit e9c710d

Please sign in to comment.