dex/Documentation/proposals/user-object.md

147 lines
6.2 KiB
Markdown
Raw Normal View History

# Proposal: user objects for revoking refresh tokens and merging accounts
Certain operations require tracking users the have logged in through the server
and storing them in the backend. Namely, allowing end users to revoke refresh
tokens and merging existing accounts with upstream providers.
While revoking refresh tokens is relatively easy, merging accounts is a
difficult problem. What if display names or emails are different? What happens
to a user with two remote identities with the same upstream service? Should
this be presented differently for a user with remote identities for different
upstream services? This proposal only covers a minimal merging implementation
by guaranteeing that merged accounts will always be presented to clients with
the same user ID.
This proposal defines the following objects and methods to be added to the
storage package to allow user information to be persisted.
```go
// User is an end user which has logged in to the server.
//
// Users do not hold additional data, such as emails, because claim information
// is always supplied by an upstream provider during the auth flow. The ID is
// the only information from this object which overrides the claims produced by
// connectors.
//
// Clients which wish to associate additional data with a user must do so on
// their own. The server only guarantees that IDs will be constant for an end
// user, no matter what backend they use to login.
type User struct {
// A string which uniquely identifies the user for the server. This overrides
// the ID provided by the connector in the ID Token claims.
ID string
// A list of clients who have been issued refresh tokens for this user.
//
// When a refresh token is redeemed, the server will check this field to
// ensure that the client is still on this list. To revoke a client,
// remove it from here.
AuthorizedClients []AuthorizedClient
// A set of remote identities which are able to login as this user.
RemoteIdentities []RemoteIdentity
}
// AuthorizedClient is a client that has a refresh token out for this user.
type AuthorizedClient struct {
// The ID of the client.
ClientID string
// The last time a token was refreshed.
LastRefreshed time.Time
}
// RemoteIdentity is the smallest amount of information that identifies a user
// with a remote service. It indicates which remote identities should be able
// to login as a specific user.
//
// RemoteIdentity contains an username so an end user can be displayed this
// object and reason about what upstream profile it represents. It is not used
// to cache claims, such as groups or emails, because these are always provided
// by the upstream identity system during login.
type RemoteIdentity struct {
// The ID of the connector used to login the user.
ConnectorID string
// A string which uniquely identifies the user with the remote system.
ConnectorUserID stirng
// Optional, human readable name for this remote identity. Only used when
// displaying the remote identity to the end user (e.g. when merging
// accounts). NOT used for determining ID Token claims.
Username string
}
```
`UserID` fields will be added to the `AuthRequest`, `AuthCode` and `RefreshToken`
structs. When a user logs in successfully through a connector
[here](https://github.com/dexidp/dex/blob/95a61454b522edd6643ced36b9d4b9baa8059556/server/handlers.go#L227),
the server will attempt to either get the user, or create one if none exists with
the remote identity.
`AuthorizedClients` serves two roles. First is makes displaying the set of
clients a user is logged into easy. Second, because we don't assume multi-object
transactions, we can't ensure deleting all refresh tokens a client has for a
user. Between listing the set of refresh tokens and deleting a token, a client
may have already redeemed the token and created a new one.
When an OAuth2 client exchanges a code for a token, the following steps are
taken to populate the `AuthorizedClients`:
1. Get token where the user has authorized the `offline_access` scope.
1. Update the user checking authorized clients. If client is not in the list,
add it.
1. Create a refresh token and return the token.
When a OAuth2 client attempts to renew a refresh token, the server ensures that
the token hasn't been revoked.
1. Check authorized clients and update the `LastRefreshed` timestamp. If client
isn't in list error out and delete the refresh token.
1. Continue renewing the refresh token.
When the end user revokes a client, the following steps are used to.
1. Update the authorized clients by removing the client from the list. This
atomic action causes any renew attempts to fail.
1. Iterate through list of refresh tokens and garbage collect any tokens issued
by the user for the client. This isn't atomic, but exists so a user can
re-authorize a client at a later time without authorizing old refresh tokens.
This is clunky due to the lack of multi-object transactions. E.g. we can't delete
all the refresh tokens at once because we don't have that guarantee.
Merging accounts becomes extremely simple. Just add another remote identity to
the user object.
We hope to provide a web interface that a user can login to to perform these
actions. Perhaps using a well known client issued exclusively for the server.
The new `User` object requires adding the following methods to the storage
interface, and (as a nice side effect) deleting the `ListRefreshTokens()` method.
```go
type Storage interface {
// ...
CreateUser(u User) error
DeleteUser(id string) error
GetUser(id string) error
GetUserByRemoteIdentity(connectorID, connectorUserID string) (User, error)
// Updates are assumed to be atomic.
//
// When a UpdateUser is called, if clients are removed from the
// AuthorizedClients list, the underlying storage SHOULD clean up refresh
// tokens issued for the removed clients. This allows backends with
// multi-transactional capabilities to utilize them, while key-value stores
// only guarantee best effort.
UpdateUser(id string, updater func(old User) (User, error)) error
}
```
Importantly, this will be the first object which has a secondary index.
The Kubernetes client will simply list all the users in memory then iterate over
them to support this (possibly followed by a "watch" based optimization). SQL
implementations will have an easier time.