-
Notifications
You must be signed in to change notification settings - Fork 2
Add authentication to Leaderboard backend with wallet-based sessions #753
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
base: aryan/swarm-leadership-page-mvp
Are you sure you want to change the base?
Add authentication to Leaderboard backend with wallet-based sessions #753
Conversation
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
262b3bc to
9b24778
Compare
c95306f to
1364819
Compare
9b24778 to
6164f58
Compare
b3b1875 to
04fe2d6
Compare
a2c3cc7 to
66315ae
Compare
04fe2d6 to
d7b5c98
Compare
66315ae to
decea15
Compare
decea15 to
b5445e1
Compare
679daa4 to
9c6956d
Compare
b5445e1 to
5e98b69
Compare
9c6956d to
a9fce85
Compare
| try { | ||
| const { primary_wallet } = c.req.valid("json") | ||
| // Use a hardcoded, Ethereum-style message for leaderboard authentication | ||
| const message = `\x19Leaderboard Signed Message:\nHappyChain Leaderboard Authentication Request for ${primary_wallet}` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this should include a unique nonce to protect against replay attacks i believe. don't need to follow the SIWE spec exactly i don't think, but the rational is good here, and its a good guide to follow https://eips.ethereum.org/EIPS/eip-4361#message-fields & https://docs.login.xyz/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Finally implemented this. After hrs of going through and implemented the ERC, found out that viem already had helper functions for this stuff 🫠
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
but i bet you understand it better now 😁
| setCookie(c, "session_id", session.id, { | ||
| httpOnly: true, | ||
| secure: true, | ||
| domain: "localhost", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
likely this should be a env variable :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yessir, refactored the env.ts and cookieCOnfig 🫡
| if (!user) { | ||
| return c.json({ ok: false, error: "User not found" }, 404) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if this happened, then i think something catastrophic happened right? maybe want to clear the cookie and log them out or handle this in some way
... does this want to be part of the requireAuth middleware even?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this should ever happen. Maybe I should add more logging around it to know if it does.
But since user: User | undefined after fetching it from the repository, I can't not add this line of if (!user); residual habit from writing in golang 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added requireAuth to it, just so authenticated users can fetch themselves. I don't have any usecase for this endpoint right now. It's just present as a stub for now
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i suspected that was the case :) yeah if something supposedly impossible happens like this, but you have a check anyways to fix types or whatever, should probably log when things go sideways, just in case 👍
| try { | ||
| const session = await this.db | ||
| .selectFrom("auth_sessions") | ||
| .where("id", "=", sessionId) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you could add a whereExists('users.id', '=', 'auth_sessions.user_id') (i probably have the wrong syntax here) so that deleted users sessions are automatically invalidated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated this, added an innerJoin, so that it makes sure the user still exists when fetching the session related to it
| await this.db | ||
| .updateTable("auth_sessions") | ||
| .set({ last_used_at: new Date().toISOString() }) | ||
| .where("id", "=", sessionId) | ||
| .executeTakeFirstOrThrow() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you are taking first already, why not update and fetch in one go & save a query?
| await this.db | |
| .updateTable("auth_sessions") | |
| .set({ last_used_at: new Date().toISOString() }) | |
| .where("id", "=", sessionId) | |
| .executeTakeFirstOrThrow() | |
| const session = await this.db | |
| .updateTable("auth_sessions") | |
| .set({ last_used_at: new Date().toISOString() }) | |
| .where("id", "=", sessionId) | |
| .returningAll() | |
| .executeTakeFirstOrThrow() |
- this works well in postres, unsure about sqlite
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For this and the above comment, here's my new implementation :-
async verifySession(sessionId: AuthSessionTableId): Promise<AuthSession | undefined> {
try {
// First check if the session exists and belongs to an existing user
const session = await this.db
.selectFrom("auth_sessions")
.innerJoin("users", "users.id", "auth_sessions.user_id")
.where("auth_sessions.id", "=", sessionId)
.select(["auth_sessions.id", "auth_sessions.user_id", "auth_sessions.primary_wallet",
"auth_sessions.created_at", "auth_sessions.last_used_at"])
.executeTakeFirst()
if (!session) {
return undefined
}
// Update the last_used_at timestamp
const currentTime = new Date().toISOString()
// We don't need the result of this update since we already have the session data
// and the only thing that changed is the last_used_at timestamp, which isn't usually
// needed by the application logic
await this.db
.updateTable("auth_sessions")
.set({ last_used_at: currentTime })
.where("id", "=", sessionId)
.executeTakeFirstOrThrow()
// Return the session with the updated timestamp
// The session object already has the correct types from the database schema
// so we need to preserve those types
return {
...session,
last_used_at: new Date(currentTime) // Convert string to Date to match expected type
}
} catch (error) {
console.error("Error verifying session:", error)
return undefined
}
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
my comment was more that i think you can do it all in a single more efficient query :) I probably have syntax slightly wrong, and it simply might not work with sqlite, but something like this should have the same result I think?
async verifySession(sessionId: AuthSessionTableId): Promise<AuthSession | undefined> {
try {
const session = await this.db
.updateTable("auth_sessions")
.set({ last_used_at: new Date().toISOString() })
.where("id", "=", sessionId)
.where(({ exists, selectFrom }) =>
exists(
selectFrom('users')
.select('users.id')
.whereRef('users.id', '=', 'auth_sessions.user_id')
)
)
.returning([
"auth_sessions.id",
"auth_sessions.user_id",
"auth_sessions.primary_wallet",
"auth_sessions.created_at",
"auth_sessions.last_used_at"
])
.executeTakeFirstOrThrow()
return session
} catch (error) {
console.error("Error verifying session:", error)
return undefined
}
}|
|
||
| async deleteSession(sessionId: AuthSessionTableId): Promise<boolean> { | ||
| try { | ||
| await this.db.deleteFrom("auth_sessions").where("id", "=", sessionId).executeTakeFirstOrThrow() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
instead of throwing, i think execute can return the number of rows deleted. and you can return true/false from that
| if (walletEntry) { | ||
| user = await this.findById(walletEntry.user_id) | ||
| if (includeWallets) { | ||
| const wallets = await this.getUserWallets(user.id) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would be nice if this was a join and a single query
…verification [skip ci]
… system [skip ci]
…lidation files [skip ci]
…rror handling [skip ci]
…route handlers [skip ci]
…ame icon [skip ci]
… wallet retrieval [skip ci]
… wallet retrieval [skip ci]
…n authrepository [skip ci]
96e78dc to
cb3c190
Compare
5e98b69 to
74cfd9a
Compare
…ons to .env.example
Deploying happychain with
|
| Latest commit: |
4f63489
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://e0a44d65.happychain.pages.dev |
| Branch Preview URL: | https://aryan-leaderboard-auth.happychain.pages.dev |
| example: { | ||
| primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", | ||
| message: | ||
| "happychain.app wants you to sign in with your Ethereum account:\n0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa\n\nSign this message to authenticate with HappyChain Leaderboard. This will not trigger a blockchain transaction or cost any gas fees.\n\nURI: https://happychain.app/login\nVersion: 1\nChain ID: 216\nNonce: 7f8e9d1c6b3a2e5f4d7c8b9a1e3f5d7c\nIssued At: 2025-05-23T09:30:00.000Z\nExpiration Time: 2025-05-23T09:35:00.000Z", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not a bit deal, but this might be nicer as a template string. (maybe this doesn't play as nice with the docs though? not sure)
| "happychain.app wants you to sign in with your Ethereum account:\n0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa\n\nSign this message to authenticate with HappyChain Leaderboard. This will not trigger a blockchain transaction or cost any gas fees.\n\nURI: https://happychain.app/login\nVersion: 1\nChain ID: 216\nNonce: 7f8e9d1c6b3a2e5f4d7c8b9a1e3f5d7c\nIssued At: 2025-05-23T09:30:00.000Z\nExpiration Time: 2025-05-23T09:35:00.000Z", | |
| message: ` | |
| happychain.app wants you to sign in with your Ethereum account: | |
| 0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa | |
| Sign this message to authenticate with HappyChain Leaderboard. This will not trigger a blockchain transaction or cost any gas fees. | |
| URI: https://happychain.app/login | |
| Version: 1 | |
| Chain ID: 216 | |
| Nonce: 7f8e9d1c6b3a2e5f4d7c8b9a1e3f5d7c | |
| Issued At: 2025-05-23T09:30:00.000Z | |
| Expiration Time: 2025-05-23T09:35:00.000Z", |
also out of curiosity is happychain.app correct?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might be wrong, but there was some rule that we can't use \n or 0xa0 or whatever character it is, inside this message.
And yeah, the happychain.app and other values are all stub😅, they're mostly optional too
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you are using \n not me 😅 but doesn't matter haha, was just a thought as i was reading through. its fine 🤝
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will change this😭🙇🏻♂️🙇🏻♂️
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
haha idk, mine might not work

Linked Issues
Description
This PR implements authentication functionality for the leaderboard backend, enabling users to authenticate using wallet signatures. The implementation includes:
auth_sessionstable to store user sessionsAuthRepositorywith methods for challenge generation, signature verification, and session managementThe authentication flow follows a challenge-response pattern where users request a challenge message, sign it with their wallet, and submit the signature to verify their identity and create a session.
Toggle Checklist
Checklist
Basics
norswap/build-system-caching).Reminder: PR review guidelines
Correctness
testnet, mainnet, standalone wallet, ...).
Tested in Chrome with local development environment. Verified authentication flow with wallet signature and session management.
Tested scenarios:
Challenge request and signature verification
Session creation and validation
User info retrieval
Session listing and logout
C4. I have performed a thorough self-review of my code after submitting the PR,
and have updated the code & comments accordingly.
Architecture & Documentation
(2) commenting these boundaries correctly, (3) adding inline comments for context when needed.
Public APIS and meaningful (non-local) internal APIs are properly documented in code comments.
in a Markdown document.
make changesetforbreaking and meaningful changes in packages (not required for cleanups & refactors).