Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
127 changes: 99 additions & 28 deletions meteor-backend/server/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,12 @@ WebApp.connectHandlers.use('/api/whoami', proxyWhoamiHandler);
// fail with "not connected to the internet"). An HTML page that performs a
// page-level navigation to the deep link — plus a tap-link fallback — is
// honored consistently across iOS and Android WebViews.
function sendNativeAuthRedirect(res, token, resumeToken) {
const deepLink =
`timehuddle://auth?meteor_token=${encodeURIComponent(token)}&` +
`meteor_resume=${encodeURIComponent(resumeToken)}`;
function sendNativeAuthRedirect(res, token, resumeToken, extraParams = {}) {
const params = new URLSearchParams({ meteor_token: token, meteor_resume: resumeToken });
for (const [key, value] of Object.entries(extraParams)) {
if (value) params.set(key, value);
}
const deepLink = `timehuddle://auth?${params.toString()}`;
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(
`<!DOCTYPE html><html><head><meta charset="utf-8">` +
Expand All @@ -295,6 +297,32 @@ function sendNativeAuthRedirect(res, token, resumeToken) {
);
}

// OAuth `state` round-trips through the third-party IdP unmodified, so it's
// the only place to smuggle context (native flag + pending team/org join
// links) through the redirect-away-and-back flow. Encoded as base64url JSON.
function encodeOAuthState({ isNative, join, invite, orgInvite } = {}) {
const payload = { n: !!isNative, r: Random.secret() };
if (join) payload.j = join;
if (invite) payload.i = invite;
if (orgInvite) payload.o = orgInvite;
return Buffer.from(JSON.stringify(payload)).toString('base64url');
}

function decodeOAuthState(state) {
try {
const payload = JSON.parse(Buffer.from(state || '', 'base64url').toString('utf8'));
return {
isNative: !!payload.n,
join: payload.j || null,
invite: payload.i || null,
orgInvite: payload.o || null,
};
} catch {
// Legacy format (plain 'native_' prefix) — no join/invite context available.
return { isNative: state?.startsWith('native_') ?? false, join: null, invite: null, orgInvite: null };
}
}

// ============================================================================
// GitHub OAuth Endpoints
// ============================================================================
Expand All @@ -304,8 +332,14 @@ WebApp.connectHandlers.use('/auth/github', (req, res, next) => {
// Strip the query string so ?native=1 doesn't break the path match.
const pathname = req.url.split('?')[0];
if (pathname !== '/' && pathname !== '') { next(); return; }
const isNative = new URL(req.url, process.env.ROOT_URL).searchParams.get('native') === '1';
const credentialToken = (isNative ? 'native_' : '') + Random.secret();
const reqParams = new URL(req.url, process.env.ROOT_URL).searchParams;
const isNative = reqParams.get('native') === '1';
const credentialToken = encodeOAuthState({
isNative,
join: reqParams.get('join'),
invite: reqParams.get('invite'),
orgInvite: reqParams.get('org_invite'),
});
const callbackUrl = `${process.env.ROOT_URL}/auth/github/callback`;

console.log('[github-oauth] Initiating OAuth flow, native=' + isNative);
Expand Down Expand Up @@ -413,17 +447,25 @@ WebApp.connectHandlers.use('/auth/github/callback', async (req, res) => {
.sign(secret);

// Redirect back — native apps get a deep link, browsers get the frontend URL
const isNative = state?.startsWith('native_');
const { isNative, join, invite, orgInvite } = decodeOAuthState(state);
if (isNative) {
sendNativeAuthRedirect(res, token, stampedToken.token);
sendNativeAuthRedirect(res, token, stampedToken.token, {
join,
invite,
org_invite: orgInvite,
});
} else {
const frontendUrl =
process.env.CORS_ORIGINS?.split(',')[0] || 'http://localhost:3000';
const redirectParams = new URLSearchParams({
meteor_token: token,
meteor_resume: stampedToken.token,
});
if (join) redirectParams.set('join', join);
if (invite) redirectParams.set('invite', invite);
if (orgInvite) redirectParams.set('org_invite', orgInvite);
res.writeHead(302, {
Location:
`${frontendUrl}/app/dashboard` +
`?meteor_token=${token}&` +
`meteor_resume=${stampedToken.token}`,
Location: `${frontendUrl}/app/dashboard?${redirectParams.toString()}`,
});
res.end();
}
Expand All @@ -443,8 +485,14 @@ WebApp.connectHandlers.use('/auth/google', (req, res, next) => {
// Strip the query string so ?native=1 doesn't break the path match.
const pathname = req.url.split('?')[0];
if (pathname !== '/' && pathname !== '') { next(); return; }
const isNative = new URL(req.url, process.env.ROOT_URL).searchParams.get('native') === '1';
const state = (isNative ? 'native_' : '') + Random.secret();
const reqParams = new URL(req.url, process.env.ROOT_URL).searchParams;
const isNative = reqParams.get('native') === '1';
const state = encodeOAuthState({
isNative,
join: reqParams.get('join'),
invite: reqParams.get('invite'),
orgInvite: reqParams.get('org_invite'),
});
const callbackUrl =
`${process.env.ROOT_URL}/auth/google/callback`

Expand Down Expand Up @@ -472,7 +520,7 @@ WebApp.connectHandlers.use('/auth/google/callback',
new URL(req.url, process.env.ROOT_URL).searchParams
)
const { code } = callbackParams
const isNative = callbackParams.state?.startsWith('native_')
const { isNative, join, invite, orgInvite } = decodeOAuthState(callbackParams.state)

if (!code) {
res.writeHead(400)
Expand Down Expand Up @@ -556,16 +604,24 @@ WebApp.connectHandlers.use('/auth/google/callback',

// Redirect back — native apps get a deep link, browsers get the frontend URL
if (isNative) {
sendNativeAuthRedirect(res, token, stampedToken.token)
sendNativeAuthRedirect(res, token, stampedToken.token, {
join,
invite,
org_invite: orgInvite,
})
} else {
const frontendUrl =
process.env.CORS_ORIGINS?.split(',')[0] ||
'http://localhost:3000'
const redirectParams = new URLSearchParams({
meteor_token: token,
meteor_resume: stampedToken.token,
})
if (join) redirectParams.set('join', join)
if (invite) redirectParams.set('invite', invite)
if (orgInvite) redirectParams.set('org_invite', orgInvite)
res.writeHead(302, {
Location:
`${frontendUrl}/app/dashboard` +
`?meteor_token=${token}&` +
`meteor_resume=${stampedToken.token}`
Location: `${frontendUrl}/app/dashboard?${redirectParams.toString()}`
})
res.end()
}
Expand All @@ -587,8 +643,14 @@ WebApp.connectHandlers.use('/auth/apple', (req, res, next) => {
// Strip the query string so ?native=1 doesn't break the path match.
const pathname = req.url.split('?')[0];
if (pathname !== '/' && pathname !== '') { next(); return; }
const isNative = new URL(req.url, process.env.ROOT_URL).searchParams.get('native') === '1';
const state = (isNative ? 'native_' : '') + Random.secret();
const reqParams = new URL(req.url, process.env.ROOT_URL).searchParams;
const isNative = reqParams.get('native') === '1';
const state = encodeOAuthState({
isNative,
join: reqParams.get('join'),
invite: reqParams.get('invite'),
orgInvite: reqParams.get('org_invite'),
});
const callbackUrl =
`${process.env.ROOT_URL}/auth/apple/callback`

Expand Down Expand Up @@ -617,7 +679,7 @@ WebApp.connectHandlers.use('/auth/apple/callback',
const code = params.get('code')
const idToken = params.get('id_token')
const userParam = params.get('user')
const isNative = params.get('state')?.startsWith('native_') ?? false
const { isNative, join, invite, orgInvite } = decodeOAuthState(params.get('state'))

if (!code && !idToken) {
res.writeHead(400)
Expand Down Expand Up @@ -693,16 +755,25 @@ WebApp.connectHandlers.use('/auth/apple/callback',

// Redirect back — native apps get a deep link, browsers get the frontend URL.
if (isNative) {
sendNativeAuthRedirect(res, token, stampedToken.token);
sendNativeAuthRedirect(res, token, stampedToken.token, {
join,
invite,
org_invite: orgInvite,
});
} else {
const frontendUrl =
process.env.CORS_ORIGINS?.split(',')[0] ||
'http://localhost:3000'

const redirectUrl =
`${frontendUrl}/app/dashboard` +
`?meteor_token=${token}&` +
`meteor_resume=${stampedToken.token}`
const redirectParams = new URLSearchParams({
meteor_token: token,
meteor_resume: stampedToken.token,
})
if (join) redirectParams.set('join', join)
if (invite) redirectParams.set('invite', invite)
if (orgInvite) redirectParams.set('org_invite', orgInvite)

const redirectUrl = `${frontendUrl}/app/dashboard?${redirectParams.toString()}`

// Use HTML redirect since Apple uses POST
res.writeHead(200, {
Expand Down
52 changes: 50 additions & 2 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ _log(`window.Capacitor=${JSON.stringify(Object.keys((window as any).Capacitor ||

import { InboxPage } from './features/inbox/InboxPage';
import { enterpriseApi } from './lib/api';
import { subscribeNewNotifications } from './lib/ddp';
import { getDdpClient, subscribeNewNotifications } from './lib/ddp';
import { MESSAGES_PENDING_THREAD_KEY } from './lib/constants';
import { autoRegisterPush, checkPushNotificationStatus } from './lib/nativePush';
import { SessionProvider, useSession } from './lib/useSession';
Expand All @@ -70,6 +70,12 @@ import { UsernameClaimModal } from './ui/UsernameClaimModal';

let _deepLinkToken: string | null = null;

// Pending team/org join context from an OAuth deep link (?join=/?invite=/
// ?org_invite= carried through the native redirect-away-and-back flow, since
// the URL bar isn't available to read query params from on native). Applied
// once the App component sees an authenticated session.
let _pendingOAuthJoin: { join?: string; invite?: string; orgInvite?: string } | null = null;

if (Capacitor.isNativePlatform()) {
void CapApp.addListener('appUrlOpen', ({ url }) => {
try {
Expand All @@ -78,6 +84,16 @@ if (Capacitor.isNativePlatform()) {
// OAuth callback: timehuddle://auth?meteor_token=...&meteor_resume=...
if (parsed.host === 'auth') {
const meteorResume = parsed.searchParams.get('meteor_resume');
const join = parsed.searchParams.get('join');
const invite = parsed.searchParams.get('invite');
const orgInvite = parsed.searchParams.get('org_invite');
if (join || invite || orgInvite) {
_pendingOAuthJoin = {
join: join ?? undefined,
invite: invite ?? undefined,
orgInvite: orgInvite ?? undefined,
};
}
// Close the in-app browser and return to the app.
void Browser.close();
if (meteorResume) {
Expand Down Expand Up @@ -124,7 +140,7 @@ if (Capacitor.isNativePlatform()) {
_log('App component defined — modules loaded');

const App: React.FC = () => {
const { user, loading, needsUsernameClaim } = useSession();
const { user, loading, needsUsernameClaim, refetch } = useSession();
const [ownershipChecked, setOwnershipChecked] = React.useState(false);
const [showTakeOwnershipModal, setShowTakeOwnershipModal] = React.useState(false);

Expand All @@ -133,6 +149,38 @@ const App: React.FC = () => {
if (user) void autoRegisterPush(user.id);
}, [user]);

// Apply a pending team/org join from a social sign-in (?join=/?invite=/
// ?org_invite=). Social sign-in redirects away to the IdP and back, so it
// never runs LoginForm's password-flow `acceptInvitation()` — this is the
// equivalent for OAuth, reading the params from the URL (web) or the
// `timehuddle://auth` deep link (native).
React.useEffect(() => {
if (!user) return;
const params = new URLSearchParams(window.location.search);
const join = params.get('join') ?? _pendingOAuthJoin?.join ?? null;
const invite = params.get('invite') ?? _pendingOAuthJoin?.invite ?? null;
const orgInvite = params.get('org_invite') ?? _pendingOAuthJoin?.orgInvite ?? null;
if (!join && !invite && !orgInvite) return;
_pendingOAuthJoin = null;

void (async () => {
const ddp = getDdpClient();
try {
if (invite) await ddp.acceptTeamInvitation(invite);
if (orgInvite) await ddp.acceptOrgInvitation(orgInvite);
if (join) await ddp.joinTeamByQrCode(join);
} catch (err) {
console.error('[oauth] pending team/org join failed:', err);
}
const url = new URL(window.location.href);
url.searchParams.delete('join');
url.searchParams.delete('invite');
url.searchParams.delete('org_invite');
window.history.replaceState(null, '', url.toString());
await refetch();
})();
}, [user, refetch]);

// SSE fallback: show a browser Notification for every incoming SSE event.
// This fires even when FCM/VAPID push delivery is unreliable (e.g. localhost).
// Skipped on native Capacitor (APNs handles it), when permission not granted,
Expand Down
21 changes: 18 additions & 3 deletions src/ui/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -382,20 +382,35 @@ export const LoginForm: React.FC<LoginFormProps> = ({ initialMode }) => {
setSocialLoadingId(provider.id);
setSocialError(null);
try {
// Pending team/org join context (from ?join=/?invite=/?org_invite= links)
// must survive the full redirect-away-and-back OAuth round trip, since
// sign-in via social providers never runs the password-flow
// `acceptInvitation()` logic below.
const pendingJoinParams: Record<string, string> = {};
if (joinTeamCode) pendingJoinParams.join = joinTeamCode;
if (invitationToken) pendingJoinParams.invite = invitationToken;
if (orgInvitationToken) pendingJoinParams.org_invite = orgInvitationToken;

if (provider.kind === 'meteor-oauth') {
const oauthUrl = `${METEOR_BASE_URL}${provider.meteorPath}`;
const oauthParams = new URLSearchParams(pendingJoinParams);
if (Capacitor.isNativePlatform()) oauthParams.set('native', '1');
const oauthUrl = `${METEOR_BASE_URL}${provider.meteorPath}?${oauthParams.toString()}`;
if (Capacitor.isNativePlatform()) {
// On native, open in an in-app browser with ?native=1 so the backend
// redirects to the timehuddle://auth deep link instead of the web URL.
await Browser.open({ url: `${oauthUrl}?native=1`, presentationStyle: 'popover' });
await Browser.open({ url: oauthUrl, presentationStyle: 'popover' });
} else {
window.location.href = oauthUrl;
}
return;
}

// Existing oauth2 and social handling
const callbackURL = `${window.location.origin}/app/dashboard`;
const callbackUrl = new URL(`${window.location.origin}/app/dashboard`);
for (const [key, value] of Object.entries(pendingJoinParams)) {
callbackUrl.searchParams.set(key, value);
}
const callbackURL = callbackUrl.toString();
const url =
provider.kind === 'oauth2'
? await authApi.signInWithOAuth2(provider.id, callbackURL)
Expand Down
Loading