diff --git a/config/default.json b/config/default.json index 191d4407d..20bf25c7e 100644 --- a/config/default.json +++ b/config/default.json @@ -25,6 +25,12 @@ "clientID": "", "clientSecret": "" }, + "apple": { + "clientID": "", + "teamID": "", + "keyID": "", + "privateKey": "" + }, "path": "/authn", "service": "users" } diff --git a/eslint.config.shared.js b/eslint.config.shared.js index 8829fd82e..0a6d10491 100644 --- a/eslint.config.shared.js +++ b/eslint.config.shared.js @@ -23,6 +23,7 @@ const baseConfig = { require: 'readonly', global: 'readonly', URL: 'readonly', + URLSearchParams: 'readonly', // Browser globals window: 'readonly', document: 'readonly', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e8be0cf9..bec22d73b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,9 @@ importers: helmet: specifier: ^8.1.0 version: 8.1.0 + jose: + specifier: ^4.15.9 + version: 4.15.9 node-fetch: specifier: ^3.3.2 version: 3.3.2 diff --git a/src/app/App.tsx b/src/app/App.tsx index 6e16c75b8..933a32195 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4,15 +4,6 @@ import * as React from 'react'; -import { initializeApp } from '@firebase/app'; -import { - getAuth, - connectAuthEmulator, - onAuthStateChanged, - Auth as FirebaseAuth, - User as FirebaseUser, -} from '@firebase/auth'; - import { useLocation, Route, RouteComponentProps, Switch, Redirect } from 'wouter'; import { defined } from '@simlin/core/common'; @@ -25,12 +16,6 @@ import { User } from './User'; import styles from './App.module.css'; -const config = { - apiKey: 'AIzaSyConH72HQl9xOtjmYJO9o2kQ9nZZzl96G8', - authDomain: 'auth.simlin.com', -}; -const firebaseApp = initializeApp(config); - interface EditorMatchParams { username: string; projectName: string; @@ -42,8 +27,8 @@ class UserInfoSingleton { private resultPromise?: Promise<[User | undefined, number]>; private result?: [User | undefined, number]; constructor() { - // store this promise; we might race calling get() below, but all racers will - // await this single fetch result. + // Store this promise; we might race calling get() below, but all racers + // will await this single fetch result. this.fetch(); } @@ -75,7 +60,7 @@ class UserInfoSingleton { this.fetch(); if (resultPromise) { - // don't leave the promise un-awaited + // Don't leave the in-flight promise un-awaited await resultPromise; } } @@ -87,8 +72,6 @@ interface AppState { authUnknown: boolean; isNewUser?: boolean; user?: User; - auth: FirebaseAuth; - firebaseIdToken?: string | null; } class InnerApp extends React.PureComponent<{}, AppState> { @@ -97,91 +80,19 @@ class InnerApp extends React.PureComponent<{}, AppState> { constructor(props: {}) { super(props); - const isDevServer = process.env.NODE_ENV === 'development'; - const auth = getAuth(firebaseApp); - if (isDevServer) { - connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }); - } - this.state = { authUnknown: true, - auth, }; - // notify our app when a user logs in - onAuthStateChanged(auth, this.authStateChanged); - setTimeout(this.getUserInfo); } - authStateChanged = (user: FirebaseUser | null) => { - setTimeout(this.asyncAuthStateChanged, undefined, user); - }; - - asyncAuthStateChanged = async (user: FirebaseUser | null) => { - if (!user) { - this.setState({ firebaseIdToken: null }); - return; - } - - const firebaseIdToken = await user.getIdToken(); - this.setState({ firebaseIdToken }); - await this.maybeLogin(undefined, firebaseIdToken); - }; - - async maybeLogin(authIsKnown = false, firebaseIdToken?: string): Promise { - authIsKnown = authIsKnown || !this.state.authUnknown; - if (!authIsKnown) { - return; - } - - // if we know the user, we don't need to log in - const [user] = await userInfo.get(); - if (user) { - return; - } - - const idToken = firebaseIdToken ?? this.state.firebaseIdToken; - if (idToken === null || idToken === undefined) { - return; - } - - const bodyContents = { - idToken, - }; - - const base = this.getBaseURL(); - const apiPath = `${base}/session`; - const response = await fetch(apiPath, { - credentials: 'same-origin', - method: 'POST', - cache: 'no-cache', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(bodyContents), - }); - - const status = response.status; - if (!(status >= 200 && status < 400)) { - const body = await response.json(); - const errorMsg = - body && body.error ? (body.error as string) : `HTTP ${status}; maybe try a different username ¯\\_(ツ)_/¯`; - // this.appendModelError(errorMsg); - console.log(`session error: ${errorMsg}`); - return undefined; - } - - this.handleUsernameChanged(); - } - getUserInfo = async (): Promise => { const [user, status] = await userInfo.get(); if (!(status >= 200 && status < 400) || !user) { this.setState({ authUnknown: false, }); - await this.maybeLogin(true); return; } const isNewUser = user.id.startsWith(`temp-`); @@ -230,11 +141,9 @@ class InnerApp extends React.PureComponent<{}, AppState> { const projectParam = urlParams.get('project'); if (projectParam) return ; - // if a user is navigating to a project, - // skip the high level auth check, to enable public models if (!/\/.*\/.*/.test(window.location.pathname)) { if (!this.state.user) { - return ; + return ; } if (this.state.isNewUser) { diff --git a/src/app/Login.tsx b/src/app/Login.tsx index a62eca5f0..100a694bb 100644 --- a/src/app/Login.tsx +++ b/src/app/Login.tsx @@ -4,17 +4,6 @@ import * as React from 'react'; -import { - signInWithRedirect, - GoogleAuthProvider, - OAuthProvider, - Auth as FirebaseAuth, - fetchSignInMethodsForEmail, - createUserWithEmailAndPassword, - updateProfile, - sendPasswordResetEmail, - signInWithEmailAndPassword, -} from '@firebase/auth'; import { AppleIcon, EmailIcon, @@ -32,11 +21,27 @@ import typography from './typography.module.css'; import styles from './Login.module.css'; -type EmailLoginStates = 'showEmail' | 'showPassword' | 'showSignup' | 'showProviderRedirect' | 'showRecover'; +type EmailLoginStates = + | 'showEmail' + | 'showPassword' + | 'showSignup' + | 'showProviderRedirect' + | 'showProviderUnavailable' + | 'showRecover'; +type OAuthProviderId = 'google.com' | 'apple.com'; + +interface OAuthProvidersResponse { + oauthProviders?: unknown; +} + +interface ProviderLookupResponse extends OAuthProvidersResponse { + providers?: unknown; + registered?: unknown; +} export interface LoginProps { disabled: boolean; - auth: FirebaseAuth; + onLoginSuccess?: () => void; } interface LoginState { @@ -47,14 +52,8 @@ interface LoginState { passwordError: string | undefined; fullName: string; fullNameError: string | undefined; - provider: 'google.com' | 'apple.com' | undefined; -} - -function appleProvider(): OAuthProvider { - const provider = new OAuthProvider('apple.com'); - provider.addScope('email'); - provider.addScope('name'); - return provider; + provider: OAuthProviderId | undefined; + oauthProviders: OAuthProviderId[]; } export const GoogleIcon: React.FunctionComponent = (props) => { @@ -80,21 +79,57 @@ export class Login extends React.Component { fullName: '', fullNameError: undefined, provider: undefined, + oauthProviders: [], }; } - appleLoginClick = () => { - const provider = appleProvider(); - setTimeout(async () => { - await signInWithRedirect(this.props.auth, provider); + componentDidMount() { + void this.loadOAuthProviders(); + } + + normalizeOAuthProviders(rawProviders: unknown): OAuthProviderId[] { + if (!Array.isArray(rawProviders)) { + return []; + } + + return rawProviders.filter((provider): provider is OAuthProviderId => { + return provider === 'google.com' || provider === 'apple.com'; }); + } + + getProviderDisplayName(provider: OAuthProviderId): string { + return provider === 'google.com' ? 'Google' : 'Apple'; + } + + isOAuthProviderEnabled(provider: OAuthProviderId): boolean { + return this.state.oauthProviders.includes(provider); + } + + loadOAuthProviders = async () => { + try { + const response = await fetch('/auth/providers', { + credentials: 'same-origin', + }); + if (!response.ok) { + throw new Error(`Failed to fetch OAuth providers (${response.status})`); + } + + const { oauthProviders } = (await response.json()) as OAuthProvidersResponse; + this.setState({ oauthProviders: this.normalizeOAuthProviders(oauthProviders) }); + } catch { + this.setState({ oauthProviders: [] }); + } + }; + + appleLoginClick = () => { + const currentPath = window.location.pathname + window.location.search; + const returnUrl = encodeURIComponent(currentPath); + window.location.href = `/auth/apple?returnUrl=${returnUrl}`; }; googleLoginClick = () => { - const provider = new GoogleAuthProvider(); - provider.addScope('profile'); - setTimeout(async () => { - await signInWithRedirect(this.props.auth, provider); - }); + const currentPath = window.location.pathname + window.location.search; + const returnUrl = encodeURIComponent(currentPath); + window.location.href = `/auth/google?returnUrl=${returnUrl}`; }; emailLoginClick = () => { this.setState({ emailLoginFlow: 'showEmail' }); @@ -109,7 +144,7 @@ export class Login extends React.Component { this.setState({ email: event.target.value }); }; onEmailCancel = () => { - this.setState({ emailLoginFlow: undefined }); + this.setState({ emailLoginFlow: undefined, provider: undefined }); }; onSubmitEmail = async () => { const email = this.state.email.trim(); @@ -118,24 +153,50 @@ export class Login extends React.Component { return; } - const methods = await fetchSignInMethodsForEmail(this.props.auth, email); - if (methods.includes('password')) { - this.setState({ emailLoginFlow: 'showPassword' }); - } else if (methods.length === 0) { - this.setState({ emailLoginFlow: 'showSignup' }); - } else { - // we only allow 1 method - const method = methods[0]; - if (method === 'google.com' || method === 'apple.com') { - this.setState({ - emailLoginFlow: 'showProviderRedirect', - provider: methods[0] as 'google.com' | 'apple.com', - }); + try { + const response = await fetch('/auth/providers', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + + const { providers, registered, oauthProviders } = (await response.json()) as ProviderLookupResponse; + const availableOAuthProviders = this.normalizeOAuthProviders(oauthProviders); + const accountOAuthProviders = Array.isArray(providers) + ? providers.filter((provider): provider is OAuthProviderId => { + return provider === 'google.com' || provider === 'apple.com'; + }) + : []; + + if (Array.isArray(providers) && providers.includes('password')) { + this.setState({ emailLoginFlow: 'showPassword', oauthProviders: availableOAuthProviders, provider: undefined }); + } else if (!registered) { + this.setState({ emailLoginFlow: 'showSignup', oauthProviders: availableOAuthProviders, provider: undefined }); } else { - this.setState({ - emailError: 'an unknown error occurred; try a different email address', - }); + const availableProvider = accountOAuthProviders.find((provider) => availableOAuthProviders.includes(provider)); + if (availableProvider) { + this.setState({ + emailLoginFlow: 'showProviderRedirect', + oauthProviders: availableOAuthProviders, + provider: availableProvider, + }); + } else if (accountOAuthProviders.length > 0) { + this.setState({ + emailLoginFlow: 'showProviderUnavailable', + oauthProviders: availableOAuthProviders, + provider: accountOAuthProviders[0], + }); + } else { + this.setState({ + emailError: 'an unknown error occurred; try a different email address', + oauthProviders: availableOAuthProviders, + }); + } } + } catch (err) { + console.log(err); + this.setState({ emailError: 'Failed to check email. Please try again.' }); } }; onSubmitRecovery = async () => { @@ -145,7 +206,16 @@ export class Login extends React.Component { return; } - await sendPasswordResetEmail(this.props.auth, email); + try { + await fetch('/auth/reset-password', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + } catch (err) { + console.log(err); + } this.setState({ emailLoginFlow: 'showPassword', @@ -166,15 +236,32 @@ export class Login extends React.Component { return; } - const password = this.state.password.trim(); + // Password whitespace is significant (Firebase treats it as part of the + // password), so we only check for empty -- do not trim. + const password = this.state.password; if (!password) { this.setState({ passwordError: 'Enter a password to continue' }); return; } try { - const userCred = await createUserWithEmailAndPassword(this.props.auth, email, password); - await updateProfile(userCred.user, { displayName: fullName }); + const response = await fetch('/auth/signup', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + password, + displayName: fullName, + }), + }); + + if (response.ok) { + this.props.onLoginSuccess?.(); + } else { + const { error } = await response.json(); + this.setState({ passwordError: error || 'Something went wrong' }); + } } catch (err) { console.log(err); if (err instanceof Error) { @@ -198,14 +285,28 @@ export class Login extends React.Component { return; } - const password = this.state.password.trim(); + // Password whitespace is significant (Firebase treats it as part of the + // password), so we only check for empty -- do not trim. + const password = this.state.password; if (!password) { - this.setState({ passwordError: 'Enter your email address to continue' }); + this.setState({ passwordError: 'Enter your password to continue' }); return; } try { - await signInWithEmailAndPassword(this.props.auth, email, password); + const response = await fetch('/auth/login', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + if (response.ok) { + this.props.onLoginSuccess?.(); + } else { + const { error } = await response.json(); + this.setState({ passwordError: error || 'Incorrect password' }); + } } catch (err) { console.log(err); if (err instanceof Error) { @@ -349,7 +450,11 @@ export class Login extends React.Component { ); break; case 'showProviderRedirect': - const provider = this.state.provider === 'google.com' ? 'Google' : 'Apple'; + if (!this.state.provider) { + loginUI =
; + break; + } + const provider = this.getProviderDisplayName(this.state.provider); loginUI = (
@@ -374,6 +479,31 @@ export class Login extends React.Component { ); break; + case 'showProviderUnavailable': + if (!this.state.provider) { + loginUI =
; + break; + } + const unavailableProvider = this.getProviderDisplayName(this.state.provider); + loginUI = ( + + + +
Sign in unavailable
+

+ This account uses {unavailableProvider} sign-in for {this.state.email}, but {unavailableProvider}{' '} + sign-in is not configured in this environment. +

+
+ + + + +
+ ); + break; case 'showRecover': loginUI = ( @@ -411,17 +541,21 @@ export class Login extends React.Component { default: loginUI = (
- - + {this.isOAuthProviderEnabled('apple.com') ? ( + + ) : undefined} + {this.isOAuthProviderEnabled('google.com') ? ( + + ) : undefined}