Skip to content

Commit 1c106a2

Browse files
authored
fix(auth): handle invitation with managed auth (NangoHQ#2535)
## Describe your changes Fixes https://linear.app/nango/issue/NAN-1479/fix-managed-auth-with-org-should-clear-invitation Fixes https://linear.app/nango/issue/NAN-1478/fix-invitation-to-an-existing-account-doesnt-work-in-managed-auth Fixes https://linear.app/nango/issue/NAN-1477/fix-invitation-with-managed-auth-doesnt-work-anymore After refactoring invitation, one path was broken. When debugging I found other issues. I also took the time to refactor the endpoints too. ### Major Changes - Managed auth endpoint dedicated file Note there was two endpoint to handle getting a URL to use Google wether you had a invitation or not, it's not a single endpoint. - Fix: correctly pass invitation token to Google Auth - Fix: when you invite an account that log with Google, it now correctly accepts invitation - Fix: when you log with Google, it now correctly clear pending invitations if any - Fix: when you invite an account that log with Google that already exists, it now correctly accepts invitation ### Minor changes - `GET /meta` dedicated file I initially wanted to sync feature flag with this endpoint, but it's a call that require a session. In the end I didn't modified the endpoint, except the response object that now doesn't return email (which was already returned by `/user`) - You can now enable or disable auth via process.env - Fix: user serialization was passing `accountId` instead of `account_id`, I messed up in a previous PR ## 🧪 How to test? - Add `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` to your `.env` - Add `FLAG_AUTH_ENABLED=true` `FLAG_MANAGED_AUTH_ENABLED=true` to your `.env` - Log out if you are currently logged in with the default account - Try signin and signup - Delete your account in database - Try inviting this account - Go to `/signup/__TOKEN__` and use this invite with Google auth
1 parent 8f55b7d commit 1c106a2

File tree

22 files changed

+373
-351
lines changed

22 files changed

+373
-351
lines changed

packages/server/lib/clients/auth.client.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@ import { BasicStrategy } from 'passport-http';
44
import express from 'express';
55
import session from 'express-session';
66
import path from 'path';
7-
import { AUTH_ENABLED, isBasicAuthEnabled } from '@nangohq/utils';
7+
import { flagHasAuth, isBasicAuthEnabled } from '@nangohq/utils';
88
import { database } from '@nangohq/database';
99
import { dirname, userService } from '@nangohq/shared';
1010
import crypto from 'crypto';
1111
import util from 'util';
1212
import cookieParser from 'cookie-parser';
1313
import connectSessionKnex from 'connect-session-knex';
14-
import { userToAPI } from '../formatters/user.js';
1514

1615
const KnexSessionStore = connectSessionKnex(session);
1716

@@ -41,7 +40,7 @@ export function setupAuth(app: express.Router) {
4140
app.use(passport.initialize());
4241
app.use(passport.session());
4342

44-
if (AUTH_ENABLED) {
43+
if (flagHasAuth) {
4544
passport.use(
4645
// eslint-disable-next-line @typescript-eslint/no-misused-promises
4746
new LocalStrategy({ usernameField: 'email', passwordField: 'password' }, async function (
@@ -103,9 +102,9 @@ export function setupAuth(app: express.Router) {
103102
);
104103
}
105104

106-
passport.serializeUser(function (user: Express.User, cb) {
105+
passport.serializeUser(function (user: any, cb) {
107106
process.nextTick(function () {
108-
cb(null, userToAPI(user));
107+
cb(null, { id: user.id, email: user.email, name: user.name, account_id: user.account_id } as Express.User);
109108
});
110109
});
111110

Original file line numberDiff line numberDiff line change
@@ -1,61 +1,5 @@
11
import type { Request, Response, NextFunction } from 'express';
22

3-
import { basePublicUrl, getLogger, Err, Ok } from '@nangohq/utils';
4-
import type { Result } from '@nangohq/utils';
5-
import { getWorkOSClient } from '../clients/workos.client.js';
6-
import { userService, accountService, errorManager, NangoError, getInvitation, acceptInvitation } from '@nangohq/shared';
7-
8-
export interface WebUser {
9-
id: number;
10-
accountId: number;
11-
email: string;
12-
name: string;
13-
}
14-
15-
const logger = getLogger('Server.AuthController');
16-
17-
interface InviteAccountBody {
18-
accountId: number;
19-
}
20-
interface InviteAccountState extends InviteAccountBody {
21-
token: string;
22-
}
23-
24-
const allowedProviders = ['GoogleOAuth'];
25-
26-
const parseState = (state: string): Result<InviteAccountState> => {
27-
try {
28-
const parsed = JSON.parse(Buffer.from(state, 'base64').toString('ascii')) as InviteAccountState;
29-
return Ok(parsed);
30-
} catch {
31-
const error = new Error('Invalid state');
32-
return Err(error);
33-
}
34-
};
35-
36-
const createAccountIfNotInvited = async (name: string, state?: string): Promise<number | null> => {
37-
if (!state) {
38-
const account = await accountService.createAccount(`${name}'s Organization`);
39-
if (!account) {
40-
throw new NangoError('account_creation_failure');
41-
}
42-
return account.id;
43-
}
44-
45-
const parsedState: Result<InviteAccountState> = parseState(state);
46-
47-
if (parsedState.isOk()) {
48-
const { accountId, token } = parsedState.value;
49-
const validToken = await getInvitation(token);
50-
if (validToken) {
51-
await acceptInvitation(token);
52-
}
53-
return accountId;
54-
}
55-
56-
return null;
57-
};
58-
593
class AuthController {
604
logout(req: Request, res: Response<any, never>, next: NextFunction) {
615
try {
@@ -70,150 +14,6 @@ class AuthController {
7014
next(err);
7115
}
7216
}
73-
74-
getManagedLogin(req: Request, res: Response<any, never>, next: NextFunction) {
75-
try {
76-
const provider = req.query['provider'] as string;
77-
78-
if (!provider || !allowedProviders.includes(provider)) {
79-
errorManager.errRes(res, 'invalid_provider');
80-
return;
81-
}
82-
83-
const workos = getWorkOSClient();
84-
85-
const oAuthUrl = workos.userManagement.getAuthorizationUrl({
86-
clientId: process.env['WORKOS_CLIENT_ID'] || '',
87-
provider,
88-
redirectUri: `${basePublicUrl}/api/v1/login/callback`
89-
});
90-
91-
res.send({ url: oAuthUrl });
92-
} catch (err) {
93-
next(err);
94-
}
95-
}
96-
97-
getManagedLoginWithInvite(req: Request, res: Response<any, never>, next: NextFunction) {
98-
try {
99-
const workos = getWorkOSClient();
100-
const provider = req.query['provider'] as string;
101-
102-
if (!provider || !allowedProviders.includes(provider)) {
103-
errorManager.errRes(res, 'invalid_provider');
104-
return;
105-
}
106-
107-
const token = req.params['token'] as string;
108-
109-
const body: InviteAccountBody = req.body as InviteAccountBody;
110-
111-
if (!body || body.accountId === undefined) {
112-
errorManager.errRes(res, 'missing_params');
113-
return;
114-
}
115-
116-
if (!provider || !token) {
117-
errorManager.errRes(res, 'missing_params');
118-
return;
119-
}
120-
121-
const accountId = body.accountId;
122-
123-
const inviteParams: InviteAccountState = {
124-
accountId,
125-
token
126-
};
127-
128-
const oAuthUrl = workos.userManagement.getAuthorizationUrl({
129-
clientId: process.env['WORKOS_CLIENT_ID'] || '',
130-
provider,
131-
redirectUri: `${basePublicUrl}/api/v1/login/callback`,
132-
state: Buffer.from(JSON.stringify(inviteParams)).toString('base64')
133-
});
134-
135-
res.send({ url: oAuthUrl });
136-
} catch (err) {
137-
next(err);
138-
}
139-
}
140-
141-
async loginCallback(req: Request, res: Response<any, never>, next: NextFunction) {
142-
try {
143-
const { code, state } = req.query;
144-
145-
const workos = getWorkOSClient();
146-
147-
if (!code) {
148-
const error = new NangoError('missing_managed_login_callback_code');
149-
logger.error(error);
150-
res.redirect(basePublicUrl);
151-
return;
152-
}
153-
154-
const { user: authorizedUser, organizationId } = await workos.userManagement.authenticateWithCode({
155-
clientId: process.env['WORKOS_CLIENT_ID'] || '',
156-
code: code as string
157-
});
158-
159-
const existingUser = await userService.getUserByEmail(authorizedUser.email);
160-
161-
if (existingUser) {
162-
req.login(existingUser, function (err) {
163-
if (err) {
164-
return next(err);
165-
}
166-
res.redirect(`${basePublicUrl}/`);
167-
});
168-
169-
return;
170-
}
171-
172-
const name =
173-
authorizedUser.firstName || authorizedUser.lastName
174-
? `${authorizedUser.firstName || ''} ${authorizedUser.lastName || ''}`
175-
: authorizedUser.email.split('@')[0];
176-
177-
let accountId: number | null = null;
178-
179-
if (organizationId) {
180-
// in this case we have a pre registered organization with workos
181-
// let's make sure it exists in our system
182-
const organization = await workos.organizations.getOrganization(organizationId);
183-
184-
const account = await accountService.getOrCreateAccount(organization.name);
185-
186-
if (!account) {
187-
throw new NangoError('account_creation_failure');
188-
}
189-
accountId = account.id;
190-
} else {
191-
if (!name) {
192-
throw new NangoError('missing_name_for_account_creation');
193-
}
194-
195-
accountId = await createAccountIfNotInvited(name, state as string);
196-
197-
if (!accountId) {
198-
throw new NangoError('account_creation_failure');
199-
}
200-
}
201-
202-
const user = await userService.createUser(authorizedUser.email, name as string, '', '', accountId);
203-
if (!user) {
204-
throw new NangoError('user_creation_failure');
205-
}
206-
207-
req.login(user, function (err) {
208-
if (err) {
209-
return next(err);
210-
}
211-
res.redirect(`${basePublicUrl}/`);
212-
});
213-
} catch (err) {
214-
next(err);
215-
}
216-
}
21717
}
21818

21919
export default new AuthController();

packages/server/lib/controllers/environment.controller.ts

+1-42
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,12 @@ import {
1010
getWebsocketsPath,
1111
getOauthCallbackUrl,
1212
getGlobalWebhookReceiveUrl,
13-
getOnboardingProgress,
14-
userService,
1513
generateSlackConnectionId,
16-
externalWebhookService,
17-
NANGO_VERSION
14+
externalWebhookService
1815
} from '@nangohq/shared';
1916
import { NANGO_ADMIN_UUID } from './account.controller.js';
2017
import type { RequestLocals } from '../utils/express.js';
2118

22-
export interface GetMeta {
23-
environments: Pick<DBEnvironment, 'name'>[];
24-
email: string;
25-
version: string;
26-
baseUrl: string;
27-
debugMode: boolean;
28-
onboardingComplete: boolean;
29-
}
30-
3119
export interface EnvironmentAndAccount {
3220
environment: DBEnvironment;
3321
env_variables: DBEnvironmentVariable[];
@@ -39,35 +27,6 @@ export interface EnvironmentAndAccount {
3927
}
4028

4129
class EnvironmentController {
42-
async meta(req: Request, res: Response<GetMeta, never>, next: NextFunction) {
43-
try {
44-
const sessionUser = req.user;
45-
if (!sessionUser) {
46-
errorManager.errRes(res, 'user_not_found');
47-
return;
48-
}
49-
50-
const user = await userService.getUserById(sessionUser.id);
51-
if (!user) {
52-
errorManager.errRes(res, 'user_not_found');
53-
return;
54-
}
55-
56-
const environments = await environmentService.getEnvironmentsByAccountId(user.account_id);
57-
const onboarding = await getOnboardingProgress(sessionUser.id);
58-
res.status(200).send({
59-
environments,
60-
version: NANGO_VERSION,
61-
email: sessionUser.email,
62-
baseUrl,
63-
debugMode: req.session.debugMode === true,
64-
onboardingComplete: onboarding?.complete || false
65-
});
66-
} catch (err) {
67-
next(err);
68-
}
69-
}
70-
7130
async getEnvironment(_: Request, res: Response<any, Required<RequestLocals>>, next: NextFunction) {
7231
try {
7332
const { environment, account, user } = res.locals;

0 commit comments

Comments
 (0)