Skip to content

Commit fec5fc6

Browse files
committed
server: move Firebase auth to server-side endpoints
Migrate Firebase authentication from client-side SDK to server-side endpoints that call the Firebase REST API. This reduces client bundle size by removing the Firebase SDK dependencies (~80-200KB) and enables future migration to alternative auth backends. The implementation includes: - Email/password auth endpoints (/auth/login, /auth/signup, etc.) - OAuth handlers for Google and Apple Sign-In with full server-side token exchange - Firestore-backed OAuth state storage for CSRF protection - Full cryptographic verification of Apple ID tokens using JWKS - Return URL validation to prevent open redirect attacks - User protobuf schema extended with providerUserId for stable OAuth provider identity Client code now uses simple redirects for OAuth and fetch calls for email/password auth, with all Firebase SDK code removed.
1 parent 007d3fb commit fec5fc6

32 files changed

Lines changed: 5179 additions & 291 deletions

config/default.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
"clientID": "",
2626
"clientSecret": ""
2727
},
28+
"apple": {
29+
"clientID": "",
30+
"teamID": "",
31+
"keyID": "",
32+
"privateKey": ""
33+
},
2834
"path": "/authn",
2935
"service": "users"
3036
}

eslint.config.shared.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const baseConfig = {
2323
require: 'readonly',
2424
global: 'readonly',
2525
URL: 'readonly',
26+
URLSearchParams: 'readonly',
2627
// Browser globals
2728
window: 'readonly',
2829
document: 'readonly',

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/App.tsx

Lines changed: 4 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,6 @@
44

55
import * as React from 'react';
66

7-
import { initializeApp } from '@firebase/app';
8-
import {
9-
getAuth,
10-
connectAuthEmulator,
11-
onAuthStateChanged,
12-
Auth as FirebaseAuth,
13-
User as FirebaseUser,
14-
} from '@firebase/auth';
15-
167
import { useLocation, Route, RouteComponentProps, Switch, Redirect } from 'wouter';
178

189
import { defined } from '@simlin/core/common';
@@ -25,12 +16,6 @@ import { User } from './User';
2516

2617
import styles from './App.module.css';
2718

28-
const config = {
29-
apiKey: 'AIzaSyConH72HQl9xOtjmYJO9o2kQ9nZZzl96G8',
30-
authDomain: 'auth.simlin.com',
31-
};
32-
const firebaseApp = initializeApp(config);
33-
3419
interface EditorMatchParams {
3520
username: string;
3621
projectName: string;
@@ -42,8 +27,8 @@ class UserInfoSingleton {
4227
private resultPromise?: Promise<[User | undefined, number]>;
4328
private result?: [User | undefined, number];
4429
constructor() {
45-
// store this promise; we might race calling get() below, but all racers will
46-
// await this single fetch result.
30+
// Store this promise; we might race calling get() below, but all racers
31+
// will await this single fetch result.
4732
this.fetch();
4833
}
4934

@@ -75,7 +60,7 @@ class UserInfoSingleton {
7560
this.fetch();
7661

7762
if (resultPromise) {
78-
// don't leave the promise un-awaited
63+
// Don't leave the in-flight promise un-awaited
7964
await resultPromise;
8065
}
8166
}
@@ -87,8 +72,6 @@ interface AppState {
8772
authUnknown: boolean;
8873
isNewUser?: boolean;
8974
user?: User;
90-
auth: FirebaseAuth;
91-
firebaseIdToken?: string | null;
9275
}
9376

9477
class InnerApp extends React.PureComponent<{}, AppState> {
@@ -97,91 +80,19 @@ class InnerApp extends React.PureComponent<{}, AppState> {
9780
constructor(props: {}) {
9881
super(props);
9982

100-
const isDevServer = process.env.NODE_ENV === 'development';
101-
const auth = getAuth(firebaseApp);
102-
if (isDevServer) {
103-
connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true });
104-
}
105-
10683
this.state = {
10784
authUnknown: true,
108-
auth,
10985
};
11086

111-
// notify our app when a user logs in
112-
onAuthStateChanged(auth, this.authStateChanged);
113-
11487
setTimeout(this.getUserInfo);
11588
}
11689

117-
authStateChanged = (user: FirebaseUser | null) => {
118-
setTimeout(this.asyncAuthStateChanged, undefined, user);
119-
};
120-
121-
asyncAuthStateChanged = async (user: FirebaseUser | null) => {
122-
if (!user) {
123-
this.setState({ firebaseIdToken: null });
124-
return;
125-
}
126-
127-
const firebaseIdToken = await user.getIdToken();
128-
this.setState({ firebaseIdToken });
129-
await this.maybeLogin(undefined, firebaseIdToken);
130-
};
131-
132-
async maybeLogin(authIsKnown = false, firebaseIdToken?: string): Promise<void> {
133-
authIsKnown = authIsKnown || !this.state.authUnknown;
134-
if (!authIsKnown) {
135-
return;
136-
}
137-
138-
// if we know the user, we don't need to log in
139-
const [user] = await userInfo.get();
140-
if (user) {
141-
return;
142-
}
143-
144-
const idToken = firebaseIdToken ?? this.state.firebaseIdToken;
145-
if (idToken === null || idToken === undefined) {
146-
return;
147-
}
148-
149-
const bodyContents = {
150-
idToken,
151-
};
152-
153-
const base = this.getBaseURL();
154-
const apiPath = `${base}/session`;
155-
const response = await fetch(apiPath, {
156-
credentials: 'same-origin',
157-
method: 'POST',
158-
cache: 'no-cache',
159-
headers: {
160-
'Content-Type': 'application/json',
161-
},
162-
body: JSON.stringify(bodyContents),
163-
});
164-
165-
const status = response.status;
166-
if (!(status >= 200 && status < 400)) {
167-
const body = await response.json();
168-
const errorMsg =
169-
body && body.error ? (body.error as string) : `HTTP ${status}; maybe try a different username ¯\\_(ツ)_/¯`;
170-
// this.appendModelError(errorMsg);
171-
console.log(`session error: ${errorMsg}`);
172-
return undefined;
173-
}
174-
175-
this.handleUsernameChanged();
176-
}
177-
17890
getUserInfo = async (): Promise<void> => {
17991
const [user, status] = await userInfo.get();
18092
if (!(status >= 200 && status < 400) || !user) {
18193
this.setState({
18294
authUnknown: false,
18395
});
184-
await this.maybeLogin(true);
18596
return;
18697
}
18798
const isNewUser = user.id.startsWith(`temp-`);
@@ -230,11 +141,9 @@ class InnerApp extends React.PureComponent<{}, AppState> {
230141
const projectParam = urlParams.get('project');
231142
if (projectParam) return <Redirect to={projectParam} />;
232143

233-
// if a user is navigating to a project,
234-
// skip the high level auth check, to enable public models
235144
if (!/\/.*\/.*/.test(window.location.pathname)) {
236145
if (!this.state.user) {
237-
return <Login disabled={this.state.authUnknown} auth={this.state.auth} />;
146+
return <Login disabled={this.state.authUnknown} onLoginSuccess={this.handleUsernameChanged} />;
238147
}
239148

240149
if (this.state.isNewUser) {

0 commit comments

Comments
 (0)