Merge pull request #594 from ericchiang/dev-proposal-upstream-refreshing
Documentation/proposals: add a proposal for keeping data in-sync during refreshes
This commit is contained in:
commit
86b2d93966
1 changed files with 165 additions and 0 deletions
165
Documentation/proposals/upstream-refreshing.md
Normal file
165
Documentation/proposals/upstream-refreshing.md
Normal file
|
@ -0,0 +1,165 @@
|
|||
# Proposal: upstream refreshing
|
||||
|
||||
## TL;DR
|
||||
|
||||
Today, if a user deletes their GitHub account, dex will keep allowing clients to
|
||||
refresh tokens on that user's behalf because dex never checks back in with
|
||||
GitHub.
|
||||
|
||||
This is a proposal to change the connector package so the dex can check back
|
||||
in with GitHub.
|
||||
|
||||
## The problem
|
||||
|
||||
When dex is federaing to an upstream identity provider (IDP), we want to ensure
|
||||
claims being passed onto clients remain fresh. This includes data such as Google
|
||||
accounts display names, LDAP group membership, account deactivations. Changes to
|
||||
these on an upstream IDP should always be reflected in the claims dex passes to
|
||||
its own clients.
|
||||
|
||||
Refresh tokens make this complicated. When refreshing a token, unlike normal
|
||||
logins, dex doesn't have the opportunity to prompt for user interaction. For
|
||||
example, if dex is proxying to a LDAP server, it won't have the user's username
|
||||
and passwords.
|
||||
|
||||
Dex can't do this today because connectors have no concept of checking back in
|
||||
with an upstream provider (with the sole exception of groups). They're only
|
||||
called during the initial login, and never consulted when dex needs to mint a
|
||||
new refresh token for a client. Additionally, connectors aren't actually aware
|
||||
of the scopes being requested by the client, so they don't know when they should
|
||||
setup the ability to check back in and have to treat every request identically.
|
||||
|
||||
## Changes to the connector package
|
||||
|
||||
The biggest changes proposed impact the connector package and connector
|
||||
implementations.
|
||||
|
||||
1. Connectors should be consulted when dex attempts to refresh a token.
|
||||
2. Connectors should be aware of the scopes requested by the client.
|
||||
|
||||
The second bullet is important because of the first. If a client isn't
|
||||
requesting a refresh token, the connector shouldn't do the extra work, such as
|
||||
requesting additional upstream scopes.
|
||||
|
||||
to address the first point, a top level `Scopes` object will be added to the
|
||||
connector package to express the scopes requested by the client. The
|
||||
`CallbackConnector` and `PasswordConnector` will be updated accordingly.
|
||||
|
||||
```go
|
||||
// Scopes represents additional data requested by the clients about the end user.
|
||||
type Scopes struct{
|
||||
// The client has requested a refresh token from the server.
|
||||
OfflineAccess bool
|
||||
|
||||
// The client has requested group information about the end user.
|
||||
Groups bool
|
||||
}
|
||||
|
||||
// CallbackConnector is an interface implemented by connectors which use an OAuth
|
||||
// style redirect flow to determine user information.
|
||||
type CallbackConnector interface {
|
||||
// The initial URL to redirect the user to.
|
||||
//
|
||||
// OAuth2 implementations should request different scopes from the upstream
|
||||
// identity provider based on the scopes requested by the downstream client.
|
||||
// For example, if the downstream client requests a refresh token from the
|
||||
// server, the connector should also request a token from the provider.
|
||||
LoginURL(s Scopes, callbackURL, state string) (string, error)
|
||||
|
||||
// Handle the callback to the server and return an identity.
|
||||
HandleCallback(s Scopes, r *http.Request) (identity Identity, state string, err error)
|
||||
}
|
||||
|
||||
// PasswordConnector is an interface implemented by connectors which take a
|
||||
// username and password.
|
||||
type PasswordConnector interface {
|
||||
Login(s Scopes, username, password string) (identity Identity, validPassword bool, err error)
|
||||
}
|
||||
```
|
||||
|
||||
The existing `GroupsConnector` plays two roles.
|
||||
|
||||
1. The connector only attempts to grab groups when the downstream client requests it.
|
||||
2. Allow group information to be refreshed.
|
||||
|
||||
The first issue is remedied by the added `Scopes` struct. This proposal also
|
||||
hopes to generalize the need of the second role by adding a more general
|
||||
`RefreshConnector`:
|
||||
|
||||
```go
|
||||
type Identity struct {
|
||||
// Existing fields...
|
||||
|
||||
// Groups are added to the identity object, since connectors are now told
|
||||
// if they're being requested.
|
||||
|
||||
// The set of groups a user is a member of.
|
||||
Groups []string
|
||||
}
|
||||
|
||||
|
||||
// RefreshConnector is a connector that can update the client claims.
|
||||
type RefreshConnector interface {
|
||||
// Refresh is called when a client attempts to claim a refresh token. The
|
||||
// connector should attempt to update the identity object to reflect any
|
||||
// changes since the token was last refreshed.
|
||||
Refresh(s Scopes, identity Identity) (Identity, error)
|
||||
|
||||
// TODO(ericchiang): Should we allow connectors to indicate that the user has
|
||||
// been delete or an upstream token has been revoked? This would allow us to
|
||||
// know when we should remove the downstream refresh token, and when there was
|
||||
// just a server error, but might be hard to determine for certain protocols.
|
||||
// Might be safer to always delete the downstream token if the Refresh()
|
||||
// method returns an error.
|
||||
}
|
||||
```
|
||||
|
||||
## Example changes to the "passwordDB" connector
|
||||
|
||||
The `passwordDB` connector is the internal connector maintained by the server.
|
||||
As an example, these are the changes to that connector if this change was
|
||||
accepted.
|
||||
|
||||
```go
|
||||
func (db passwordDB) Login(s connector.Scopes, username, password string) (connector.Identity, bool, error) {
|
||||
// No change to existing implementation. Scopes can be ignored since we'll
|
||||
// always have access to the password objects.
|
||||
|
||||
}
|
||||
|
||||
func (db passwordDB) Refresh(s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
|
||||
// If the user has been deleted, the refresh token will be rejected.
|
||||
p, err := db.s.GetPassword(identity.Email)
|
||||
if err != nil {
|
||||
if err == storage.ErrNotFound {
|
||||
return connector.Identity{}, errors.New("user not found")
|
||||
}
|
||||
return connector.Identity{}, fmt.Errorf("get password: %v", err)
|
||||
}
|
||||
|
||||
// User removed but a new user with the same email exists.
|
||||
if p.UserID != identity.UserID {
|
||||
return connector.Identity{}, errors.New("user not found")
|
||||
}
|
||||
|
||||
// If a user has updated their username, that will be reflected in the
|
||||
// refreshed token.
|
||||
identity.Username = p.Username
|
||||
return identity, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
Certain providers, such as Google, will only grant a single refresh token for each
|
||||
client + end user pair. The second time one's requested, no refresh token is
|
||||
returned. This means refresh tokens must be stored by dex as objects on an
|
||||
upstream identity rather than part of a downstream refresh even.
|
||||
|
||||
Right now `ConnectorData` is too general for this since it is only stored with a
|
||||
refresh token and can't be shared between sessions. This should be rethought in
|
||||
combination with the [`user-object.md`](./user-object.md) proposal to see if
|
||||
there are reasonable ways for us to do this.
|
||||
|
||||
This isn't a problem for providers like GitHub because they return the same
|
||||
refresh token every time. We don't need to track a token per client.
|
Reference in a new issue