Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Passwordless email and local dev server #857

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d5ff451
Remove cognitiveServicesKey endpoint
lazerwalker Sep 9, 2024
f01a2f5
add express
lazerwalker Sep 9, 2024
df49005
add route definitions
lazerwalker Sep 9, 2024
ae5b580
update client node version for dev tooling
lazerwalker Sep 11, 2024
f49f7a3
add concurrently and tsx dev client/server setup
lazerwalker Sep 11, 2024
5723d88
first stab at http and websocket server
lazerwalker Sep 11, 2024
2c95c6c
add redis function to create or fetch auth secret
lazerwalker Sep 13, 2024
ba92250
pin node version in server
lazerwalker Sep 13, 2024
c21da36
remove firebase from server, add easy-no-password
lazerwalker Sep 13, 2024
158b88c
authenticate validates enp token instead of hitting firebase
lazerwalker Sep 13, 2024
80ab1c2
refactor authenticate to not be azure-specific
lazerwalker Sep 13, 2024
e98a014
Add /sendMagicEmail endpoint, but don't send emails yet
lazerwalker Sep 13, 2024
203ac6b
Add auth doc
lazerwalker Sep 13, 2024
fe21556
Use UUID userIds instead of emails
lazerwalker Sep 14, 2024
6891f7d
Fix azure sendMagicEmail auth
lazerwalker Sep 14, 2024
43c6f41
Migrate client from Firebase to magic emails
lazerwalker Sep 18, 2024
6b2cb85
Fix auth permissions issue with isRegistered
lazerwalker Sep 18, 2024
1baf872
Add local server and enp deps to server
lazerwalker Sep 18, 2024
0241ac8
Local dev server more or less works
lazerwalker Sep 18, 2024
e8b65eb
Update readme
lazerwalker Sep 18, 2024
9722c16
Add Redis instructions
lazerwalker Sep 19, 2024
4854bf8
Add image build step to client dev
lazerwalker Sep 19, 2024
e3f1f7d
Style email form
lazerwalker Sep 19, 2024
be083a6
More styling, and 'email sent' message
lazerwalker Sep 19, 2024
2057c73
Update admin panel with new flow
lazerwalker Sep 19, 2024
7c80cd2
harden /negotiatePubSub against bad actors spoofing userId
lazerwalker Sep 26, 2024
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
8 changes: 1 addition & 7 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -1,8 +1,2 @@
SERVER_HOSTNAME=https://localhost
FIREBASE_API_KEY=firebase_api_key
FIREBASE_AUTH_DOMAIN=firebase_auth_domain
FIREBASE_PROJECT_ID=firebase_project_id
FIREBASE_STORAGE_BUCKET=firebase_storage_bucket
FIREBASE_MESSAGING_SENDER_ID=firebase_messaging_sender_id
FIREBASE_APP_ID=firebase_app_id
SERVER_HOSTNAME=https://localhost:3000

26 changes: 0 additions & 26 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,10 @@ on:
# LMAO at how long this is.
SERVER_HOSTNAME:
required: true
FIREBASE_API_KEY:
required: true
FIREBASE_AUTH_DOMAIN:
required: true
FIREBASE_PROJECT_ID:
required: true
FIREBASE_STORAGE_BUCKET:
required: true
FIREBASE_MESSAGING_SENDER_ID:
required: true
FIREBASE_APP_ID:
required: true
AZURE_FUNCTION_APP_NAME:
required: true
AZURE_FUNCTIONAPP_PUBLISH_PROFILE:
required: true
FIREBASE_SERVER_JSON:
required: true
DEPLOY_WEBHOOK_KEY:
required: true
ASWA_API_TOKEN:
Expand All @@ -42,12 +28,6 @@ jobs:
run: npm run build
env:
SERVER_HOSTNAME: ${{ secrets.SERVER_HOSTNAME }}
FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }}
FIREBASE_AUTH_DOMAIN: ${{ secrets.FIREBASE_AUTH_DOMAIN }}
FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }}
FIREBASE_STORAGE_BUCKET: ${{ secrets.FIREBASE_STORAGE_BUCKET }}
FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}

# - name: Deploy GH Pages
# uses: peaceiris/actions-gh-pages@v3
Expand All @@ -73,12 +53,6 @@ jobs:
APP_NAME: ${{ secrets.AZURE_FUNCTION_APP_NAME }}
TOKEN: ${{ secrets.DEPLOY_WEBHOOK_KEY }}

- name: Create Firebase JSON file
working-directory: server
run: echo $FIREBASE_SERVER_JSON > firebase-admin.json
env:
FIREBASE_SERVER_JSON: ${{ secrets.FIREBASE_SERVER_JSON }}

