Repository: jupyter-server/jupyter_server

Issue:
identity API
### Problem

As we proceed with authorization (#165, https://github.com/jupyterlab/jupyterlab/issues/11434, https://github.com/jupyterlab/jupyterlab/issues/11355), it's becoming apparent that frontends like JupyterLab are going to want to know, at least to some degree, what permissions they have. They also want things like the name, etc. for populating the identity widget (see https://github.com/jupyterlab/jupyterlab/pull/11443).


### Proposed Solution

- Add `GET /api/identity` endpoint to return the current user identity, and define a _minimum_ model (additional keys should be allowed)
- define Python API for providing this information, gathered from the current `get_current_user` and Authorizer extension points.

Example:

```python
user = self.get_current_user()
# backward-compat, cast str to username
if isinstance(user, str):
    user = {"username": user} # jupyterhub uses
else:
    user_model = user # assumes dict. Technically could have been any truthy object, before
user_model["permissions"] = self.authorizer.get_permissions(user)
```

User model (coordinate fields with https://github.com/jupyterlab/jupyterlab/pull/11443) should have at least a `username` string and `permissions` dict, representing state of #165.

```python
{
    "username": str, # only required name field. jupyterhub uses only 'name' because it has no 'real' name fields
    # other name fields should all be optional (should be defined, but may be null)
    "given_name": str, # optional
    "permissions": {
        "resource": ["read", "write", "execute"],
    },
}
```

The permissions part is tricky, because this _assumes_ clear, complete declarative permissions.
However, none of the proposed examples in #165 actually work that way.
An alternative for the permissions part would be to have an explicit _check permissions_ endpoint that takes a list of permissions to check, and returns which are permitted. This avoids the need to always return all possible permissions, and only returns answers to the questions the client needs to know, and would remove the need for permissions to be part of the identity model.

In JupyterHub, at least, permissions _are_ a declarative property of the identity model, so we could shift the implementation of the Authorizer in that direction, too.


### Additional context


* https://github.com/jupyter-server/jupyter_server/pull/165
* https://github.com/jupyterlab/jupyterlab/issues/11355
* https://github.com/jupyterlab/jupyterlab/pull/11443


Discussion:
SylvainCorlay: Thanks for opening this.

I would be super happy to see a minimal version of this merged (the change to `get_current_user`). I would like to build upon it to create a new handler in the JupyterLab server extension listing all users connected to documents through the RTC WebSocket.

```JSON
{
    "notebook:Untitled1": [
        USER1, USER3
    ], 
    "notebook:example": {
        USER2, USER3, USER4
   }
}
```

Whenever a user connects or disconnects to a document via the RTC WebSocket, they would add or removes themselves from that dict.
SylvainCorlay: Hey @minrk, I think that it may be relevant to look at the `ICurrentUser` API that was added in issues and PRs (https://github.com/jupyterlab/jupyterlab/pull/11443, https://github.com/jupyterlab/jupyterlab/issues/11657, https://github.com/jupyterlab/jupyterlab/pull/11774), in which it was discussed what fields should be included in the user model.
minrk: @SylvainCorlay yes, absolutely!

[IUser](https://jupyterlab.readthedocs.io/en/latest/api/modules/user.IUser.html#User) currently has these fields:

```
Readonly anonymous: boolean

    Whether the user is anonymous or not.

Optional Readonly avatar_url?: string

    User's avatar url. The url to the user's image for the icon.

Readonly color: string

    User's cursor color and icon color if avatar_url is undefined (there is no image).

Optional Readonly cursor?: Cursor

    User's cursor position on the document.

If undefined, the user is not on a document.

Readonly displayName: string

    User's last name.

Readonly initials: string

    User's name initials.

Readonly name: string

    User's name.

Readonly username: string

User's Username.
```

First question: what fields should come from the server. At first glance, it seems like username, name, and displayName make sense. Should the server also be responsible for picking avatar url and color? What about anonymous?

Second question: what should be done when some fields are undefined? In almost all cases, `username` is going to be the only piece of information available, so the result is presumably: `displayName=name=username`, `initials=username[0]`. Should it be the responsibility of the server to fill out some or all of these, or should they be defined and null to let the UI make its own choices for generating defaults from username?

My first impression:

- username is the only field required to be defined and non-null
- name, displayName, initials must be present, but should be null (or empty strings?) if the identity provider does not have an authoritative answer (i.e. server should not derive these from username, but Lab may)
- other fields in IUser _may_ be present, and should be used by JupyterLab if they are (color, avatar_url), but they rarely will be, if ever. Or we could just say no here, at least for now.
- not quite sure what to do for the 'anonymous' field - is that an implementation detail in Lab, derived from e.g. `username == 'anonymous'`?

hbcarlos: Hi @minrk, thank you soo much for working on this!

If I understood correctly, the `get_user` method is a hook that the server extensions that provide the user's identity must overwrite. If this is correct, in my opinion, `username`, `name`, `display_name`, `initials`, and `avatar_url` should come from the server. The reason is that all this information usually comes from the identity provider, and we can not assume `displayName=name=username` or `initials=username[0]`. The extension overwriting the `get_user` method should define this logic depending on which identity provider the information comes from.

- `username` is the unique identifier of the user.
- `name` is the complete name of the user. Some identity providers split this information into name and family name or last name.
- `display_name` is the name that users want to show to others and how they want to display it. Some identity providers provide this field because, depending on the region, names are written in a different order, or some parts are omitted (family name, name or name family name, etc.). There is a long discussion with some references here https://github.com/jupyterlab/jupyterlab/issues/11657.
- `initials` depend on how the name or display name is composed, so we can not assume anything.
- `avatar_url` is the URL to the user's profile image. This URL is different depending on the identity provider and could be null.
- `color` is the cursor color or the color used to identify the user on the UI. Even though it is pure frontend related, it is information that should be stored with the other fields, this way it doesn't change every time the user opens a new session. Not sure how to do it, I suggest leaving it as a frontend problem for now and we can iterate later to find a better solution.
- `cursor` pure frontend stuff and we will handle that in the frontend.

In my opinion, we should enforce the extensions to specify the `username`, `name`, `display_name`, `initials` by making them non-null and writing proper documentation about each field. In the case of not having one of the fields, they can always use an empty string.
 
minrk: Thanks for filling in!

> in my opinion, username, name, display_name, initials, and avatar_url should come from the server. The reason is that all this information usually comes from the identity provider, and we can not assume displayName=name=username or initials=username[0]. 

Yes, I agree the server should provide this information _when available_. But in the ~90% of cases where `username` is all the identity provider gives us, my question is whether the server should communicate to JupyterLab directly that this information is absent by leaving empty strings (as JupyterLab assumes [here](https://github.com/jupyterlab/jupyterlab/pull/11774/files#diff-9314afb6a079904cd1c73b415cf54fd8fbc368a66d53c880fe7777ae3e478dbeR112)), or should the server implement its own defaults for empty values?.

I don't really understand the difference between `name` and `displayName` from JupyterLab's perspective. When would you use `name` instead of `displayName`? Where does JupyterLab use `.name` and not `.displayName`?




> * `color` is the cursor color or the color used to identify the user on the UI. Even though it is pure frontend related, it is information that should be stored with the other fields, this way it doesn't change every time the user opens a new session. Not sure how to do it, I suggest leaving it as a frontend problem for now and we can iterate later to find a better solution.

I think that makes sense. It makes sense to me to follow the same pattern as `avatar_url`: implement it in the server model so it can be provided if the IdP has such information, but it will usually be `null` which means Lab still has to choose.
hbcarlos: > But in the ~90% of cases where username is all the identity provider gives us, my question is whether the server should communicate to JupyterLab directly that this information is absent by leaving empty strings (as JupyterLab assumes here), or should the server implement its own defaults for empty values?

If you mean the default implementation, we should follow the anonymous pattern implemented at the moment in the frontend, creating random usernames from the list of Jupiter's moons. But this should probably be a JupyterLab server extension. In any other case (Hub, or other solutions), if the IdP doesn't have the information, then those fields will be an empty string (the collaborative experience will be completely broken in those cases, but we can not do anything, that is the responsibility of who is deploying the application).

> I don't understand the difference between name and displayName from JupyterLab's perspective. When would you use name instead of displayName? Where does JupyterLab use .name and not .displayName?

On the UI, we only use `displayName`. The difference is that some deployments could show the username, others only the name or name and family name. It is just a way of being more flexible because we can have infinite possibilities. This is an excellent reference to understand the problem with the name https://www.w3.org/International/questions/qa-personal-names.
minrk: > If you mean the default implementation,

I mean JupyterHub - normally, only a username is provided, no other information. So in the vast majority of _fully authenticated_ deployments of Jupyter, a username is all we have to work with, so it's an important case to handle.

>  if the IdP doesn't have the information, then those fields will be an empty string (the collaborative experience will be completely broken in those cases, but we can not do anything, that is the responsibility of who is deploying the application).

Why is it broken? I don't see any reason that more than a username is needed for anything to function, only aesthetic refinement. This is going to be close to all cases in practice, so it probably shouldn't be broken, and it seems to be handled just fine by using `name=display_name=username`. I'm mainly asking whether you think it's useful to the frontend to be able to make those decisions when the information is unavailable (as the code [already does today](https://github.com/jupyterlab/jupyterlab/blob/4ee00194f263a7f729b1607e64c6efbf8a012545/packages/user/src/menu.ts#L126)), or if it should be the responsibility of the backend to duplicate username into these fields (i.e. JupyterLab shouldn't know the duplication is happening).

> we should follow the anonymous pattern implemented at the moment in the frontend

Moving that to the server makes sense, whether as an extension or in the default server itself. Probably for another PR, though, at least. What is the `anonymous` field used for in JupyterLab? Is that something that would be meaningful to expose from the server, or an internal detail?

hbcarlos: > Why is it broken? I don't see any reason that more than a username is needed for anything to function, only aesthetic refinement. This is going to be close to all cases in practice, so it probably shouldn't be broken, and it seems to be handled just fine by using name=display_name=username. I'm mainly asking whether you think it's useful to the frontend to be able to make those decisions when the information is unavailable (as the code already does today), or if it should be the responsibility of the backend to duplicate username into these fields (i.e. JupyterLab shouldn't know the duplication is happening).

What I meant with broken is that, If we leave the `displayName` empty, we don't see the user's name in the UI.
It is possible to use `name=display_name=username`, but we can not decide on the front end or the backend. That logic should be implemented by who overrides the `get_user` method. For example, I suppose that JupyterHub overrides that method to provide its user information, then Hub is the one deciding what should be on each of those fields. Because if we decide how to fill those fields, that will affect every IdP.

> Moving that to the server makes sense, whether as an extension or in the default server itself. Probably for another PR, though, at least.

Sure, I can work on this later in another PR. That will help me to get into the server source code.

> What is the anonymous field used for in JupyterLab?

That field is used to know whether the user is randomly generated on the frontend or is an identified user whose name comes from an IdP. The idea is to allow users to rename themself. The problem is that if the user information comes from an identity provider, we can not rename the user because we need to PUT the info to the IdP, and should not rename the user because the identity is verified by the IdP, if we allow renaming on the frontend anyone can impersonate someone else.

> Is that something that would be meaningful to expose from the server, or an internal detail

We can remove it from now because we can not rename users yet.
codecov-commenter: # [Codecov](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server) Report
> Merging [#671](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server) (535b043) into [main](https://codecov.io/gh/jupyter-server/jupyter_server/commit/87ab9bbcdb4aa0841e30dc3281a0e7f3c1311970?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server) (87ab9bb) will **increase** coverage by `0.61%`.
> The diff coverage is `91.87%`.

> :exclamation: Current head 535b043 differs from pull request most recent head 72da3a1. Consider uploading reports for the commit 72da3a1 to get more accurate results
[![Impacted file tree graph](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671/graphs/tree.svg?width=650&height=150&src=pr&token=S9WiBg2iL0&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server)](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server)

```diff
@@            Coverage Diff             @@
##             main     #671      +/-   ##
==========================================
+ Coverage   77.80%   78.42%   +0.61%     
==========================================
  Files         110      117       +7     
  Lines       10464    10749     +285     
  Branches     1404     1454      +50     
==========================================
+ Hits         8142     8430     +288     
+ Misses       1930     1928       -2     
+ Partials      392      391       -1     
```


| [Impacted Files](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server) | Coverage Δ | |
|---|---|---|
| [jupyter\_server/auth/login.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvYXV0aC9sb2dpbi5weQ==) | `68.93% <ø> (ø)` | |
| [jupyter\_server/config\_manager.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvY29uZmlnX21hbmFnZXIucHk=) | `94.59% <ø> (ø)` | |
| [jupyter\_server/extension/application.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvZXh0ZW5zaW9uL2FwcGxpY2F0aW9uLnB5) | `74.31% <ø> (ø)` | |
| [jupyter\_server/extension/handler.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvZXh0ZW5zaW9uL2hhbmRsZXIucHk=) | `58.62% <0.00%> (ø)` | |
| [jupyter\_server/gateway/gateway\_client.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvZ2F0ZXdheS9nYXRld2F5X2NsaWVudC5weQ==) | `76.19% <ø> (ø)` | |
| [jupyter\_server/gateway/managers.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvZ2F0ZXdheS9tYW5hZ2Vycy5weQ==) | `60.70% <ø> (ø)` | |
| [jupyter\_server/services/contents/manager.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvc2VydmljZXMvY29udGVudHMvbWFuYWdlci5weQ==) | `88.18% <ø> (ø)` | |
| [jupyter\_server/services/kernels/kernelmanager.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvc2VydmljZXMva2VybmVscy9rZXJuZWxtYW5hZ2VyLnB5) | `80.63% <ø> (ø)` | |
| [jupyter\_server/services/sessions/sessionmanager.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvc2VydmljZXMvc2Vzc2lvbnMvc2Vzc2lvbm1hbmFnZXIucHk=) | `90.97% <ø> (+3.47%)` | :arrow_up: |
| [jupyter\_server/terminal/terminalmanager.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvdGVybWluYWwvdGVybWluYWxtYW5hZ2VyLnB5) | `88.75% <ø> (+1.25%)` | :arrow_up: |
| ... and [50 more](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server). Last update [87ab9bb...72da3a1](https://codecov.io/gh/jupyter-server/jupyter_server/pull/671?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server).

minrk: > What I meant with broken is that, If we leave the displayName empty, we don't see the user's name in the UI

Ah, sure. A value needs to be set for these fields eventually, by the time they are actually used. My question is about the (majority of) cases where some fields aren't available, which piece of code is responsible for filling in the derivative values, in the order in which they occur:

1. The IdP adapter (e.g. JupyterHub) - this means duplicating the same logic in every adapter, and making backward-incompatible requirements for all current implementations of get_user.
2.  jupyter-server - single implementation, consistent spec, values always defined in the model retrieved by Lab (same as 1. from Lab's perspective, but much simpler and more broadly compatible)
3. Lab and other clients - able to make decisions with the most available information (e.g. when using fallback `displayName=username`, render it differently somehow because it is known to be a 'username' and not a for-humans 'display name'.)

2. is probably the simplest, while 3. allows the UI to make the most informed display decisions because it knows what fields are actually authoritative vs generated from fallbacks. 1. offers no benefits over 2, so I don't think it makes sense.

> we can not decide on the front end or the backend. 

I don't think that follows. We can't _define_ `display_name=username`, but we can have:

```python
if not display_name:
    display_name = username # or name, etc.
```

without any issues. JupyterLab already has exactly this logic: `displayName !== '' ? displayName : name`.

I've updated this PR to [implement 2](https://github.com/jupyter-server/jupyter_server/pull/671/files#diff-4409cadd6a43c116458c8c27a301d2c729fb74a53eae10f14cff25f83ff5d97aR42-R65) to show you what I mean - these are purely _fallbacks_ for when authoritative info for some fields is unavailable; the IdP adapter may return any and all fields it has an answer for (this includes computing its own derivative values, so 2. covers a strict superset of 1.), so this is just filling in the missing values _when they are missing_.

echarles: This PR builds on top of the authorization branch, which makes perfectly sense.

To get the gist of the changes related to identity, I use this link https://github.com/Zsailer/jupyter_server/compare/authorization...minrk:identity
hbcarlos: > is probably the simplest, while 3. allows the UI to make the most informed display decisions because it knows what fields are actually authoritative vs generated from fallbacks. 1. offers no benefits over 2, so I don't think it makes sense.

I can't entirely agree with this part because, in the server or the frontend, we do not know if the IdP is using a UUID, email, or specific combination of the name to create a username (to create a unique identifier). I don't think it is good to display a UUID as the name for users. We are not the most informed to make a display decision.

Maybe, the best we can do, is introduce the `anonymous` attribute and a mandatory with the `username.` The IdP has two options:
1. sets `anonymous=true`, which means the user is authenticated but doesn't have an identity. Then the IdP adapter provides the `username`, and we create an anonymous name from a random Jupyter's moon (the same logic we are currently using on the frontend).
2. sets `anonymous=false`, which means the user is authenticated and has an identity. Then the IdP adapter provides at least the `username` and `display_name`.

> I've updated this PR to implement 2 to show you what I mean - these are purely fallbacks for when authoritative info for some fields is unavailable; the IdP adapter may return any and all fields it has an answer for (this includes computing its own derivative values, so 2. covers a strict superset of 1.), so this is just filling in the missing values when they are missing.

As a fallback, this is fine. But still, if the username is a UUID, we will be displaying UUIDs in the UI.

> JupyterLab already has exactly this logic: displayName !== '' ? displayName : name.

That's not 100% true. The logic you are talking about is [here](https://github.com/jupyterlab/jupyterlab/blob/4ee00194f263a7f729b1607e64c6efbf8a012545/packages/user/src/model.ts#L166-L169) and is a temporary solution to allow anonymous users to rename themself by introducing the name, color, and initials as a parameter in the URL. And for example, we are not extracting the initials from the name, as you can see on this code. Instead, we keep the old initials forcing the user to introduce them. The reason is there is a lot of different cultures where names are entirely different, and we should never assume anything.

```ts
let name = decodeURIComponent(env.getParam('--username', ''));
let color = env.getParam('--usercolor', '');
let initials = decodeURIComponent(env.getParam('--initials', ''));
.
.
.
this._username = user.username as string;
this._name = name !== '' ? name : (user.name as string);
this._displayName = name !== '' ? name : (user.displayName as string);
this._initials = initials !== '' ? initials : (user.initials as string);
```
minrk: I understand the concern about showing UUIDs to the user, that makes sense. I think it's reasonable, then, to keep the implementation of fallbacks I have here and put that requirement on the IdP adapter that _if_ all it has for a username is a UUID, it should provide a (generated) real name as well. That can be the responsibility of the IdP and a _suggestion_ to always provide a 'nice' name, since everything works correctly with any username, but the experience is more pleasant for UUID usernames. If users should have the right to edit anonymous names, it makes sense that this would be a server-side feature, as it will have server-side storage components.

I think we can work out the details of who generates/tracks these anonymous names in a second round and/or extension. If anonymous providers are common (or we just need it for the default case), an implementation of anonymous utilities may well make sense in this repo.

> The reason is there is a lot of different cultures where names are entirely different, and we should never assume anything.

I agree that's true - the problem is that we are implementing every step of this process, and initials aren't provided anywhere. So it's still up to 'us' to do it, just not JupyterLab. If JupyterLab forces Jupyter Server to do it, and if Server doesn't do it itself, it's forcing JupyterHub to do it. At no point in that process are initials available from a trustworthy source. So what should we do, then?

Can we eliminate the requirement for initials, since there's ~never a safe way to generate them? If the user should be able to pick them, then I think it should be JupyterLab's responsibility, as that's the only place they are used, and also the only place UI can feasibly be presented to do so. Maybe Server needs to be involved, too, for storage purposes, but I think this is principally a task for Lab.
minrk: How's this for a general design:

1. Identity provider should provide as much information as it has available.
2. If only a username is available and usernames aren't very human friendly, IdP _may_ derive `name` and/or `display_name` if there's an appropriate scheme based on the structure of usernames (e.g. `email.split('@')[0]` if only a single email domain is allowed, or an `Anonymous Moon` name picked based on the hash of a UUID username)
3. Server will fill some derivative fields if left empty, but will never _transform_ names due to a lack of localization info. That is:
   ```python
    if not name:
        name = username
    if not display_name:
        display_name = name
   ```
4. if there is no authoritative source for initials, it will be left as an empty string, because we do not do automatic transforms of names anywhere.

The result is that server can guarantee these fields are present and non-empty: `username`, `name`, `display_name`, but `initials` may be an empty string, and `avatar_url`, `color` may be null.
hbcarlos: > Can we eliminate the requirement for initials, since there's ~never a safe way to generate them? If the user should be able to pick them, then I think it should be JupyterLab's responsibility, as that's the only place they are used, and also the only place UI can feasibly be presented to do so. Maybe Server needs to be involved, too, for storage purposes, but I think this is principally a task for Lab.

That's true. Usually, IdPs don't provide initials so I think we can deal with the Initials in the frontend for now and will see what to do with them. I was thinking maybe to change initials for random images of jupyter's moons a la Google Docs with animals, but we can think about it on a second round.

> The result is that server can guarantee these fields are present and non-empty: username, name, display_name, but initials may be an empty string, and avatar_url, color may be null

I like the design you are presenting, just one thing, I think would be better to have initials as null instead of empty string.
So, `username`, `name`, `display_name` non-empty and `initials`, `avatar_url` and `color` may be null.
minrk: > I think would be better to have initials as null instead of empty string.

👍 so `null` for all unspecified values. Make sense, I'll get on that. I think I have all the info to get a complete draft implementation. Thank you!

> This PR builds on top of the authorization branch, which makes perfectly sense.

If it would help with review, I _can_ remove the permissions field from this PR and rebase without #165. Then I could add the permissions field in a third PR once this and #165 are merged.

Configuration/implementation question: do folks have a preference for whether the identity-provider-adapter and Authorizer should be different classes or just one? I initially thought just one (currently reflected in this PR) since I suspect they will ~always go together, but it might be better for them to be separate classes to allow for one identity provider to be mixed with different authorization schemes (e.g. oauth + read-only, etc.).

- pro single class: most 'real' cases (e.g. jupyterhub) will be a single implementation, and these can be selected with a single config value. Harder to have a non-sensible config by selecting only one of the two.
- pro multiple class: every _valid_ combination of identity provider and authorization pattern would need to be a separate class (or support class-level configuration to modify authorization).
echarles: > If it would help with review, I can remove the permissions field from this PR and rebase without https://github.com/jupyter-server/jupyter_server/pull/165. Then I could add the permissions field in a third PR once this and https://github.com/jupyter-server/jupyter_server/pull/165 are merged.

With the link I have shared https://github.com/Zsailer/jupyter_server/compare/authorization...minrk:identity I am fine having this PR as it is and avoid additional work to you.

Authorization and Authentication (aka identity) are `Auth` related and looking at them at the same time is an added value to me. I would understand if other think they are separated concerns and deserves separated PR.

To me, both can remain in this PR.

> Configuration/implementation question: do folks have a preference for whether the identity-provider-adapter and Authorizer should be different classes or just one?

They sounds to me like separated classes, but I need to makes sense of what you say in the above comment: ` Harder to have a non-sensible config by selecting only one of the two.`
minrk: > They sounds to me like separated classes

That's what I'm thinking, too (`jupyter_server.auth.IdentityProvider` atm).

> They sounds to me like separated classes, but I need to makes sense of what you say in the above comment

I mean, for example, to configure Jupyter Server to run under JupyterHub, it must always set _both_ the authorizer and identity provider, e.g.

```python
c.ServerApp.authorizer_class = JupyterHubAuthorizer
c.ServerApp.identity_provider_class = JupyterHubIdentityProvider
```

If one made the mistake of only setting `authorizer_class`, it wouldn't work at all because the JupyterHubAuthorizer _assumes_ the user object is what was returned from the `JupyterHubIdentityProvider` (it doesn't make sense to use JupyterHubAuthorizer with any other IdentityProvider).

On the other hand, setting only the JupyterHub identity provider would _not_ result in properly applying permissions - it would appear to work because authentication would be correct. But authenticated requests that _shouldn't_ have permission will be allowed, too (it's easy to overlook a configuration error when things that are supposed to work do, but things that aren't supposed to work do, too). This is _technically correct_ because that's what the default authorizer is meant to do, but is likely not the configurer's _intention_.

In the specific case of JupyterHub, I don't expect this to be an issue because it will be taken care of by the `jupyterhub-singleuser` wrapper, but that's the _kind_ of error I was thinking of.
echarles: > In the specific case of JupyterHub, I don't expect this to be an issue because it will be taken care of by the jupyterhub-singleuser wrapper, but that's the kind of error I was thinking of.

OK, I see, thx for clarifying. There are so many configuration to setup that I don't see that as an issue to ask the platform administrator to define needed configuration... as long (in capitals)...as  long as the system is secured by default.
minrk: > It looks like the identity API itself defers to the Authorizer subclass to return a user model. By providing a custom Authorizer, any deployment of of Jupyter Sever can integrate with 1) whatever service/API they are using for identity, and 2) the actual permissions/access system about who can access what. 

Yes, though I think the latest consensus is that we'll have separate `IdentityProvider` to provide the user model, and `Authorizer` to make authorization decisions based on that model and the resource being requested. I haven't made those changes yet. These are loosely coupled in that the Authorizer _may_ make assumptions about the user model from the identity provider. Applications that provide _both_ (like JupyterHub) will implement both APIs, while others may only provide one or the other (e.g. an OAuthIdentityProvider with an EverybodysReadOnly Authorizer).

> On the frontend, is the plan to have ICurrentUser in JupyterLab pull information from the identity API?

Yes, that's the goal, so we want to make sure we have all the fields ICurrentUser may want defined, and be clear what to expect/require when certain fields aren't available from the IdentityProvider.
echarles: > Yes, though I think the latest consensus is that we'll have separate IdentityProvider to provide the user model, and Authorizer to make authorization decisions based on that model and the resource being requested. 

This is indeed what has been discussed recently.

> It looks like the identity API itself defers to the Authorizer subclass to return a user model

Looking at the diff, there is indeed a `def user_model(self, user: Any) -> Dict:` method on the `jupyter_server/services/auth/authorizer.py` file which confuses me are the user model is a responbility of the `identity` aspect. Would it be make sense to create a `jupyter_server/services/auth/identity.py` file and move that method there?
minrk: > Would it be make sense to create a jupyter_server/services/auth/identity.py file and move that method there?

Yup, that's the idea. I have done it locally already, hope to have a draft up by the end of the week.
minrk: Rebased now that #671 is merged. Not quite ready for a full review, but close enough that folks can take a look.

Overview:

IdentityProvider defines the Authentication API to be combined with Authorizer for authorization.

- `IdentityProvider.get_user(Handler)` is the new API for returning a user to be stored in `.current_user` for `@authenticated` handlers. This is the object passed to `Authorizer.is_authorized()`.
  The default implementation preserves existing behavior by calling `LoginHandler.get_user()`, but if a subclass defines this method,
  LoginHandler.get_user is ignored.
- `IdentityProvider.identity_model` casts the above object to a standard model, suitable for consumption by JupyterLab's IUser. The default implementation works with common known implementations (the default in jupyter server, and JupyterHub), but will likely want to be overridden in most cases.
- `/api/me` is served by `IdentityHandler` which returns the identity model with a `permissions` key. `permissions` may be passed as a JSON-serialized dict of `{resource: [action]}`. The dict returned will have the exact same form, only including _authorized_ permissions.

Remaining tasks:

- [x] define IdentityModel
- [x] implement default IdentityProvider
- [x] implement /api/me returning identity model, including IUser fields and permissions
- [ ] docs
- [ ] example
- [ ] tests

Design decisions:

- [ ] should the default `identity_model` fail if it cannot determine a username, or use 'unknown' or an otherwise generated value? This is a backward-compatibility issue, as currently any opaque truthy object is allowed for a custom `LoginHandler.get_user` implementation.
- [ ] Should additional fields be allowed in the identity model if the identity provider has them (jupyterhub user models have fields like 'kind', group membership, etc.), and if so, how should they be included (an `identity.ext` black-box dict?)
- [ ] Should there be a field identifying the identity provider itself?
- [ ] Should we use the IdentityModel, and/or enforce our own User class directly in `.current_user` - this may be hard without breaking compatibility.

Things that I think are good to do, but ideally not in this PR:

- [ ] deprecate LoginHandler.get_user and other authentication customization options on LoginHandler in favor of consolidating authentication customization on IdentityProvider (`IdentityProvider.get_user` already has higher priority, but default implementation for backward-compatibility still calls LoginHandler.get_user where the default authentication implementation still resides)
- [ ] (as suggested by @hbcarlos) implement new default anonymous user scheme with generated names, instead of using UUIDs, since these may now be visible
minrk: I added some docs and tests. Could still use an example, but I think there's enough to review for now.
minrk: Thanks for the reviews! I'll try to address it early next week.
davidbrochart: The `/api/me` endpoint looks similar to what we have in Jupyverse:
```json
{
  "anonymous": true,
  "username": "",
  "name": "string",
  "color": "string",
  "avatar": "string",
  "workspace": "{}",
  "settings": "{}",
  "oauth_accounts": [],
  "id": "string",
  "email": "user@example.com",
  "is_active": true,
  "is_superuser": false,
  "is_verified": false
}
```
However, because we're using [FastAPI Users](https://github.com/fastapi-users/fastapi-users), `username` has to be an email address, but I think we can work around that.
We don't have authorization yet, but we plan on adding a `permissions` field. We do have other fields though, like `settings` and `workspace`, because they are stored per user in a database.
I guess this PR doesn't prevent populating the user model with extra fields, right?
minrk: > I guess this PR doesn't prevent populating the user model with extra fields, right?

It doesn't (it will probably make sense for JupyterHub to provide additional fields as well), so I'd like to have a decision on how exactly that should be expressed. One option is an `identity.ext` field that's a dict of dicts for clearly labeled "extended attributes," so we might have e.g. `identity.ext.jupyterhub.groups = ["student"]`. A more "wild west" option is to allow any additional fields at the top-level, but that presents a namespace issue when the reference spec wants to claim more names that an extension might have already chosen.
davidbrochart: I think namespacing is always better.
minrk: Switched over to a User dataclass instead of IdentityModel dict. This simplifies things a bit, as filling defaults and optional fields can be handled in `__init__`, and there's now a standard API to be passed to e.g. Authorizer.is_authorized. Backward-compatibility is going to be trickier (JupyterHub overrides .get_current_user, I believe), but this will be better in the long run.



@vidartf makes a good point about the existing LoginHandler implementation. I definitely think it needs to be addressed before _release_ of the new APIs, but addressing it here will make the PR quite a bit bigger. I can do that, if folks think that's better.

I happen to like JupyterHub's design where the LoginHandler is configured _by_ the Authenticator (the equivalent of an IdentityProvider), rather than on the base Application class. I think putting configuration on Handler classes isn't a great pattern, and it's good to move away from that. Backward-compatibility may be tricky to maintain. I think it makes sense for ~all of what's configurable on LoginHandler to move to the new classes.
minrk: Thanks for the input! I think most things are resolved. I updated the comments for display_name based on https://github.com/jupyterlab/jupyterlab/pull/12175 .

Do folks have an opinion on extension fields? I'd like to e.g. put JupyterHub fields (scope list, group membership, etc.) somewhere in the user model. Does `identity.ext.jupyterhub.x = y` feel okay, or is there another idea? Is an extension _endpoint_ preferred to extending the user model on the identity endpoint?


minrk: To resolve the conflict, this now reverts #704 to solve the problem by ensuring the default Authorizer/IdentityProvider are created on access, rather than adding a warning where `is_authenticated` is called.
minrk: @hbcarlos excellent question! I think we still need to prioritize the token for the purposes of setting the `_token_authenticated` state flag, but that doesn't mean we need to _ignore_ the user id in the cookie.

I made what I think is the smallest change here, and would like to tackle it more comprehensively in a separate PR, implementing a new default AnonymousIdentityProvider and deprecating LoginHandler as the auth extension point. What I've done here:

1. if token-authenticated, set `_token_authenticated = True` (important to keep for CORS reasons)
2. if cookie is present, use it for user_id _whether token-authenticated or not_ (wouldn't always make sense, but does for the default implementation)
3. if token-authenticated _and_ cookie is not present, generate new user_id (this is typically the first authenticated request)

Essentially, the only change is in the case where token-authenticated, the user id is `cookie_user_id or new_user_id()` rather than unconditional `new_user_id()`.

hbcarlos: Thanks, @minrk!
That will work to keep track of the user.

I can work on the AnonymousIdentityProvider once this PR is merged. Also, that would help me to get into the server source code.
SylvainCorlay: LGTM
Zsailer: > @vidartf makes a good point about the existing LoginHandler implementation. I definitely think it needs to be addressed before release of the new APIs, but addressing it here will make the PR quite a bit bigger. I can do that, if folks think that's better.

@minrk does this warrant a major release (i.e. 2.0) of Jupyter Server from your perspective?

We tend to cut minor and patch releases fairly frequently as PRs are merged (thanks to the convenience from Jupyter Releaser). If this needs a major release because of backward incompatibility, I think we should branch this repo—`1.x` and `main` (as the 2.x)—before merging this PR and get on the same page as a team about releases going forward. 

We haven't really established any rules around releasing since the risk has been fairly low. I want to avoid accidentally releasing this before it's ready. I'll open an issue in team-compass to discuss further.
minrk: Even if it doesn't strictly break anything, this is big enough that I think it warrants 2.0, and splitting this for 2.x development so you can keep doing 1.x releases makes sense. The main thing is the new assumption that `get_current_user()` will return an `auth.User` object, which is a substantial change. I think there are a few todos before this is ready to release, that I think are better accomplished as discrete PRs. Whether that means making a feature branch for 2.x or branching off 1.x for backport releases, I'll leave up to you.

The main todos that I don't want to do in this PR, I think are:

1. move config from LoginHandler to IdentityProvider

2. new default identity scheme with generated usernames instead of UUIDs (@hbcarlos) doesn't _need_ to block the first release, but I think it will be informative about the method breakdown for subclasses

3. decision on extensions to the user model (asked above, could do here if folks agree)

I'm working on a test deployment with draft implementations for JupyterHub which I think will shed some light on missing pieces.
minrk: Had to squash and rebase to deal with conflicts with #762
minrk: I've run a successful test with this PR and https://github.com/jupyterhub/jupyterhub/pull/3833

With this, I'm able to run a fully authenticated JupyterLab server with read-only access for some users _without_ the JupyterHub wrappers at all, purely an implementation of the IdentityProvider and Authorizer APIs. That tells me we are pretty close to my goal of being able to replace the jupyterhub-singleuser wrapper and its extensive monkeypatching with a public-api-consuming ExtensionApp. The main thing I needed was a `get_handlers()` (as seen in JupyterHub.Authenticator and in ContentsManager) to register the oauth callback handler.

Config files: https://gist.github.com/e5f47d6f4253532635e9716076db0fb4
Zsailer: We've created a 1.x branch during this week's contributing hour. `main` now represents our 2.x branch. We're going to merge this PR here as part of this new branch. 

Thanks for all the great work here @minrk!
minrk: I can make async get_user work, I'm just thinking through the deprecation process.

The 'right' way to check the current user is to access `handler.current_user`. This has _always_ been the case, but sometimes the underlying `.get_current_user()` is called directly, instead. This has been harmless until now, because `.current_user` is by default a cached proxy to `.get_current_user(). If `.get_current_user` is allowed to be async, it can return a truthy coroutine, even when the request is not authenticated, granting unauthorized access if code isn't updated.

Assuming nobody overrides `.get_current_user`, this is not hard to handle: we can populate `.current_user` in prepare, and raise or show a deprecation warning if `.get_current_user()` is called instead (e.g. warn and return if .current_user is already set, raise if it isn't e.g. due to .prepare() being overridden without super, or unusual inheritance).

The trickier case is what JupyterHub does currently, which is override `.get_current_user()` itself (the standard way to override auth in Tornado). If we ignore that and call IdentityProvider.get_user directly, JupyterHub auth will be bypassed and all requests will be allowed without warning. This is the main situation I want to avoid. Deprecation would be best, error second choice, but it shouldn't silently ignore the deprecated auth override in favor of allowing everything.

So far, I think:

1. base class .get_current_user raises/warns
1. detect if using default IdentityProvider and overridden get_current_user: warn and call overridden method
1. otherwise, call identityProvider.get_user directly

That way, setting a custom IdentityProvider takes precedence, but the deprecated approach still works without issue. Only downside is the somewhat convoluted check for the deprecated case on each request.

minrk: FWIW, I'd also be okay if the JupyterHub approach errored in a simple, reliable way, as long as the failure isn't allowing requests it shouldn't. That just means JupyterHub users need to wait for a release before upgrading to server 2.0. I'm fine saying that, but I want to be very sure that the incompatible-versions failure mode is not ignoring auth altogether.
minrk: Has `warn_disabled_authorization` been released in 1.x? If so, I can backport the fix removing that from this PR.
minrk: Same issue comes up again in check_xsrf_cookie, called in `_execute`, needs token_authenticated -> needs current_user -> before prepare -> cannot be async. Need to think a bit on that one.
Zsailer: 
> Has `warn_disabled_authorization` been released in 1.x? If so, I can backport the fix removing that from this PR.

Yes, it was released in [v1.15.5](https://github.com/jupyter-server/jupyter_server/releases/tag/v1.15.5) and above.

minrk: Worked out the async get_user. I _suspect_ this may come up as a sticky issue a few times in the future because some of tornado's 'pre-flight' checks run pre-prepare, and many of them now require running post-prepare.

Zsailer: > Worked out the async get_user

🎉  Great work, Min!

> I suspect this may come up as a sticky issue a few times in the future because some of tornado's 'pre-flight' checks run pre-prepare, and many of them now require running post-prepare.

Do you mind expanding on this a little bit? I'm don't immediately see the issue, but I'm sure you're experience from JupyterHub make you much more keen to see these issues. 

This looks good to go in my mind! :+1: 🚀 
Zsailer: I'm going to merge this now, since we've had multiple rounds of review. I think it's better to get this merge and start using it. We can iterate in future PRs if issues arise. 

Thanks so much, @minrk! Super excited for this work!

Patch:
diff --git a/docs/source/conf.py b/docs/source/conf.py
index c78443f01..3e2877828 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -73,6 +73,7 @@ extensions = [
     "sphinx.ext.intersphinx",
     "sphinx.ext.autosummary",
     "sphinx.ext.mathjax",
+    "sphinx.ext.napoleon",
     "IPython.sphinxext.ipython_console_highlighting",
     "sphinxcontrib_github_alt",
     "sphinxcontrib.openapi",
@@ -131,7 +132,7 @@ language = None
 
 # The reST default role (used for this markup: `text`) to use for all
 # documents.
-# default_role = None
+default_role = "literal"
 
 # If true, '()' will be appended to :func: etc. cross-reference text.
 # add_function_parentheses = True
@@ -360,6 +361,7 @@ intersphinx_mapping = {
     "nbconvert": ("https://nbconvert.readthedocs.io/en/latest/", None),
     "nbformat": ("https://nbformat.readthedocs.io/en/latest/", None),
     "jupyter": ("https://jupyter.readthedocs.io/en/latest/", None),
+    "tornado": ("https://www.tornadoweb.org/en/stable/", None),
 }
 
 spelling_lang = "en_US"
diff --git a/docs/source/operators/security.rst b/docs/source/operators/security.rst
index 87148cbe4..04568cb80 100644
--- a/docs/source/operators/security.rst
+++ b/docs/source/operators/security.rst
@@ -77,17 +77,129 @@ but this is **NOT RECOMMENDED**, unless authentication or access restrictions ar
     c.ServerApp.token = ''
     c.ServerApp.password = ''
 
-Authorization
--------------
+
+Authentication and Authorization
+--------------------------------
 
 .. versionadded:: 2.0
 
+There are two steps to deciding whether to allow a given request to be happen.
+
+The first step is "Authentication" (identifying who is making the request).
+This is handled by the :class:`.IdentityProvider`.
+
+Whether a given user is allowed to take a specific action is called "Authorization",
+and is handled separately, by an :class:`.Authorizer`.
+
+These two classes may work together,
+as the information returned by the IdentityProvider is given to the Authorizer when it makes its decisions.
+
+Authentication always takes precedence because if no user is authenticated,
+no authorization checks need to be made,
+as all requests requiring _authorization_ must first complete _authentication_.
+
+Identity Providers
+******************
+
+The :class:`.IdentityProvider` class is responsible for the "authorization" step,
+identifying the user making the request,
+and constructing information about them.
+
+It principally implements two methods.
+
+.. autoclass:: jupyter_server.auth.IdentityProvider
+
+  .. automethod:: get_user
+  .. automethod:: identity_model
+
+The first is :meth:`.IdentityProvider.get_user`.
+This method is given a RequestHandler, and is responsible for deciding whether there is an authenticated user making the request.
+If the request is authenticated, it should return a :class:`.jupyter_server.auth.User` object representing the authenticated user.
+It should return None if the request is not authenticated.
+
+The default implementation accepts token or password authentication.
+
+This User object will be available as `self.current_user` in any request handler.
+Request methods decorated with tornado's `@web.authenticated` decorator
+will only be allowed if this method returns something.
+
+The User object will be a Python :py:class:`dataclasses.dataclass`, `jupyter_server.auth.User`:
+
+.. autoclass:: jupyter_server.auth.User
+
+A custom IdentityProvider _may_ return a custom subclass.
+
+
+The next method an identity provider has is :meth:`~.IdentityProvider.identity_model`.
+`identity_model(user)` is responsible for transforming the user object returned from `.get_user()`
+into a standard identity model dictionary,
+for use in the `/api/me` endpoint.
+
+If your user object is a simple username string or a dict with a `username` field,
+you may not need to implement this method, as the default implementation will suffice.
+
+Any required fields missing from the dict returned by this method will be filled-out with defaults.
+Only `username` is strictly required, if that is all the information the identity provider has available.
+
+Missing will be derived according to:
+
+- if `name` is missing, use `username`
+- if `display_name` is missing, use `name`
+
+Other required fields will be filled with `None`.
+
+
+Identity Model
+^^^^^^^^^^^^^^
+
+The identity model is the model accessed at `/api/me`,
+and describes the currently authenticated user.
+
+It has the following fields:
+
+username
+  (string)
+  Unique string identifying the user.
+  Must be non-empty.
+name
+  (string)
+  For-humans name of the user.
+  May be the same as `username` in systems where only usernames are available.
+display_name
+  (string)
+  Alternate rendering of name for display, such as a nickname.
+  Often the same as `name`.
+initials
+  (string or null)
+  Short string of initials.
+  Initials should not be derived automatically due to localization issues.
+  May be `null` if unavailable.
+avatar_url
+  (string or null)
+  URL of an avatar image to be used for the user.
+  May be `null` if unavailable.
+color
+  (string or null)
+  A CSS color string to use as a preferred color,
+  such as for collaboration cursors.
+  May be `null` if unavailable.
+
+Authorization
+*************
+
+Authorization is the second step in allowing an action,
+after a user has been _authenticated_ by the IdentityProvider.
+
 Authorization in Jupyter Server serves to provide finer grained control of access to its
 API resources. With authentication, requests are accepted if the current user is known by
 the server. Thus it can restrain access to specific users, but there is no way to give allowed
 users more or less permissions. Jupyter Server provides a thin and extensible authorization layer
 which checks if the current user is authorized to make a specific request.
 
+.. autoclass:: jupyter_server.auth.Authorizer
+
+  .. automethod:: is_authorized
+
 This is done by calling a ``is_authorized(handler, user, action, resource)`` method before each
 request handler. Each request is labeled as either a "read", "write", or "execute" ``action``:
 
@@ -233,6 +345,7 @@ The ``is_authorized()`` method will automatically be called whenever a handler i
 ``@authorized`` (from ``jupyter_server.auth``), similarly to the
 ``@authenticated`` decorator for authorization (from ``tornado.web``).
 
+
 Security in notebook documents
 ==============================
 
diff --git a/jupyter_server/auth/__init__.py b/jupyter_server/auth/__init__.py
index 54477ffd1..77a259956 100644
--- a/jupyter_server/auth/__init__.py
+++ b/jupyter_server/auth/__init__.py
@@ -1,3 +1,4 @@
 from .authorizer import *  # noqa
 from .decorator import authorized  # noqa
+from .identity import *  # noqa
 from .security import passwd  # noqa
diff --git a/jupyter_server/auth/authorizer.py b/jupyter_server/auth/authorizer.py
index 952cb0278..50c04f8a2 100644
--- a/jupyter_server/auth/authorizer.py
+++ b/jupyter_server/auth/authorizer.py
@@ -11,6 +11,8 @@ from traitlets.config import LoggingConfigurable
 
 from jupyter_server.base.handlers import JupyterHandler
 
+from .identity import User
+
 
 class Authorizer(LoggingConfigurable):
     """Base class for authorizing access to resources
@@ -32,23 +34,28 @@ class Authorizer(LoggingConfigurable):
     .. versionadded:: 2.0
     """
 
-    def is_authorized(self, handler: JupyterHandler, user: str, action: str, resource: str) -> bool:
+    def is_authorized(
+        self, handler: JupyterHandler, user: User, action: str, resource: str
+    ) -> bool:
         """A method to determine if `user` is authorized to perform `action`
         (read, write, or execute) on the `resource` type.
 
         Parameters
         ----------
-        user : usually a dict or string
-            A truthy model representing the authenticated user.
-            A username string by default,
-            but usually a dict when integrating with an auth provider.
+        user : jupyter_server.auth.User
+            An object representing the authenticated user,
+            as returned by :meth:`.IdentityProvider.get_user`.
+
         action : str
             the category of action for the current request: read, write, or execute.
 
         resource : str
             the type of resource (i.e. contents, kernels, files, etc.) the user is requesting.
 
-        Returns True if user authorized to make request; otherwise, returns False.
+        Returns
+        -------
+        bool
+            True if user authorized to make request; False, otherwise
         """
         raise NotImplementedError()
 
@@ -61,7 +68,9 @@ class AllowAllAuthorizer(Authorizer):
     .. versionadded:: 2.0
     """
 
-    def is_authorized(self, handler: JupyterHandler, user: str, action: str, resource: str) -> bool:
+    def is_authorized(
+        self, handler: JupyterHandler, user: User, action: str, resource: str
+    ) -> bool:
         """This method always returns True.
 
         All authenticated users are allowed to do anything in the Jupyter Server.
diff --git a/jupyter_server/auth/decorator.py b/jupyter_server/auth/decorator.py
index 72a489dbe..930d79be4 100644
--- a/jupyter_server/auth/decorator.py
+++ b/jupyter_server/auth/decorator.py
@@ -8,7 +8,7 @@ from typing import Callable, Optional, Union
 from tornado.log import app_log
 from tornado.web import HTTPError
 
-from .utils import HTTP_METHOD_TO_AUTH_ACTION, warn_disabled_authorization
+from .utils import HTTP_METHOD_TO_AUTH_ACTION
 
 
 def authorized(
@@ -57,18 +57,13 @@ def authorized(
             if not user:
                 app_log.warning("Attempting to authorize request without authentication!")
                 raise HTTPError(status_code=403, log_message=message)
-
-            # Handle the case where an authorizer wasn't attached to the handler.
-            if not self.authorizer:
-                warn_disabled_authorization()
-                return method(self, *args, **kwargs)
-
-            # Only return the method if the action is authorized.
+            # If the user is allowed to do this action,
+            # call the method.
             if self.authorizer.is_authorized(self, user, action, resource):
                 return method(self, *args, **kwargs)
-
-            # Raise an exception if the method wasn't returned (i.e. not authorized)
-            raise HTTPError(status_code=403, log_message=message)
+            # else raise an exception.
+            else:
+                raise HTTPError(status_code=403, log_message=message)
 
         return inner
 
diff --git a/jupyter_server/auth/identity.py b/jupyter_server/auth/identity.py
new file mode 100644
index 000000000..d3cca7791
--- /dev/null
+++ b/jupyter_server/auth/identity.py
@@ -0,0 +1,141 @@
+"""Identity Provider interface
+
+This defines the _authentication_ layer of Jupyter Server,
+to be used in combination with Authorizer for _authorization_.
+
+.. versionadded:: 2.0
+"""
+from dataclasses import asdict, dataclass
+from typing import Any, Optional
+
+from tornado.web import RequestHandler
+from traitlets.config import LoggingConfigurable
+
+# from dataclasses import field
+
+
+@dataclass
+class User:
+    """Object representing a User
+
+    This or a subclass should be returned from IdentityProvider.get_user
+    """
+
+    username: str  # the only truly required field
+
+    # these fields are filled from username if not specified
+    # name is the 'real' name of the user
+    name: str = ""
+    # display_name is a shorter name for us in UI,
+    # if different from name. e.g. a nickname
+    display_name: str = ""
+
+    # these fields are left as None if undefined
+    initials: Optional[str] = None
+    avatar_url: Optional[str] = None
+    color: Optional[str] = None
+
+    # TODO: extension fields?
+    # ext: Dict[str, Dict[str, Any]] = field(default_factory=dict)
+
+    def __post_init__(self):
+        self.fill_defaults()
+
+    def fill_defaults(self):
+        """Fill out default fields in the identity model
+
+        - Ensures all values are defined
+        - Fills out derivative values for name fields fields
+        - Fills out null values for optional fields
+        """
+
+        # username is the only truly required field
+        if not self.username:
+            raise ValueError(f"user.username must not be empty: {self}")
+
+        # derive name fields from username -> name -> display name
+        if not self.name:
+            self.name = self.username
+        if not self.display_name:
+            self.display_name = self.name
+
+    def to_dict(self):
+        pass
+
+
+def _backward_compat_user(got_user: Any) -> User:
+    """Backward-compatibility for LoginHandler.get_user
+
+    Prior to 2.0, LoginHandler.get_user could return anything truthy.
+
+    Typically, this was either a simple string username,
+    or a simple dict.
+
+    Make some effort to allow common patterns to keep working.
+    """
+    if isinstance(got_user, str):
+        return User(username=got_user)
+    elif isinstance(got_user, dict):
+        kwargs = {}
+        if "username" not in got_user:
+            if "name" in got_user:
+                kwargs["username"] = got_user["name"]
+        for field in User.__dataclass_fields__:
+            if field in got_user:
+                kwargs[field] = got_user[field]
+        try:
+            return User(**kwargs)
+        except TypeError:
+            raise ValueError(f"Unrecognized user: {got_user}")
+    else:
+        raise ValueError(f"Unrecognized user: {got_user}")
+
+
+class IdentityProvider(LoggingConfigurable):
+    """
+    Interface for providing identity
+
+    _may_ be a coroutine.
+
+    Two principle methods:
+
+    - :meth:`~.IdentityProvider.get_user` returns a :class:`~.User` object
+      for successful authentication, or None for no-identity-found.
+    - :meth:`~.IdentityProvider.identity_model` turns a :class:`~.User` into a JSONable dict.
+      The default is to use :py:meth:`dataclasses.asdict`,
+      and usually shouldn't need override.
+
+    .. versionadded:: 2.0
+    """
+
+    def get_user(self, handler: RequestHandler) -> User:
+        """Get the authenticated user for a request
+
+        Must return a :class:`.jupyter_server.auth.User`,
+        though it may be a subclass.
+
+        Return None if the request is not authenticated.
+        """
+
+        if handler.login_handler is None:
+            return User("anonymous")
+
+        # The default: call LoginHandler.get_user for backward-compatibility
+        # TODO: move default implementation to this class,
+        # deprecate `LoginHandler.get_user`
+        user = handler.login_handler.get_user(handler)
+        if user and not isinstance(user, User):
+            return _backward_compat_user(user)
+        return user
+
+    def identity_model(self, user: User) -> dict:
+        """Return a User as an Identity model"""
+        # TODO: validate?
+        return asdict(user)
+
+    def get_handlers(self) -> list:
+        """Return list of additional handlers for this identity provider
+
+        For example, an OAuth callback handler.
+        """
+        return []
diff --git a/jupyter_server/auth/login.py b/jupyter_server/auth/login.py
index 382077d9e..6eb07e574 100644
--- a/jupyter_server/auth/login.py
+++ b/jupyter_server/auth/login.py
@@ -152,7 +152,7 @@ class LoginHandler(JupyterHandler):
         """
         if getattr(handler, "_user_id", None) is None:
             # ensure get_user has been called, so we know if we're token-authenticated
-            handler.get_current_user()
+            handler.current_user
         return getattr(handler, "_token_authenticated", False)
 
     @classmethod
@@ -165,17 +165,20 @@ class LoginHandler(JupyterHandler):
         # called on LoginHandler itself.
         if getattr(handler, "_user_id", None):
             return handler._user_id
-        user_id = cls.get_user_token(handler)
-        if user_id is None:
-            get_secure_cookie_kwargs = handler.settings.get("get_secure_cookie_kwargs", {})
-            user_id = handler.get_secure_cookie(handler.cookie_name, **get_secure_cookie_kwargs)
-            if user_id:
-                user_id = user_id.decode()
-        else:
-            cls.set_login_cookie(handler, user_id)
+        token_user_id = cls.get_user_token(handler)
+        cookie_user_id = cls.get_user_cookie(handler)
+        # prefer token to cookie if both given,
+        # because token is always explicit
+        user_id = token_user_id or cookie_user_id
+        if token_user_id:
+            # if token-authenticated, persist user_id in cookie
+            # if it hasn't already been stored there
+            if user_id != cookie_user_id:
+                cls.set_login_cookie(handler, user_id)
             # Record that the current request has been authenticated with a token.
             # Used in is_token_authenticated above.
             handler._token_authenticated = True
+
         if user_id is None:
             # If an invalid cookie was sent, clear it to prevent unnecessary
             # extra warnings. But don't do this on a request with *no* cookie,
@@ -192,6 +195,15 @@ class LoginHandler(JupyterHandler):
         handler._user_id = user_id
         return user_id
 
+    @classmethod
+    def get_user_cookie(cls, handler):
+        """Get user-id from a cookie"""
+        get_secure_cookie_kwargs = handler.settings.get("get_secure_cookie_kwargs", {})
+        user_id = handler.get_secure_cookie(handler.cookie_name, **get_secure_cookie_kwargs)
+        if user_id:
+            user_id = user_id.decode()
+        return user_id
+
     @classmethod
     def get_user_token(cls, handler):
         """Identify the user based on a token in the URL or Authorization header
@@ -215,7 +227,17 @@ class LoginHandler(JupyterHandler):
             authenticated = True
 
         if authenticated:
-            return uuid.uuid4().hex
+            # token does not correspond to user-id,
+            # which is stored in a cookie.
+            # still check the cookie for the user id
+            user_id = cls.get_user_cookie(handler)
+            if user_id is None:
+                # no cookie, generate new random user_id
+                user_id = uuid.uuid4().hex
+                handler.log.info(
+                    f"Generating new user_id for token-authenticated request: {user_id}"
+                )
+            return user_id
         else:
             return None
 
diff --git a/jupyter_server/auth/utils.py b/jupyter_server/auth/utils.py
index b939b87ae..3f129dce6 100644
--- a/jupyter_server/auth/utils.py
+++ b/jupyter_server/auth/utils.py
@@ -8,16 +8,11 @@ import warnings
 
 
 def warn_disabled_authorization():
+    """DEPRECATED, does nothing"""
     warnings.warn(
-        "The Tornado web application does not have an 'authorizer' defined "
-        "in its settings. In future releases of jupyter_server, this will "
-        "be a required key for all subclasses of `JupyterHandler`. For an "
-        "example, see the jupyter_server source code for how to "
-        "add an authorizer to the tornado settings: "
-        "https://github.com/jupyter-server/jupyter_server/blob/"
-        "653740cbad7ce0c8a8752ce83e4d3c2c754b13cb/jupyter_server/serverapp.py"
-        "#L234-L256",
-        FutureWarning,
+        "jupyter_server.auth.utils.warn_disabled_authorization is deprecated",
+        DeprecationWarning,
+        stacklevel=2,
     )
 
 
diff --git a/jupyter_server/base/handlers.py b/jupyter_server/base/handlers.py
index 42f7fb3d5..b3c827bc6 100644
--- a/jupyter_server/base/handlers.py
+++ b/jupyter_server/base/handlers.py
@@ -3,6 +3,7 @@
 # Distributed under the terms of the Modified BSD License.
 import datetime
 import functools
+import inspect
 import ipaddress
 import json
 import mimetypes
@@ -134,9 +135,21 @@ class AuthenticatedHandler(web.RequestHandler):
             self.force_clear_cookie(self.cookie_name)
 
     def get_current_user(self):
-        if self.login_handler is None:
-            return "anonymous"
-        return self.login_handler.get_user(self)
+        clsname = self.__class__.__name__
+        msg = (
+            f"Calling `{clsname}.get_current_user()` directly is deprecated in jupyter-server 2.0."
+            " Use `self.current_user` instead (works in all versions)."
+        )
+        if hasattr(self, "_jupyter_current_user"):
+            # backward-compat: return _jupyter_current_user
+            warnings.warn(
+                msg,
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            return self._jupyter_current_user
+        # haven't called get_user in prepare, raise
+        raise RuntimeError(msg)
 
     def skip_check_origin(self):
         """Ask my login_handler if I should skip the origin_check
@@ -166,7 +179,7 @@ class AuthenticatedHandler(web.RequestHandler):
     @property
     def logged_in(self):
         """Is a user currently logged in?"""
-        user = self.get_current_user()
+        user = self.current_user
         return user and not user == "anonymous"
 
     @property
@@ -193,8 +206,46 @@ class AuthenticatedHandler(web.RequestHandler):
 
     @property
     def authorizer(self):
+        if "authorizer" not in self.settings:
+            warnings.warn(
+                "The Tornado web application does not have an 'authorizer' defined "
+                "in its settings. In future releases of jupyter_server, this will "
+                "be a required key for all subclasses of `JupyterHandler`. For an "
+                "example, see the jupyter_server source code for how to "
+                "add an authorizer to the tornado settings: "
+                "https://github.com/jupyter-server/jupyter_server/blob/"
+                "653740cbad7ce0c8a8752ce83e4d3c2c754b13cb/jupyter_server/serverapp.py"
+                "#L234-L256",
+            )
+            from jupyter_server.auth import AllowAllAuthorizer
+
+            self.settings["authorizer"] = AllowAllAuthorizer(
+                config=self.settings.get("config", None)
+            )
+
         return self.settings.get("authorizer")
 
+    @property
+    def identity_provider(self):
+        if "identity_provider" not in self.settings:
+            warnings.warn(
+                "The Tornado web application does not have an 'identity_provider' defined "
+                "in its settings. In future releases of jupyter_server, this will "
+                "be a required key for all subclasses of `JupyterHandler`. For an "
+                "example, see the jupyter_server source code for how to "
+                "add an identity provider to the tornado settings: "
+                "https://github.com/jupyter-server/jupyter_server/blob/"
+                "aa8fd8b3faf37466eeb99689d5555314c5bf6640/jupyter_server/serverapp.py"
+                "#L253",
+            )
+            from jupyter_server.auth import IdentityProvider
+
+            # no identity provider set, load default
+            self.settings["identity_provider"] = IdentityProvider(
+                config=self.settings.get("config", None)
+            )
+        return self.settings["identity_provider"]
+
 
 class JupyterHandler(AuthenticatedHandler):
     """Jupyter-specific extensions to authenticated handling
@@ -310,6 +361,13 @@ class JupyterHandler(AuthenticatedHandler):
     def set_default_headers(self):
         """Add CORS headers, if defined"""
         super().set_default_headers()
+
+    def set_cors_headers(self):
+        """Add CORS headers, if defined
+
+        Now that current_user is async (jupyter-server 2.0),
+        must be called at the end of prepare(), instead of in set_default_headers.
+        """
         if self.allow_origin:
             self.set_header("Access-Control-Allow-Origin", self.allow_origin)
         elif self.allow_origin_pat:
@@ -448,6 +506,9 @@ class JupyterHandler(AuthenticatedHandler):
 
     def check_xsrf_cookie(self):
         """Bypass xsrf cookie checks when token-authenticated"""
+        if not hasattr(self, "_jupyter_current_user"):
+            # Called too early, will be checked later
+            return
         if self.token_authenticated or self.settings.get("disable_check_xsrf", False):
             # Token-authenticated requests do not need additional XSRF-check
             # Servers without authentication are vulnerable to XSRF
@@ -507,9 +568,40 @@ class JupyterHandler(AuthenticatedHandler):
             )
         return allow
 
-    def prepare(self):
+    async def prepare(self):
         if not self.check_host():
             raise web.HTTPError(403)
+
+        from jupyter_server.auth import IdentityProvider
+
+        if (
+            type(self.identity_provider) is IdentityProvider
+            and inspect.getmodule(self.get_current_user).__name__ != __name__
+        ):
+            # check for overridden get_current_user + default IdentityProvider
+            # deprecated way to override auth (e.g. JupyterHub < 3.0)
+            # allow deprecated, overridden get_current_user
+            warnings.warn(
+                "Overriding JupyterHandler.get_current_user is deprecated in jupyter-server 2.0."
+                " Use an IdentityProvider class.",
+                DeprecationWarning,
+                # stacklevel not useful here
+            )
+            user = self.get_current_user()
+        else:
+            user = self.identity_provider.get_user(self)
+            if inspect.isawaitable(user):
+                # IdentityProvider.get_user _may_ be async
+                user = await user
+
+        # self.current_user for tornado's @web.authenticated
+        # self._jupyter_current_user for backward-compat in deprecated get_current_user calls
+        # and our own private checks for whether .current_user has been set
+        self.current_user = self._jupyter_current_user = user
+        # complete initial steps which require auth to resolve first:
+        self.set_cors_headers()
+        if self.request.method not in {"GET", "HEAD", "OPTIONS"}:
+            self.check_xsrf_cookie()
         return super().prepare()
 
     # ---------------------------------------------------------------
@@ -602,10 +694,10 @@ class JupyterHandler(AuthenticatedHandler):
 class APIHandler(JupyterHandler):
     """Base class for API handlers"""
 
-    def prepare(self):
+    async def prepare(self):
+        await super().prepare()
         if not self.check_origin():
             raise web.HTTPError(404)
-        return super().prepare()
 
     def write_error(self, status_code, **kwargs):
         """APIHandler errors are JSON, not human pages"""
@@ -627,14 +719,6 @@ class APIHandler(JupyterHandler):
         self.log.warning(reply["message"])
         self.finish(json.dumps(reply))
 
-    def get_current_user(self):
-        """Raise 403 on API handlers instead of redirecting to human login page"""
-        # preserve _user_cache so we don't raise more than once
-        if hasattr(self, "_user_cache"):
-            return self._user_cache
-        self._user_cache = user = super().get_current_user()
-        return user
-
     def get_login_url(self):
         # if get_login_url is invoked in an API handler,
         # that means @web.authenticated is trying to trigger a redirect.
diff --git a/jupyter_server/base/zmqhandlers.py b/jupyter_server/base/zmqhandlers.py
index 28e296c72..ad6342af8 100644
--- a/jupyter_server/base/zmqhandlers.py
+++ b/jupyter_server/base/zmqhandlers.py
@@ -19,8 +19,6 @@ from jupyter_client.session import Session
 from tornado import ioloop, web
 from tornado.websocket import WebSocketHandler
 
-from jupyter_server.auth.utils import warn_disabled_authorization
-
 from .handlers import JupyterHandler
 
 
@@ -315,16 +313,13 @@ class AuthenticatedZMQStreamHandler(ZMQStreamHandler, JupyterHandler):
         the websocket finishes completing.
         """
         # authenticate the request before opening the websocket
-        user = self.get_current_user()
+        user = self.current_user
         if user is None:
             self.log.warning("Couldn't authenticate WebSocket connection")
             raise web.HTTPError(403)
 
         # authorize the user.
-        if not self.authorizer:
-            # Warn if there is not authorizer.
-            warn_disabled_authorization()
-        elif not self.authorizer.is_authorized(self, user, "execute", "kernels"):
+        if not self.authorizer.is_authorized(self, user, "execute", "kernels"):
             raise web.HTTPError(403)
 
         if self.get_argument("session_id", False):
diff --git a/jupyter_server/gateway/handlers.py b/jupyter_server/gateway/handlers.py
index a36f2d4fa..e31302c46 100644
--- a/jupyter_server/gateway/handlers.py
+++ b/jupyter_server/gateway/handlers.py
@@ -48,7 +48,7 @@ class WebSocketChannelsHandler(WebSocketHandler, JupyterHandler):
         the websocket finishes completing.
         """
         # authenticate the request before opening the websocket
-        if self.get_current_user() is None:
+        if self.current_user is None:
             self.log.warning("Couldn't authenticate WebSocket connection")
             raise web.HTTPError(403)
 
diff --git a/jupyter_server/log.py b/jupyter_server/log.py
index d23799456..0a50160ec 100644
--- a/jupyter_server/log.py
+++ b/jupyter_server/log.py
@@ -8,6 +8,7 @@ import json
 
 from tornado.log import access_log
 
+from .auth import User
 from .prometheus.log_functions import prometheus_log_method
 
 
@@ -44,7 +45,23 @@ def log_request(handler):
         uri=request.uri,
         request_time=request_time,
     )
-    msg = "{status} {method} {uri} ({ip}) {request_time:.2f}ms"
+    # log username
+    # make sure we don't break anything
+    # in case mixins cause current_user to not be a User somehow
+    try:
+        user = handler.current_user
+    except Exception:
+        user = None
+    if user:
+        if isinstance(user, User):
+            username = user.username
+        else:
+            username = "unknown"
+    else:
+        username = ""
+    ns["username"] = username
+
+    msg = "{status} {method} {uri} ({username}@{ip}) {request_time:.2f}ms"
     if status >= 400:
         # log bad referers
         ns["referer"] = request.headers.get("Referer", "None")
diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py
index 1a25bfb07..964bd0b58 100644
--- a/jupyter_server/serverapp.py
+++ b/jupyter_server/serverapp.py
@@ -91,6 +91,7 @@ from jupyter_server import (
 from jupyter_server._sysinfo import get_sys_info
 from jupyter_server._tz import utcnow
 from jupyter_server.auth.authorizer import AllowAllAuthorizer, Authorizer
+from jupyter_server.auth.identity import IdentityProvider
 from jupyter_server.auth.login import LoginHandler
 from jupyter_server.auth.logout import LogoutHandler
 from jupyter_server.base.handlers import (
@@ -212,7 +213,9 @@ class ServerWebApplication(web.Application):
         default_url,
         settings_overrides,
         jinja_env_options,
+        *,
         authorizer=None,
+        identity_provider=None,
     ):
         if authorizer is None:
             warnings.warn(
@@ -221,7 +224,16 @@ class ServerWebApplication(web.Application):
                 RuntimeWarning,
                 stacklevel=2,
             )
-            authorizer = AllowAllAuthorizer(jupyter_app)
+            authorizer = AllowAllAuthorizer(parent=jupyter_app)
+
+        if identity_provider is None:
+            warnings.warn(
+                "identity_provider unspecified. Using default IdentityProvider."
+                " Specify an identity_provider to avoid this message.",
+                RuntimeWarning,
+                stacklevel=2,
+            )
+            identity_provider = IdentityProvider(parent=jupyter_app)
 
         settings = self.init_settings(
             jupyter_app,
@@ -237,6 +249,7 @@ class ServerWebApplication(web.Application):
             settings_overrides,
             jinja_env_options,
             authorizer=authorizer,
+            identity_provider=identity_provider,
         )
         handlers = self.init_handlers(default_services, settings)
 
@@ -256,7 +269,9 @@ class ServerWebApplication(web.Application):
         default_url,
         settings_overrides,
         jinja_env_options=None,
+        *,
         authorizer=None,
+        identity_provider=None,
     ):
 
         _template_path = settings_overrides.get(
@@ -338,6 +353,7 @@ class ServerWebApplication(web.Application):
             kernel_spec_manager=kernel_spec_manager,
             config_manager=config_manager,
             authorizer=authorizer,
+            identity_provider=identity_provider,
             # handlers
             extra_services=extra_services,
             # Jupyter stuff
@@ -395,6 +411,8 @@ class ServerWebApplication(web.Application):
 
         # Add extra handlers from contents manager.
         handlers.extend(settings["contents_manager"].get_extra_handlers())
+        # And from identity provider
+        handlers.extend(settings["identity_provider"].get_handlers())
 
         # If gateway mode is enabled, replace appropriate handlers to perform redirection
         if GatewayClient.instance().gateway_enabled:
@@ -1488,6 +1506,13 @@ class ServerApp(JupyterApp):
         help=_i18n("The authorizer class to use."),
     )
 
+    identity_provider_class = Type(
+        default_value=IdentityProvider,
+        klass=IdentityProvider,
+        config=True,
+        help=_i18n("The identity provider class to use."),
+    )
+
     trust_xheaders = Bool(
         False,
         config=True,
@@ -1811,6 +1836,7 @@ class ServerApp(JupyterApp):
             log=self.log,
         )
         self.authorizer = self.authorizer_class(parent=self, log=self.log)
+        self.identity_provider = self.identity_provider_class(parent=self, log=self.log)
 
     def init_logging(self):
         # This prevents double log messages because tornado use a root logger that
@@ -1898,6 +1924,7 @@ class ServerApp(JupyterApp):
             self.tornado_settings,
             self.jinja_environment_options,
             authorizer=self.authorizer,
+            identity_provider=self.identity_provider,
         )
         if self.certfile:
             self.ssl_options["certfile"] = self.certfile
diff --git a/jupyter_server/services/api/api.yaml b/jupyter_server/services/api/api.yaml
index 844831e04..976e5726f 100644
--- a/jupyter_server/services/api/api.yaml
+++ b/jupyter_server/services/api/api.yaml
@@ -33,6 +33,16 @@ parameters:
     in: path
     description: file path
     type: string
+  permissions:
+    name: permissions
+    type: string
+    required: false
+    in: query
+    description: |
+      JSON-serialized dictionary of `{"resource": ["action",]}`
+      (dict of lists of strings) to check.
+      The same dictionary structure will be returned,
+      containing only the actions for which the user is authorized.
   checkpoint_id:
     name: checkpoint_id
     required: true
@@ -616,7 +626,42 @@ paths:
           description: Forbidden to access
         404:
           description: Not found
-
+  /api/me:
+    get:
+      summary: |
+        Get the identity of the currently authenticated user.
+        If present, a `permissions` argument may be specified
+        to check what actions the user currently is authorized to take.
+      tags:
+        - identity
+      parameters:
+        - $ref: "#/parameters/permissions"
+      responses:
+        200:
+          description: The user's identity and permissions
+          schema:
+            type: object
+            properties:
+              identity:
+                $ref: "#/definitions/Identity"
+              permissions:
+                $ref: "#/definitions/Permissions"
+            example:
+              identity:
+                username: minrk
+                name: Min Ragan-Kelley
+                display_name: Min RK
+                initials: MRK
+                avatar_url: null
+                color: null
+              permissions:
+                contents:
+                  - read
+                  - write
+                kernels:
+                  - read
+                  - write
+                  - execute
   /api/status:
     get:
       summary: Get the current status/activity of the server.
@@ -663,6 +708,53 @@ definitions:
         type: number
         description: |
           The total number of running kernels.
+  Identity:
+    description: The identity of the currently authenticated user
+    properties:
+      username:
+        type: string
+        description: |
+          Unique string identifying the user
+      name:
+        type: string
+        description: |
+          For-humans name of the user.
+          May be the same as `username` in systems where
+          only usernames are available.
+      display_name:
+        type: string
+        description: |
+          Alternate rendering of name for display.
+          Often the same as `name`.
+      initials:
+        type: string
+        description: |
+          Short string of initials.
+          Initials should not be derived automatically due to localization issues.
+          May be `null` if unavailable.
+      avatar_url:
+        type: string
+        description: |
+          URL of an avatar to be used for the user.
+          May be `null` if unavailable.
+      color:
+        type: string
+        description: |
+          A CSS color string to use as a preferred color,
+          such as for collaboration cursors.
+          May be `null` if unavailable.
+  Permissions:
+    type: object
+    description: |
+      A dict of the form: `{"resource": ["action",]}`
+      containing only the AUTHORIZED subset of resource+actions
+      from the permissions specified in the request.
+      If no permission checks were made in the request,
+      this will be empty.
+    additionalProperties:
+      type: array
+      items:
+        type: string
   KernelSpec:
     description: Kernel spec (contents of kernel.json)
     properties:
diff --git a/jupyter_server/services/api/handlers.py b/jupyter_server/services/api/handlers.py
index 1c0cca5e1..b6e20866d 100644
--- a/jupyter_server/services/api/handlers.py
+++ b/jupyter_server/services/api/handlers.py
@@ -3,6 +3,7 @@
 # Distributed under the terms of the Modified BSD License.
 import json
 import os
+from typing import Dict, List
 
 from tornado import web
 
@@ -55,7 +56,49 @@ class APIStatusHandler(APIHandler):
         self.finish(json.dumps(model, sort_keys=True))
 
 
+class IdentityHandler(APIHandler):
+    """Get the current user's identity model"""
+
+    @web.authenticated
+    def get(self):
+        permissions_json: str = self.get_argument("permissions", "")
+        bad_permissions_msg = f'permissions should be a JSON dict of {{"resource": ["action",]}}, got {permissions_json!r}'
+        if permissions_json:
+            try:
+                permissions_to_check = json.loads(permissions_json)
+            except ValueError:
+                raise web.HTTPError(400, bad_permissions_msg)
+            if not isinstance(permissions_to_check, dict):
+                raise web.HTTPError(400, bad_permissions_msg)
+        else:
+            permissions_to_check = {}
+
+        permissions: Dict[str, List[str]] = {}
+        user = self.current_user
+
+        for resource, actions in permissions_to_check.items():
+            if (
+                not isinstance(resource, str)
+                or not isinstance(actions, list)
+                or not all(isinstance(action, str) for action in actions)
+            ):
+                raise web.HTTPError(400, bad_permissions_msg)
+
+            allowed = permissions[resource] = []
+            for action in actions:
+                if self.authorizer.is_authorized(self, user=user, resource=resource, action=action):
+                    allowed.append(action)
+
+        identity: Dict = self.identity_provider.identity_model(user)
+        model = {
+            "identity": identity,
+            "permissions": permissions,
+        }
+        self.write(json.dumps(model))
+
+
 default_handlers = [
     (r"/api/spec.yaml", APISpecHandler),
     (r"/api/status", APIStatusHandler),
+    (r"/api/me", IdentityHandler),
 ]


Test patch:
diff --git a/tests/auth/test_identity.py b/tests/auth/test_identity.py
new file mode 100644
index 000000000..e60997f2f
--- /dev/null
+++ b/tests/auth/test_identity.py
@@ -0,0 +1,107 @@
+import pytest
+
+from jupyter_server.auth import IdentityProvider, User
+from jupyter_server.auth.identity import _backward_compat_user
+
+
+class CustomUser:
+    def __init__(self, name):
+        self.name = name
+
+
+@pytest.mark.parametrize(
+    "old_user, expected",
+    [
+        (
+            "str-name",
+            {"username": "str-name", "name": "str-name", "display_name": "str-name"},
+        ),
+        (
+            {"username": "user.username", "name": "user.name"},
+            {
+                "username": "user.username",
+                "name": "user.name",
+                "display_name": "user.name",
+            },
+        ),
+        (
+            {"username": "user.username", "display_name": "display"},
+            {
+                "username": "user.username",
+                "name": "user.username",
+                "display_name": "display",
+            },
+        ),
+        ({"name": "user.name"}, {"username": "user.name", "name": "user.name"}),
+        ({"unknown": "value"}, ValueError),
+        (CustomUser("custom_name"), ValueError),
+    ],
+)
+def test_identity_model(old_user, expected):
+    if isinstance(expected, type) and issubclass(expected, Exception):
+        with pytest.raises(expected):
+            user = _backward_compat_user(old_user)
+        return
+    user = _backward_compat_user(old_user)
+    idp = IdentityProvider()
+    identity = idp.identity_model(user)
+    print(identity)
+    identity_subset = {key: identity[key] for key in expected}
+    print(type(identity), type(identity_subset), type(expected))
+    assert identity_subset == expected
+
+
+@pytest.mark.parametrize(
+    "fields, expected",
+    [
+        ({"name": "user"}, TypeError),
+        (
+            {"username": "user.username"},
+            {
+                "username": "user.username",
+                "name": "user.username",
+                "initials": None,
+                "avatar_url": None,
+                "color": None,
+            },
+        ),
+        (
+            {"username": "user.username", "name": "user.name", "color": "#abcdef"},
+            {
+                "username": "user.username",
+                "name": "user.name",
+                "display_name": "user.name",
+                "color": "#abcdef",
+            },
+        ),
+        (
+            {"username": "user.username", "display_name": "display"},
+            {
+                "username": "user.username",
+                "name": "user.username",
+                "display_name": "display",
+            },
+        ),
+    ],
+)
+def test_user_defaults(fields, expected):
+    if isinstance(expected, type) and issubclass(expected, Exception):
+        with pytest.raises(expected):
+            user = User(**fields)
+        return
+    user = User(**fields)
+
+    # check expected fields
+    for key in expected:
+        assert getattr(user, key) == expected[key]
+
+    # check types
+    for key in ("username", "name", "display_name"):
+        value = getattr(user, key)
+        assert isinstance(value, str)
+        # don't allow empty strings
+        assert value
+
+    for key in ("initials", "avatar_url", "color"):
+        value = getattr(user, key)
+        assert value is None or isinstance(value, str)
diff --git a/tests/auth/test_login.py b/tests/auth/test_login.py
index 0b918d91d..9f120c13a 100644
--- a/tests/auth/test_login.py
+++ b/tests/auth/test_login.py
@@ -1,4 +1,5 @@
 """Tests for login redirects"""
+import json
 from functools import partial
 from urllib.parse import urlencode
 
@@ -92,3 +93,26 @@ async def test_next_ok(login, jp_base_url, next_path):
     expected = jp_base_url + next_path
     actual = await login(next=expected)
     assert actual == expected
+
+
+async def test_token_cookie_user_id(jp_serverapp, jp_fetch):
+    token = jp_serverapp.token
+
+    # first request with token, sets cookie with user-id
+    resp = await jp_fetch("/")
+    assert resp.code == 200
+    set_cookie = resp.headers["set-cookie"]
+    headers = {"Cookie": set_cookie}
+
+    # subsequent requests with cookie and no token
+    # receive same user-id
+    resp = await jp_fetch("/api/me", headers=headers)
+    user_id = json.loads(resp.body.decode("utf8"))
+    resp = await jp_fetch("/api/me", headers=headers)
+    user_id2 = json.loads(resp.body.decode("utf8"))
+    assert user_id["identity"] == user_id2["identity"]
+
+    # new request, just token -> new user_id
+    resp = await jp_fetch("/api/me")
+    user_id3 = json.loads(resp.body.decode("utf8"))
+    assert user_id["identity"] != user_id3["identity"]
diff --git a/tests/services/api/test_api.py b/tests/services/api/test_api.py
index c1620ff05..03bf74e97 100644
--- a/tests/services/api/test_api.py
+++ b/tests/services/api/test_api.py
@@ -1,4 +1,11 @@
 import json
+from typing import Dict, List
+from unittest import mock
+
+import pytest
+from tornado.httpclient import HTTPError
+
+from jupyter_server.auth import Authorizer, IdentityProvider, User
 
 
 async def test_get_spec(jp_fetch):
@@ -21,3 +28,151 @@ async def test_get_status(jp_fetch):
     assert status["kernels"] == 0
     assert status["last_activity"].endswith("Z")
     assert status["started"].endswith("Z")
+
+
+class MockUser(User):
+    permissions: Dict[str, List[str]]
+
+
+class MockIdentityProvider(IdentityProvider):
+    mock_user: MockUser
+
+    def get_user(self, handler):
+        # super returns a UUID
+        # return our mock user instead, as long as the request is authorized
+        authenticated = super().get_user(handler)
+        if isinstance(self.mock_user, dict):
+            self.mock_user = MockUser(**self.mock_user)
+        if authenticated:
+            return self.mock_user
+
+
+class MockAuthorizer(Authorizer):
+    def is_authorized(self, handler, user, action, resource):
+        permissions = user.permissions
+        if permissions == "*":
+            return True
+        actions = permissions.get(resource, [])
+        return action in actions
+
+
+@pytest.fixture
+def identity_provider(jp_serverapp):
+    idp = MockIdentityProvider(parent=jp_serverapp)
+    authorizer = MockAuthorizer(parent=jp_serverapp)
+    with mock.patch.dict(
+        jp_serverapp.web_app.settings,
+        {"identity_provider": idp, "authorizer": authorizer},
+    ):
+        yield idp
+
+
+@pytest.mark.parametrize(
+    "identity, expected",
+    [
+        (
+            {"username": "user.username"},
+            {
+                "username": "user.username",
+                "name": "user.username",
+                "display_name": "user.username",
+            },
+        ),
+        (
+            {"username": "user", "name": "name", "display_name": "display"},
+            {"username": "user", "name": "name", "display_name": "display"},
+        ),
+        (
+            None,
+            403,
+        ),
+    ],
+)
+async def test_identity(jp_fetch, identity, expected, identity_provider):
+    if identity:
+        identity_provider.mock_user = MockUser(**identity)
+    else:
+        identity_provider.mock_user = None
+
+    if isinstance(expected, int):
+        with pytest.raises(HTTPError) as exc:
+            await jp_fetch("api/me")
+        print(exc)
+        assert exc.value.code == expected
+        return
+
+    r = await jp_fetch("api/me")
+
+    assert r.code == 200
+    response = json.loads(r.body.decode())
+    assert set(response.keys()) == {"identity", "permissions"}
+    identity_model = response["identity"]
+    print(identity_model)
+    for key, value in expected.items():
+        assert identity_model[key] == value
+
+    assert set(identity_model.keys()) == set(User.__dataclass_fields__)
+
+
+@pytest.mark.parametrize(
+    "have_permissions, check_permissions, expected",
+    [
+        ("*", None, {}),
+        (
+            {
+                "contents": ["read"],
+                "kernels": ["read", "write"],
+                "sessions": ["write"],
+            },
+            {
+                "contents": ["read", "write"],
+                "kernels": ["read", "write", "execute"],
+                "terminals": ["execute"],
+            },
+            {
+                "contents": ["read"],
+                "kernels": ["read", "write"],
+                "terminals": [],
+            },
+        ),
+        ("*", {"contents": ["write"]}, {"contents": ["write"]}),
+    ],
+)
+async def test_identity_permissions(
+    jp_fetch, have_permissions, check_permissions, expected, identity_provider
+):
+    user = MockUser("username")
+    user.permissions = have_permissions
+    identity_provider.mock_user = user
+
+    if check_permissions is not None:
+        params = {"permissions": json.dumps(check_permissions)}
+    else:
+        params = None
+
+    r = await jp_fetch("api/me", params=params)
+    assert r.code == 200
+    response = json.loads(r.body.decode())
+    assert set(response.keys()) == {"identity", "permissions"}
+    assert response["permissions"] == expected
+
+
+@pytest.mark.parametrize(
+    "permissions",
+    [
+        "",
+        "[]",
+        '"abc"',
+        json.dumps({"resource": "action"}),
+        json.dumps({"resource": [5]}),
+        json.dumps({"resource": {}}),
+    ],
+)
+async def test_identity_bad_permissions(jp_fetch, permissions):
+    with pytest.raises(HTTPError) as exc:
+        await jp_fetch("api/me", params={"permissions": json.dumps(permissions)})
+
+    r = exc.value.response
+    assert r.code == 400
+    reply = json.loads(r.body.decode())
+    assert "permissions should be a JSON dict" in reply["message"]