Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SSO support #2578

Closed
wants to merge 2 commits into from
Closed

SSO support #2578

wants to merge 2 commits into from

Conversation

privacyguard
Copy link
Contributor

@privacyguard privacyguard commented Jul 2, 2024

Implements LemmyNet/lemmy#2930.

This PR is based on LemmyNet/lemmy#4238 and #2278 by @thepaperpilot.

We noticed that the original PR is outdated and has a lot of conflicts with the recent changes. We tried to keep the previous commits whenever possible (in lemmy-js-client and lemmy-ui).

How is works?

Admins can configure external OIDC providers from within the admin settings.
Once an OIDC provider is configured, users will be able to Sign In / Sign Up using external OIDC providers.
Available Configuration

The usual OIDC endpoints

auto_verify_email: When enabled, users signing up using OIDC won't need to go through email verification.
auto_approve_application: When enabled, users signing up using OIDC won't need manual approval even if applications are required.
account_linking_enabled: When enabled, users attempting with sign up with OIDC using an existing user email would link the OIDC account to the existing user.
Disclaimer
This is our first ever rust contribution.

Who we are? Why are we contributing to Lemmy?

Privacy Portal is an OIDC provider and an email aliasing service focused on privacy. We have decided to contribute to select open source projects that empower Free Speech online.
Our OIDC provider services are currently offered free of charge. In the future, we will have a generous free plan that will cover most deployments.
Using Privacy Portal as your OIDC provider offers your users great privacy benefits. User emails will automatically get replaced by single-purpose Privacy Aliases during sign up. Users will be able to enter any name (to be used as username). Users can benefit from email encryption and much more.

@privacyguard
Copy link
Contributor Author

