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

Auth Hooks #1993

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open

Auth Hooks #1993

wants to merge 26 commits into from

Conversation

infomiho
Copy link
Contributor

@infomiho infomiho commented Apr 24, 2024

Closes #1556

Adding hooks:

  • onBeforeSignup
  • onAfterSignup
  • onBeforeOAuthRedirect
  • onAfterOAuthTokenReceived

Left to do:

@@ -0,0 +1,30 @@
import type {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Example usage of the hooks

@@ -40,7 +40,11 @@ app todoApp {
},
},
onAuthFailedRedirectTo: "/login",
onAuthSucceededRedirectTo: "/profile"
onAuthSucceededRedirectTo: "/profile",
onBeforeSignup: import { onBeforeSignup } from "@src/auth/hooks.js",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Declare the hooks in the auth dict

const { accessToken } = await github.validateAuthorizationCode(code);
return getGithubProfile(accessToken);
},
getProviderTokens: ({ code }) => github.validateAuthorizationCode(code),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Refactor OAuth a bit to split getting tokens and getting user profile into two steps so it's easier to write a hook onAfterOAuthTokenReceived

import type { ProviderId, createUser } from '../../auth/utils.js'
import { prisma } from '../index.js'

type CommonInput = {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Each hook will receive hookName, prisma and Express's req object.

Copy link
Contributor

Choose a reason for hiding this comment

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

I suggest changing the name to CommonAuthHookParams.

Also, consider whether this affects how the IDE displays the types and whether it's worth it. The user probably doesn't care what's common and what's not common - they just want to see which params they 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.

Renamed the symbol and added the Expand helper so user would see full types
Screenshot 2024-06-05 at 16 51 33

} & CommonInput

/* On Before OAuth Redirect Hook */
export type OnBeforeOAuthRedirectHookFn = (
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Users have the ability to modify the OAuth redirect URL

const user = await createUser(
providerId,
providerData,
// Using any here because we want to avoid TypeScript errors and
// rely on Prisma to validate the data.
userFields as any,
)
if (onAfterSignupHook) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hook

@@ -34,13 +35,19 @@ export function getSignupRoute({
})

try {
await createUser(
if (onBeforeSignupHook) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hook

providerId,
providerData,
// Using any here because we want to avoid TypeScript errors and
// rely on Prisma to validate the data.
userFields as any
)
if (onAfterSignupHook) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hook

passwordResetSentAt: null,
});
try {
if (onBeforeSignupHook) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hook

// rely on Prisma to validate the data.
userFields as any,
)
if (onAfterSignupHook) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hook

@infomiho infomiho marked this pull request as ready for review May 2, 2024 12:25
@infomiho infomiho requested a review from sodic May 3, 2024 11:41
" methods: {",
" google: {}",
" },",
" onAuthFailedRedirectTo: \"/login\"",
" onAuthFailedRedirectTo: \"/login\",",
" onBeforeSignup: import { onBeforeSignup } from \"@src/auth/hooks.js\",",
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 included hooks in the e2e test

unlines
[ "entity SocialLogin {=psl",
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 cleaned up the e2e tests not use old auth concepts 👍

web/docs/auth/auth-hooks.md Outdated Show resolved Hide resolved
web/docs/auth/auth-hooks.md Outdated Show resolved Hide resolved
web/docs/auth/auth-hooks.md Outdated Show resolved Hide resolved
@infomiho
Copy link
Contributor Author

infomiho commented May 9, 2024

@sodic and I jumped on quick call to discuss some of the OAuth hooks since I felt I didn't design them to be that useful.

We concluded the following:

  • we should remove the onAfterOAuthTokenReceived hook since the developer doesn't have access to user and the accessToken at the same time. This makes the accessToken useless if you want to save it on some user for later use.
    • Solution: We'll just forward the accessToken in the onAfterSignup hook. ✅
  • we should investigate how to have "state" when using the onBeforeOAuthRedirect hook. This is the only OAuth hook where the developer can get some user input from the client. This input gets lost in the follow up hooks e.g. onAfterSignup doesn't have access to it, so it never can be saved on the user entity.
    • Idea: It would be ideal to somehow keep track of the user input across the OAuth hooks so the it can be used again in the onAfterSignup hook ✅
    • I'll investigate how to best achieve this.

@@ -77,13 +77,11 @@ const _waspConfig: ProviderConfig = {

return createOAuthProviderRouter({
provider,
stateTypes: ['state'],
optionalStateTypes: [],
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since the state is always sent and we rely on that knowledge in the new hooks, I've refactored this a bit to reflect the fact that the state will be always present in the oAuthState object.

setOAuthCookieValue(provider, res, 'state', state);
result.state = state;
}
const state = generateState();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Always set the state since it's always used and will always be used for each future OAuth provider.

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.

Gotta run now, will finish the review tomorrow morning.

Nice work with this!


For every auth hook you want to use, you need to declare it in the `auth` dict in your Wasp file:

```wasp
Copy link
Contributor

Choose a reason for hiding this comment

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

You might want to consider adding a TS/JS switch here for consistency. I'm fine either way.

import { EmailPill, UsernameAndPasswordPill, GithubPill, GooglePill, KeycloakPill } from "./Pills";
import ImgWithCaption from '@site/blog/components/ImgWithCaption'

Sometimes you need to do some action _before_ the user signs up or _after_ they sign up. Or you need to access the OAuth redirect URL or the OAuth access token. For these cases, you can use the Auth Hooks.
Copy link
Contributor

Choose a reason for hiding this comment

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

Examples would help here.

Also, I have a couple of suggestions for the style. Take the ones you want:

  • some action -> something - "some action" sounds weird to me.
  • Too many "or"s
  • For these cases... -> Auth hooks can help you here.

I also suggesting adding a sentence that explains the term "hook" to avoid the "curse of knowledge". For example: ... Auth hooks can help you here. Use them to "hook into" different parts of Wasp's authentication flow

Also, consider using the Oxford comma where appropriate ("Use the oxford comma" from the style guide)


## Available Hooks

For every auth hook you want to use, you need to declare it in the `auth` dict in your Wasp file:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
For every auth hook you want to use, you need to declare it in the `auth` dict in your Wasp file:
You must first declare all desired hooks in your Wasp file:

A little shorter while conveying the same 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'd drop the 'user' rectangle below "User account is created. It makes the graph look cleaner and I don't think we lose anything.

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 simplified the diagrams and dropped all of the "data" rects.

Copy link
Contributor

Choose a reason for hiding this comment

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

The "Redirect to Google. Get auth token. User singup data is received" part is little confusing. It's the only text that contains several actions, but there's no delimiter.
Can we separate this into three separate actions, or put some commas in there?

Also, why do we need the rectangle with oauth: token below if we can't use it? Can we drop that?

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 made it into a single action that reads okay.

Also, why do we need the rectangle with oauth: token below if we can't use it? Can we drop that?

I wanted to point out where the data that appears in the hooks comes from. But it was getting crowded so I dropped all the data rects.

type UserHookParamsToInternalHookParams<T> = T extends (
params: infer P,
) => unknown
? Omit<P, 'prisma' | 'hookName'>
Copy link
Contributor

Choose a reason for hiding this comment

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

There's some duplication here. We should query the names of these fields from CommonInput.

I've changed my mind on what I've written below. While technically still true, the wrapper is too complex to make it worth it. I'm leaving the old text for posterity:

Warning

Outdated opinion
Also, the type is generic (suggesting all three functions/wrappers act in the same way on purpose), while the functions themselves are duplicated (suggesting that there's no knowledge duplication and the structural similarities are a coincidence).

I'm not sure which one it is, but I suggest making it consistent:

  • If you want to treat the hooks independently, they shouldn't use a generic type
  • If you want them all to conform to the same interface, make a generic wrapper.

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 would work we didn't have a param we want to keep from the CommonInput which is req.

Common input has hookName, prisma and req, we want to keep out the first two. If I used keyof to get the keys of the CommonInput, I'd still need to write down the literal req which would still be duplication.

Did I understand this comment correctly?

})
{=/ onBeforeSignupHook.isDefined =}
{=^ onBeforeSignupHook.isDefined =}
export const onBeforeSignupHook = undefined
Copy link
Contributor

Choose a reason for hiding this comment

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

I suggest using null.

As for the bigger picture...

From the generated code's perspective, this is a little weird. We always have a check if(hook) before calling a hook. But that check is (again, from the runtime's perspective) always unnecessary.

Once the code is generated, the hook either null or a function. It feels weird to generate code that will perform this check considering that, at that point, we know already know the check's outcome.

I understand why you did it. You didn't want too much coupling and ugly template logic in the handler file, but I'd argue that delegating already existing knowledge to the runtime instead of acting on it immediately isn't a huge improvement.

So, I propose a third solution. Instead of setting the internal hook functions to null and undefined, set them to a no-op (e.g., () => undefined). For the one that returns a redirect URL, just return the original URL. Then, always call the hooks from the handler.

Copy link
Contributor Author

@infomiho infomiho Jun 6, 2024

Choose a reason for hiding this comment

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

I've went with no-op and identity functions 👍 Great suggestion which makes the code cleaner.


export function setOAuthCookieValue(
provider: ProviderConfig,
res: ExpressResponse,
stateType: StateType,
stateType: RequiredStateType | OptionalStateType,
Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like we could make a type StateType = RequiredStateType | OptionalStateType

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 simplified the logic so this no longer applies.

}

/* On Before Signup Hook */
export type OnBeforeSignupHookFn = (
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's add it when it proves necessary.

}

/* On Before Signup Hook */
export type OnBeforeSignupHookFn = (
Copy link
Contributor

Choose a reason for hiding this comment

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

I suggest dropping the Fn part, OnBeforeSignupHook should be enough.

I know we have SeverSetupFn and whatnot, but I'm not a fan of those either. I'd change them to SetUpServer or something.

Of course, changing this know makes our public API names inconsistent. If you think keeping the consistency is more important, I'm all for it.

I'd probably sacrifice consistency in this case, but it's your call.

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.

Ok, finally done with everything 😄

I didn't try the playing around with the feature, I trust it works. Let me know if you still want me to try it out.

All in all, this is looking pretty nicely. I left a lot of comments mostly because of:

  • The docs.
  • Refactoring suggestions.
  • The fact that I'm the only one reviewing this.

Some refactoring comments talk about the previous version of the code. Decide what you want to tackle and what not (I don't think any of them were a big deal).

As for the stuff that must be fixed, it's just one type error (ST instead of OST, you'll know when you find the comment). And please figure out why that's not failing anywhere.

Comment on lines 25 to 33
aliasedExtImportToImportJson ::
JsImportAlias ->
Path Posix (Rel importLocation) (Dir ServerSrcDir) ->
Maybe EI.ExtImport ->
Aeson.Value
aliasedExtImportToImportJson importAlias pathFromImportLocationToSrcDir maybeExtImport = GJI.jsImportToImportJson aliasedJsImport
where
jsImport = extImportToJsImport pathFromImportLocationToSrcDir <$> maybeExtImport
aliasedJsImport = JI.applyJsImportAlias (Just importAlias) <$> jsImport
Copy link
Contributor

Choose a reason for hiding this comment

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

I got lost with all the import, js import, import json functions and types... but shouldn't this thing exist already?

We are already doing aliased imports for operations, so we should have this. Can you please look into it?

Also, the function's name seems a little off. It suggest it maps an aliased ext import to import json, but it does something different (maybe too many things).

Finally, this file exports some symbols no one else uses, and even has a function or two that aren't used at all. This is not at all related to your PR, but I suggest cleaning it up a little - this JS import stuff has gotten a little out of hand. We should try to tame 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.

I got lost with all the import, js import, import json functions and types... but shouldn't this thing exist already?

We are already doing aliased imports for operations, so we should have this. Can you please look into it?

The operations aliasing function is really a special case that imports symbols from the wasp/operations/sdk vs. this function importing from user files. So I couldn't really use it here.

I believe the proper solution is to automate this i.e. #1865

): { [name in ST]: string } {
const result = {} as { [name in StateType]: string }
res: ExpressResponse
): { [name in OST]: string } & { [name in RequiredStateType]: string } {
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 use Records here? Did you want to document the name property?

Also, why use a product type instead of a union in the key type (as you did when asserting result). All in all, this is how I'd write it:

Record<RequiredStateType | OST, string>


if (stateTypes.includes('codeVerifier' as ST)) {
if (optionalStateTypes.includes('codeVerifier' as ST)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This ST here should throw an error somewhere. Can you please check whether wasp start and wasp build let it through?

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 adjusted the code, so this is no longer relevant.

Comment on lines 44 to 50
const result = {} as {
[name in OptionalStateType]: string;
} & {
[name in RequiredStateType]: 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.

Most of the other two big comments apply to this function as well, I think.

Comment on lines 11 to 13
export type RequiredStateType = 'state';

export function generateAndStoreOAuthState<ST extends StateType>(
stateTypes: ST[],
export type OptionalStateType = 'codeVerifier';
Copy link
Contributor

Choose a reason for hiding this comment

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

Besides creating a union called State (mentioned in #1993 (comment)), I also recommend specifying more information with these types and colocating all the knowledge we have on them.

For example, the value for both states is of type string, but this knowledge is sprinkled throughout the functions. I recommend we list it explicitly:

export type RequiredOAuthState = {
  state: string
}

export type OptionalOAuthState = {
  codeVerifier: string
}

export type OAuthState = RequiredOAuthState & Partial<OptionalOAuthState>

And then you have both names and values of states defined in the same place. You can get extract just the names, just the values, or both from this type:

type OptionalOAuthStateType = keyof OptionalOAuthState

The types contain all knowledge and everything else just follows. The types functions use become simpler as well:

function generateCodeVerifier(): OAuthState['codeVerifier']

function generateAndStoreOAuthState<OST extends OptionalOAuthStateType>(
  optionalStateTypes: OST[],
  provider: ProviderConfig,
  res: ExpressResponse
): OAuthState

@@ -32,37 +34,43 @@ export function generateAndStoreOAuthState<ST extends StateType>(
return result;
Copy link
Contributor

Choose a reason for hiding this comment

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

Github doesn't allow me to select the function's entire body, but the comment applies to all of it.

Recently, when reading Effective TS, I learned a couple of new things we can apply here:

  • Create objects all at once
  • Know how to iterate over objects

I won't repeat the item's content here, but here's how the code looks like after we apply:

const shouldGenerateCodeVerifier = optionalStateTypes.includes(
  'codeVerifier' as OST
)
const result = {
  state: generateState(),
  ...shouldGenerateCodeVerifier && { codeVerifier: generateCodeVerifier() },
}

let key: keyof typeof result
for (key in result) {
  setOAuthCookieValue(provider, res, key, result[key])
}

return result

If you implement some of the stuff from the state types comment, maybe it even makes sense to go full retard functional:

const stateGenerators: {
  [name in keyof OAuthState]: () => OAuthState[name]
} = {
  state: generateState,
  codeVerifier: generateCodeVerifier,
}

// ...

const stateTypes: StateType[] = [...optionalStateTypes, 'state']

const result = stateTypes.reduce(
  (result, stateType) => ({
    ...result,
    [stateType]: stateGenerators[stateType](),
  }),
  {} as OAuthState
)

let key: keyof typeof result
for (key in result) {
  setOAuthCookieValue(provider, res, key, result[key])
}

return result

This might be pushing it a little tho (although it is open-closed for new optional states). I'm just giving ideas, it's your call. The current version of the code is perfectly fine to me.

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 adjusted the code to be simpler and generate the result all the once :D

userSignupFields: UserSignupFields | undefined;
req: ExpressRequest;
accessToken: string;
oAuthState: { [name in RequiredStateType]: string };
Copy link
Contributor

Choose a reason for hiding this comment

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

Another place where i'd put a record.

Comment on lines 122 to 130
const redirectUri = await finishOAuthFlowAndGetRedirectUri({
provider,
providerProfile,
providerUserId,
userSignupFields,
req,
accessToken,
oAuthState,
})
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 a lot of arguments spanning different levels of abstraction. I see why they're all necessary though.

Do you think it maybe makes sense grouping them into a couple of objects and passing them around like that? This will depend on semantic connection between the arguments (are they always dispatched together, is one batch used for one thing and the other batch for a different thing, etc.).

You'll judge this better than I can.

stateTypes: ST[],
export type OptionalStateType = 'codeVerifier';

export function generateAndStoreOAuthState<OST extends OptionalStateType>(
Copy link
Contributor

Choose a reason for hiding this comment

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

I understand the purpose of the generic, you want to connect the argument's type to the return type.

However, using the generic complicates the rest of the code (for example, when defining callbacks such as getAuthorizationUrl, you can't just use OAuthState but must instead query the ReturnType of a different function).

This might be necessary to prevent user errors that would cause unexpected behaviour. I have a feeling it is necessary (there seems to be a connection over OST while creating provider configs) but am not 100% sure.

But if user's don't get too much from this extra type safety (do they need to know about specific optional state types they're using?), I recommend dropping the generic and just using OptionalStateType.

As mentioned, I don't know whether which is the case and, if I were to bet, I'd say that it was necessary. I can try it out and see, but you'll know right away.

NOTE: When I say users, I mean "Wasp users and calling code", not only Wasp users.

/*
The function that returns the URL to redirect the user to the
provider's login page.
*/
getAuthorizationUrl: Parameters<typeof createOAuthLoginHandler<ST>>[2],
getAuthorizationUrl: Parameters<typeof createOAuthLoginHandler<OST>>[2]
Copy link
Contributor

Choose a reason for hiding this comment

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

Disclaimer: talking about code again

Ok, I'm guessing this is where the relationship I suspected of in the previous comment shines through.

Another Effective typescript tip: Ideally, we'd want the types and and the user facing code to dictate the types/structure.

User-facing type
--> internal functions

For these fields, that does happen, but with extra steps:

User-facing type (optionalStateTypes)
--> internal functions (createOAuthLoginHandler)
--> User-facing type (getAuthorizationUrl)

Can we somehow flip this to:

User-facing types (all of them) --> internal functions

/* On After Signup Hook */
export type OnAfterSignupHookFn = (
params: OnAfterSignupHookFnInput,
) => Promise<void>
Copy link
Contributor

Choose a reason for hiding this comment

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

What happens when a user wants the hook to be a non-async function.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, we need to account for that 👍

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 adjusted the return type to allow for async and sync functions.

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.

Add hooks after login / sign-in, to execute custom server code
2 participants