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

[DUOS-2462][DUOS-2850] B2C OAuth Integration #2512

Closed
wants to merge 18 commits into from

Conversation

sjkobori
Copy link
Contributor

@sjkobori sjkobori commented Mar 27, 2024

Addresses

https://broadworkbench.atlassian.net/browse/DUOS-2462
https://broadworkbench.atlassian.net/browse/DUOS-2850

Summary

B2C

  • Add OidcBroker.ts for wrapping calls to the oidc-client-ts library
  • Adds Auth.ts for managing DUOS UI specific session logic and communicating to the OidcBroker
  • Adds OAuth2.ts for making api calls to get our oauth config
  • Adds appLoader.ts, RedirectFromOAuth.ts to handle redirect after signing in through popup window
  • Removes GoogleIS and related code

Other

  • As per request, moved the SignIn button from the Home.jsx to DuosHeader.jsx
  • Sign out calls now delegate through Auth.signOut
  • Typescript conversion for files affected by these changes (except for large files requiring their own ticket to convert)

@sjkobori sjkobori force-pushed the DUOS-2462_B2C_auth branch from fd2c720 to 1ca9f86 Compare April 3, 2024 14:18
@sjkobori sjkobori force-pushed the DUOS-2462_B2C_auth branch from 85ba8f9 to 407faf7 Compare April 12, 2024 22:21
@sjkobori sjkobori marked this pull request as ready for review April 17, 2024 13:04
@sjkobori sjkobori requested a review from a team as a code owner April 17, 2024 13:04
@sjkobori sjkobori changed the title Added oidcBroker and populated configs [DUOS-2462] B2C OAuth Integration Apr 17, 2024
@sjkobori sjkobori changed the title [DUOS-2462] B2C OAuth Integration [DUOS-2462][DUOS-2850] B2C OAuth Integration Apr 17, 2024
package.json Outdated Show resolved Hide resolved
@sjkobori sjkobori requested a review from nawatts April 22, 2024 03:37
Comment on lines +28 to +31
const metadata: Partial<OidcMetadata> = {
authorization_endpoint: `${await Config.getApiUrl()}/oauth2/authorize`,
token_endpoint: `${await Config.getApiUrl()}/oauth2/token`,
};
Copy link

Choose a reason for hiding this comment

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

I don't know the right answer here, but I wonder if adding this metadata is required. Does oidc-client-ts know how to use the authority config value to lookup the .well-known/openid-configuration? If so, we probably don't need to use the custom authorization and token endpoints.

Note that the custom endpoints were ported by me from TDR. I didn't fully understand why it has many of the custom bits. I now realize that it was mostly to be able to support switching back and forth between B2C and Google. All of that is over with and we are B2C all the way. So the custom endpoints don't really provide any value. They are used by the swagger ui but I have figured out a better way that does not require them.

Bottom line: I think we should work towards removing these endpoints.

Copy link

Choose a reason for hiding this comment

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

Copy link

Choose a reason for hiding this comment

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

looks like our config url (in dev) does not have CORS headers set. We could look into setting those or add a new DUOS api to serve the config. It actually already gets and caches the config, there is jut no api.

Copy link

Choose a reason for hiding this comment

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

I'm all for removing the oauth2 proxy endpoints if they are not needed anymore. I think another thing the custom endpoints do is inject the clientId as a scope and add ?prompt=login to the authorize request. See:

There is probably a way to set these in the oidc-client-ts frontend instead.

Copy link

Choose a reason for hiding this comment

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

Extra parameters can be set with the extraQueryParams config setting.

prompt=login is good but I don't know if the client id is required. In my experimentation with the swagger UI's oidc integration it doesn't seem to be.

@sjkobori sjkobori requested a review from rtitle April 22, 2024 15:55
silent_redirect_uri: `${window.origin}/redirect-from-oauth-silent`,
metadata,
prompt: 'consent login',
scope: 'openid email profile',
Copy link

Choose a reason for hiding this comment

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

Re my above comment: I see prompt=login is already set here. We'd need to figure out how to set the clientId as a scope if not using the custom oauth2 endpoints. (Or validate that this is still a requirement in B2C?)

},

