Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6b5eade
intial commit, setup code for auth, in progress [skip ci]
AryanGodara May 12, 2025
51b8879
feat: implement role-based auth system with middleware and signature …
AryanGodara May 12, 2025
05765bb
refactor: switch to cookie-based auth and simplify signature verifica…
AryanGodara May 14, 2025
36e436d
feat: implement role-based access control with enum-based permissions…
AryanGodara May 15, 2025
6c3afbc
refactor: replace boolean admin flags with role enums for guilds and …
AryanGodara May 15, 2025
6d13cbd
update bun.lock after rebasing [skip ci]
AryanGodara May 19, 2025
4b35736
refactor: standardize API response format with ok/data wrapper [skip ci]
AryanGodara May 20, 2025
8701b35
refactor: move OpenAPI schema resolvers from schema files to route va…
AryanGodara May 20, 2025
f579d2e
refactor: move ID parsing from routes to validation schemas and add e…
AryanGodara May 20, 2025
f8f8853
refactor: add error handling and improve type safety in repository me…
AryanGodara May 21, 2025
29f27bb
refactor: remove redundant @security BearerAuth annotations from API …
AryanGodara May 21, 2025
5e1fecb
feat: add guild leave endpoint and refactor permission middleware [sk…
AryanGodara May 22, 2025
cc19dcf
feat: restrict game score submission to game creators only [skip ci]
AryanGodara May 22, 2025
9a4f5e9
refactor: replace requireOwnership with role-based userAction middlew…
AryanGodara May 22, 2025
a2f39e1
refactor: centralize session cookie configuration and remove unused g…
AryanGodara May 22, 2025
0eabfb3
refactor: optimize database queries for session verification and user…
AryanGodara May 22, 2025
d9b69a1
refactor: optimize database queries for session verification and user…
AryanGodara May 23, 2025
fd86edc
feat: implement secure auth challenge system with message signing [sk…
AryanGodara May 23, 2025
2ac0378
feat: add auth challenge table and add migrations, add stub methods i…
AryanGodara May 23, 2025
2769e64
feat: implement SIWE challenge generation and validation for auth sys…
AryanGodara May 23, 2025
a3ea136
feat: implement EIP-4361 SIWE authentication flow with viem/siwe [ski…
AryanGodara May 23, 2025
cb3c190
docs: add basic doc comments for SIWE authentication flow [skip ci]
AryanGodara May 23, 2025
4f63489
chore: add detailed session, auth, and environment configuration opti…
AryanGodara May 23, 2025
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
27 changes: 25 additions & 2 deletions apps/leaderboard-backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
# Database settings
LEADERBOARD_DB_URL=leaderboard-backend.sqlite
DATABASE_MIGRATE_DIR=migrations

# Server settings
PORT=4545
LEADERBOARD_DB_URL="leaderboard-backend.sqlite"
DATABASE_MIGRATE_DIR="migrations"
NODE_ENV=development

# Session settings
SESSION_EXPIRY=86400
SESSION_COOKIE_NAME=session_id
SESSION_COOKIE_HTTP_ONLY=true
SESSION_COOKIE_SECURE=true
SESSION_COOKIE_DOMAIN=
SESSION_COOKIE_SAME_SITE=Lax
SESSION_COOKIE_PATH=/

# Authentication Challenge Settings (SIWE - Sign In With Ethereum)
AUTH_DOMAIN=happychain.app
AUTH_CHAIN_ID=216
AUTH_STATEMENT="Sign this message to authenticate with HappyChain Leaderboard. This will not trigger a blockchain transaction or cost any gas fees."
AUTH_URI=https://happychain.app/login
AUTH_CHALLENGE_EXPIRY=300

# External services
RPC_URL=http://localhost:8545
Binary file removed apps/leaderboard-backend/public/gameicon.png
Binary file not shown.
107 changes: 107 additions & 0 deletions apps/leaderboard-backend/src/auth/challengeGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Authentication Challenge Generator
*
* This module handles the creation of EIP-4361 (Sign-In with Ethereum) compliant
* authentication challenges using Viem's SIWE utilities.
*
* The flow is as follows:
* 1. Client requests a challenge with their wallet address
* 2. Server generates a challenge message using createSiweMessage
* 3. Server stores the challenge in the database
* 4. Client signs the message with their wallet
* 5. Client sends the signature back for verification
*/

import type { Address, Hex } from "@happy.tech/common"
import { hashMessage } from "viem"
import { createSiweMessage, generateSiweNonce } from "viem/siwe"
import { authConfig } from "../env"

/**
* Interface for challenge message options
* Following EIP-4361 Sign-In with Ethereum (SIWE) standard
*/
export interface ChallengeMessageOptions {
/** The wallet address requesting authentication */
walletAddress: Address
/** Optional request ID for tracking */
requestId?: string
/** Optional resources to include */
resources?: string[]
}

/**
* Interface for challenge message response
* Contains all data needed for verification and database storage
*/
export interface ChallengeMessage {
/** The formatted message to be signed */
message: string
/** The nonce used in the message (for verification) */
nonce: string
/** The timestamp when the challenge was issued */
issuedAt: string
/** The timestamp when the challenge will expire */
expiresAt: string
/** The wallet address that requested the challenge */
walletAddress: Address
/** Message hash for verification */
messageHash: Hex
}

/**
* Generates a secure challenge message for authentication following EIP-4361 (SIWE) standard
*
* This function creates a standardized message for wallet-based authentication that includes:
* - Domain and URI information to identify the application
* - Wallet address of the user attempting to authenticate
* - A cryptographically secure nonce to prevent replay attacks
* - Timestamps for issuance and expiration to limit the validity period
* - Optional resources that the user will be able to access
* - Optional request ID for tracking purposes
*
* The message is formatted according to the EIP-4361 standard using Viem's SIWE utilities.
* A hash of the message is also generated for database storage and verification.
*
* @param options Configuration options including wallet address and optional parameters
* @returns A challenge message object with all data needed for storage and verification
*/
export function generateChallengeMessage(options: ChallengeMessageOptions): ChallengeMessage {
const { walletAddress, requestId, resources } = options

const domain = authConfig.domain
const uri = authConfig.uri
const statement = authConfig.statement
const chainId = authConfig.chainId

const nonce = generateSiweNonce()

const now = new Date()
const issuedAt = now
const expiresAt = new Date(now.getTime() + authConfig.challengeExpirySeconds * 1000)

const message = createSiweMessage({
domain,
address: walletAddress,
statement,
uri,
version: "1",
chainId,
nonce,
issuedAt,
expirationTime: expiresAt,
requestId,
resources,
})

const messageHash = hashMessage(message)

return {
message,
nonce,
issuedAt: issuedAt.toISOString(),
expiresAt: expiresAt.toISOString(),
walletAddress,
messageHash,
}
}
4 changes: 4 additions & 0 deletions apps/leaderboard-backend/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./roles"
export * from "./middlewares"
export * from "./verifySignature"
export * from "./challengeGenerator"
44 changes: 44 additions & 0 deletions apps/leaderboard-backend/src/auth/middlewares/gameAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Context } from "hono"
import type { GameTableId } from "../../db/types"
import { GameRole, ResourceType } from "../roles"
import type { ActionType, RoleType } from "../roles"
import { requirePermission } from "./permissions"

/**
* Get the user's role in a game based on their relationship to the game
* Returns GameRole.CREATOR or GameRole.PLAYER
*/
async function getGameUserRole(c: Context): Promise<RoleType | undefined> {
const userId = c.get("userId")
const game_id = c.req.param("id")
if (!userId || !game_id) return undefined

const { gameRepo } = c.get("repos")
const gameId = Number.parseInt(game_id, 10) as GameTableId

// Check if game exists
const game = await gameRepo.findById(gameId)
if (!game) return undefined

// Check if user is the creator
if (game.admin_id === userId) {
c.set("gameRole", GameRole.CREATOR)
return GameRole.CREATOR
}

// For games, all authenticated users are considered players
return GameRole.PLAYER
}

/**
* Middleware factory for game operations that combines permission checking
* @param action The action being performed on the game
* @returns A middleware that checks if the user has permission to perform the action
*/
export const gameAction = (action: ActionType) => {
return requirePermission({
resource: ResourceType.GAME,
action,
getUserRole: getGameUserRole,
})
}
50 changes: 50 additions & 0 deletions apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Context } from "hono"
import type { GuildTableId } from "../../db/types"
import { GuildRole, ResourceType } from "../roles"
import type { ActionType, RoleType } from "../roles"
import { requirePermission } from "./permissions"

/**
* Get the user's role in a guild based on their membership status and guild relationship
* Returns GuildRole.CREATOR, GuildRole.ADMIN, GuildRole.MEMBER, or undefined if not a member
*/
async function getGuildUserRole(c: Context): Promise<RoleType | undefined> {
const userId = c.get("userId")
const guild_id = c.req.param("id")
if (!guild_id || !userId) return undefined

const { guildRepo } = c.get("repos")
const guildId = Number.parseInt(guild_id, 10) as GuildTableId

// First check if user is the creator
const guild = await guildRepo.findById(guildId)
if (!guild) return undefined

if (guild.creator_id === userId) {
c.set("guildRole", GuildRole.CREATOR) // Store role in context for potential use in handlers
return GuildRole.CREATOR
}

// Then check member status
const member = await guildRepo.findGuildMember(guildId, userId)
if (!member) return undefined

// Store role in context for potential use in handlers
c.set("guildRole", member.role)

// Return the role from the member record
return member.role
}

/**
* Middleware factory for guild operations that combines permission checking
* @param action The action being performed on the guild
* @returns A middleware that checks if the user has permission to perform the action
*/
export const guildAction = (action: ActionType) => {
return requirePermission({
resource: ResourceType.GUILD,
action,
getUserRole: getGuildUserRole,
})
}
5 changes: 5 additions & 0 deletions apps/leaderboard-backend/src/auth/middlewares/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from "./userAuth"
export * from "./gameAuth"
export * from "./guildAuth"
export * from "./requireAuth"
export * from "./permissions"
44 changes: 44 additions & 0 deletions apps/leaderboard-backend/src/auth/middlewares/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Context, MiddlewareHandler } from "hono"
import { createMiddleware } from "hono/factory"
import { type ActionType, Permissions, type ResourceType, type RoleType } from "../roles"

/**
* Generic permissions middleware for resource-based RBAC.
*
* Usage:
* permissions({ resource: "guild", action: "add_member" })
*
* Looks up the user's role for the resource and checks if it's allowed for the action.
*
* @param opts.resource: ResourceType - The resource type (e.g., "guild")
* @param opts.action - The action type (e.g., "add_member")
* @param opts.getUserRole - Custom function to get the user's role for the resource
*/
export function requirePermission({
resource,
action,
getUserRole,
}: {
resource: ResourceType
action: ActionType
getUserRole: (c: Context) => Promise<RoleType | undefined>
}): MiddlewareHandler {
return createMiddleware(async (c, next) => {
// Determine the user's role for this resource
const role = await getUserRole(c)

// Find allowed roles from the Permissions object
const allowed = Permissions?.[resource]?.[action]
if (!allowed) {
return c.json({ ok: false, error: `Permission config not found for ${resource}.${action}` }, 500)
}

// Allow if user's role is in allowed list
if (role && allowed.includes(role)) {
await next()
return
}

return c.json({ ok: false, error: "Forbidden: insufficient role/permissions" }, 403)
})
}
29 changes: 29 additions & 0 deletions apps/leaderboard-backend/src/auth/middlewares/requireAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { MiddlewareHandler } from "hono"
import { getCookie } from "hono/cookie"
import { createMiddleware } from "hono/factory"
import type { AuthSessionTableId } from "../../db/types"

/**
* Middleware that verifies if a user is authenticated via session
* Checks for session ID in cookie
*/
export const requireAuth: MiddlewareHandler = createMiddleware(async (c, next) => {
const sessionId = getCookie(c, "session_id") as AuthSessionTableId | undefined
if (!sessionId) {
return c.json({ error: "Authentication required", ok: false }, 401)
}

const { authRepo } = c.get("repos")
const session = await authRepo.verifySession(sessionId)

if (!session) {
return c.json({ error: "Invalid or expired session", ok: false }, 401)
}

// Set user context for downstream middleware and handlers
c.set("userId", session.user_id)
c.set("primaryWallet", session.primary_wallet)
c.set("sessionId", session.id)

await next()
})
64 changes: 64 additions & 0 deletions apps/leaderboard-backend/src/auth/middlewares/userAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Context } from "hono"
import type { UserTableId } from "../../db/types"
import { ActionType, ResourceType, UserRole } from "../roles"
import type { RoleType } from "../roles"
import { requirePermission } from "./permissions"

/**
* Get the user's role in relation to the request context
* For actions that require SELF permission (UPDATE, DELETE, MANAGE),
* we check if the authenticated user is accessing their own resources.
*
* @param c - The context from the request
* @param action - The action being performed (optional)
* @returns The role of the user (SELF or AUTHENTICATED)
*/
async function getUserRole(c: Context, action?: ActionType): Promise<RoleType | undefined> {
const authenticatedUserId = c.get("userId")
if (!authenticatedUserId) return undefined

// Get target identifiers from route params
const targetIdParam = c.req.param("id")
const targetWalletParam = c.req.param("primary_wallet")

// For sensitive operations (UPDATE, DELETE, MANAGE) we need to verify identity
const requiresSelfCheck =
action === ActionType.UPDATE || action === ActionType.DELETE || action === ActionType.MANAGE

// If we're checking a route that operates on a specific user
if (requiresSelfCheck) {
// Check if accessing by user ID
if (targetIdParam) {
const targetUserId = Number.parseInt(targetIdParam, 10) as UserTableId
if (authenticatedUserId === targetUserId) {
c.set("userRole", UserRole.SELF)
return UserRole.SELF
}
}

// Check if accessing by primary wallet
if (targetWalletParam) {
const authenticatedWallet = c.get("primaryWallet")
if (authenticatedWallet === targetWalletParam) {
c.set("userRole", UserRole.SELF)
return UserRole.SELF
}
}
}

// Otherwise the user is just an authenticated user accessing someone else's resources
return UserRole.AUTHENTICATED
}

/**
* Middleware factory for user operations that combines permission checking
* @param action The action being performed on the user resource
* @returns A middleware that checks if the user has permission to perform the action
*/
export const userAction = (action: ActionType) => {
return requirePermission({
resource: ResourceType.USER,
action,
getUserRole: (c) => getUserRole(c, action),
})
}
Loading