- name: Build Backend
working-directory: server
run: npm install && npm run build
Expand Down
7 changes: 0 additions & 7 deletions .github/workflows/prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,7 @@ jobs:
uses: ./.github/workflows/deploy.yml
secrets:
SERVER_HOSTNAME: ${{ secrets.SERVER_HOSTNAME }}
FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }}
FIREBASE_AUTH_DOMAIN: ${{ secrets.FIREBASE_AUTH_DOMAIN }}
FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }}
FIREBASE_STORAGE_BUCKET: ${{ secrets.FIREBASE_STORAGE_BUCKET }}
FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
AZURE_FUNCTION_APP_NAME: ${{ secrets.AZURE_FUNCTION_APP_NAME }}
AZURE_FUNCTIONAPP_PUBLISH_PROFILE: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE }}
FIREBASE_SERVER_JSON: ${{ secrets.FIREBASE_SERVER_JSON }}
DEPLOY_WEBHOOK_KEY: ${{ secrets.DEPLOY_WEBHOOK_KEY }}
ASWA_API_TOKEN: ${{secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
7 changes: 0 additions & 7 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,7 @@ jobs:
uses: ./.github/workflows/deploy.yml
secrets:
SERVER_HOSTNAME: ${{ secrets.STAGING_SERVER_HOSTNAME }}
FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }}
FIREBASE_AUTH_DOMAIN: ${{ secrets.FIREBASE_AUTH_DOMAIN }}
FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }}
FIREBASE_STORAGE_BUCKET: ${{ secrets.FIREBASE_STORAGE_BUCKET }}
FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
AZURE_FUNCTION_APP_NAME: ${{ secrets.STAGING_AZURE_FUNCTION_APP_NAME }}
AZURE_FUNCTIONAPP_PUBLISH_PROFILE: ${{ secrets.STAGING_AZURE_FUNCTIONAPP_PUBLISH_PROFILE }}
FIREBASE_SERVER_JSON: ${{ secrets.FIREBASE_SERVER_JSON }}
DEPLOY_WEBHOOK_KEY: ${{ secrets.DEPLOY_WEBHOOK_KEY }}
ASWA_API_TOKEN: ${{secrets.STAGING_ASWA_API_TOKEN}}
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
server/firebase-admin.json
server/firebase-admin*.json
node_modules
dist
.env
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
16
18
85 changes: 26 additions & 59 deletions README.md

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Authentication
This is a general overview to how authentication works, following the 2024 migration to passwordless 'magic' emails.

## A token
An authentication token is a string cryptographically generated by the server based on the time, user ID, and a secret (a random UUID we store in Redis).

We do not store tokens at all on the server (i.e. they're not in Redis), they are validated purely cryptographically.

They do have an expiration date, which we can set.

## A user ID
Since the client has access to the userIds of every logged-in user, we should not use emails as user IDs so as to avoid making email addresses more or less public. This is annoying, but such is software.

When we generate a magic link, we check an email -> userId map in Redis to see if we've generated a user ID yet. If not, generate a GUID, store it, and use that in the magic link so that the client stores that userId when logging in.

We could instead use a one-way hash function to generate userIds. Generating UUIDs instead should make it easier to build a tool to manually associate old accounts with new accounts, since we can just create a mapping from an old user ID to an email address and it should just work.


## An authenticated network request
A valid authenticated HTTP request is defined as having the following two headers:
- `Authorization: Bearer [token]`
- `userId: [userId]`

## The server's perspective
Whenever a HTTP request hits an endpoint requiring authentication, it gets passed through the `authenticate()` middleware function to validate it is a valid, non-expired token for the matching user ID. If it is not, an error is returned.^1

(we do not have a "proper" middleware system, it's just a function that gets called as part of our Azure or Express function wrapping).

Whenever a token is created or validated, it fetches the token secret from Redis. If a secret is not found, a random UUID will be generated and stored in Redis. Since tokens are short-lived, if this secret ends up getting cycled, that will silently invalidate current tokens and simply require people to login again.

There is also a dedicated endpoint, `/sendMagicEmail`, that, given an email address/user ID, sends that email address a message containing a link to the client that embeds a valid token within it.

^1: The `/updateProfile` endpoint is unique and weird. Since it is both used to create a new user profile and update an existing user profile, it is accessed by both logged-in and logged-out users, so its use of the `authenticate` functions is non-standard. Refactoring that into two endpoints is potential future work, albeit with little practical payoff.

## The client's perspective
The client's current token will be stored in localStorage. On app load, we will try to populate React app state with that token.

If there is no token, we will present a logged out state, prompting the user to enter their email address and receive a magic link.

If the page is loaded with query params for a token and a user ID, those will be stored in localStorage, and the page reloaded, allowing the app to load as if the user is logged in.

If the page is loaded with a token in localStorage, we will load the app as normal as if there is a logged-in user.

When the user is logged in, all network requests will inject the correct authentication headers, grabbing that data from React state.

If at any time a network request fails in such a way that indicates an invalid token (most likely due to token expiration), we simply delete the token from localStorage and reset the page, essentially resetting the app to a logged-out state. Ideally we would silently refresh tokens, but I do not believe `easy-no-password` gives us a way to distinguish between invalid tokens and valid-but-expired tokens, so this is fine for now.
Loading