signOut: async (): Promise<void> => {
await OidcBroker.getUserManager().removeUser();
Copy link

Choose a reason for hiding this comment

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

Just wondering if we have to clear any storage upon signout, or does removeUser take care of it?

(Just remembering that terra-ui had to clear several things upon signOut, but that might have been Terra UI's custom state store.)

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 is probably worth throwing in a clearStaleState (https://authts.github.io/oidc-client-ts/classes/UserManager.html#clearStaleState) here just to clean out anything else that may be in the store. The specific duos-ui state is cleared out in Auth.signOut. Good callout 👍

Copy link

@rtitle rtitle left a comment

Choose a reason for hiding this comment

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

Overall it looks good to me!

I do think it's an interesting exploration whether the backend oauth2 proxy endpoints are truly needed anymore..

return <div>{!showError ? signInButton() : errorAlert(errorInfo)}</div>;
};

export default SignInButton;

Choose a reason for hiding this comment

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

Just for future pr's it is nice for these sorts of js -> ts conversions to have inline pr comments on the new file for reviewers of what you changed relative to the js file since there is no direct diff shown in github. I just opened up both side by side and did the comparison for this pr.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I definitely agree it is hard to tell the difference easily. In hindsight, I should have done this TS conversion in a separate PR and then made the b2c changes in this one.

Copy link

@Ghost-in-a-Jar Ghost-in-a-Jar left a comment

Choose a reason for hiding this comment

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

This looks good overall, I would definitely smoke test this thoroughly and expect there to maybe be some unforeseen behavior/bugs given the changes. In particular make sure that the background token expiration/refresh works as expected etc. I think you set yourself up well with logging and metrics collection.

src/Routes.jsx Outdated
Comment on lines 53 to 59
<Route exact path="/backgroundsignin" render={
(routeProps) =>
checkEnv(envGroups.NON_STAGING)
? <BackgroundSignIn {...routeProps} />
: <NotFound />
} />
Copy link
Contributor

Choose a reason for hiding this comment

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

This page is what we use for E2E tests so we didn't have to manually trigger an auth dance. Currently, they're not run on PRs or in GHA, but we can run it locally. We either need a B2C version of this, or bring it back so we can test authenticated pages.

Comment on lines +23 to +25
// TODO: Determine if this is the correct user flow
// Can the user only accept ToS once the have signed in?
// What state is the user in after signIn, but before accepting ToS?
Copy link
Contributor

Choose a reason for hiding this comment

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

This is the correct flow. Yes, the user can only accept ToS after signing in - otherwise we don't know who they are and could not register their ToS acceptance in Sam. If they sign in, but have not yet accepted ToS, they are directed to this page where their only choice is to accept or reject.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That makes sense. I was trying to determine if the ToS accept button should set the setUserIsLogged to true, since by signing in, userIsLogged should already be set to true. With this context, I believe the ToS accept button should not change the userIsLogged state.

Copy link
Contributor

Choose a reason for hiding this comment

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

Correct - userIsLogged state is separate and distinct from ToS acceptance.

src/libs/auth/auth.ts Outdated Show resolved Hide resolved
.eslintrc Outdated Show resolved Hide resolved
@@ -2,7 +2,7 @@

import React from 'react';
import { mount } from 'cypress/react';
import SignIn from '../../../src/components/SignIn';
import SignInButton from '../../../src/components/SignInButton';
import { Config } from '../../../src/libs/config';

const signInText = 'Sign-in';
Copy link
Contributor

Choose a reason for hiding this comment

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

This text is no longer accurate - the first test should pass with

Suggested change
const signInText = 'Sign-in';
const signInText = 'Sign In';

Comment on lines 24 to 29
it('Spinner loads when client id is empty', function () {
cy.viewport(600, 300);
cy.stub(Config, 'getGoogleClientId').returns('');
mount(<SignIn />);
mount(<SignInButton />);
cy.contains(signInText).should('not.exist');
});
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we need this test any longer - there is longer a spinner that loads in place of the sign in text during the google client id loading time.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll update this test with the new SignInButton functionality.

.eslintrc Outdated
Comment on lines 55 to 56
// `any` is useful for incremental type improvements during the transition from JS to TS.
"@typescript-eslint/no-explicit-any": "off",
Copy link

Choose a reason for hiding this comment

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

Depending on how often an explicit any is desired, consider disabling this for specific lines or files instead of in global ESLint config. That way, it increases the friction to adding any and makes it easier to ratchet up type safety while incrementally removing any later.

package.json Outdated Show resolved Hide resolved
<div
style={{ display: 'flex', alignItems: 'center', flexDirection: orientation === 'vertical' ? 'column' : 'row' }}
>
<SignInButton customStyle={{ whiteSpace: 'nowrap', }}></SignInButton>
Copy link

Choose a reason for hiding this comment

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

Nit:

Suggested change
<SignInButton customStyle={{ whiteSpace: 'nowrap', }}></SignInButton>
<SignInButton customStyle={{ whiteSpace: 'nowrap', }} />

src/components/SignInButton.tsx Outdated Show resolved Hide resolved
Comment on lines 49 to 50
const { tosAccepted } = userStatus;
if (!isEmpty(userStatus) && !tosAccepted) {
Copy link

Choose a reason for hiding this comment

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

If userStatus was empty, wouldn't the destructuring in const { tosAccepted } = userStatus; throw an error?

When would we expect userStatus to be empty?

Copy link
Contributor

Choose a reason for hiding this comment

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

When would we expect userStatus to be empty?

I believe userStatus is one of the flavors of the Sam user info object. We collect that on every back end API call as part of the DUOS user to determine ToS acceptance.

Comment on lines +9 to +11
export const OAuth2 = {
getConfig: async (): Promise<OAuthConfig> => getConfig(),
};
Copy link

Choose a reason for hiding this comment

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

For what it's worth, as an alternative to namespace objects, modules could export individual methods and consumers could:

import * as OAuth2 from 'path/to/OAuth2';

if they really want the namespace.

Comment on lines 6 to 18
const userManager: UserManager = new UserManager(
OidcBroker.getUserManagerSettings()
);
const url = window.location.href;
const isSilent = window.location.pathname.startsWith(
'/redirect-from-oauth-silent'
);

if (isSilent) {
userManager.signinSilentCallback(url);
} else {
userManager.signinPopupCallback(url);
}
Copy link

Choose a reason for hiding this comment

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

This looks like it belongs in a useEffect hook instead of in render.

But does this need to be in a React component at all? It looks like oauth-redirect-loader could call the UserManager functions directly (and render a Spinner if desired).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think this needs to be in a React component since this should only happen once each time the user signs in through the popup window, and then I believe that app instance ends once that window closes.

} else {
userManager.signinPopupCallback(url);
}
return Spinner;
Copy link

Choose a reason for hiding this comment

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

Suggested change
return Spinner;
return <Spinner />;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Seems like spinner was not a proper React component. Since it was only used in one other place, I converted it to one in order to make this change.

Copy link

Choose a reason for hiding this comment

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

Oh. I assumed it was based on the name. But if it was an element instead, that change will likely break other places it was used.

Comment on lines 44 to 48
signIn: async (
popup: boolean,
onSuccess?: (response: OidcUser) => Promise<void> | void,
onFailure?: (response: any) => Promise<void> | void
): Promise<void> => {
Copy link

Choose a reason for hiding this comment

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

Since this is already returning a Promise, consider leaning into that pattern instead of mixing it with callbacks.

Consumers could handle success or failure with signIn(...).then(onSuccess, onFailure).

Comment on lines +21 to +23
let config: OAuthConfig | null = null;
let userManagerSettings: UserManagerSettings | null = null;
let userManager: UserManager | null = null;
Copy link

Choose a reason for hiding this comment

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

A possible alternative to global state could be to have an OidcBroker class and have this module export a singleton instance of it?

Copy link
Contributor

@rushtong rushtong left a comment

Choose a reason for hiding this comment

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

See comments inline - we need at least one test fix and the background sign-in feature replaced. Outside of that, this works as expected. I've been able to test standard OAuth with google users and OAuth with an azure account.

Copy link
Contributor

@fboulnois fboulnois left a comment

Choose a reason for hiding this comment

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

I was able to run the PR after correctly making certain dependencies explicit. After limited testing, I was able to log in and out of my Google and Azure accounts and verify that the sign in button was in the login bar.

@sjkobori sjkobori requested a review from a team as a code owner May 9, 2024 11:23
@sjkobori sjkobori requested review from rushtong and aarohinadkarni and removed request for a team May 9, 2024 11:23
@rushtong rushtong marked this pull request as draft May 9, 2024 11:23
# Conflicts:
#	.eslintrc
#	src/components/Spinner.tsx
#	src/custom.d.ts
#	tsconfig.json
@sjkobori
Copy link
Contributor Author

#2611 Will continue this work as this PR too large to easily track the changes.

@sjkobori sjkobori closed this Jun 25, 2024
@rushtong rushtong deleted the DUOS-2462_B2C_auth branch August 29, 2024 21:40
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.

7 participants