Skip to content

feat: validate uuid and sign out scope parameters to functions #1063

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/GoTrueAdminApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
_request,
_userResponse,
} from './lib/fetch'
import { resolveFetch } from './lib/helpers'
import { resolveFetch, validateUUID } from './lib/helpers'
import {
AdminUserAttributes,
GenerateLinkParams,
Expand All @@ -19,6 +19,8 @@ import {
AuthMFAAdminListFactorsParams,
AuthMFAAdminListFactorsResponse,
PageParams,
SIGN_OUT_SCOPES,
SignOutScope,
} from './lib/types'
import { AuthError, isAuthError } from './lib/errors'

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<UserResponse> {
validateUUID(uid)

try {
return await _request(this.fetch, 'GET', `${this.url}/admin/users/${uid}`, {
headers: this.headers,
Expand All @@ -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<UserResponse> {
validateUUID(uid)

try {
return await _request(this.fetch, 'PUT', `${this.url}/admin/users/${uid}`, {
body: attributes,
Expand All @@ -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<UserResponse> {
validateUUID(id)

try {
return await _request(this.fetch, 'DELETE', `${this.url}/admin/users/${id}`, {
headers: this.headers,
Expand All @@ -286,6 +300,8 @@ export default class GoTrueAdminApi {
private async _listFactors(
params: AuthMFAAdminListFactorsParams
): Promise<AuthMFAAdminListFactorsResponse> {
validateUUID(params.userId)

try {
const { data, error } = await _request(
this.fetch,
Expand All @@ -311,6 +327,9 @@ export default class GoTrueAdminApi {
private async _deleteFactor(
params: AuthMFAAdminDeleteFactorParams
): Promise<AuthMFAAdminDeleteFactorResponse> {
validateUUID(params.userId)
validateUUID(params.id)

try {
const data = await _request(
this.fetch,
Expand Down
10 changes: 9 additions & 1 deletion src/lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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')
}
}
3 changes: 3 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
38 changes: 22 additions & 16 deletions test/GoTrueApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -513,7 +513,7 @@ describe('GoTrueAdminApi', () => {
expect(uid).toBeTruthy()

const { error: enrollError } = await authClientWithSession.mfa.enroll({
factorType: 'totp'
factorType: 'totp',
})
expect(enrollError).toBeNull()

Expand All @@ -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()
})

})
})