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

Refresh tokens, freshness pattern and scopes #1075

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from

Conversation

jtv8
Copy link
Contributor

@jtv8 jtv8 commented Sep 5, 2022

This is a draft for feedback, and follows on from this discussion earlier in the year: #350

I've made a first attempt here at implementing refresh tokens and the "freshness pattern" from fastapi-jwt-auth. It doesn't yet have any updates to docs, etc, as I'd like to get your initial input first.

Breaking changes

Any implementation of these features involves breaking changes to parts of the API. This is, unfortunately, inevitable because any solution will need to address these challenges:

Token metadata

It's no longer sufficient to determine whether a token simply exists for a given user and strategy, because we now also need to:

  • Determine a token's "fresh" status
  • Distinguish between an access token and a refresh token

For JWTStrategy this is straightforward (adding additional claims to the token), but for other strategies this requires non-backward-compatible changes. In this solution, that includes storing JSON in the Redis value for RedisStrategy and adding additional fields for DatabaseStrategy.

We also need to consider how to store and retrieve this metadata with the Strategy. For this I propose a Pydantic model, UserTokenData, which wraps the user object (conforming to UserProtocol) and its metadata. In this first draft I've created four metadata fields:

Field Description
created_at: datetime the UTC datetime when the token was issued
expires_at: Optional[datetime] the UTC datetime when the token expires - this is no longer set by the Strategy but passed in by the AuthenticationBackend (see below)
last_authenticated: datetime the UTC datetime when the user was last explicitly authenticated (not with a refresh token) - a token is considered "fresh" when created_at == last_authenticated
scopes: Set[str] distinguishes between an access and refresh token, and can be extended for other purposes later

Token response model

It's now no longer sufficient for a Transport instance to receive a string as a token, as it now needs to process an access tokan and (optionally) a refresh token. In this draft I've created a model

class TransportTokenResponse(BaseModel):
    access_token: str
    refresh_token: Optional[str] = None

which replaces the previous str type expected by Transport.get_login_response.

Moving token lifetime to AuthenticationBackend

As access tokens and refresh tokens have different lifetimes - and this could be extended to other token types in future - I've proposed removing the token lifetime configuration from Strategy and instead setting it in AuthenticationBackend, as well as whether refresh tokens should be generated and accepted:

    access_token_lifetime_seconds: Optional[int] = 3600,
    refresh_token_enabled: bool = False,
    refresh_token_lifetime_seconds: Optional[int] = 86400,

New features

New refresh router

I've added an OAuth2-compatible token refresh router, get_refresh_router in refresh.py for processing refresh tokens.

New "fresh" keyword arg in Authenticator methods

  • The public methods in Authenticator now have a fresh: bool keyword arg, which, when true, will throw 403 Forbidden if the token is not fresh.
  • I've also added an additional method, current_token, for users who need to inspect the token metadata.

Scopes

I've borrowed the concept of OAuth2 scopes to distinguish between access tokens and refresh tokens, and I've also defined some additional scopes to distinguish between classes of users.

Enum String Description
SystemScope.USER "fastapi-users:user" An access token belonging to an active user
SystemScope.SUPERUSER "fastapi-users:superuser" An access token belonging to an active superuser
SystemScope.VERIFIED "fastapi-users:verified" An access token belonging to an active and verified user
SystemScope.REFRESH "fastapi-users:refresh" A refresh token

This could be developed further - for example, both system- and user-defined routes could have "required scopes" that restrict what routes a particular token is permitted to access. By adding user-defined scopes, this could be used as a basis for a general-purpose user permissions system.

Potential additional features

The following additional security measures might be valuable but would require additional work:

  • Preventing refresh token reuse: store the created_at datetime for the most recently used refresh token so that it (and any older refresh token) cannot be reused.
  • Refreshing OAuth2 tokens: on token refresh, refresh an associated OAuth2 token with the original provider if it has expired
  • Checking for revoked OAuth2 tokens: on token refresh, re-verify an associated OAuth2 token with the original provider

Open questions

  • How should CookieTransport handle the concept of refresh tokens? Currently it ignores them entirely.

Alternative ideas

  • This could be implemented in a non-breaking way by implementing it only for IWTStrategy and BearerTransport and having any use of refresh tokens / freshness with other strategies raise a NotImplementedError, but I do think it's possible that users will want this for other strategies and transports.
  • I also considered using separate strategies for access and refresh tokens by adding a get_refresh_strategy to AuthenticationBackend, but this adds additional complexity. If this is something that user feedback indicates would be likely to be used I could add it back in.

Feedback welcome

Please let me know whether this is heading in the right direction and what other changes / different approaches you might have in mind!

@jtv8
Copy link
Contributor Author

jtv8 commented Sep 6, 2022

@frankie567 - would love your feedback on this before I proceed further!

@frankie567
Copy link
Member

Hi @jtv8! I appreciate your work here, but this seems really really huge changes.

I'll definitely have a look, because there are lot of good ideas here. However, I'll probably not merging the changes as if but rather reimplementing pieces by pieces — with proper credit for you for sure — so I can adapt things and better understand how to fit it in the big picture.

@jtv8
Copy link
Contributor Author

jtv8 commented Sep 6, 2022

Yes, that makes sense! You might find it hard to chunk it up into very small changes because some classes/modules that are impacted are still quite tightly coupled - e.g. UserProtocol is used everywhere and changing it to another type has a lot of implications.

That said, if you want to implement it progressively I'd start with the ability to store token metadata - it's the hardest thing to change and it enables a lot of the other features here (and future ones as well).

One other thing to consider: for future extensibility, I also toyed with the idea of allowing storage of arbitrary key-value metadata for tokens. It would be simple enough to implement with the JWT and Redis strategies, but much harder with the database one. Serialising it as JSON and storing it in a large text field would be the simplest solution (but not the only one).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants