auth.ts
1 import z from 'zod'; 2 import { authenticatedCall, call, AccountSuspendedError } from './call'; 3 import { jwtDecode } from 'jwt-decode'; 4 import type { WaveUser } from './types/user'; 5 import parseRes from './utils/parse-res'; 6 import { goto, invalidateAll } from '$app/navigation'; 7 import { browser } from '$app/environment'; 8 9 const accessClaimJwtSchema = z.object({ 10 iss: z.literal('drips-wave'), 11 sub: z.uuid(), 12 iat: z.number().int(), 13 exp: z.number().int(), 14 name: z.string(), 15 email: z.email(), 16 picture: z.url(), 17 signUpDate: z.coerce.date(), 18 payoutAddresses: z 19 .object({ 20 stellar: z.string().nullable(), 21 }) 22 .optional(), 23 permissions: z.array(z.string()).optional(), 24 }); 25 26 export type WaveLoggedInUser = WaveUser & { 27 name: string; 28 email: string; 29 avatarUrl: string; 30 payoutAddresses?: { 31 stellar: string | null; 32 }; 33 signUpDate: Date; 34 permissions?: string[]; 35 }; 36 37 export function getAccessTokenCookieClientSide(): string | null { 38 if (!browser) return null; 39 const match = document.cookie.match(new RegExp('(^| )wave_access_token=([^;]+)')); 40 return match ? decodeURIComponent(match[2]) : null; 41 } 42 43 const EXPIRY_BUFFER_SECONDS = 30; // Refresh if expiring within 30 seconds 44 45 let loggingOut = false; 46 47 export function getUserData(jwt: string | null): WaveLoggedInUser | null { 48 if (!jwt) { 49 return null; 50 } 51 52 const parsed = accessClaimJwtSchema.safeParse(jwtDecode(jwt)); 53 54 // maybe we added a new claim, in this case the user needs to refresh the token 55 // usually does not require a re-login as the refresh token is still valid 56 if (!parsed.success) { 57 return null; 58 } 59 60 const { data: content } = parsed; 61 62 const now = Math.floor(Date.now() / 1000); 63 if (content.exp < now + EXPIRY_BUFFER_SECONDS) { 64 return null; 65 } 66 67 return { 68 id: content.sub, 69 gitHubUsername: content.name, 70 name: content.name, 71 gitHubAvatarUrl: content.picture, 72 email: content.email, 73 avatarUrl: content.picture, 74 signUpDate: content.signUpDate, 75 payoutAddresses: content.payoutAddresses, 76 permissions: content.permissions, 77 }; 78 } 79 80 export async function getRefreshedAuthToken(manualCookie?: string) { 81 if (browser && loggingOut) return null; 82 83 try { 84 const res = await call('/api/auth/token/refresh', { 85 method: 'POST', 86 credentials: 'include', 87 headers: manualCookie ? { Cookie: manualCookie } : {}, 88 }); 89 90 const data = z 91 .object({ 92 accessToken: z.string(), 93 }) 94 .parse(res); 95 96 // Defensive: if loggingOut were true we'd have returned early above, 97 // but reset it here as a safeguard against future refactors. 98 if (browser) loggingOut = false; 99 100 return data.accessToken; 101 } catch (e) { 102 // eslint-disable-next-line no-console 103 console.error('Failed to refresh auth token:', e); 104 105 await logOut(); 106 107 if (e instanceof AccountSuspendedError) { 108 await goto('/wave/suspended'); 109 return null; 110 } 111 112 await invalidateAll(); 113 114 return null; 115 } 116 } 117 118 export async function redeemGitHubOAuthCode(code: string, state: string) { 119 const res = await call('/api/auth/oauth/github/redeem-login', { 120 method: 'POST', 121 headers: { 122 'Content-Type': 'application/json', 123 }, 124 body: JSON.stringify({ code, state }), 125 credentials: 'include', 126 }); 127 128 const data = z 129 .object({ 130 accessToken: z.string(), 131 newUser: z.boolean(), 132 }) 133 .parse(res); 134 135 if (browser) loggingOut = false; // Defensive: ensure flag is cleared after successful login 136 137 return data; 138 } 139 140 export async function logOut() { 141 if (browser) loggingOut = true; 142 try { 143 await call('/api/auth/logout', { 144 method: 'POST', 145 credentials: 'include', 146 }); 147 } catch { 148 if (browser) loggingOut = false; 149 } 150 } 151 152 export async function getIntercomJwt() { 153 return parseRes( 154 z.object({ 155 token: z.string(), 156 }), 157 await authenticatedCall(undefined, '/api/user/intercom-identity'), 158 ); 159 } 160 161 export async function getNovuHmac() { 162 return parseRes( 163 z.object({ 164 hmacHash: z.string(), 165 }), 166 await authenticatedCall(undefined, '/api/user/novu-identity'), 167 ); 168 }