120 lines
3.7 KiB
TypeScript
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
|
|
]
|
|
});
|