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

Refresh token impl #2212

Merged
merged 37 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8151dd7
Implement login hooks
infomiho Jul 29, 2024
3d3dc18
Refresh token impl
infomiho Jul 30, 2024
69de38b
Update login hook params
infomiho Jul 30, 2024
a12863b
Cleanup
infomiho Jul 30, 2024
5a8ec33
Update e2e tests
infomiho Jul 30, 2024
ac77fd2
Merge branch 'miho-login-hooks' into miho-refresh-token
infomiho Jul 31, 2024
e43062b
Adds headless test
infomiho Jul 31, 2024
a37e843
Cleanup
infomiho Jul 31, 2024
c007564
Fix typo
infomiho Jul 31, 2024
4a55109
Merge branch 'miho-login-hooks' into miho-refresh-token
infomiho Jul 31, 2024
89d930b
Move extra helpers. Cleanup
infomiho Jul 31, 2024
57966af
e2e tests
infomiho Jul 31, 2024
4fa259b
Use dynamic imports for types
infomiho Jul 31, 2024
f0c2f2b
Cleanup and update comment
infomiho Aug 1, 2024
a992c69
Merge branch 'main' into miho-login-hooks
infomiho Aug 2, 2024
b918cb1
Merge branch 'miho-login-hooks' into miho-refresh-token
infomiho Aug 6, 2024
60e82ee
Adds docs
infomiho Aug 7, 2024
76217bc
Merge branch 'miho-login-hooks' into miho-refresh-token
infomiho Aug 7, 2024
5afc8b0
Docs and formatting
infomiho Aug 7, 2024
3c09189
e2e tests
infomiho Aug 7, 2024
2608397
PR comments
infomiho Aug 8, 2024
4af397b
PR comments #2
infomiho Aug 8, 2024
9586476
PR comments #3
infomiho Aug 8, 2024
5d4b8dd
Merge branch 'miho-login-hooks' into miho-refresh-token
infomiho Aug 8, 2024
9c5ab8c
Update providerName type to be consistent
infomiho Aug 8, 2024
78a6317
Update comment
infomiho Aug 8, 2024
e450e6e
Merge branch 'miho-login-hooks' into miho-refresh-token
infomiho Aug 8, 2024
2f050ea
Merge branch 'main' into miho-refresh-token
infomiho Aug 8, 2024
1a36669
Cleanup docs
infomiho Aug 9, 2024
e11cb65
Tap out
infomiho Aug 12, 2024
aff1acb
e2e tests
infomiho Aug 12, 2024
d19cac7
Merge branch 'main' into miho-refresh-token
infomiho Aug 22, 2024
a7e8493
PR comments
infomiho Aug 22, 2024
6fbeeda
PR comments #2
infomiho Aug 22, 2024
2df9b52
Define OAuth provider object in SDK explicitly
infomiho Aug 22, 2024
2e42655
Update docs
infomiho Aug 22, 2024
48c110b
PR comments
infomiho Aug 26, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions waspc/data/Generator/templates/sdk/wasp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"./client/test": "./dist/client/test/index.js",
"./client": "./dist/client/index.js",
"./dev": "./dist/dev/index.js",
"./server/oauth": "./dist/server/oauth/index.js",
sodic marked this conversation as resolved.
Show resolved Hide resolved

{=! todo(filip): Fixes below are for type errors in 0.13.1, remove ASAP =}
{=! Used by our code (SDK for full-stack type safety), uncodumented (but accessible) for users. =}
Expand Down
84 changes: 49 additions & 35 deletions waspc/data/Generator/templates/sdk/wasp/server/auth/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{{={= =}=}}
import type { Request as ExpressRequest } from 'express'
import { type ProviderId, createUser, findAuthWithUserBy } from '../../auth/utils.js'
import { prisma } from '../index.js'
import { Expand } from '../../universal/types.js'
import { ProviderName } from '../_types/index.js';
sodic marked this conversation as resolved.
Show resolved Hide resolved

// PUBLIC API
export type OnBeforeSignupHook = (
Expand Down Expand Up @@ -35,7 +37,7 @@ export type OnAfterLoginHook = (
export type InternalAuthHookParams = {
/**
* Prisma instance that can be used to interact with the database.
*/
*/
prisma: typeof prisma
}

Expand All @@ -48,86 +50,98 @@ export type InternalAuthHookParams = {
type OnBeforeSignupHookParams = {
/**
* Provider ID object that contains the provider name and the provide user ID.
*/
*/
providerId: ProviderId
/**
* Request object that can be used to access the incoming request.
*/
*/
req: ExpressRequest
} & InternalAuthHookParams

type OnAfterSignupHookParams = {
/**
* Provider ID object that contains the provider name and the provide user ID.
*/
*/
providerId: ProviderId
/**
* User object that was created during the signup process.
*/
*/
user: Awaited<ReturnType<typeof createUser>>
oauth?: {
/**
* Access token that was received during the OAuth flow.
*/
accessToken: string
/**
* Unique request ID that was generated during the OAuth flow.
*/
uniqueRequestId: string
},
/**
* OAuth flow data that was generated during the OAuth flow. This is only
* available if the user signed up using OAuth.
*/
oauth?: OAuthParams
sodic marked this conversation as resolved.
Show resolved Hide resolved
/**
* Request object that can be used to access the incoming request.
*/
*/
req: ExpressRequest
} & InternalAuthHookParams

type OnBeforeOAuthRedirectHookParams = {
/**
* URL that the OAuth flow should redirect to.
*/
*/
url: URL
/**
* Unique request ID that was generated during the OAuth flow.
*/
uniqueRequestId: string
*/
uniqueRequestId: OAuthParams['uniqueRequestId']
sodic marked this conversation as resolved.
Show resolved Hide resolved
/**
* Request object that can be used to access the incoming request.
*/
*/
req: ExpressRequest
} & InternalAuthHookParams

type OnBeforeLoginHookParams = {
/**
* Provider ID object that contains the provider name and the provide user ID.
*/
*/
providerId: ProviderId
/**
* Request object that can be used to access the incoming request.
*/
*/
req: ExpressRequest
} & InternalAuthHookParams

type OnAfterLoginHookParams = {
/**
* Provider ID object that contains the provider name and the provide user ID.
*/
*/
providerId: ProviderId
oauth?: {
/**
* Access token that was received during the OAuth flow.
*/
accessToken: string
/**
* Unique request ID that was generated during the OAuth flow.
*/
uniqueRequestId: string
},
/**
* User that is logged in.
*/
*/
user: Awaited<ReturnType<typeof findAuthWithUserBy>>['user']
/**
* OAuth flow data that was generated during the OAuth flow. This is only
* available if the user logged in using OAuth.
*/
oauth?: OAuthParams
/**
* Request object that can be used to access the incoming request.
*/
*/
req: ExpressRequest
} & InternalAuthHookParams

// PRIVATE API
export type OAuthParams = {
/**
* Unique request ID that was generated during the OAuth flow.
*/
uniqueRequestId: string
} & (
{=# enabledProviders.isGoogleAuthEnabled =}
| { providerName: 'google'; tokens: import('arctic').GoogleTokens }
{=/ enabledProviders.isGoogleAuthEnabled =}
{=# enabledProviders.isDiscordAuthEnabled =}
| { providerName: 'discord'; tokens: import('arctic').DiscordTokens }
{=/ enabledProviders.isDiscordAuthEnabled =}
{=# enabledProviders.isGitHubAuthEnabled =}
| { providerName: 'github'; tokens: import('arctic').GitHubTokens }
{=/ enabledProviders.isGitHubAuthEnabled =}
{=# enabledProviders.isKeycloakAuthEnabled =}
| { providerName: 'keycloak'; tokens: import('arctic').KeycloakTokens }
{=/ enabledProviders.isKeycloakAuthEnabled =}
| never
)
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type {
OnBeforeLoginHook,
OnAfterLoginHook,
InternalAuthHookParams,
OAuthParams,
} from './hooks.js'

{=# isEmailAuthEnabled =}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { type ProviderConfig } from "wasp/auth/providers/types";

// PRIVATE API (SDK)
export function ensureEnvVarsForProvider<EnvVarName extends string>(
envVarNames: EnvVarName[],
sodic marked this conversation as resolved.
Show resolved Hide resolved
provider: ProviderConfig,
providerName: string,
): Record<EnvVarName, string> {
sodic marked this conversation as resolved.
Show resolved Hide resolved
const result: Record<string, string> = {};
for (const envVarName of envVarNames) {
const value = process.env[envVarName];
if (!value) {
throw new Error(`${envVarName} env variable is required when using the ${provider.displayName} auth provider.`);
throw new Error(`${envVarName} env variable is required when using the ${providerName} auth provider.`);
}
result[envVarName] = value;
}
Expand Down
31 changes: 31 additions & 0 deletions waspc/data/Generator/templates/sdk/wasp/server/oauth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{{={= =}=}}
{=# enabledProviders.isGoogleAuthEnabled =}
// PUBLIC API
export * as google from './providers/google.js';
{=/ enabledProviders.isGoogleAuthEnabled =}
{=# enabledProviders.isDiscordAuthEnabled =}
// PUBLIC API
export * as discord from './providers/discord.js';
{=/ enabledProviders.isDiscordAuthEnabled =}
{=# enabledProviders.isGitHubAuthEnabled =}
// PUBLIC API
export * as github from './providers/github.js';
{=/ enabledProviders.isGitHubAuthEnabled =}
{=# enabledProviders.isKeycloakAuthEnabled =}
// PUBLIC API
export * as keycloak from './providers/keycloak.js';
{=/ enabledProviders.isKeycloakAuthEnabled =}

// PRIVATE API
export {
loginPath,
callbackPath,
exchangeCodeForTokenPath,
handleOAuthErrorAndGetRedirectUri,
getRedirectUriForOneTimeCode,
} from './redirect.js'

// PRIVATE API
export {
tokenStore,
} from './oneTimeCode.js'
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createJWT, validateJWT, TimeSpan } from '../../auth/jwt.js'

export const tokenStore = createTokenStore();
Copy link
Contributor

Choose a reason for hiding this comment

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

Any chance of avoiding a singleton module here?

Copy link
Contributor Author

@infomiho infomiho Aug 22, 2024

Choose a reason for hiding this comment

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

We need the one time tokens to allow the client to exchange one time codes for session IDs. If the server and client were on the same domain, it would be possible. This is not the case right now, so we need this kind of a singleton that keeps track of the tokens. This is not introduced in this PR btw. so I wouldn't change it in this PR irregardless.

At some point in the future, we'll need to figure out the cookies story so we can avoid this.

Copy link
Contributor

Choose a reason for hiding this comment

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

We need the one time tokens to allow the client to exchange one time codes for session IDs. If the server and client were on the same domain, it would be possible.

Oh, I understand we need the single instance that keeps the tokens. What I meant was "Can we not use a singleton pattern for it?"

This is not introduced in this PR btw. so I wouldn't change it in this PR irregardless.

It's perfectly fine not to do it now. FYI, here are two good posts I have bookmarked that will help us avoid singletons in the future (although I admit I sometimes break this rule myself):

Good thing we're not testing anything so the singletons aren't causing any problems 🥲

Feel free to resolve.

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 not a singleton in a sense that there can only exist only one instance of the object. It's true that we create a single instance that we have to use in both places where it's needed. In the case of unit tests, we could make the token store a param of the which ever fn we wanted to unit test.


function createTokenStore() {
const usedTokens = new Map<string, number>();

const validFor = new TimeSpan(1, 'm') // 1 minute
const cleanupAfter = 1000 * 60 * 60; // 1 hour

function createToken(userId: string): Promise<string> {
return createJWT(
{
id: userId,
},
{
expiresIn: validFor,
}
);
}

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

function isUsed(token: string): boolean {
return usedTokens.has(token);
}

function markUsed(token: string): void {
usedTokens.set(token, Date.now());
cleanUp();
}

function cleanUp(): void {
const now = Date.now();
for (const [token, timestamp] of usedTokens.entries()) {
if (now - timestamp > cleanupAfter) {
usedTokens.delete(token);
}
}
}

return {
createToken,
verifyToken,
isUsed,
markUsed,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{{={= =}=}}
import { Discord } from "arctic";

import { ensureEnvVarsForProvider } from "../env.js";
import { getRedirectUriForCallback } from "../redirect.js";

export const id = "{= providerId =}";
sodic marked this conversation as resolved.
Show resolved Hide resolved
export const displayName = "{= displayName =}";

const env = ensureEnvVarsForProvider(
["DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET"],
displayName
);

export const oAuthClient = new Discord(
env.DISCORD_CLIENT_ID,
env.DISCORD_CLIENT_SECRET,
getRedirectUriForCallback(id).toString(),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{{={= =}=}}
import { GitHub } from "arctic";

import { ensureEnvVarsForProvider } from "../env.js";

export const id = "{= providerId =}";
sodic marked this conversation as resolved.
Show resolved Hide resolved
export const displayName = "{= displayName =}";

const env = ensureEnvVarsForProvider(
["GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET"],
sodic marked this conversation as resolved.
Show resolved Hide resolved
displayName
);

export const oAuthClient = new GitHub(
env.GITHUB_CLIENT_ID,
env.GITHUB_CLIENT_SECRET,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{{={= =}=}}
import { Google } from "arctic";

import { ensureEnvVarsForProvider } from "../env.js";
import { getRedirectUriForCallback } from "../redirect.js";

export const id = "{= providerId =}";
export const displayName = "{= displayName =}";

const env = ensureEnvVarsForProvider(
["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"],
displayName,
);

export const oAuthClient = new Google(
env.GOOGLE_CLIENT_ID,
env.GOOGLE_CLIENT_SECRET,
getRedirectUriForCallback(id).toString(),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{{={= =}=}}
import { Keycloak } from "arctic";

import { ensureEnvVarsForProvider } from "../env.js";
import { getRedirectUriForCallback } from "../redirect.js";

export const id = "{= providerId =}";
export const displayName = "{= displayName =}";

const env = ensureEnvVarsForProvider(
["KEYCLOAK_REALM_URL", "KEYCLOAK_CLIENT_ID", "KEYCLOAK_CLIENT_SECRET"],
displayName,
);

export const oAuthClient = new Keycloak(
env.KEYCLOAK_REALM_URL,
env.KEYCLOAK_CLIENT_ID,
env.KEYCLOAK_CLIENT_SECRET,
getRedirectUriForCallback(id).toString(),
);
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
{{={= =}=}}
import { config } from 'wasp/server'
import { HttpError } from 'wasp/server'
import { config, HttpError } from '../../server/index.js'

// PRIVATE API (server)
export const loginPath = '{= serverOAuthLoginHandlerPath =}'
export const callbackPath = '{= serverOAuthCallbackHandlerPath =}'

// PRIVATE API (server)
export const exchangeCodeForTokenPath = '{= serverExchangeCodeForTokenHandlerPath =}'
const clientOAuthCallbackPath = '{= clientOAuthCallbackPath =}'

export function getRedirectUriForCallback(providerName: string): URL {
return new URL(`${config.serverUrl}/auth/${providerName}/${callbackPath}`);
}
// PRIVATE API (server)
export const callbackPath = '{= serverOAuthCallbackHandlerPath =}'

const clientOAuthCallbackPath = '{= clientOAuthCallbackPath =}'

// PRIVATE API (server)
export function getRedirectUriForOneTimeCode(oneTimeCode: string): URL {
return new URL(`${config.frontendUrl}${clientOAuthCallbackPath}#${oneTimeCode}`);
}

// PRIVATE API (server)
export function handleOAuthErrorAndGetRedirectUri(error: unknown): URL {
if (error instanceof HttpError) {
const errorMessage = isHttpErrorWithExtraMessage(error)
Expand All @@ -26,6 +29,11 @@ export function handleOAuthErrorAndGetRedirectUri(error: unknown): URL {
return getRedirectUriForError("An unknown error occurred while trying to log in with the OAuth provider.");
}

// PRIVATE API (SDK)
export function getRedirectUriForCallback(providerName: string): URL {
return new URL(`${config.serverUrl}/auth/${providerName}/${callbackPath}`);
}

function getRedirectUriForError(error: string): URL {
return new URL(`${config.frontendUrl}${clientOAuthCallbackPath}?error=${error}`);
}
Expand Down
Loading
Loading