Skip to content

Commit

Permalink
[Issue #3240] Write docs for API authentication logic (#3329)
Browse files Browse the repository at this point in the history
## Summary
Fixes #3240

### Time to review: __10 mins__

## Changes proposed
Added documentation for the authentication logic

## Context for reviewers
Hopefully covered most of the useful auth details, please let me know if
anything isn't clear or should have more detail
  • Loading branch information
chouinar authored Dec 20, 2024
1 parent 2188831 commit c4fab20
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 27 deletions.
1 change: 1 addition & 0 deletions api/src/api/users/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
For further details on how our authentication and user logic works see [authentication.md](/documentation/api/authentication.md)
1 change: 1 addition & 0 deletions api/src/auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
For further details on how our authentication and user logic works see [authentication.md](/documentation/api/authentication.md)
1 change: 1 addition & 0 deletions api/src/services/users/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
For further details on how our authentication and user logic works see [authentication.md](/documentation/api/authentication.md)
27 changes: 0 additions & 27 deletions api/tests/src/api/users/test_user_route_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,6 @@
from tests.lib.auth_test_utils import create_jwt
from tests.src.db.models.factories import LinkExternalUserFactory, LoginGovStateFactory

# To help illustrate what we are testing, here is a diagram
#
# Our endpoints Fake/test endpoints
# ============= ===================
#
# ._________________. .____________________.
# | | redirects to | |
# | /login | ------------->| /oauth-authorize |
# |_________________| |____________________|
# /
# redirects to /
# |----------------------
# .________V________. .____________________.
# | | calls | |
# | /login/callback | ------------->| /token |
# |_________________| |____________________|
# |
# |
# | .____________________.
# | redirects to | |
# -----------------------> | /login/result |
# |____________________|
#
# For testing, we create an oauth-authorize endpoint that redirects back
# and sets up a few basic pieces of information on the Oauth side that later
# can be picked up in the token endpoint.

##########################################
# Full login flow tests
##########################################
Expand Down
163 changes: 163 additions & 0 deletions documentation/api/authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@

# User Auth
User auth follows a standard [OAuth2 flow](https://oauth.net/2/) with [Login.gov](https://login.gov) as our backend provider.
Our API serves as a wrapper for a lot of the OAuth logic.

## Login Flow
```mermaid
sequenceDiagram
autonumber
participant frontend
participant api
participant login.gov
frontend->>+api: user clicks login
Note over api: /users/login
api->>-login.gov: redirect
activate login.gov
Note over login.gov: /authorize
login.gov->>+api: redirect
deactivate login.gov
Note over api: /users/login/callback
break if an error occurs
api->>frontend: redirect
end
api-->>login.gov: POST
login.gov-->>api: id_token
Note over login.gov: /token
api->>-frontend: redirect
Note over frontend: /auth/callback
```

At a high level, a user goes to our login endpoint, gets redirected to login.gov, logs in there,
and then gets redirected back to our API where we then create a session attached to the user
in our DB and return a key that can be used in other endpoints.

### Login
From the frontend, a user selects login, which sends them to the API's /users/login endpoint.

This endpoint sets up the parameters to call the [login.gov /authorize](https://developers.login.gov/oidc/authorization/) endpoint
which consists of several static configuration values (including where they should redirect when finished) plus two that change every request:
* state - A UUID that we will receive back from login.gov after the redirect
* [nonce](https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes) - A UUID that we will receive back after requesting a token, helps prevent replay attacks

We store the state and nonce value in our `login_gov_state` DB table.

### Login.gov Authorize
A user logs into their account or makes a new account. After successfully completing
this process they are redirected back to our /users/login/callback endpoint where
the rest of the login process will occur.

### Callback Validation
We [receive a redirect back](https://developers.login.gov/oidc/authorization/#authorization-response) from login.gov with a code, state, and optionally errors if there was an issue.

If any error occurred, error.

We use the state value to fetch the state we stored previously in our database.
If the state does not exist, error.

If there was a state in our DB, before any further processing we delete the record.
Even if we later error, we do not want a state value to ever be re-usable to avoid
the potential for [replay attacks](https://en.wikipedia.org/wiki/Replay_attack).

### Getting the Login.gov Token
Using the code we received back from login.gov, we call their [token endpoint](https://developers.login.gov/oidc/token/).
To call the endpoint we also need a [client_assertion jwt](https://developers.login.gov/oidc/token/#client_assertion)
which is created from a private key we generated, and gave Login.gov the public key certificate.

If there is any error with [the response](https://developers.login.gov/oidc/token/#token-response) from login.gov, error.

We parse the JWT, validating it against the [public keys](https://developers.login.gov/oidc/certificates/)
provided by login.gov. We refresh these in the event we don't find a matching key based on the KID (key ID).

We additionally validate that the token is valid across several other fields (expired, audience, issuer, etc), but
most importantly we check that the `nonce` value in the key matches the one we originally sent to login.gov.
If any of these validations fail, we error.

### Creating our own JWT

If all validation passed, we'll then check if the `sub` value from the token matches
any user in `link_external_user` table. If not, we'll create a record + create a user.
In either case, we'll update the email from login.gov in this table.

We create a `user_token_session` object in our database which has a `token_id`, `expires_at` timestamp, and `is_valid` boolean.

The expiration of the token we create is not managed in the token itself, but instead in our database, and can be managed
by our logout and refresh endpoints.

With this session token we generate a JWT containing the `token_id`, `user_id` and a few miscellaneous JWT attributes.

### Redirect to final destination
With the token we redirect to the configured final destination URL with a message, token, and whether the user was new.

In future iterations we may adjust this logic to allow for the final destination to be set by the caller in the /users/login endpoint, however
for security reasons we'll want to limit where we can send to.

## Error handling
Because these aren't standard GET/POST/etc. endpoints and instead handle
all responses with redirects, we redirect to the same final destination in the event
an error occurs. We send an `error` and `error_description` parameter with rough
information about what issue occurred.

In both our login and callback endpoints we wrap the entire endpoint in a try/catch
block that handles catching errors and doing this redirect to the final destination.

Most errors fall into two categories:
* Misconfiguration in the API
* Someone is calling the callback URL directly with random data

Because we need to protect ourselves against both of these, many error scenarios
are actually not something we would ever expect a user to hit.

## Logout
The logout endpoint will log out the session associated with the passed-in token.

It does this by setting the `is_valid` boolean to False.

## Refresh
The refresh endpoint will extend how long the token is valid for of the passed-in token.

It does this by setting the `expires_at` timestamp to 30 minutes from now.

If a token is already expired, it cannot be refreshed.

## Using the token
In endpoints that support user authentication, you can pass a header like `X-SGG-Token <your token>` in order to authenticate.

You can see which endpoints currently support this by going to our [OpenAPI docs](https://api.simpler.grants.gov/docs)
and finding which endpoints can consume the `X-SGG-Token` header.

When an API endpoint that is configured to use JWT auth receives a token, it parses and validates the fields.
We use the public key we generated to verify that we were the ones who originally generated the token which
is largely where the security comes from.

If a token doesn't exist, is no longer valid, or has expired, we reject the request with a 401.

### Example
To enable this auth for a given endpoint, add `api_jwt_auth` to the auth required parameter
of your endpoint. Additionally, you can fetch the user token session object from the auth.

```py
from src.adapters import db
from src.adapters.db import flask_db
from src.api import response
from src.auth.api_jwt_auth import api_jwt_auth

@example_blueprint.post("/my-example")
@example_blueprint.auth_required(api_jwt_auth)
@flask_db.with_db_session()
def user_token_logout(db_session: db.Session) -> response.ApiResponse:

user_token_session: UserTokenSession = api_jwt_auth.current_user # type: ignore
with db_session.begin():
do_something(user_token_session.user)

return response.ApiResponse("success")
```

# System-to-system Auth
Our system-to-system (S2S) authentication approach right now is in development, while a basic key auth
approach currently exists, further improvements are being scoped out.

0 comments on commit c4fab20

Please sign in to comment.