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

Merged
merged 39 commits into from
Jun 20, 2024
Merged

Auth Hooks #1993

merged 39 commits into from
Jun 20, 2024

Conversation

infomiho
Copy link
Contributor

@infomiho infomiho commented Apr 24, 2024

Closes #1556

Adding hooks:

  • onBeforeSignup
  • onAfterSignup
  • onBeforeOAuthRedirect
  • onAfterOAuthTokenReceived

Left to do:

@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
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.

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!

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
web/static/img/auth-hooks/signup_flow_with_hooks.png Outdated Show resolved Hide resolved
web/static/img/auth-hooks/oauth_flow_with_hooks.png Outdated Show resolved Hide resolved
waspc/data/Generator/templates/server/src/auth/hooks.ts Outdated Show resolved Hide resolved
waspc/data/Generator/templates/server/src/auth/hooks.ts Outdated Show resolved Hide resolved
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 15 to 17
export type OAuthState<UsesCodeVerifier extends boolean = false> = {
state: string;
} & (UsesCodeVerifier extends true
Copy link
Contributor

@sodic sodic Jun 10, 2024

Choose a reason for hiding this comment

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

Boolean type arguments are generally considered an antipattern (even more than boolean arguments in functions, who also get a bad rep). This is because they move the type system away from "declaring sets of values" and into "implementing decision algorithms":

  • Foo<User> - It's a Foo of Users (whatever Foo is).
  • Foo<true> - Hmm.

Of course, boolean type arguments are sometimes necessary, and we do use them throughout the codebase. But if we can help it, we should avoid them.

All in all, this is a pretty complex type, it includes: a type parameter (and a boolean one at that), a default value, a type constraint, an intersection type, and an inline conditional type.

I understand this code, but put yourself in someone else's shoes. Would you say the prevented duplication/extra keystrokes are worth all the additional complexity?

It might be necessary, I haven't looked at the details, you'll know best. I just wanted to say "If there's a way to have the same thing with a little more duplication and dumber generics, I'd go for it." You can resolve when you're happy.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, I've decided to be explicit about the OAuth flow type we are using i.e. with or without PKCE and this is what will result in the state object 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.

Something like

export type OAuthStateFor<
  OT extends OAuthType
> = OAuthStateForOAuthType[OT];

export type OAuthStateWithCodeFor<OT extends OAuthType> = OAuthStateFor<OT> & {
  code: string;
};

export type OAuthType = keyof OAuthStateForOAuthType;

export type OAuthStateFieldName = keyof OAuthState | keyof OAuthStateWithPKCE;

type OAuthStateForOAuthType = {
  OAuth2: OAuthState,
  OAuth2WithPKCE: OAuthStateWithPKCE,
};

type OAuthState = {
  state: string;
};

type OAuthStateWithPKCE = {
  state: string;
  codeVerifier: string;
};

@infomiho infomiho mentioned this pull request Jun 12, 2024
5 tasks
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.

Everything looks great, approved (finally, sorry for the delay 😅)

I left some comments though, so check them out.

/**
* This is a no-op function since the user didn't define the onAfterSignup hook.
*/
export const onAfterSignupHook: InternalFunctionForHook<OnAfterSignupHook> = async (params) => {}
Copy link
Contributor

Choose a reason for hiding this comment

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

Maaaybe just add an underscore to params.

*/
stateTypes: ST[],
userSignupFields: UserSignupFields | undefined,
oAuthType: OT
Copy link
Contributor

Choose a reason for hiding this comment

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

I like the new API where you specify the type instead of the details.

Comment on lines 37 to 50
export function generateAndStoreOAuthState<OT extends OAuthType>({
oAuthType,
provider,
res,
}: {
oAuthType: OT,
provider: ProviderConfig,
res: ExpressResponse,
): { [name in ST]: string } {
const result = {} as { [name in StateType]: string }

if (stateTypes.includes('state' as ST)) {
const state = generateState();
setOAuthCookieValue(provider, res, 'state', state);
result.state = state;
}
res: ExpressResponse
}): OAuthStateFor<OT> {
return {
...generateAndStoreState(provider, res),
...(oAuthType === 'OAuth2WithPKCE' && generateAndStoreCodeVerifier(provider, res)),
};
}
Copy link
Contributor

@sodic sodic Jun 20, 2024

Choose a reason for hiding this comment

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

This is not important enough to hold the PR (especially because this is all Wasp private code), and I should have mentioned it when you first introduced the change (it slipped my mind).

So, you don't need to change anything. I'm writing this just FYI for the future.


It's better to have:

entireState = generateEntireState()
storeEntireState(entireState)

Than:

state1 = generateAndStoreState1()
state2 = generateAndStoreState2()
entireState = { state1, state2 }

In other words, the code should be as pure as possible for as long as possible, keeping side effects at the system's edge.
That's why my initial suggestion looked like this: #1993 (comment). My bad, I should have emphasized it more.

Haskell is great because it enforces this rule with its type signatures. You only want to deal with monads if necessary, so you keep them at the edge of your code. It pushes you into the "pit of success"

When making design decisions like this, I personally found it helpful to imagine TypeScript isn't as permissive with side effects and think, "How would I write this in Haskell."

It's important to note that this isn't exclusive to side effects. The same principle applies whenever you divide responsibilities across nested function calls.

Another code smell you can look out for is having more than a single function called doFooAndBar:

  • If it's only the top function, then all good.
  • If the fooAndBar naming continues all the way down into subfunctions, that's often a sign that you've divided the responsibilities along the wrong axis.

In this example, we have:

generateAndStoreState
 - generating logic
 - storing logic
generateAndStoreCodeVerifier
 - generating logic
 - storing logic

generateAndStoreOAuthState
 - generating logic

The generating logic is sprinkled across multiple methods, while the storing logic is repeated twice. If you decided to use localStorage instead of cookies, you'd have to remember to change it in two places.
Compare that with something like this:

generateAndStoreOAuthState
  entireState = generateOAuthState()
  storeState(entireState)

You could build these methods from what I suggested earlier:

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

// begin storeOAuthState function
let key: keyof typeof result
for (key in result) {
  setOAuthCookieValue(provider, res, key, result[key])
}
// end storeOAuthState function

return result

With this approach, all storage calls are defined in the same place. Also, the generating logic is better localized.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense 👍

I'll split up the code to work in "stages". Thanks for the suggestion!

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 now written the methods as:

export function generateAndStoreOAuthState(...) {
  const state: OAuthStateFor<OT> = {
    ...generateState(),
    ...(oAuthType === 'OAuth2WithPKCE' && generateCodeVerifier()),
  };

  storeOAuthStateInCookies(provider, res, state);

  return state;
}

export function validateAndGetOAuthState(...) {
  const state: OAuthStateWithCodeFor<OT> = {
    ...getCode(req),
    ...getState(req),
    ...(oAuthType === 'OAuth2WithPKCE' && getCodeVerifier(provider, req)),
  };

  validateOAuthState(provider, req, state);

  return state;
}

Doing the for (key in result) bit gives me these kinds of errors:

Argument of type 'string' is not assignable to parameter of type 'OAuthStateFieldName'.ts(2345)

and

Argument of type 'OAuthStateFor<OT>[keyof OAuthStateFor<OT>]' is not assignable to parameter of type 'string'.
  Type 'OAuthStateFor<OT>[string] | OAuthStateFor<OT>[number] | OAuthStateFor<OT>[symbol]' is not assignable to type 'string'.
    Type 'OAuthStateFor<OT>[string]' is not assignable to type 'string'.

so I went with a more basic approach of doing hardcoded calls.

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 found the reason for these type errors is using generics e.g. OAuthStateFor<OT> vs. just using all the available values with OAuthStateFor<OAuthType>. Works now 👍

web/docs/auth/auth-hooks.md Show resolved Hide resolved
}

function generateAndStoreCodeVerifier(
function storeOAuthStateInCookies<OT extends OAuthType>(
Copy link
Contributor

Choose a reason for hiding this comment

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

I would leave the implementation details out of the function's name -> storeOAuthState.

Makes it easier to change storage mechanisms too, the beauty of encapsulation :)

state: OAuthStateFor<OT>
): void {
setOAuthCookieValue(provider, res, 'state', state.state);
if (isOAuthStateWithPKCE(state)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this typeguard necessary?

We'll have to add a new "save" statement whenever we add a field, and the type system does not enforce it (i.e., it's possible to forget).

Why not simply loop over the state object and store everything that's inside? That way, if we add a new field, it will work automatically.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because state is a union of OAuthState and OAuthStateWithPKCE.

As I mentioned here: #1993 (comment)

Iterating over the values requires from me to assert the types 😢 I can do that ofc

@infomiho infomiho merged commit 115ed2f into main Jun 20, 2024
6 checks passed
@infomiho infomiho deleted the miho-auth-hooks-2 branch June 20, 2024 15:10
infomiho added a commit that referenced this pull request Jun 21, 2024
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