Please note that this PR depends on the latest lemmy-js-client (which doens't currently have a published version to use)

@privacyguard
Copy link
Contributor Author

@dessalines
We've updated this PR with the SSO changes but it seems that the latest lemmy-js-client includes additional changes that currently would break the main banch.

The issues can be seen in the linting results.

Some of the changes are related to the following changes amongst others:
LemmyNet/lemmy#4841
LemmyNet/lemmy#5017

We will wait until these are addressed in main (with the latest lemmy-js-client) before rebasing this PR.

@dessalines
Copy link
Member

dessalines commented Sep 19, 2024

This just needs a new lemmy-js-client deploy then to account for those changes? I've just pushed up 0.20.0-alpha.8 .

EDIT: Oh I gotcha now, I've fixed those broken ones in #2695 . You can either merge from that to pass tests, or just wait until that gets merged soon.

@SleeplessOne1917
Copy link
Member

EDIT: Oh I gotcha now, I've fixed those broken ones in #2695 . You can either merge from that to pass tests, or just wait until that gets merged soon.

I'm gonna give this a local lookover once that's done.

@privacyguard
Copy link
Contributor Author

We'll wait until the other PR is merged to complete this PR in one shot.

Co-authored-by: Anthony Lawn <[email protected]>
@privacyguard
Copy link
Contributor Author

@SleeplessOne1917 we integrated the latest fixes from main and passed linting checks. This PR is ready for review.

@@ -250,7 +258,7 @@ export class AdminSettings extends Component<
>
<div className="row">
<TaglineForm
taglines={this.state.siteRes.taglines}
taglines={this.state.siteRes.taglines || []}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like it wasn't necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We added this today because we were getting taglines as undefined and map function was being called on it.

const newOAuthProvider = res.data;
this.setState(s => {
s.siteRes.admin_oauth_providers = (
s.siteRes.admin_oauth_providers || []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of these || [] are probably unecessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both oauth_providers and admin_oauth_providers are optional.

export interface GetSiteResponse {
    // ...
    oauth_providers?: Array<PublicOAuthProvider>;
    admin_oauth_providers?: Array<OAuthProvider>;
}

);
if (index >= 0) {
Object.assign(
s.siteRes.admin_oauth_providers[index] || {},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I greatly doubt this Object.assign is necessary. Check out the editListImmutable function we have.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

editListImmutable seems to require a key in every object. Example:

let list = [{ provider: { id: 1 } }, { provider: { id: 2 } }];
let item = { provider: { id: 2, new: value } };

In our case, the data structure is:

let siteRes = { admin_oauth_providers: [{id: 1}, {id: 2}] };
let item = { id: 2, new: value };

page: 1 + Math.floor(index / this.itemsPerPage),
loading: false,
})),
customEmojis: (this.isoData.site_res.custom_emojis || []).map(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change was probably inadvertant also.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one also we were getting undefined as custom_emojis.

@@ -52,6 +57,9 @@ async function handleLoginSuccess(i: Login, loginRes: LoginResponse) {

if (site.state === "success") {
UserService.Instance.myUserInfo = site.data.my_user;
const isoData = setIsoData(i.context);
isoData.site_res.oauth_providers = site.data.oauth_providers;
isoData.site_res.admin_oauth_providers = site.data.admin_oauth_providers;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really strange. IsoData is only for the initial fetch, and you should just use siteRes if you need these providers afterward.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's been a while since we wrote this... We needed to update the the site_res data because the oauth_providers from site_res changes depending on whether the user is logged in as an admin or not.

When you first load the page (not authenticated), you only get the public oauth_provider data needed to display the SSO login buttons. If you log in as an admin, we override this data. If you log out we override the data too.

That said this is not critical data (it does not include any secrets).

src/shared/components/home/login.tsx Outdated Show resolved Hide resolved
src/shared/components/home/login.tsx Outdated Show resolved Hide resolved
src/shared/components/home/login.tsx Outdated Show resolved Hide resolved
src/shared/components/home/login.tsx Outdated Show resolved Hide resolved
async componentDidMount() {
// store state in local storage
const local_oauth_state = JSON.parse(
localStorage.getItem("oauth_state") || "{}",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Go through and get rid of all these || {}, they're not necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is different from the rest. In this case, we're checking LocalStorage which could return null. JSON.parse can handle null in javascript but in TS it requires a string.

@@ -98,7 +100,7 @@ class PasswordInput extends Component<PasswordInputProps, PasswordInputState> {
autoComplete={isNew ? "new-password" : "current-password"}
onInput={onInput}
value={value}
required
required={required !== false}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you using required !== false because you want it to default to true even if it's undefined?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. We wanted to keep it as required by default and add the ability to disable it when needed.

Copy link
Member

@SleeplessOne1917 SleeplessOne1917 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to the suggested code changes, I think the UI for adding OAuth providers in the admin settings needs some work. The text inputs are very wide when I don't think they need to be. I think it would be best to display them in 2 columns except on mobile, where they can all be one column.

For the OAuth ID claim, when adding the Privacy Portal preset, the value is "sub". I have no idea what this means. More generally, I think an explanation of what all of those fields do would be helpful, whether that takes the form of help buttons on the input labels that give a description of them, an info box on the page that explains how to set things up, or even a link to a third party resource that can teach the user how to do it.

Finally, for the Oauth Scopes field, I see for the Privacy Portal preset the input has 2 words separated by a space: "openid" and "email". Are these supposed to be selections from a limited set of values? If so, I think a fieldset with checkboxes for those options would be best. If not, I'll have to think of something else.

Since the change to the UI could enc up being a lot, I'll gladly take a stab at it once you implement the code changes me and Dessalines suggested.

src/shared/components/home/login.tsx Outdated Show resolved Hide resolved
src/shared/components/home/login.tsx Outdated Show resolved Hide resolved
src/shared/components/home/login.tsx Outdated Show resolved Hide resolved
src/shared/components/home/oauth-callback.tsx Outdated Show resolved Hide resolved
Comment on lines +60 to +68
if (
!this.props.state ||
!this.props.code ||
!local_oauth_state?.state ||
!local_oauth_state?.oauth_provider_id ||
!local_oauth_state?.expires_at ||
this.props.state !== local_oauth_state.state ||
local_oauth_state.expires_at < Date.now()
) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given all of the negations, I think it would be good to DeMorgan this:

Suggested change
if (
!this.props.state ||
!this.props.code ||
!local_oauth_state?.state ||
!local_oauth_state?.oauth_provider_id ||
!local_oauth_state?.expires_at ||
this.props.state !== local_oauth_state.state ||
local_oauth_state.expires_at < Date.now()
) {
if (
!(this.props.state &&
this.props.code &&
local_oauth_state?.state &&
local_oauth_state?.oauth_provider_id &&
local_oauth_state?.expires_at &&
this.props.state === local_oauth_state.state) ||
local_oauth_state.expires_at < Date.now()
) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels a bit cleaner not to have a parenthesis. But we can update it if you have a strong opinion on it.

src/shared/components/home/signup.tsx Outdated Show resolved Hide resolved
Comment on lines 328 to 333
[
this.titleName(siteView),
...(oauth_provider !== undefined
? [`(${oauth_provider.display_name})`]
: []),
].join(" ")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be written as:

Suggested change
[
this.titleName(siteView),
...(oauth_provider !== undefined
? [`(${oauth_provider.display_name})`]
: []),
].join(" ")
[
this.titleName(siteView),
...(oauth_provider
? [`(${oauth_provider.display_name})`]
: []),
].join(" ")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

Comment on lines 431 to 438
return handleUseOAuthProvider({
i: undefined,
index: undefined,
oauth_provider: oauthProvider,
username,
answer,
show_nsfw,
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be:

Suggested change
return handleUseOAuthProvider({
i: undefined,
index: undefined,
oauth_provider: oauthProvider,
username,
answer,
show_nsfw,
});
return handleUseOAuthProvider({
oauth_provider: oauthProvider,
username,
answer,
show_nsfw,
});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

src/shared/components/home/signup.tsx Outdated Show resolved Hide resolved
src/shared/components/person/settings.tsx Outdated Show resolved Hide resolved
@SleeplessOne1917
Copy link
Member

@privacyguard Thanks for doing the code changes. I'm going to be tweaking the UI for this PR a bit and should have my own PR out either Sunday or Monday. I do still need some questions answered. In case you missed them (I made a lot of review comments so they might have gotten buried):

For the OAuth ID claim, when adding the Privacy Portal preset, the value is "sub". I have no idea what this means. More generally, I think an explanation of what all of those fields do would be helpful, whether that takes the form of help buttons on the input labels that give a description of them, an info box on the page that explains how to set things up, or even a link to a third party resource that can teach the user how to do it.

[F]or the Oauth Scopes field, I see for the Privacy Portal preset the input has 2 words separated by a space: "openid" and "email". Are these supposed to be selections from a limited set of values? If so, I think a fieldset with checkboxes for those options would be best. If not, I'll have to think of something else.

@privacyguard
Copy link
Contributor Author

@SleeplessOne1917 perfect.

Concerning the documentation for all the fields: comments from Lemmy.

Concerning the scope, these scopes are semi-standard. Some of them are standard scopes such as openid and others are provider specific. Also the separator of the scopes can differ from one provider to another. The Privacy Portal provider like many others uses spaces as separator. Some providers use commas for example. Please note that in OAUTH the scope is a single string field that includes multiple values (standard and potentially non-standard values).

Concerning the sub, when trying to fetch user data from the provider (using the user-authorized scope), the data is returned as application/json in the provider API response. The Lemmy backend needs to be able to identify which key in this document can be used as a user_id. Many providers, especially those who support OIDC, use the key sub to be consistent with the JWT sub claim used for ids.

@SleeplessOne1917
Copy link
Member

While working on this, I came across something about the DTOs that can be tweaked to make it easier for clients: LemmyNet/lemmy#5046

When that PR is merged, I'll generate the types for the JS client and make a PR for that. Then I'll get back to the changes that I made branching off of this PR's branch. The changes I'm making to the frontend diverge from this PR enough that I'll most likely make my PR merge directlly into main instead of the branch this PR uses.

@SleeplessOne1917
Copy link
Member

I ended up getting a bit sidetracked on this due to the backend PR for the oauth struct serde serialization and juggling some PRs using the new js client version. With that stuff out of the way, my PR should be up some time this week.

@privacyguard
Copy link
Contributor Author

Closing in favor of SSO Support - alternate UI

@privacyguard privacyguard deleted the sso_support branch October 10, 2024 16:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants