From 54fa59264dc26821fe09026e30c25944d522fa0a Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Mon, 12 May 2025 15:57:58 +0200 Subject: [PATCH] feat: validate uuid and sign out scope parameters to functions --- src/GoTrueAdminApi.ts | 23 +++++++++++++++++++++-- src/lib/helpers.ts | 10 +++++++++- src/lib/types.ts | 3 +++ test/GoTrueApi.test.ts | 38 ++++++++++++++++++++++---------------- 4 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src/GoTrueAdminApi.ts b/src/GoTrueAdminApi.ts index 79f19839..5ccbe074 100644 --- a/src/GoTrueAdminApi.ts +++ b/src/GoTrueAdminApi.ts @@ -5,7 +5,7 @@ import { _request, _userResponse, } from './lib/fetch' -import { resolveFetch } from './lib/helpers' +import { resolveFetch, validateUUID } from './lib/helpers' import { AdminUserAttributes, GenerateLinkParams, @@ -19,6 +19,8 @@ import { AuthMFAAdminListFactorsParams, AuthMFAAdminListFactorsResponse, PageParams, + SIGN_OUT_SCOPES, + SignOutScope, } from './lib/types' import { AuthError, isAuthError } from './lib/errors' @@ -59,8 +61,14 @@ export default class GoTrueAdminApi { */ async signOut( jwt: string, - scope: 'global' | 'local' | 'others' = 'global' + scope: SignOutScope = SIGN_OUT_SCOPES[0] ): Promise<{ data: null; error: AuthError | null }> { + if (SIGN_OUT_SCOPES.indexOf(scope) < 0) { + throw new Error( + `@supabase/auth-js: Parameter scope must be one of ${SIGN_OUT_SCOPES.join(', ')}` + ) + } + try { await _request(this.fetch, 'POST', `${this.url}/logout?scope=${scope}`, { headers: this.headers, @@ -219,6 +227,8 @@ export default class GoTrueAdminApi { * This function should only be called on a server. Never expose your `service_role` key in the browser. */ async getUserById(uid: string): Promise { + validateUUID(uid) + try { return await _request(this.fetch, 'GET', `${this.url}/admin/users/${uid}`, { headers: this.headers, @@ -241,6 +251,8 @@ export default class GoTrueAdminApi { * This function should only be called on a server. Never expose your `service_role` key in the browser. */ async updateUserById(uid: string, attributes: AdminUserAttributes): Promise { + validateUUID(uid) + try { return await _request(this.fetch, 'PUT', `${this.url}/admin/users/${uid}`, { body: attributes, @@ -266,6 +278,8 @@ export default class GoTrueAdminApi { * This function should only be called on a server. Never expose your `service_role` key in the browser. */ async deleteUser(id: string, shouldSoftDelete = false): Promise { + validateUUID(id) + try { return await _request(this.fetch, 'DELETE', `${this.url}/admin/users/${id}`, { headers: this.headers, @@ -286,6 +300,8 @@ export default class GoTrueAdminApi { private async _listFactors( params: AuthMFAAdminListFactorsParams ): Promise { + validateUUID(params.userId) + try { const { data, error } = await _request( this.fetch, @@ -311,6 +327,9 @@ export default class GoTrueAdminApi { private async _deleteFactor( params: AuthMFAAdminDeleteFactorParams ): Promise { + validateUUID(params.userId) + validateUUID(params.id) + try { const data = await _request( this.fetch, diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 012e58e4..6931423b 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -1,6 +1,6 @@ import { API_VERSION_HEADER_NAME, BASE64URL_REGEX } from './constants' import { AuthInvalidJwtError } from './errors' -import { base64UrlToUint8Array, stringFromBase64URL, stringToBase64URL } from './base64url' +import { base64UrlToUint8Array, stringFromBase64URL } from './base64url' import { JwtHeader, JwtPayload, SupportedStorage } from './types' export function expiresAt(expiresIn: number) { @@ -357,3 +357,11 @@ export function getAlgorithm(alg: 'RS256' | 'ES256'): RsaHashedImportParams | Ec throw new Error('Invalid alg claim') } } + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + +export function validateUUID(str: string) { + if (!UUID_REGEX.test(str)) { + throw new Error('@supabase/auth-js: Expected parameter to be UUID but is not') + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 9e0c1f63..b2d3b01d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1279,3 +1279,6 @@ export interface JWK { kid?: string [key: string]: any } + +export const SIGN_OUT_SCOPES = ['global', 'local', 'others'] as const +export type SignOutScope = typeof SIGN_OUT_SCOPES[number] diff --git a/test/GoTrueApi.test.ts b/test/GoTrueApi.test.ts index 8c0dba89..523b4be9 100644 --- a/test/GoTrueApi.test.ts +++ b/test/GoTrueApi.test.ts @@ -16,7 +16,7 @@ import { import type { GenerateLinkProperties, User } from '../src/lib/types' const INVALID_EMAIL = 'xx:;x@x.x' -const INVALID_USER_ID = 'invalid-uuid' +const NON_EXISTANT_USER_ID = '83fd9e20-7a80-46e4-bf29-a86e3d6bbf66' describe('GoTrueAdminApi', () => { describe('User creation', () => { @@ -152,7 +152,7 @@ describe('GoTrueAdminApi', () => { }) test('getUserById() returns AuthError when user id is invalid', async () => { - const { error, data } = await serviceRoleApiClient.getUserById(INVALID_USER_ID) + const { error, data } = await serviceRoleApiClient.getUserById(NON_EXISTANT_USER_ID) expect(error).not.toBeNull() expect(data.user).toBeNull() @@ -283,7 +283,7 @@ describe('GoTrueAdminApi', () => { }) test('deleteUser() returns AuthError when user id is invalid', async () => { - const { error, data } = await serviceRoleApiClient.deleteUser(INVALID_USER_ID) + const { error, data } = await serviceRoleApiClient.deleteUser(NON_EXISTANT_USER_ID) expect(error).not.toBeNull() expect(data.user).toBeNull() @@ -479,7 +479,7 @@ describe('GoTrueAdminApi', () => { test('listUsers() returns AuthError when page is invalid', async () => { const { error, data } = await serviceRoleApiClient.listUsers({ page: -1, - perPage: 10 + perPage: 10, }) expect(error).not.toBeNull() @@ -489,8 +489,8 @@ describe('GoTrueAdminApi', () => { describe('Update User', () => { test('updateUserById() returns AuthError when user id is invalid', async () => { - const { error, data } = await serviceRoleApiClient.updateUserById(INVALID_USER_ID, { - email: 'new@email.com' + const { error, data } = await serviceRoleApiClient.updateUserById(NON_EXISTANT_USER_ID, { + email: 'new@email.com', }) expect(error).not.toBeNull() @@ -513,7 +513,7 @@ describe('GoTrueAdminApi', () => { expect(uid).toBeTruthy() const { error: enrollError } = await authClientWithSession.mfa.enroll({ - factorType: 'totp' + factorType: 'totp', }) expect(enrollError).toBeNull() @@ -526,35 +526,41 @@ describe('GoTrueAdminApi', () => { const factorId = data?.factors[0].id expect(factorId).toBeDefined() - const { data: deletedData, error: deletedError } = await serviceRoleApiClient.mfa.deleteFactor({ - userId: uid, - id: factorId! - }) + const { data: deletedData, error: deletedError } = + await serviceRoleApiClient.mfa.deleteFactor({ + userId: uid, + id: factorId!, + }) expect(deletedError).toBeNull() expect(deletedData).not.toBeNull() const deletedId = (deletedData as any)?.data?.id console.log('deletedId:', deletedId) expect(deletedId).toEqual(factorId) - const { data: latestData, error: latestError } = await serviceRoleApiClient.mfa.listFactors({ userId: uid }) + const { data: latestData, error: latestError } = await serviceRoleApiClient.mfa.listFactors({ + userId: uid, + }) expect(latestError).toBeNull() expect(latestData).not.toBeNull() expect(Array.isArray(latestData?.factors)).toBe(true) expect(latestData?.factors.length).toEqual(0) }) - test('mfa.listFactors returns AuthError for invalid user', async () => { - const { data, error } = await serviceRoleApiClient.mfa.listFactors({ userId: INVALID_USER_ID }) + const { data, error } = await serviceRoleApiClient.mfa.listFactors({ + userId: NON_EXISTANT_USER_ID, + }) expect(data).toBeNull() expect(error).not.toBeNull() }) test('mfa.deleteFactors returns AuthError for invalid user', async () => { - const { data, error } = await serviceRoleApiClient.mfa.deleteFactor({ userId: INVALID_USER_ID , id: '1' }) + const { data, error } = await serviceRoleApiClient.mfa.deleteFactor({ + userId: NON_EXISTANT_USER_ID, + id: NON_EXISTANT_USER_ID, + }) expect(data).toBeNull() expect(error).not.toBeNull() }) - }) })