Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Issue #3103] Pass client assertion jwt to token endpoint #3190

Merged
merged 30 commits into from
Dec 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e544bb9
WIP
chouinar Dec 2, 2024
399788f
Mostly end-to-end connection
chouinar Dec 3, 2024
176ee23
Remove unused file
chouinar Dec 3, 2024
3555c32
Hacky fix
chouinar Dec 3, 2024
a411dff
Create ERD diagram and Update OpenAPI spec
nava-platform-bot Dec 3, 2024
aad0fbe
Fix indent
chouinar Dec 4, 2024
e0db4ff
[Issue #3080] Error handling in the login endpoints
chouinar Dec 4, 2024
20c6929
[Issue #3076] Manage state value when calling/returning from login.go…
chouinar Dec 5, 2024
9765fd3
Create ERD diagram and Update OpenAPI spec
nava-platform-bot Dec 5, 2024
edc813b
[Issue #3102] Call Oauth token endpoint
chouinar Dec 6, 2024
9f6560a
Create ERD diagram and Update OpenAPI spec
nava-platform-bot Dec 6, 2024
076acbe
[Issue #2810] Create token for auth and connect everything
chouinar Dec 6, 2024
7562ae2
Merge branch 'main' into chouinar/3080-error-handling
chouinar Dec 6, 2024
c19f665
Merge branch 'chouinar/3080-error-handling' into chouinar/3076-login-…
chouinar Dec 6, 2024
4e15626
Merge branch 'chouinar/3076-login-gov-state' into chouinar/3102-token…
chouinar Dec 6, 2024
7a53c8f
Create ERD diagram and Update OpenAPI spec
nava-platform-bot Dec 6, 2024
2068ccf
Create ERD diagram and Update OpenAPI spec
nava-platform-bot Dec 6, 2024
e7b0f89
Merge branch 'chouinar/3102-token-endpoint' into chouinar/2810-connec…
chouinar Dec 6, 2024
0ad5f2b
Create ERD diagram and Update OpenAPI spec
nava-platform-bot Dec 6, 2024
8fdbe2e
Merge branch 'main' into chouinar/3076-login-gov-state
chouinar Dec 6, 2024
ebc06a9
Merge branch 'chouinar/3076-login-gov-state' into chouinar/3102-token…
chouinar Dec 6, 2024
86362e0
Merge branch 'chouinar/3102-token-endpoint' into chouinar/2810-connec…
chouinar Dec 6, 2024
0946c03
Merge branch 'main' into chouinar/3102-token-endpoint
chouinar Dec 10, 2024
dad8d36
Merge branch 'chouinar/3102-token-endpoint' into chouinar/2810-connec…
chouinar Dec 10, 2024
1bd88e6
[Issue #3103] Pass client assertion jwt to token endpoint
chouinar Dec 11, 2024
b27a7d7
Merge branch 'main' into chouinar/2810-connect-it-all
chouinar Dec 12, 2024
f73478d
Merge branch 'chouinar/2810-connect-it-all' into chouinar/3103-jwt-as…
chouinar Dec 12, 2024
a5abdf0
Create ERD diagram and Update OpenAPI spec
nava-platform-bot Dec 12, 2024
dd0a18a
Merge branch 'main' into chouinar/3103-jwt-assertion
chouinar Dec 12, 2024
1117fe8
Create ERD diagram and Update OpenAPI spec
nava-platform-bot Dec 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
)