Skip to content

Commit

Permalink
Merge pull request #1128 from jetstreamapp/bug/1076-remember-device
Browse files Browse the repository at this point in the history
Loosen remember device restrictions
  • Loading branch information
paustint authored Dec 31, 2024
2 parents e4b6b1c + 3aa6e05 commit 043c9fb
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 168 deletions.
316 changes: 160 additions & 156 deletions apps/api/src/app/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,182 +320,186 @@ const signin = createRoute(routeDefinition.signin.validators, async ({ body, par
/**
* FIXME: This should probably be broken up and logic moved to the auth service
*/
const callback = createRoute(routeDefinition.callback.validators, async ({ body, params, query, clearCookie }, req, res, next) => {
let provider: Provider | null = null;
try {
const providers = listProviders();
const callback = createRoute(
routeDefinition.callback.validators,
async ({ body, params, query, setCookie, clearCookie }, req, res, next) => {
let provider: Provider | null = null;
try {
const providers = listProviders();

provider = providers[params.provider];
if (!provider) {
throw new InvalidParameters('Missing provider');
}
provider = providers[params.provider];
if (!provider) {
throw new InvalidParameters('Missing provider');
}

let isNewUser = false;
const {
pkceCodeVerifier,
nonce,
linkIdentity: linkIdentityCookie,
returnUrl,
rememberDevice,
} = getCookieConfig(ENV.USE_SECURE_COOKIES);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const cookies = parseCookie(req.headers.cookie!);
clearOauthCookies(res);

let isNewUser = false;
const {
pkceCodeVerifier,
nonce,
linkIdentity: linkIdentityCookie,
returnUrl,
rememberDevice,
} = getCookieConfig(ENV.USE_SECURE_COOKIES);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const cookies = parseCookie(req.headers.cookie!);
clearOauthCookies(res);
if (provider.type === 'oauth') {
// oauth flow
const { userInfo } = await validateCallback(
provider.provider as OauthProviderType,
new URLSearchParams(query),
cookies[pkceCodeVerifier.name],
cookies[nonce.name]
);

if (!userInfo.email) {
throw new InvalidParameters('Missing email from OAuth provider');
}

if (provider.type === 'oauth') {
// oauth flow
const { userInfo } = await validateCallback(
provider.provider as OauthProviderType,
new URLSearchParams(query),
cookies[pkceCodeVerifier.name],
cookies[nonce.name]
);

if (!userInfo.email) {
throw new InvalidParameters('Missing email from OAuth provider');
}
const providerUser = {
id: userInfo.sub,
email: userInfo.email,
emailVerified: userInfo.email_verified ?? false,
givenName: userInfo.given_name,
familyName: userInfo.family_name,
username: userInfo.preferred_username || (userInfo.username as string | undefined) || userInfo.email,
name:
userInfo.name ??
(userInfo.given_name && userInfo.family_name ? `${userInfo.given_name} ${userInfo.family_name}` : userInfo.email),
picture: (userInfo.picture_thumbnail as string | undefined) || userInfo.picture,
};

// If user has an active session and user is linking an identity to an existing account
// link and redirect to profile page
if (req.session.user && cookies[linkIdentityCookie.name] === 'true') {
await linkIdentityToUser({
userId: req.session.user.id,
provider: provider.provider,
providerUser,
});
createUserActivityFromReq(req, res, {
action: 'LINK_IDENTITY',
method: provider.provider.toUpperCase(),
success: true,
});
redirect(res, cookies[returnUrl.name] || `${ENV.JETSTREAM_CLIENT_URL}/profile`);
return;
}

const providerUser = {
id: userInfo.sub,
email: userInfo.email,
emailVerified: userInfo.email_verified ?? false,
givenName: userInfo.given_name,
familyName: userInfo.family_name,
username: userInfo.preferred_username || (userInfo.username as string | undefined) || userInfo.email,
name:
userInfo.name ??
(userInfo.given_name && userInfo.family_name ? `${userInfo.given_name} ${userInfo.family_name}` : userInfo.email),
picture: (userInfo.picture_thumbnail as string | undefined) || userInfo.picture,
};

// If user has an active session and user is linking an identity to an existing account
// link and redirect to profile page
if (req.session.user && cookies[linkIdentityCookie.name] === 'true') {
await linkIdentityToUser({
userId: req.session.user.id,
const sessionData = await handleSignInOrRegistration({
providerType: provider.type,
provider: provider.provider,
providerUser,
});
createUserActivityFromReq(req, res, {
action: 'LINK_IDENTITY',
method: provider.provider.toUpperCase(),
success: true,
});
redirect(res, cookies[returnUrl.name] || `${ENV.JETSTREAM_CLIENT_URL}/profile`);
return;
}

const sessionData = await handleSignInOrRegistration({
providerType: provider.type,
provider: provider.provider,
providerUser,
});
isNewUser = sessionData.isNewUser;
isNewUser = sessionData.isNewUser;

initSession(req, sessionData);
} else if (provider.type === 'credentials' && req.method === 'POST') {
if (!body || !('action' in body)) {
throw new InvalidAction('Missing action in body');
initSession(req, sessionData);
} else if (provider.type === 'credentials' && req.method === 'POST') {
if (!body || !('action' in body)) {
throw new InvalidAction('Missing action in body');
}
const { action, csrfToken, email, password } = body;
await verifyCSRFFromRequestOrThrow(csrfToken, req.headers.cookie || '');

const sessionData =
action === 'login'
? await handleSignInOrRegistration({
providerType: 'credentials',
action,
email,
password,
})
: await handleSignInOrRegistration({
providerType: 'credentials',
action,
email,
name: body.name,
password,
});

isNewUser = sessionData.isNewUser;

initSession(req, sessionData);
} else {
throw new InvalidProvider(`Provider type ${provider.type} is not supported. Method=${req.method}`);
}
const { action, csrfToken, email, password } = body;
await verifyCSRFFromRequestOrThrow(csrfToken, req.headers.cookie || '');

const sessionData =
action === 'login'
? await handleSignInOrRegistration({
providerType: 'credentials',
action,
email,
password,
})
: await handleSignInOrRegistration({
providerType: 'credentials',
action,
email,
name: body.name,
password,
});

isNewUser = sessionData.isNewUser;

initSession(req, sessionData);
} else {
throw new InvalidProvider(`Provider type ${provider.type} is not supported. Method=${req.method}`);
}

if (!req.session.user) {
throw new AuthError('Session not initialized');
}
if (!req.session.user) {
throw new AuthError('Session not initialized');
}

// check for remembered device - emailVerification cannot be bypassed
if (
cookies[rememberDevice.name] &&
Array.isArray(req.session.pendingVerification) &&
req.session.pendingVerification.length > 0 &&
req.session.pendingVerification.find((item) => item.type !== 'email')
) {
const deviceId = cookies[rememberDevice.name];
const isDeviceRemembered = await hasRememberDeviceRecord({
userId: req.session.user.id,
deviceId,
ipAddress: res.locals.ipAddress || getApiAddressFromReq(req),
userAgent: req.get('User-Agent'),
});
if (isDeviceRemembered) {
req.session.pendingVerification = null;
} else {
// deviceId is not valid, remove cookie
clearCookie(rememberDevice.name, rememberDevice.options);
// check for remembered device - emailVerification cannot be bypassed
if (
cookies[rememberDevice.name] &&
Array.isArray(req.session.pendingVerification) &&
req.session.pendingVerification.length > 0 &&
req.session.pendingVerification.find((item) => item.type !== 'email')
) {
const deviceId = cookies[rememberDevice.name];
const isDeviceRemembered = await hasRememberDeviceRecord({
userId: req.session.user.id,
deviceId,
userAgent: req.get('User-Agent'),
});
if (isDeviceRemembered) {
req.session.pendingVerification = null;
// refresh cookie expiration
setCookie(rememberDevice.name, deviceId, rememberDevice.options);
} else {
// deviceId is not valid, remove cookie
clearCookie(rememberDevice.name, rememberDevice.options);
}
}
}

if (Array.isArray(req.session.pendingVerification) && req.session.pendingVerification.length > 0) {
const initialVerification = req.session.pendingVerification[0];
if (Array.isArray(req.session.pendingVerification) && req.session.pendingVerification.length > 0) {
const initialVerification = req.session.pendingVerification[0];

if (initialVerification.type === 'email') {
await sendEmailVerification(req.session.user.email, initialVerification.token, EMAIL_VERIFICATION_TOKEN_DURATION_HOURS);
} else if (initialVerification.type === '2fa-email') {
await sendVerificationCode(req.session.user.email, initialVerification.token, TOKEN_DURATION_MINUTES);
}
if (initialVerification.type === 'email') {
await sendEmailVerification(req.session.user.email, initialVerification.token, EMAIL_VERIFICATION_TOKEN_DURATION_HOURS);
} else if (initialVerification.type === '2fa-email') {
await sendVerificationCode(req.session.user.email, initialVerification.token, TOKEN_DURATION_MINUTES);
}

await setCsrfCookie(res);
await setCsrfCookie(res);

if (provider.type === 'oauth') {
redirect(res, `/auth/verify`);
} else {
sendJson(res, { error: false, redirect: `/auth/verify` });
}
} else {
if (isNewUser) {
await sendWelcomeEmail(req.session.user.email);
}
// No verification required
if (provider.type === 'oauth') {
redirect(res, ENV.JETSTREAM_CLIENT_URL);
if (provider.type === 'oauth') {
redirect(res, `/auth/verify`);
} else {
sendJson(res, { error: false, redirect: `/auth/verify` });
}
} else {
// this was an API call, client will handle redirect
sendJson(res, {
error: false,
redirect: ENV.JETSTREAM_CLIENT_URL,
});
if (isNewUser) {
await sendWelcomeEmail(req.session.user.email);
}
// No verification required
if (provider.type === 'oauth') {
redirect(res, ENV.JETSTREAM_CLIENT_URL);
} else {
// this was an API call, client will handle redirect
sendJson(res, {
error: false,
redirect: ENV.JETSTREAM_CLIENT_URL,
});
}
}
}

createUserActivityFromReq(req, res, {
action: 'LOGIN',
method: provider.provider.toUpperCase(),
success: true,
});
} catch (ex) {
createUserActivityFromReqWithError(req, res, ex, {
action: 'LOGIN',
email: body && 'email' in body ? body.email : undefined,
method: provider?.provider?.toUpperCase(),
success: false,
});
next(ensureAuthError(ex));
createUserActivityFromReq(req, res, {
action: 'LOGIN',
method: provider.provider.toUpperCase(),
success: true,
});
} catch (ex) {
createUserActivityFromReqWithError(req, res, ex, {
action: 'LOGIN',
email: body && 'email' in body ? body.email : undefined,
method: provider?.provider?.toUpperCase(),
success: false,
});
next(ensureAuthError(ex));
}
}
});
);

const verification = createRoute(routeDefinition.verification.validators, async ({ body, user, setCookie }, req, res, next) => {
try {
Expand Down
3 changes: 0 additions & 3 deletions apps/api/src/app/routes/route.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,6 @@ export async function checkAuth(req: express.Request, res: express.Response, nex
}
}

// TODO: consider adding a check for IP address - but should allow some buffer in case people change networks
// especially if the ip addresses are very far away

if (user && !pendingVerification) {
telemetryAddUserToAttributes(user);
return next();
Expand Down
2 changes: 1 addition & 1 deletion apps/landing/components/auth/VerifyEmailOr2fa.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const FormSchema = z.object({
csrfToken: z.string(),
code: z.string().min(6).max(6),
type: z.enum(['email', '2fa-otp', '2fa-email']),
rememberDevice: z.boolean().optional().default(false),
rememberDevice: z.boolean().optional().default(true),
});

function getTitleText(authFactor: TwoFactorType, email?: Maybe<string>) {
Expand Down
Loading

0 comments on commit 043c9fb

Please sign in to comment.