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

Use Arctic for OAuth providers #1851

Merged
merged 1 commit into from
Mar 18, 2024
Merged

Conversation

infomiho
Copy link
Contributor

@infomiho infomiho commented Mar 4, 2024

Status

Needs to be implemented

  • We need the serverUrl in our server environment (⚠️ breaking change)
    Define the WASP_SERVER_URL env var #1856
  • We need to implement support for providing configFn again
  • We need to implement env vars validation again (e.g. GOOGLE_CLIENT_ID)
  • Fetch full user info for Github like the Passport libs did (mostly to get the email which is important to Wasp users)
  • Improve error handling in case of an error with OAuth providers
    • Redirect to the client with the error message instead of showing JSON.
  • Fix the type error in OAuthCallback.tsx
  • Write migration docs Docs updates for OAuth overhaul #1890
  • Redirect with Prisma errors in the query params raises an error due to Unicode chars in the query
  • Write a migration script (bonus)
  • Write a specific error handling if users used "scope" in their configFn before to say "Hey, we changed this, you have to use "scopes" now" to make the migration easier (bonus)

Improvements

  • The client page now receives a one-time-code and exchanges it for a session
  • Move some of the paths (login path, oauth callback paths) in Haskell
  • createToken and verifyToken functions need to be refactored (tech debt from old auth)
    Replaces jsonwebtoken with oslo/jwt #1852
  • DRY up the code for google.ts and github.ts (to reduce the effort of adding next providers)

Changes for user

  • They need to change the redirect URL from client to server since we now do the OAuth redirect from backend to Google to backend (⚠️ breaking change)

Open questions

  • The property in the user config (when users use configFn property in Wasp file) to set scopes changed from scope to scopes (⚠️ breaking change) -> we can maybe make it backwards compatible?
  • Passport strategies that we used mapped the Google data into a specific shape -> do we want to do it as well to be backwards compatible? (Same things goes for Github)

Closes #1817

try {
const { accessToken } = await github.validateAuthorizationCode(code);

// TODO: maybe an additional request to get the user's email?
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The passport strategy did it by default if the scopes included user or user:email https://github.com/cfsghost/passport-github/blob/master/lib/strategy.js#L119

Copy link
Member

@Martinsos Martinsos Mar 8, 2024

Choose a reason for hiding this comment

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

What are the options? Either we get it for them, or they get it on their own.

Maybe let's do the same as Passport strategy did, it sounds useful in practice, and will be a less of a breaking change.

But if you feel that is too much, we can also skip it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay, this comment got outdated with latest changes. I replicated the logic that Passport had to keep the same information available. I think that makes the most sense!

@infomiho infomiho changed the base branch from main to arctic-feature March 5, 2024 17:55
return <Redirect to="{= onAuthSucceededRedirectTo =}" />;
}

/* TODO: Decide if we want to redirect to a page that shows the error message */
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 vote that we show the user an error instead of redirecting to "onAuthFailedRedirectTo" or if we were to redirect to "onAuthFailedRedirectTo" we attach an error message to the redirect.

Copy link
Member

Choose a reason for hiding this comment

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

yeah that sounds about right! I guess "onAuthFailedRedirectTo" serves multiple things -> where to send user if they try to open a page that needs auth but they are not authenticated, but then also where to send user on failed login. First scenario is ok, but we don't want to use it upon failed login / signup -> better to just show an error message.

Copy link
Contributor Author

@infomiho infomiho Mar 8, 2024

Choose a reason for hiding this comment

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

better to just show an error message.

I agree. Let's do that.

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 failed logins and signups do any redirecting as it currently stands. Or do they?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@@ -16,19 +16,11 @@ export type ProviderConfig = {
// Unique provider identifier, used as part of URL paths
id: ProviderName;
displayName: string;
// Each provider config can have an init method which is ran on setup time
// e.g. for oAuth providers this is the time when the Passport strategy is registered.
init?(provider: ProviderConfig): Promise<InitData>;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not needed anymore

env.GITHUB_CLIENT_SECRET,
);

router.get('/login', async (_req, res) => {
Copy link
Contributor Author

@infomiho infomiho Mar 7, 2024

Choose a reason for hiding this comment

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

Hard coded path, ideally we would move it to Haskell


// We need a user id to create the auth token, so we either find an existing user
// or create a new one if none exists for this provider.
async function getAuthIdFromProviderDetails(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Existing function

@@ -1,9 +1,7 @@
export function config() {
console.log('Inside user-supplied GitHub config')
return {
clientID: process.env['GITHUB_CLIENT_ID'],
Copy link
Contributor Author

@infomiho infomiho Mar 7, 2024

Choose a reason for hiding this comment

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

No need for client ID or client secret in the user config any more. Although, I believe that wasn't needed for some time since we merged our default options and their options.

@@ -119,28 +118,8 @@ genOAuthConfig provider maybeUserConfig pathToConfigDst = return $ C.mkTmplFdWit
relPathFromAuthConfigToServerSrcDir :: Path Posix (Rel importLocation) (Dir C.ServerSrcDir)
relPathFromAuthConfigToServerSrcDir = [reldirP|../../../|]

getJsonForOAuthConfigProps :: OAuthAuthProvider -> [Aeson.Value]
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Moved the env variables knowledge in the template

Copy link
Contributor

Choose a reason for hiding this comment

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

How come you decided to do this?

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 believe we prematurely abstracted stuff here. This code assumes that there are only client ID and client secret env vars, but in reality, there can be 2, 3 or 4 even env vars needed to set up a provider. We don't use the knowledge about the env vars in Haskell so it wasn't really useful.

Also, I wanted to keep things as simple as possible to make the rewrite easier :)

Generator FileDraft
genOAuthConfig provider maybeUserConfig pathToConfigDst = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData)
genOAuthConfig provider maybeUserConfig pathToConfigTmpl = return $ C.mkTmplFdWithData tmplFile (Just tmplData)
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 used _oauth.ts as a common template before, but now each provider has their own template file because their code now differs (the way we fetch the user info, some providers require extra steps, some not)

Copy link
Contributor

Choose a reason for hiding this comment

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

Understood!

Still, the new provider files have a lot in common. There are differences, sure, but there's definitely repetition. Could we extract the similar parts into functions and maximize the signal-to-noise ratio in the createRouter functions.

This will probably make adding new providers simpler. Still, I haven't studied the similarities and differences between the provider files in extreme detail, you'll be a better judge of whether such an effort makes sense or not.

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've given it a shot to minimise the repeated code. We could probably go further, but I didn't to be too smart about inventing new abstractions, this now feels okay, check it out and tell me what do you think. Should we go further?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think that's enough abstraction for now. We'll come back to it when we add more providers and are smarter.

@infomiho infomiho changed the title Arctic integration (WIP) Use Arctic for OAuth providers Mar 7, 2024
@infomiho infomiho marked this pull request as ready for review March 7, 2024 19:33
Copy link
Member

@Martinsos Martinsos left a comment

Choose a reason for hiding this comment

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

Nice job @infomiho , I like the analytical and detailed approach!

I answered some top level stuff, and will leave the details to @sodic . Let me know if I missed something that you would like to get my opinion on.

scope -> scopes: why did that change? Because underlying thing names it that way? What is better, sounds like there is some semantic involved with those names? Is it actually scope or scopes, what makes more sense? I wouldn't try to avoid breaking change, I would instead go for the name that we think works the best, be it a breaking change or not, doesn't matter.

Passport strategies mapped the provider data into a specific shape -> how breaking is it? Will they need to migrate the database, because we stored that stuff in the DB? If so, then maybe it might make sense to do that mapping. If we are doing the mapping, is it complex, does it feel silly, or does it make sense, do we lose some info, ... -> how do we feel about it?
If there is no DB migration involved, then I would certainly not bother with any mapping (unless you think they way data is returned by Artcic is ugly and could benefit from mapping).

@sodic
Copy link
Contributor

sodic commented Mar 11, 2024

The property in the user config (when users use configFn property in Wasp file) to set scopes changed from scope to scopes (⚠️ breaking change) -> we can maybe make it backwards compatible?

I don't think we should focus on the breaking change part - we're not stable enough for such sacrifices in consistency (we shouldn't support both names).. Pick the more appropriate name, regardless of whether it causes a breaking change or not. What we can do is throw a nice error. Instead of "Unknown property: scope". We can say "Please change 'scope' to 'scopes' in this file".

Passport strategies that we used mapped the Google data into a specific shape -> do we want to do it as well to be backwards compatible? (Same things goes for Github)

I think the same principle applies here. Wasp is still immature enough to have the luxury of breaking stuff on account of creating something of high quality. Of course, change purely for the sake of change is also something we should avoid (especially when it impacts users). In short:

  • If the current format is a random thing people at passport) came up with and you see something obvious you want to change, I say "go for it".
  • If the current format is a well-documented or standard way of storing this particular provider's data (which could be the case if everyone uses passport), I say we should leave it as is.

By the way, can we create migration scripts for these things?

Copy link
Contributor

@sodic sodic left a comment

Choose a reason for hiding this comment

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

A couple of old comments

waspc/examples/todoApp/src/auth/github.js Show resolved Hide resolved
waspc/examples/todoApp/src/user.ts Outdated Show resolved Hide resolved
waspc/data/Generator/templates/react-app/src/router.tsx Outdated Show resolved Hide resolved
return <Redirect to="{= onAuthSucceededRedirectTo =}" />;
}

/* TODO: Decide if we want to redirect to a page that shows the error message */
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 failed logins and signups do any redirecting as it currently stands. Or do they?

@Martinsos
Copy link
Member

Nice job @infomiho , I like the analytical and detailed approach!

I answered some top level stuff, and will leave the details to @sodic . Let me know if I missed something that you would like to get my opinion on.

scope -> scopes: why did that change? Because underlying thing names it that way? What is better, sounds like there is some semantic involved with those names? Is it actually scope or scopes, what makes more sense? I wouldn't try to avoid breaking change, I would instead go for the name that we think works the best, be it a breaking change or not, doesn't matter.

Passport strategies mapped the provider data into a specific shape -> how breaking is it? Will they need to migrate the database, because we stored that stuff in the DB? If so, then maybe it might make sense to do that mapping. If we are doing the mapping, is it complex, does it feel silly, or does it make sense, do we lose some info, ... -> how do we feel about it? If there is no DB migration involved, then I would certainly not bother with any mapping (unless you think they way data is returned by Artcic is ugly and could benefit from mapping).

@infomiho just a reminder not to miss this comment.

@infomiho
Copy link
Contributor Author

Of course! I've been waiting to get input from both Filip and you and I'll give some sort of a conclusion after we agree on what is the best here 👍

@infomiho
Copy link
Contributor Author

@Martinsos

scope -> scopes: why did that change? Because underlying thing names it that way? What is better, sounds like there is some semantic involved with those names? Is it actually scope or scopes, what makes more sense? I wouldn't try to avoid breaking change, I would instead go for the name that we think works the best, be it a breaking change or not, doesn't matter.

We just passed through the options to Passport before. Passport used scope, and that we pass through the options to Arctic, Arctic uses scopes. If we don't care about the backwards compatibility, I think it's okay to change the naming.

IMHO We'll need good migration docs from 0.12 to 0.13 (Maybe even a script is possible?)

Passport strategies mapped the provider data into a specific shape -> how breaking is it? Will they need to migrate the database, because we stored that stuff in the DB? If so, then maybe it might make sense to do that mapping. If we are doing the mapping, is it complex, does it feel silly, or does it make sense, do we lose some info, ... -> how do we feel about it?
If there is no DB migration involved, then I would certainly not bother with any mapping (unless you think they way data is returned by Artcic is ugly and could benefit from mapping).

  1. No database migrations will be necessary. What changes: the shape of the data they receive in userSignupFields handlers. So, if they used data.profile.emails[0] they'll need to use ... data.profile.email or smth along those lines to get the email.
  2. Passport has their own format they tried to keep across their strategies: https://www.passportjs.org/reference/normalized-profile/ We could try to replicate their approach. Alternatively, we can just return what we receive from the provider. This will make our maintenance easier since if something changes, we are just passing it along anyways and no changes from our side our needed.

@sodic

I don't think we should focus on the breaking change part - we're not stable enough for such sacrifices in consistency (we shouldn't support both names).. Pick the more appropriate name, regardless of whether it causes a breaking change or not.

Makes sense 👍

What we can do is throw a nice error. Instead of "Unknown property: scope". We can say "Please change 'scope' to 'scopes' in this file".

This could be a nice little touch. I've done stuff like this e.g. "Hey this error might mean you didn't run migrations" and it seemed to help the user.

If the current format is a random thing people at passport) came up with and you see something obvious you want to change, I say "go for it".

As I said above, the format is something Passport invented and tried to stick to. But they are not the "one true solution" for OAuth, so I'd say, maybe stick to just passing through the info we get from providers. At least that way, it matches what the providers' might have in their docs?

By the way, can we create migration scripts for these things?

+1 from me. I'd like to have at least good 0.12 to 0.13 migration docs.

@Martinsos
Copy link
Member

@Martinsos

scope -> scopes: why did that change? Because underlying thing names it that way? What is better, sounds like there is some semantic involved with those names? Is it actually scope or scopes, what makes more sense? I wouldn't try to avoid breaking change, I would instead go for the name that we think works the best, be it a breaking change or not, doesn't matter.

We just passed through the options to Passport before. Passport used scope, and that we pass through the options to Arctic, Arctic uses scopes. If we don't care about the backwards compatibility, I think it's okay to change the naming.

IMHO We'll need good migration docs from 0.12 to 0.13 (Maybe even a script is possible?)

Passport strategies mapped the provider data into a specific shape -> how breaking is it? Will they need to migrate the database, because we stored that stuff in the DB? If so, then maybe it might make sense to do that mapping. If we are doing the mapping, is it complex, does it feel silly, or does it make sense, do we lose some info, ... -> how do we feel about it?
If there is no DB migration involved, then I would certainly not bother with any mapping (unless you think they way data is returned by Artcic is ugly and could benefit from mapping).

  1. No database migrations will be necessary. What changes: the shape of the data they receive in userSignupFields handlers. So, if they used data.profile.emails[0] they'll need to use ... data.profile.email or smth along those lines to get the email.
  2. Passport has their own format they tried to keep across their strategies: https://www.passportjs.org/reference/normalized-profile/ We could try to replicate their approach. Alternatively, we can just return what we receive from the provider. This will make our maintenance easier since if something changes, we are just passing it along anyways and no changes from our side our needed.

@sodic

I don't think we should focus on the breaking change part - we're not stable enough for such sacrifices in consistency (we shouldn't support both names).. Pick the more appropriate name, regardless of whether it causes a breaking change or not.

Makes sense 👍

What we can do is throw a nice error. Instead of "Unknown property: scope". We can say "Please change 'scope' to 'scopes' in this file".

This could be a nice little touch. I've done stuff like this e.g. "Hey this error might mean you didn't run migrations" and it seemed to help the user.

If the current format is a random thing people at passport) came up with and you see something obvious you want to change, I say "go for it".

As I said above, the format is something Passport invented and tried to stick to. But they are not the "one true solution" for OAuth, so I'd say, maybe stick to just passing through the info we get from providers. At least that way, it matches what the providers' might have in their docs?

By the way, can we create migration scripts for these things?

+1 from me. I'd like to have at least good 0.12 to 0.13 migration docs.

I would then say let's do what is easy for us and also gives us nice DX. Wasp is in Beta, and it is ok we are changing fast.
I wouldn't do a migration script, because we can have instructions and they are not complex, they can be done manually. This will not be such a big release as 0.12.
Also, I wouldn't throw an error if they passed scope -> that is something we have to maintain then, we are not yet 1.0 and no need for this. Let's just mention it in the Changelog under migration instructions and that is ok.

@infomiho infomiho added the auth label Mar 14, 2024
Copy link
Contributor

@sodic sodic left a comment

Choose a reason for hiding this comment

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

Left improvement suggestions, but no major problems.

Here's the only major thing I'd like us to discuss: #1851 (comment)

Nice work!

@sodic sodic mentioned this pull request Mar 15, 2024
2 tasks
export function OAuthCallbackPage() {
const { isLoading, error, user } = useOAuthCallbackHandler();

if (user !== undefined && user !== null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

How come it can be both of these? I'm guessing because undefined happens on the first render, while null happens when the user isn't logged in?

If so, there's a trick that many linters recommend (although I'm not sure I like it). It sits halfway between being fully explicit and using coercion:

if (user != null) {
  // ...
}

This covers both undefined and null.

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'd like to skip the non strict check if possible. I know this looks ugly e.g. checking both undefined and null but it's at least not relying on implicit JS behaviour.

const { id: authId } = await tokenStore.verifyToken(code);
const auth = await findAuthWithUserBy({ id: authId })

if (!auth) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Explicit check (if you want)

Comment on lines 57 to 63
function verifyToken(token: string) {
return validateJWT<{ id: string }>(token);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Why not:

function getVerifiedToken(token: string) Promise<id: string> {
  return validateJWT(token);
}

It's a part of the public API through the returned object, so it makes sense to type it explicitly. Also, I'd say it's always better to type function signatures and leave the type inference for the body than the other way around.

Comment on lines 18 to 32
const result = {} as { [name in StateType]: string }

if (stateTypes.includes('state' as ST)) {
const state = generateState();
setOAuthCookieValue(provider, res, 'state', state);
result.state = state;
}

if (stateTypes.includes('codeVerifier' as ST)) {
const codeVerifier = generateCodeVerifier();
setOAuthCookieValue(provider, res, 'codeVerifier', codeVerifier);
result.codeVerifier = codeVerifier;
}

return result as { [name in ST]: string };
Copy link
Contributor

Choose a reason for hiding this comment

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

A less error prone way to implement this is by generating the resulting state first, and then using that to set the value. In pseudocode:

// construct the result
setOAuthCookieValue(provider, res, result); // this function would iterate the object and set all cookies
return result

I probably wouldn't do this now though and would just merge this. Not a big deal.

}
result.code = code;

return result as { [name in ST]: string } & { code: string };
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 you need this cast as the function is currently written.


const authId = await getAuthIdFromProviderDetails(providerId, providerProfile, userSignupFields);

if (!authId) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Explicit check if you want

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 function always returns a value or it throws an error, so I've removed this check.

Generator FileDraft
genOAuthConfig provider maybeUserConfig pathToConfigDst = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData)
genOAuthConfig provider maybeUserConfig pathToConfigTmpl = return $ C.mkTmplFdWithData tmplFile (Just tmplData)
Copy link
Contributor

Choose a reason for hiding this comment

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

I think that's enough abstraction for now. We'll come back to it when we add more providers and are smarter.

Signed-off-by: Mihovil Ilakovac <mihovil@ilakovac.com>
@infomiho infomiho merged commit a45b7a9 into arctic-feature Mar 18, 2024
6 checks passed
@infomiho infomiho deleted the miho-arctic-integration branch March 18, 2024 11:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants