Files
esports/src/lib/server/auth.ts

120 lines
3.7 KiB
TypeScript

// src/lib/server/auth.ts
import { log } from '$lib/server/logger';
import { betterAuth } from 'better-auth/minimal';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import { env } from '$env/dynamic/private';
import { getRequestEvent } from '$app/server';
import { db } from '$lib/server/db';
import { genericOAuth } from 'better-auth/plugins';
import { team, teamMember, playerRating } from './db/schema';
import { proxyImage } from './storage/r2';
// Stash the osu! profile between mapProfileToUser and user.create.after
// (both run in the same request, so this is safe)
let pendingOsuProfile: { rank: number | null } | null = null;
function eloFromRank(rank: number | null): { elo: number; osuRank: number | null } {
// Unranked → treat as rank 10,000,000 (yields 0 ELO)
const effectiveRank = rank && rank > 0 ? rank : 10_000_000;
const rawElo = 3500 - Math.log10(effectiveRank) * 500;
return { elo: Math.round(Math.max(0, Math.min(3500, rawElo))), osuRank: rank };
}
export const auth = betterAuth({
baseURL: env.ORIGIN,
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, { provider: 'pg' }),
emailAndPassword: { enabled: true },
databaseHooks: {
user: {
create: {
after: async (user) => {
const [personalTeam] = await db
.insert(team)
.values({
name: user.name,
isPersonal: true,
ownerId: user.id,
avatarUrl: user.image
})
.returning();
await db.insert(teamMember).values({
teamId: personalTeam.id,
userId: user.id,
role: 'owner'
});
const { elo, osuRank } = eloFromRank(pendingOsuProfile?.rank ?? null);
pendingOsuProfile = null;
await db.insert(playerRating).values({
userId: user.id,
elo,
initialElo: elo,
osuRankAtSeed: osuRank
});
log.auth.info({ name: user.name, elo, osuRank }, 'seeded new user rating');
}
}
}
},
plugins: [
genericOAuth({
config: [
{
providerId: 'osu',
clientId: env.OSU_CLIENT_ID!,
clientSecret: env.OSU_CLIENT_SECRET!,
authorizationUrl: 'https://osu.ppy.sh/oauth/authorize',
tokenUrl: 'https://osu.ppy.sh/oauth/token',
userInfoUrl: 'https://osu.ppy.sh/api/v2/me',
scopes: ['identify', 'public'],
pkce: false,
authentication: 'post',
// ── FIX: Use ORIGIN env var instead of hardcoded localhost ──
redirectURI: `${env.ORIGIN}/api/auth/oauth2/callback/osu`,
getToken: async ({ code, redirectURI }) => {
const res = await fetch('https://osu.ppy.sh/oauth/token', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
client_id: env.OSU_CLIENT_ID!,
client_secret: env.OSU_CLIENT_SECRET!,
code,
grant_type: 'authorization_code',
redirect_uri: redirectURI
})
});
const data = await res.json();
if (!res.ok) {
log.auth.error({ data }, 'osu! token exchange failed');
throw new Error('Token exchange failed');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
accessTokenExpiresAt: new Date(Date.now() + data.expires_in * 1000),
scopes: ['identify', 'public'],
raw: data
};
},
async mapProfileToUser(profile) {
pendingOsuProfile = { rank: profile.statistics?.global_rank ?? null };
return {
name: profile.username,
image: await proxyImage(profile.avatar_url),
email: `${profile.id}@osu.local`
};
}
}
]
}),
sveltekitCookies(getRequestEvent) // must be last
]
});