/ src / lib / utils / wave / auth.ts
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  }