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

[NAN-578] Implement Google Social Login/Signup #1872

Merged
merged 17 commits into from Mar 20, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

105 changes: 105 additions & 0 deletions packages/server/lib/controllers/auth.controller.ts
@@ -1,4 +1,5 @@
import type { Request, Response, NextFunction } from 'express';
import { WorkOS } from '@workos-inc/node';
import crypto from 'crypto';
import util from 'util';
import { resetPasswordSecret, getUserAccountAndEnvironmentFromSession } from '../utils/utils.js';
Expand All @@ -15,6 +16,7 @@ import {
AnalyticsTypes,
isCloud,
getBaseUrl,
getBasePublicUrl,
NangoError,
createOnboardingProvider
} from '@nangohq/shared';
Expand All @@ -26,6 +28,16 @@ export interface WebUser {
name: string;
}

interface InviteAccountState {
accountId: number;
token: string;
}

let workos: WorkOS | null = null;
if (process.env['WORKOS_API_KEY']) {
khaliqgant marked this conversation as resolved.
Show resolved Hide resolved
workos = new WorkOS(process.env['WORKOS_API_KEY']);
}

class AuthController {
async signin(req: Request, res: Response, next: NextFunction) {
try {
Expand Down Expand Up @@ -260,6 +272,99 @@ class AuthController {
next(error);
}
}

async getHostedLogin(req: Request, res: Response, next: NextFunction) {
try {
const provider = req.query['provider'] as string;
bodinsamuel marked this conversation as resolved.
Show resolved Hide resolved

if (!workos) {
errorManager.errRes(res, 'workos_not_configured');
return;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

see my comment above. This is not a concern for the user and I think it would be better to crash the server at startup

Copy link
Member Author

Choose a reason for hiding this comment

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

WorkOS is only required for Cloud so don't think it should crash here.

Copy link
Collaborator

@TBonnin TBonnin Mar 20, 2024

Choose a reason for hiding this comment

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

what I mean is this is a configuration issue that would better detected as soon as possible (aka: when the app starts) rather than deferred to whenever a request is being received.
For sure for non-cloud setup this configuration can be ignored.

I prefer never to deployed a misconfigured app than getting a bug report by an end-user

Copy link
Member Author

Choose a reason for hiding this comment

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

Yup, I gotcha, but who are we protecting here? Developers running the application locally? Cloud will have the necessary env variables.

Copy link
Collaborator

@TBonnin TBonnin Mar 20, 2024

Choose a reason for hiding this comment

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

Yup, I gotcha, but who are we protecting here?

We protecting our future self

Cloud will have the necessary env variables.

They will, until one day the infra changes and configuration is somehow forgotten or messed up.
In general trying to detect error as soon as possible is a good principle to follow.

Not a blocker though.


const body = req.body;
khaliqgant marked this conversation as resolved.
Show resolved Hide resolved

const oAuthUrl = workos?.userManagement.getAuthorizationUrl({
clientId: process.env['WORKOS_CLIENT_ID'] || '',
khaliqgant marked this conversation as resolved.
Show resolved Hide resolved
provider,
redirectUri: `${getBaseUrl()}/api/v1/login/callback`,
state: body ? Buffer.from(JSON.stringify(body)).toString('base64') : ''
});

res.send({ url: oAuthUrl });
} catch (err) {
next(err);
}
}

async createAccountIfNotInvited(state: string, name: string): Promise<number | null> {
khaliqgant marked this conversation as resolved.
Show resolved Hide resolved
const parsedAccount = (state ? JSON.parse(Buffer.from(state, 'base64').toString('ascii')) : null) as InviteAccountState | null;
const accountId = typeof parsedAccount?.accountId !== 'undefined' ? parsedAccount.accountId : null;

if (accountId !== null) {
const token = parsedAccount?.token;
const validToken = await userService.getInvitedUserByToken(token as string);
if (validToken) {
await userService.markAcceptedInvite(token as string);
}

return accountId;
}
const account = await environmentService.createAccount(`${name}'s Organization`);
if (!account) {
throw new NangoError('account_creation_failure');
}
return account.id;
}

async loginCallback(req: Request, res: Response, next: NextFunction) {
try {
const { code, state } = req.query;
bodinsamuel marked this conversation as resolved.
Show resolved Hide resolved

if (!workos) {
errorManager.errRes(res, 'workos_not_configured');
return;
}

const { user: authorizedUser } = await workos.userManagement.authenticateWithCode({
clientId: process.env['WORKOS_CLIENT_ID'] || '',
code: code as string
});

let user: User | null = null;
const existingUser = await userService.getUserByEmail(authorizedUser.email);
bodinsamuel marked this conversation as resolved.
Show resolved Hide resolved

if (existingUser) {
user = existingUser;
} else {
const name =
authorizedUser.firstName || authorizedUser.lastName
? `${authorizedUser.firstName || ''} ${authorizedUser.lastName || ''}`
: authorizedUser.email.split('@')[0];

const accountId = await this.createAccountIfNotInvited(state as string, name as string);

if (!accountId) {
throw new NangoError('account_creation_failure');
}

const createdUser = await userService.createUser(authorizedUser.email, name as string, '', '', accountId);
if (!createdUser) {
throw new NangoError('user_creation_failure');
}
user = createdUser;
}

req.login(user, function (err) {
if (err) {
return next(err);
}
res.redirect(`${getBasePublicUrl()}/`);
});
} catch (err) {
next(err);
}
}
}

export default new AuthController();
12 changes: 7 additions & 5 deletions packages/server/lib/server.ts
Expand Up @@ -55,14 +55,14 @@ const app = express();
// Auth
AuthClient.setup(app);

const apiAuth = [authMiddleware.secretKeyAuth, rateLimiterMiddleware];
const apiPublicAuth = [authMiddleware.publicKeyAuth, rateLimiterMiddleware];
const apiAuth = [authMiddleware.secretKeyAuth.bind(authMiddleware), rateLimiterMiddleware];
const apiPublicAuth = [authMiddleware.publicKeyAuth.bind(authMiddleware), rateLimiterMiddleware];
const webAuth =
isCloud() || isEnterprise()
? [passport.authenticate('session'), authMiddleware.sessionAuth, rateLimiterMiddleware]
? [passport.authenticate('session'), authMiddleware.sessionAuth.bind(authMiddleware), rateLimiterMiddleware]
: isBasicAuthEnabled()
? [passport.authenticate('basic', { session: false }), authMiddleware.basicAuth, rateLimiterMiddleware]
: [authMiddleware.noAuth, rateLimiterMiddleware];
? [passport.authenticate('basic', { session: false }), authMiddleware.basicAuth.bind(authMiddleware), rateLimiterMiddleware]
: [authMiddleware.noAuth.bind(authMiddleware), rateLimiterMiddleware];

app.use(
express.json({
Expand Down Expand Up @@ -149,6 +149,8 @@ if (AUTH_ENABLED) {
app.route('/api/v1/signin').post(rateLimiterMiddleware, passport.authenticate('local'), authController.signin.bind(authController));
app.route('/api/v1/forgot-password').put(rateLimiterMiddleware, authController.forgotPassword.bind(authController));
app.route('/api/v1/reset-password').put(rateLimiterMiddleware, authController.resetPassword.bind(authController));
app.route('/api/v1/hosted/signup').post(rateLimiterMiddleware, authController.getHostedLogin.bind(authController));
app.route('/api/v1/login/callback').get(rateLimiterMiddleware, authController.loginCallback.bind(authController));
Dismissed Show dismissed Hide dismissed
}

// Webapp routes (session auth).
Expand Down
1 change: 1 addition & 0 deletions packages/server/package.json
Expand Up @@ -24,6 +24,7 @@
"dependencies": {
"@hapi/boom": "^10.0.1",
"@nangohq/shared": "^0.39.5",
"@workos-inc/node": "^6.2.0",
"axios": "^1.3.4",
"connect-session-knex": "^3.0.1",
"cookie-parser": "^1.4.6",
Expand Down
82 changes: 82 additions & 0 deletions packages/webapp/src/components/ui/button/Auth/Google.tsx
@@ -0,0 +1,82 @@
interface Props {
Copy link
Contributor

Choose a reason for hiding this comment

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

I was the impression that /ui folder was for Generic reusable component, this one is really a one of. (my pov is that everything is related to ui anyway so it's either an unnecessary hierarchy level or it's specific for one scenario)

text: string;
setServerErrorMessage: (message: string) => void;
invitedAccountID?: number;
token?: string;
}

interface PostBody {
method: string;
headers: {
'Content-Type': string;
};
body?: string;
}

export default function GoogleButton({ text, setServerErrorMessage, invitedAccountID, token }: Props) {
const googleLogin = async () => {
const postBody: PostBody = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
};

if (invitedAccountID && token) {
postBody.body = JSON.stringify({
accountId: invitedAccountID,
token
});
}
const res = await fetch('/api/v1/hosted/signup?provider=GoogleOAuth', postBody);

if (res?.status === 200) {
const data = await res.json();
const { url } = data;
window.location = url;
} else if (res != null) {
const errorMessage = (await res.json()).error;
setServerErrorMessage(errorMessage);
}
};
return (
<button
onClick={googleLogin}
className="flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm w-full text-sm font-medium text-white bg-dark-600 hover:bg-gray-700"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18px" className="inline" viewBox="0 0 512 512">
<path
fill="#fbbd00"
d="M120 256c0-25.367 6.989-49.13 19.131-69.477v-86.308H52.823C18.568 144.703 0 198.922 0 256s18.568 111.297 52.823 155.785h86.308v-86.308C126.989 305.13 120 281.367 120 256z"
data-original="#fbbd00"
/>
<path
fill="#0f9d58"
d="m256 392-60 60 60 60c57.079 0 111.297-18.568 155.785-52.823v-86.216h-86.216C305.044 385.147 281.181 392 256 392z"
data-original="#0f9d58"
/>
<path
fill="#31aa52"
d="m139.131 325.477-86.308 86.308a260.085 260.085 0 0 0 22.158 25.235C123.333 485.371 187.62 512 256 512V392c-49.624 0-93.117-26.72-116.869-66.523z"
data-original="#31aa52"
/>
<path
fill="#3c79e6"
d="M512 256a258.24 258.24 0 0 0-4.192-46.377l-2.251-12.299H256v120h121.452a135.385 135.385 0 0 1-51.884 55.638l86.216 86.216a260.085 260.085 0 0 0 25.235-22.158C485.371 388.667 512 324.38 512 256z"
data-original="#3c79e6"
/>
<path
fill="#cf2d48"
d="m352.167 159.833 10.606 10.606 84.853-84.852-10.606-10.606C388.668 26.629 324.381 0 256 0l-60 60 60 60c36.326 0 70.479 14.146 96.167 39.833z"
data-original="#cf2d48"
/>
<path
fill="#eb4132"
d="M256 120V0C187.62 0 123.333 26.629 74.98 74.98a259.849 259.849 0 0 0-22.158 25.235l86.308 86.308C162.883 146.72 206.376 120 256 120z"
data-original="#eb4132"
/>
</svg>
<span className="ml-4">{text}</span>
</button>
);
}
2 changes: 1 addition & 1 deletion packages/webapp/src/index.css
Expand Up @@ -3,7 +3,7 @@ body,
#root {
width: 100%;
height: 100%;
background-color: #0e1014;
background-color: #010101;
}

@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@300;400;500;600;700&display=swap');
Expand Down
10 changes: 6 additions & 4 deletions packages/webapp/src/layout/DefaultLayout.tsx
Expand Up @@ -4,11 +4,13 @@ interface DefaultLayoutI {

export default function DefaultLayout({ children }: DefaultLayoutI) {
return (
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-md">
<img className="mx-auto h-20 w-auto" src="/logo-dark-background-vertical.svg" alt="Your Company" />
<div className="flex min-h-full flex-col justify-center py-10 sm:px-6 lg:px-8">
<div className="bg-dark-800 border border-[#141417] shadow shadow-zinc-900 p-12 mx-auto px-16">
<div className="">
<img className="mx-auto h-14 w-auto" src="/logo-dark.svg" alt="Nango" />
</div>
{children}
</div>
{children}
</div>
);
}
16 changes: 7 additions & 9 deletions packages/webapp/src/pages/ForgotPassword.tsx
Expand Up @@ -30,23 +30,21 @@ export default function Signin() {
return (
<>
<DefaultLayout>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-bg-dark-gray py-8 px-4 shadow sm:rounded-lg sm:px-10">
<h2 className="mt-2 text-center text-3xl font-semibold tracking-tight text-white">Request Password Reset</h2>
<div className="flex flex-col justify-center">
<div className="w-80">
<h2 className="mt-4 text-center text-[20px] text-white">Request password reset</h2>
<form className="mt-6 space-y-6" onSubmit={handleSubmit}>
<div>
<div>
<label htmlFor="email" className="text-text-light-gray block text-sm font-semibold">
Email
</label>
<div className="mt-1">
<input
id="email"
name="email"
type="email"
placeholder="Email"
autoComplete="email"
required
className="border-border-gray bg-bg-black text-text-light-gray focus:ring-blue block h-11 w-full appearance-none rounded-md border px-3 py-2 text-base placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none"
className="border-border-gray bg-dark-600 placeholder-dark-500 text-text-light-gray block h-11 w-full appearance-none rounded-md border px-3 py-2 text-[14px] placeholder-gray-400 shadow-sm focus:outline-none"
/>
</div>
</div>
Expand All @@ -55,9 +53,9 @@ export default function Signin() {
<div className="grid">
<button
type="submit"
className="border-border-blue bg-bg-dark-blue active:ring-border-blue mt-4 flex h-12 place-self-center rounded-md border px-4 pt-3 text-base font-semibold text-blue-500 shadow-sm hover:border-2 active:ring-2 active:ring-offset-2"
className="bg-white flex h-11 justify-center rounded-md border px-4 pt-3 text-[14px] text-black shadow active:ring-2 active:ring-offset-2"
>
Send Password Reset Email
Send password reset email
</button>
{serverErrorMessage && <p className="mt-6 place-self-center text-sm text-red-600">{serverErrorMessage}</p>}
</div>
Expand Down