Add support for OTPs and enhance sensitive apis with OTP verification (#191)

One-Time Password (OTP) package added with comprehensive token management, including OTP verification for team account deletion and ownership transfer.
This commit is contained in:
Giancarlo Buomprisco
2025-03-01 16:35:09 +07:00
committed by GitHub
parent 20f7fd2c22
commit d31f3eb993
60 changed files with 3543 additions and 1363 deletions

View File

@@ -48,6 +48,7 @@ export default async function EmailPage(props: EmailPageProps) {
'change-email-address-email': 'Change Email Address Email',
'reset-password-email': 'Reset Password Email',
'magic-link-email': 'Magic Link Email',
'otp-email': 'OTP Email',
}}
/>
}

View File

@@ -1,45 +1,48 @@
import {
renderAccountDeleteEmail,
renderInviteEmail,
renderOtpEmail,
} from '@kit/email-templates';
export async function loadEmailTemplate(id: string) {
if (id === 'account-delete-email') {
return renderAccountDeleteEmail({
productName: 'Makerkit',
userDisplayName: 'Giancarlo',
});
}
switch (id) {
case 'account-delete-email':
return renderAccountDeleteEmail({
productName: 'Makerkit',
userDisplayName: 'Giancarlo',
});
if (id === 'invite-email') {
return renderInviteEmail({
teamName: 'Makerkit',
teamLogo:
'',
inviter: 'Giancarlo',
invitedUserEmail: 'test@makerkit.dev',
link: 'https://makerkit.dev',
productName: 'Makerkit',
});
}
case 'invite-email':
return renderInviteEmail({
teamName: 'Makerkit',
teamLogo: '',
inviter: 'Giancarlo',
invitedUserEmail: 'test@makerkit.dev',
link: 'https://makerkit.dev',
productName: 'Makerkit',
});
if (id === 'magic-link-email') {
return loadFromFileSystem('magic-link');
}
case 'otp-email':
return renderOtpEmail({
productName: 'Makerkit',
otp: '123456',
});
if (id === 'reset-password-email') {
return loadFromFileSystem('reset-password');
}
case 'magic-link-email':
return loadFromFileSystem('magic-link');
if (id === 'change-email-address-email') {
return loadFromFileSystem('change-email-address');
}
case 'reset-password-email':
return loadFromFileSystem('reset-password');
if (id === 'confirm-email') {
return loadFromFileSystem('confirm-email');
}
case 'change-email-address-email':
return loadFromFileSystem('change-email-address');
throw new Error(`Email template not found: ${id}`);
case 'confirm-email':
return loadFromFileSystem('confirm-email');
default:
throw new Error(`Email template not found: ${id}`);
}
}
async function loadFromFileSystem(fileName: string) {

View File

@@ -75,6 +75,14 @@ export default async function EmailsPage() {
</CardButtonHeader>
</Link>
</CardButton>
<CardButton asChild>
<Link href={'/emails/otp-email'}>
<CardButtonHeader>
<CardButtonTitle>OTP Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
</div>
</div>
</PageBody>

View File

@@ -7,7 +7,7 @@ const testIgnore: string[] = [];
if (!enableBillingTests) {
console.log(
`Billing tests are disabled. To enable them, set the environment variable ENABLE_BILLING_TESTS=true.`,
`Current value: "${process.env.ENABLE_BILLING_TESTS}"`
`Current value: "${process.env.ENABLE_BILLING_TESTS}"`,
);
testIgnore.push('*-billing.spec.ts');
@@ -45,15 +45,14 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
navigationTimeout: 5000,
},
// test timeout set to 1 minutes
timeout: 60 * 1000,
expect: {
// expect timeout set to 10 seconds
timeout: 10 * 1000,
},
/* Configure projects for major browsers */
projects: [
{

View File

@@ -1,14 +1,17 @@
import { Page, expect } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po';
import { OtpPo } from '../utils/otp.po';
export class AccountPageObject {
private readonly page: Page;
public auth: AuthPageObject;
private otp: OtpPo;
constructor(page: Page) {
this.page = page;
this.auth = new AuthPageObject(page);
this.otp = new OtpPo(page);
}
async setup() {
@@ -58,32 +61,16 @@ export class AccountPageObject {
await this.page.click('[data-test="account-password-form"] button');
}
async deleteAccount() {
await expect(async () => {
await this.page.click('[data-test="delete-account-button"]');
async deleteAccount(email: string) {
// Click the delete account button to open the modal
await this.page.click('[data-test="delete-account-button"]');
await this.page.fill(
'[data-test="delete-account-input-field"]',
'DELETE',
);
// Complete the OTP verification process
await this.otp.completeOtpVerification(email);
const click = this.page.click(
'[data-test="confirm-delete-account-button"]',
);
await this.page.waitForTimeout(500);
const response = await this.page
.waitForResponse((resp) => {
return (
resp.url().includes('home/settings') &&
resp.request().method() === 'POST'
);
})
.then((response) => {
expect(response.status()).toBe(303);
});
await Promise.all([click, response]);
}).toPass();
await this.page.click('[data-test="confirm-delete-account-button"]');
}
getProfileName() {

View File

@@ -1,6 +1,7 @@
import { Page, expect, test } from '@playwright/test';
import { AccountPageObject } from './account.po';
import {AuthPageObject} from "../authentication/auth.po";
test.describe('Account Settings', () => {
let page: Page;
@@ -51,22 +52,22 @@ test.describe('Account Settings', () => {
test.describe('Account Deletion', () => {
test('user can delete their own account', async ({ page }) => {
const account = new AccountPageObject(page);
const auth = new AuthPageObject(page);
await account.setup();
const { email } = await account.setup();
const request = account.deleteAccount();
await account.deleteAccount(email);
const response = page
.waitForResponse((resp) => {
return (
resp.url().includes('home/settings') &&
resp.request().method() === 'POST'
);
})
.then((response) => {
expect(response.status()).toBe(303);
});
await page.waitForURL('/');
await Promise.all([request, response]);
await page.goto('/auth/sign-in');
// sign in will now fail
await auth.signIn({
email,
password: 'testingpassword',
});
await expect(page.locator('[data-test="auth-error-message"]')).toBeVisible();
});
});

View File

@@ -25,7 +25,7 @@ export class AuthPageObject {
}
async signIn(params: { email: string; password: string }) {
await this.page.waitForTimeout(1000);
await this.page.waitForTimeout(500);
await this.page.fill('input[name="email"]', params.email);
await this.page.fill('input[name="password"]', params.password);
@@ -37,7 +37,7 @@ export class AuthPageObject {
password: string;
repeatPassword: string;
}) {
await this.page.waitForTimeout(1000);
await this.page.waitForTimeout(500);
await this.page.fill('input[name="email"]', params.email);
await this.page.fill('input[name="password"]', params.password);
@@ -50,6 +50,7 @@ export class AuthPageObject {
email: string,
params: {
deleteAfter: boolean;
subject?: string;
} = {
deleteAfter: true,
},
@@ -79,6 +80,10 @@ export class AuthPageObject {
});
await this.visitConfirmEmailLink(email);
return {
email,
};
}
async updatePassword(password: string) {

View File

@@ -51,6 +51,23 @@ test.describe('Auth flow', () => {
expect(page.url()).toContain('/');
});
test('will sign out using the dropdown', async ({ page }) => {
const auth = new AuthPageObject(page);
await page.goto('/home/settings');
await auth.signIn({
email: 'test@makerkit.dev',
password: 'testingpassword',
});
await page.waitForURL('/home/settings');
await auth.signOut();
await page.waitForURL('/');
});
});
test.describe('Protected routes', () => {

View File

@@ -2,35 +2,60 @@ import { expect, test } from '@playwright/test';
import { AuthPageObject } from './auth.po';
const email = 'owner@makerkit.dev';
const newPassword = (Math.random() * 10000).toString();
test.describe('Password Reset Flow', () => {
test.describe.configure({ mode: 'serial' });
test('will reset the password and sign in with new one', async ({ page }) => {
const auth = new AuthPageObject(page);
await page.goto('/auth/password-reset');
let email = '';
await page.fill('[name="email"]', email);
await page.click('[type="submit"]');
await expect(async () => {
email = `test-${Math.random() * 10000}@makerkit.dev`;
await auth.visitConfirmEmailLink(email);
await page.goto('/auth/sign-up');
await page.waitForURL('/update-password');
await auth.signUp({
email,
password: 'password',
repeatPassword: 'password',
});
await auth.updatePassword(newPassword);
await auth.visitConfirmEmailLink(email, {
deleteAfter: true,
subject: 'Confirm your email',
});
await page
.locator('a', {
hasText: 'Back to Home Page',
})
.click();
await page.context().clearCookies();
await page.reload();
await page.waitForURL('/home');
await page.goto('/auth/password-reset');
await auth.signOut();
await page.fill('[name="email"]', email);
await page.click('[type="submit"]');
await auth.visitConfirmEmailLink(email, {
deleteAfter: true,
subject: 'Reset your password',
});
await page.waitForURL('/update-password', {
timeout: 1000,
});
await auth.updatePassword(newPassword);
await page
.locator('a', {
hasText: 'Back to Home Page',
})
.click();
await page.waitForURL('/home');
}).toPass();
await page.context().clearCookies();
await page.reload();
await page
.locator('a', {
@@ -43,6 +68,8 @@ test.describe('Password Reset Flow', () => {
password: newPassword,
});
await page.waitForURL('/home');
await page.waitForURL('/home', {
timeout: 2000,
});
});
});

View File

@@ -64,7 +64,7 @@ export class InvitationsPageObject {
})
.click();
return expect(this.page.url()).toContain('members');
await this.page.waitForURL('**/home/*/members');
}).toPass()
}

View File

@@ -1,20 +1,25 @@
import { Page, expect } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po';
import { OtpPo } from '../utils/otp.po';
export class TeamAccountsPageObject {
private readonly page: Page;
public auth: AuthPageObject;
public otp: OtpPo;
constructor(page: Page) {
this.page = page;
this.auth = new AuthPageObject(page);
this.otp = new OtpPo(page);
}
async setup(params = this.createTeamName()) {
await this.auth.signUpFlow('/home');
const { email } = await this.auth.signUpFlow('/home');
await this.createTeam(params);
return { email, teamName: params.teamName, slug: params.slug };
}
getTeamFromSelector(teamName: string) {
@@ -39,6 +44,18 @@ export class TeamAccountsPageObject {
}).toPass();
}
goToMembers() {
return expect(async () => {
await this.page
.locator('a', {
hasText: 'Members',
})
.click();
await this.page.waitForURL('**/home/*/members');
}).toPass();
}
goToBilling() {
return expect(async () => {
await this.page
@@ -94,18 +111,11 @@ export class TeamAccountsPageObject {
}).toPass();
}
async deleteAccount(teamName: string) {
async deleteAccount(email: string) {
await expect(async () => {
await this.page.click('[data-test="delete-team-trigger"]');
await expect(
this.page.locator('[data-test="delete-team-form-confirm-input"]'),
).toBeVisible();
await this.page.fill(
'[data-test="delete-team-form-confirm-input"]',
teamName,
);
await this.otp.completeOtpVerification(email);
const click = this.page.click(
'[data-test="delete-team-form-confirm-button"]',
@@ -117,6 +127,51 @@ export class TeamAccountsPageObject {
}).toPass();
}
async updateMemberRole(memberEmail: string, newRole: string) {
await expect(async () => {
// Find the member row and click the actions button
const memberRow = this.page.getByRole('row', { name: memberEmail });
await memberRow.getByRole('button').click();
// Click the update role option in the dropdown menu
await this.page.getByText('Update Role').click();
// Select the new role
await this.page.click('[data-test="role-selector-trigger"]');
await this.page.click(`[data-test="role-option-${newRole}"]`);
// Click the confirm button
const click = this.page.click('[data-test="confirm-update-member-role"]');
// Wait for the update to complete and page to reload
const response = this.page.waitForURL('**/home/*/members');
return Promise.all([click, response]);
}).toPass();
}
async transferOwnership(memberEmail: string, ownerEmail: string) {
await expect(async () => {
// Find the member row and click the actions button
const memberRow = this.page.getByRole('row', { name: memberEmail });
await memberRow.getByRole('button').click();
// Click the transfer ownership option in the dropdown menu
await this.page.getByText('Transfer Ownership').click();
// Complete OTP verification
await this.otp.completeOtpVerification(ownerEmail);
// Click the confirm button
const click = this.page.click('[data-test="confirm-transfer-ownership-button"]');
// Wait for the transfer to complete and page to reload
const response = this.page.waitForURL('**/home/*/members');
return Promise.all([click, response]);
}).toPass();
}
createTeamName() {
const random = (Math.random() * 100000000).toFixed(0);

View File

@@ -1,7 +1,74 @@
import { Page, expect, test } from '@playwright/test';
import { InvitationsPageObject } from '../invitations/invitations.po';
import { TeamAccountsPageObject } from './team-accounts.po';
// Helper function to set up a team with a member
async function setupTeamWithMember(page: Page, memberRole = 'member') {
// Setup invitations page object
const invitations = new InvitationsPageObject(page);
const teamAccounts = invitations.teamAccounts;
// Setup team with owner
const { email: ownerEmail, slug } = await invitations.setup();
// Navigate to members page
await invitations.navigateToMembers();
// Create a new member email and invite them with the specified role
const memberEmail = invitations.auth.createRandomEmail();
const invites = [
{
email: memberEmail,
role: memberRole,
},
];
await invitations.openInviteForm();
await invitations.inviteMembers(invites);
// Verify the invitation was sent
await expect(invitations.getInvitations()).toHaveCount(1);
// Sign out the current user
await page.context().clearCookies();
// Sign up with the new member email and accept the invitation
await invitations.auth.visitConfirmEmailLink(memberEmail);
await invitations.auth.signUp({
email: memberEmail,
password: 'password',
repeatPassword: 'password',
});
await invitations.auth.visitConfirmEmailLink(memberEmail);
await invitations.acceptInvitation();
await invitations.teamAccounts.openAccountsSelector();
await expect(invitations.teamAccounts.getTeams()).toHaveCount(1);
// Sign out and sign back in as the original owner
await page.context().clearCookies();
await page.goto('/auth/sign-in');
await invitations.auth.signIn({
email: ownerEmail,
password: 'password',
});
await page.waitForURL('/home');
// Navigate to the team members page
await page.goto(`/home/${slug}/members`);
return { invitations, teamAccounts, ownerEmail, memberEmail, slug };
}
test.describe('Team Accounts', () => {
let page: Page;
let teamAccounts: TeamAccountsPageObject;
@@ -31,15 +98,15 @@ test.describe('Team Accounts', () => {
});
});
test.describe('Account Deletion', () => {
test.describe('Team Account Deletion', () => {
test('user can delete their team account', async ({ page }) => {
const teamAccounts = new TeamAccountsPageObject(page);
const params = teamAccounts.createTeamName();
await teamAccounts.setup(params);
const { email } = await teamAccounts.setup(params);
await teamAccounts.goToSettings();
await teamAccounts.deleteAccount(params.teamName);
await teamAccounts.deleteAccount(email);
await teamAccounts.openAccountsSelector();
await expect(
@@ -47,3 +114,62 @@ test.describe('Account Deletion', () => {
).not.toBeVisible();
});
});
test.describe('Team Member Role Management', () => {
test("owner can update a team member's role", async ({ page }) => {
// Setup team with a regular member
const { teamAccounts, memberEmail } = await setupTeamWithMember(page);
// Get the current role badge text
const memberRow = page.getByRole('row', { name: memberEmail });
const initialRoleBadge = memberRow.locator(
'[data-test="member-role-badge"]',
);
await expect(initialRoleBadge).toHaveText('Member');
// Update the member's role to admin
await teamAccounts.updateMemberRole(memberEmail, 'owner');
// Wait for the page to fully load after the update
await page.waitForTimeout(1000);
// Verify the role was updated successfully
const updatedRoleBadge = page
.getByRole('row', { name: memberEmail })
.locator('[data-test="member-role-badge"]');
await expect(updatedRoleBadge).toHaveText('Owner');
});
});
test.describe('Team Ownership Transfer', () => {
test('owner can transfer ownership to another team member', async ({
page,
}) => {
// Setup team with an owner member (required for ownership transfer)
const { teamAccounts, ownerEmail, memberEmail } = await setupTeamWithMember(
page,
'owner',
);
// Transfer ownership to the member
await teamAccounts.transferOwnership(memberEmail, ownerEmail);
// Wait for the page to fully load after the transfer
await page.waitForTimeout(1000);
// Verify the transfer was successful by checking if the primary owner badge
// is now on the new owner's row
const memberRow = page.getByRole('row', { name: memberEmail });
// Check for the primary owner badge on the member's row
await expect(memberRow.locator('text=Primary Owner')).toBeVisible({
timeout: 5000,
});
// The original owner should no longer have the primary owner badge
const ownerRow = page.getByRole('row', { name: ownerEmail.split('@')[0] });
await expect(ownerRow.locator('text=Primary Owner')).not.toBeVisible();
});
});

View File

@@ -8,6 +8,7 @@ export class Mailbox {
email: string,
params: {
deleteAfter: boolean;
subject?: string;
},
) {
const mailbox = email.split('@')[0];
@@ -18,13 +19,17 @@ export class Mailbox {
throw new Error('Invalid email');
}
const json = await this.getInviteEmail(mailbox, params);
const json = await this.getEmail(mailbox, params);
if (!json?.body) {
throw new Error('Email body was not found');
}
console.log('Email found');
console.log(`Email found for ${email}`, {
id: json.id,
subject: json.subject,
date: json.date,
});
const html = (json.body as { html: string }).html;
const el = parse(html);
@@ -40,10 +45,49 @@ export class Mailbox {
return this.page.goto(linkHref);
}
async getInviteEmail(
/**
* Retrieves an OTP code from an email
* @param email The email address to check for the OTP
* @param deleteAfter Whether to delete the email after retrieving the OTP
* @returns The OTP code
*/
async getOtpFromEmail(email: string, deleteAfter: boolean = true) {
const mailbox = email.split('@')[0];
console.log(`Retrieving OTP from mailbox ${email} ...`);
if (!mailbox) {
throw new Error('Invalid email');
}
const json = await this.getEmail(mailbox, {
deleteAfter,
subject: `One-time password for Makerkit`,
});
if (!json?.body) {
throw new Error('Email body was not found');
}
const html = (json.body as { html: string }).html;
const text = html.match(
new RegExp(`Your one-time password is: (\\d{6})`),
)?.[1];
if (text) {
console.log(`OTP code found in text: ${text}`);
return text;
}
throw new Error('Could not find OTP code in email');
}
async getEmail(
mailbox: string,
params: {
deleteAfter: boolean;
subject?: string;
},
) {
const url = `http://127.0.0.1:54324/api/v1/mailbox/${mailbox}`;
@@ -54,13 +98,34 @@ export class Mailbox {
throw new Error(`Failed to fetch emails: ${response.statusText}`);
}
const json = (await response.json()) as Array<{ id: string }>;
const json = (await response.json()) as Array<{
id: string;
subject: string;
}>;
if (!json || !json.length) {
console.log(`No emails found for mailbox ${mailbox}`);
return;
}
const messageId = json[0]?.id;
const message = params.subject
? (() => {
const filtered = json.filter(
(item) => item.subject === params.subject,
);
console.log(
`Found ${filtered.length} emails with subject ${params.subject}`,
);
return filtered[filtered.length - 1];
})()
: json[0];
console.log(`Message: ${JSON.stringify(message)}`);
const messageId = message?.id;
const messageUrl = `${url}/${messageId}`;
const messageResponse = await fetch(messageUrl);

View File

@@ -0,0 +1,63 @@
import { Page, expect } from '@playwright/test';
import { Mailbox } from './mailbox';
export class OtpPo {
private readonly page: Page;
private readonly mailbox: Mailbox;
constructor(page: Page) {
this.page = page;
this.mailbox = new Mailbox(page);
}
/**
* Completes the OTP verification process
* @param email The email address to send the OTP to
*/
async completeOtpVerification(email: string) {
// Click the "Send Verification Code" button
await this.page.click('[data-test="otp-send-verification-button"]');
// wait for the OTP to be sent
await this.page.waitForTimeout(500);
await expect(async () => {
// Get the OTP code from the email
const otpCode = await this.getOtpCodeFromEmail(email);
expect(otpCode).not.toBeNull();
// Enter the OTP code
await this.enterOtpCode(otpCode);
}).toPass();
// Click the "Verify Code" button
await this.page.click('[data-test="otp-verify-button"]');
}
/**
* Retrieves the OTP code from an email
* @param email The email address to check for the OTP
* @returns The OTP code
*/
async getOtpCodeFromEmail(email: string) {
// Get the OTP from the email
const otpCode = await this.mailbox.getOtpFromEmail(email);
if (!otpCode) {
throw new Error('Failed to retrieve OTP code from email');
}
return otpCode;
}
/**
* Enters the OTP code into the input fields
* @param otpCode The 6-digit OTP code
*/
async enterOtpCode(otpCode: string) {
console.log(`Entering OTP code: ${otpCode}`);
await this.page.fill('[data-input-otp]', otpCode);
}
}

View File

@@ -93,7 +93,7 @@ async function getLayoutState() {
const sidebarOpenCookieValue = sidebarOpenCookie
? sidebarOpenCookie.value === 'false'
: personalAccountNavigationConfig.sidebarCollapsed;
: !personalAccountNavigationConfig.sidebarCollapsed;
const style =
layoutStyleCookie?.value ?? personalAccountNavigationConfig.style;

View File

@@ -126,12 +126,13 @@ async function getLayoutState(account: string) {
const cookieStore = await cookies();
const sidebarOpenCookie = cookieStore.get('sidebar:state');
const layoutCookie = cookieStore.get('layout-style');
const layoutStyle = layoutCookie?.value as PageLayoutStyle;
const config = getTeamAccountSidebarConfig(account);
const sidebarOpenCookieValue = sidebarOpenCookie
? sidebarOpenCookie.value === 'false'
: config.sidebarCollapsed;
: !config.sidebarCollapsed;
return {
open: sidebarOpenCookieValue,

View File

@@ -77,29 +77,7 @@ export type Database = {
updated_at?: string | null;
updated_by?: string | null;
};
Relationships: [
{
foreignKeyName: 'accounts_created_by_fkey';
columns: ['created_by'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
{
foreignKeyName: 'accounts_primary_owner_user_id_fkey';
columns: ['primary_owner_user_id'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
{
foreignKeyName: 'accounts_updated_by_fkey';
columns: ['updated_by'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
];
Relationships: [];
};
accounts_memberships: {
Row: {
@@ -158,27 +136,6 @@ export type Database = {
referencedRelation: 'roles';
referencedColumns: ['name'];
},
{
foreignKeyName: 'accounts_memberships_created_by_fkey';
columns: ['created_by'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
{
foreignKeyName: 'accounts_memberships_updated_by_fkey';
columns: ['updated_by'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
{
foreignKeyName: 'accounts_memberships_user_id_fkey';
columns: ['user_id'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
];
};
billing_customers: {
@@ -304,13 +261,6 @@ export type Database = {
referencedRelation: 'user_accounts';
referencedColumns: ['id'];
},
{
foreignKeyName: 'invitations_invited_by_fkey';
columns: ['invited_by'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
{
foreignKeyName: 'invitations_role_fkey';
columns: ['role'];
@@ -320,6 +270,69 @@ export type Database = {
},
];
};
nonces: {
Row: {
client_token: string;
created_at: string;
description: string | null;
expires_at: string;
id: string;
last_verification_at: string | null;
last_verification_ip: unknown | null;
last_verification_user_agent: string | null;
metadata: Json | null;
nonce: string;
purpose: string;
revoked: boolean;
revoked_reason: string | null;
scopes: string[] | null;
tags: string[] | null;
used_at: string | null;
user_id: string | null;
verification_attempts: number;
};
Insert: {
client_token: string;
created_at?: string;
description?: string | null;
expires_at: string;
id?: string;
last_verification_at?: string | null;
last_verification_ip?: unknown | null;
last_verification_user_agent?: string | null;
metadata?: Json | null;
nonce: string;
purpose: string;
revoked?: boolean;
revoked_reason?: string | null;
scopes?: string[] | null;
tags?: string[] | null;
used_at?: string | null;
user_id?: string | null;
verification_attempts?: number;
};
Update: {
client_token?: string;
created_at?: string;
description?: string | null;
expires_at?: string;
id?: string;
last_verification_at?: string | null;
last_verification_ip?: unknown | null;
last_verification_user_agent?: string | null;
metadata?: Json | null;
nonce?: string;
purpose?: string;
revoked?: boolean;
revoked_reason?: string | null;
scopes?: string[] | null;
tags?: string[] | null;
used_at?: string | null;
user_id?: string | null;
verification_attempts?: number;
};
Relationships: [];
};
notifications: {
Row: {
account_id: string;
@@ -727,6 +740,19 @@ export type Database = {
updated_at: string;
};
};
create_nonce: {
Args: {
p_user_id?: string;
p_purpose?: string;
p_expires_in_seconds?: number;
p_metadata?: Json;
p_description?: string;
p_tags?: string[];
p_scopes?: string[];
p_revoke_previous?: boolean;
};
Returns: Json;
};
create_team_account: {
Args: {
account_name: string;
@@ -785,6 +811,12 @@ export type Database = {
Args: Record<PropertyKey, never>;
Returns: Json;
};
get_nonce_status: {
Args: {
p_id: string;
};
Returns: Json;
};
get_upper_system_role: {
Args: Record<PropertyKey, never>;
Returns: string;
@@ -851,6 +883,13 @@ export type Database = {
};
Returns: boolean;
};
revoke_nonce: {
Args: {
p_id: string;
p_reason?: string;
};
Returns: boolean;
};
team_account_workspace: {
Args: {
account_slug: string;
@@ -930,6 +969,18 @@ export type Database = {
updated_at: string;
};
};
verify_nonce: {
Args: {
p_token: string;
p_purpose: string;
p_user_id?: string;
p_required_scopes?: string[];
p_max_verification_attempts?: number;
p_ip?: unknown;
p_user_agent?: string;
};
Returns: Json;
};
};
Enums: {
app_permissions:
@@ -1034,6 +1085,7 @@ export type Database = {
owner_id: string | null;
path_tokens: string[] | null;
updated_at: string | null;
user_metadata: Json | null;
version: string | null;
};
Insert: {
@@ -1047,6 +1099,7 @@ export type Database = {
owner_id?: string | null;
path_tokens?: string[] | null;
updated_at?: string | null;
user_metadata?: Json | null;
version?: string | null;
};
Update: {
@@ -1060,6 +1113,7 @@ export type Database = {
owner_id?: string | null;
path_tokens?: string[] | null;
updated_at?: string | null;
user_metadata?: Json | null;
version?: string | null;
};
Relationships: [
@@ -1081,6 +1135,7 @@ export type Database = {
key: string;
owner_id: string | null;
upload_signature: string;
user_metadata: Json | null;
version: string;
};
Insert: {
@@ -1091,6 +1146,7 @@ export type Database = {
key: string;
owner_id?: string | null;
upload_signature: string;
user_metadata?: Json | null;
version: string;
};
Update: {
@@ -1101,6 +1157,7 @@ export type Database = {
key?: string;
owner_id?: string | null;
upload_signature?: string;
user_metadata?: Json | null;
version?: string;
};
Relationships: [
@@ -1237,6 +1294,10 @@ export type Database = {
updated_at: string;
}[];
};
operation: {
Args: Record<PropertyKey, never>;
Returns: string;
};
search: {
Args: {
prefix: string;
@@ -1348,3 +1409,18 @@ export type Enums<
: PublicEnumNameOrOptions extends keyof PublicSchema['Enums']
? PublicSchema['Enums'][PublicEnumNameOrOptions]
: never;
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof PublicSchema['CompositeTypes']
| { schema: keyof Database },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database;
}
? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
: never = never,
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema['CompositeTypes']
? PublicSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions]
: never;

View File

@@ -73,6 +73,20 @@
"label": "Member"
}
},
"otp": {
"requestVerificationCode": "Request Verification Code",
"requestVerificationCodeDescription": "We must verify your identity to continue with this action. We'll send a verification code to the email address {{email}}.",
"sendingCode": "Sending Code...",
"sendVerificationCode": "Send Verification Code",
"enterVerificationCode": "Enter Verification Code",
"codeSentToEmail": "We've sent a verification code to the email address {{email}}.",
"verificationCode": "Verification Code",
"enterCodeFromEmail": "Enter the 6-digit code we sent to your email.",
"verifying": "Verifying...",
"verifyCode": "Verify Code",
"requestNewCode": "Request New Code",
"errorSendingCode": "Error sending code. Please try again."
},
"cookieBanner": {
"title": "Hey, we use cookies \uD83C\uDF6A",
"description": "This website uses cookies to ensure you get the best experience on our website.",

View File

@@ -0,0 +1,346 @@
create extension if not exists pg_cron;
-- Create a table to store one-time tokens (nonces)
CREATE TABLE IF NOT EXISTS public.nonces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_token TEXT NOT NULL, -- token sent to client (hashed)
nonce TEXT NOT NULL, -- token stored in DB (hashed)
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NULL, -- Optional to support anonymous tokens
purpose TEXT NOT NULL, -- e.g., 'password-reset', 'email-verification', etc.
-- Status fields
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
used_at TIMESTAMPTZ,
revoked BOOLEAN NOT NULL DEFAULT FALSE, -- For administrative revocation
revoked_reason TEXT, -- Reason for revocation if applicable
-- Audit fields
verification_attempts INTEGER NOT NULL DEFAULT 0, -- Track attempted uses
last_verification_at TIMESTAMPTZ, -- Timestamp of last verification attempt
last_verification_ip INET, -- For tracking verification source
last_verification_user_agent TEXT, -- For tracking client information
-- Extensibility fields
metadata JSONB DEFAULT '{}'::JSONB, -- optional metadata
scopes TEXT[] DEFAULT '{}' -- OAuth-style authorized scopes
);
-- Create indexes for efficient lookups
CREATE INDEX IF NOT EXISTS idx_nonces_status ON public.nonces (client_token, user_id, purpose, expires_at)
WHERE used_at IS NULL AND revoked = FALSE;
-- Enable Row Level Security (RLS)
ALTER TABLE public.nonces ENABLE ROW LEVEL SECURITY;
-- RLS policies
-- Users can view their own nonces for verification
CREATE POLICY "Users can read their own nonces"
ON public.nonces
FOR SELECT
USING (
user_id = (select auth.uid())
);
-- Create a function to create a nonce
CREATE OR REPLACE FUNCTION public.create_nonce(
p_user_id UUID DEFAULT NULL,
p_purpose TEXT DEFAULT NULL,
p_expires_in_seconds INTEGER DEFAULT 3600, -- 1 hour by default
p_metadata JSONB DEFAULT NULL,
p_scopes TEXT[] DEFAULT NULL,
p_revoke_previous BOOLEAN DEFAULT TRUE -- New parameter to control automatic revocation
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_client_token TEXT;
v_nonce TEXT;
v_expires_at TIMESTAMPTZ;
v_id UUID;
v_plaintext_token TEXT;
v_revoked_count INTEGER;
BEGIN
-- Revoke previous tokens for the same user and purpose if requested
-- This only applies if a user ID is provided (not for anonymous tokens)
IF p_revoke_previous = TRUE AND p_user_id IS NOT NULL THEN
WITH revoked AS (
UPDATE public.nonces
SET
revoked = TRUE,
revoked_reason = 'Superseded by new token with same purpose'
WHERE
user_id = p_user_id
AND purpose = p_purpose
AND used_at IS NULL
AND revoked = FALSE
AND expires_at > NOW()
RETURNING 1
)
SELECT COUNT(*) INTO v_revoked_count FROM revoked;
END IF;
-- Generate a 6-digit token
v_plaintext_token := (100000 + floor(random() * 900000))::text;
v_client_token := crypt(v_plaintext_token, gen_salt('bf'));
-- Still generate a secure nonce for internal use
v_nonce := encode(gen_random_bytes(24), 'base64');
v_nonce := crypt(v_nonce, gen_salt('bf'));
-- Calculate expiration time
v_expires_at := NOW() + (p_expires_in_seconds * interval '1 second');
-- Insert the new nonce
INSERT INTO public.nonces (
client_token,
nonce,
user_id,
expires_at,
metadata,
purpose,
scopes
)
VALUES (
v_client_token,
v_nonce,
p_user_id,
v_expires_at,
COALESCE(p_metadata, '{}'::JSONB),
p_purpose,
COALESCE(p_scopes, '{}'::TEXT[])
)
RETURNING id INTO v_id;
-- Return the token information
-- Note: returning the plaintext token, not the hash
RETURN jsonb_build_object(
'id', v_id,
'token', v_plaintext_token,
'expires_at', v_expires_at,
'revoked_previous_count', COALESCE(v_revoked_count, 0)
);
END;
$$;
grant execute on function public.create_nonce to service_role;
-- Create a function to verify a nonce
CREATE OR REPLACE FUNCTION public.verify_nonce(
p_token TEXT,
p_purpose TEXT,
p_user_id UUID DEFAULT NULL,
p_required_scopes TEXT[] DEFAULT NULL,
p_max_verification_attempts INTEGER DEFAULT 5,
p_ip INET DEFAULT NULL,
p_user_agent TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_nonce RECORD;
v_matching_count INTEGER;
BEGIN
-- Add debugging info
RAISE NOTICE 'Verifying token: %, purpose: %, user_id: %', p_token, p_purpose, p_user_id;
-- Count how many matching tokens exist before verification attempt
SELECT COUNT(*) INTO v_matching_count
FROM public.nonces
WHERE purpose = p_purpose;
-- Update verification attempt counter and tracking info for all matching tokens
UPDATE public.nonces
SET
verification_attempts = verification_attempts + 1,
last_verification_at = NOW(),
last_verification_ip = COALESCE(p_ip, last_verification_ip),
last_verification_user_agent = COALESCE(p_user_agent, last_verification_user_agent)
WHERE
client_token = crypt(p_token, client_token)
AND purpose = p_purpose;
-- Find the nonce by token and purpose
-- Modified to handle user-specific tokens better
SELECT *
INTO v_nonce
FROM public.nonces
WHERE
client_token = crypt(p_token, client_token)
AND purpose = p_purpose
-- Only apply user_id filter if the token was created for a specific user
AND (
-- Case 1: Anonymous token (user_id is NULL in DB)
(user_id IS NULL)
OR
-- Case 2: User-specific token (check if user_id matches)
(user_id = p_user_id)
)
AND used_at IS NULL
AND NOT revoked
AND expires_at > NOW();
-- Check if nonce exists
IF v_nonce.id IS NULL THEN
RETURN jsonb_build_object(
'valid', false,
'message', 'Invalid or expired token'
);
END IF;
-- Check if max verification attempts exceeded
IF p_max_verification_attempts > 0 AND v_nonce.verification_attempts > p_max_verification_attempts THEN
-- Automatically revoke the token
UPDATE public.nonces
SET
revoked = TRUE,
revoked_reason = 'Maximum verification attempts exceeded'
WHERE id = v_nonce.id;
RETURN jsonb_build_object(
'valid', false,
'message', 'Token revoked due to too many verification attempts',
'max_attempts_exceeded', true
);
END IF;
-- Check scopes if required
IF p_required_scopes IS NOT NULL AND array_length(p_required_scopes, 1) > 0 THEN
-- Fix scope validation to properly check if token scopes contain all required scopes
-- Using array containment check: array1 @> array2 (array1 contains array2)
IF NOT (v_nonce.scopes @> p_required_scopes) THEN
RETURN jsonb_build_object(
'valid', false,
'message', 'Token does not have required permissions',
'token_scopes', v_nonce.scopes,
'required_scopes', p_required_scopes
);
END IF;
END IF;
-- Mark nonce as used
UPDATE public.nonces
SET used_at = NOW()
WHERE id = v_nonce.id;
-- Return success with metadata
RETURN jsonb_build_object(
'valid', true,
'user_id', v_nonce.user_id,
'metadata', v_nonce.metadata,
'scopes', v_nonce.scopes,
'purpose', v_nonce.purpose
);
END;
$$;
grant execute on function public.verify_nonce to authenticated,service_role;
-- Create a function to revoke a nonce
CREATE OR REPLACE FUNCTION public.revoke_nonce(
p_id UUID,
p_reason TEXT DEFAULT NULL
)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_affected_rows INTEGER;
BEGIN
UPDATE public.nonces
SET
revoked = TRUE,
revoked_reason = p_reason
WHERE
id = p_id
AND used_at IS NULL
AND NOT revoked
RETURNING 1 INTO v_affected_rows;
RETURN v_affected_rows > 0;
END;
$$;
grant execute on function public.revoke_nonce to service_role;
-- Create a function to clean up expired nonces
CREATE OR REPLACE FUNCTION kit.cleanup_expired_nonces(
p_older_than_days INTEGER DEFAULT 1,
p_include_used BOOLEAN DEFAULT TRUE,
p_include_revoked BOOLEAN DEFAULT TRUE
)
RETURNS INTEGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_count INTEGER;
BEGIN
-- Count and delete expired or used nonces based on parameters
WITH deleted AS (
DELETE FROM public.nonces
WHERE
(
-- Expired and unused tokens
(expires_at < NOW() AND used_at IS NULL)
-- Used tokens older than specified days (if enabled)
OR (p_include_used = TRUE AND used_at < NOW() - (p_older_than_days * interval '1 day'))
-- Revoked tokens older than specified days (if enabled)
OR (p_include_revoked = TRUE AND revoked = TRUE AND created_at < NOW() - (p_older_than_days * interval '1 day'))
)
RETURNING 1
)
SELECT COUNT(*) INTO v_count FROM deleted;
RETURN v_count;
END;
$$;
-- Create a function to get token status (for administrative use)
CREATE OR REPLACE FUNCTION public.get_nonce_status(
p_id UUID
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_nonce public.nonces;
BEGIN
SELECT * INTO v_nonce FROM public.nonces WHERE id = p_id;
IF v_nonce.id IS NULL THEN
RETURN jsonb_build_object('exists', false);
END IF;
RETURN jsonb_build_object(
'exists', true,
'purpose', v_nonce.purpose,
'user_id', v_nonce.user_id,
'created_at', v_nonce.created_at,
'expires_at', v_nonce.expires_at,
'used_at', v_nonce.used_at,
'revoked', v_nonce.revoked,
'revoked_reason', v_nonce.revoked_reason,
'verification_attempts', v_nonce.verification_attempts,
'last_verification_at', v_nonce.last_verification_at,
'last_verification_ip', v_nonce.last_verification_ip,
'is_valid', (v_nonce.used_at IS NULL AND NOT v_nonce.revoked AND v_nonce.expires_at > NOW())
);
END;
$$;
-- Comments for documentation
COMMENT ON TABLE public.nonces IS 'Table for storing one-time tokens with enhanced security and audit features';
COMMENT ON FUNCTION public.create_nonce IS 'Creates a new one-time token for a specific purpose with enhanced options';
COMMENT ON FUNCTION public.verify_nonce IS 'Verifies a one-time token, checks scopes, and marks it as used';
COMMENT ON FUNCTION public.revoke_nonce IS 'Administratively revokes a token to prevent its use';
COMMENT ON FUNCTION kit.cleanup_expired_nonces IS 'Cleans up expired, used, or revoked tokens based on parameters';
COMMENT ON FUNCTION public.get_nonce_status IS 'Retrieves the status of a token for administrative purposes';

View File

@@ -10,6 +10,19 @@ alter default PRIVILEGES in schema makerkit revoke execute on FUNCTIONS from pub
alter default PRIVILEGES in schema makerkit grant execute on FUNCTIONS to anon,
authenticated, service_role;
create or replace function makerkit.get_id_by_identifier(
identifier text
)
returns uuid
as $$
begin
return (select id from auth.users where raw_user_meta_data->>'test_identifier' = identifier);
end;
$$ language PLPGSQL;
create or replace function makerkit.set_identifier(
identifier text,
user_email text

View File

@@ -0,0 +1,853 @@
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan(); -- Use no_plan for flexibility
select tests.create_supabase_user('token_creator', 'creator@example.com');
select tests.create_supabase_user('token_verifier', 'verifier@example.com');
-- ==========================================
-- Test 1: Permission Tests
-- ==========================================
-- Test 1.1: Regular users cannot create nonces directly
select tests.authenticate_as('token_creator');
select throws_ok(
$$ select public.create_nonce(auth.uid(), 'password-reset', 3600) $$,
'permission denied for function create_nonce',
'Regular users should not be able to create nonces directly'
);
-- Test 1.2: Regular users cannot revoke nonces
select throws_ok(
$$ select public.revoke_nonce('00000000-0000-0000-0000-000000000000'::uuid, 'test') $$,
'permission denied for function revoke_nonce',
'Regular users should not be able to revoke tokens'
);
-- Test 1.3: Service role can create nonces
set local role service_role;
-- Create a token and store it for later verification
do $$
declare
token_result jsonb;
begin
token_result := public.create_nonce(
null,
'password-reset',
3600,
'{"redirect_url": "/reset-password"}'::jsonb,
ARRAY['auth:reset']
);
-- Store the token for later verification
perform set_config('app.settings.test_token', token_result->>'token', false);
perform set_config('app.settings.test_token_id', token_result->>'id', false);
perform set_config('app.settings.test_token_json', token_result::text, false);
end $$;
-- Check token result properties
select ok(
(current_setting('app.settings.test_token_json', false)::jsonb) ? 'id',
'Token result should contain an id'
);
select ok(
(current_setting('app.settings.test_token_json', false)::jsonb) ? 'token',
'Token result should contain a token'
);
select ok(
(current_setting('app.settings.test_token_json', false)::jsonb) ? 'expires_at',
'Token result should contain an expiration time'
);
set local role postgres;
-- Create a token for an authenticated user
do $$
declare
token_result jsonb;
auth_user_id uuid;
begin
auth_user_id := makerkit.get_id_by_identifier('token_creator');
token_result := public.create_nonce(
auth_user_id,
'email-verification',
3600
);
-- Store the token for later user verification
perform set_config('app.settings.user_token', token_result->>'token', false);
perform set_config('app.settings.user_token_id', token_result->>'id', false);
perform set_config('app.settings.user_token_json', token_result::text, false);
end $$;
-- Check user token result properties
select ok(
(current_setting('app.settings.user_token_json', false)::jsonb) ? 'id',
'Token result with minimal params should contain an id'
);
select ok(
(current_setting('app.settings.user_token_json', false)::jsonb) ? 'token',
'Token result with minimal params should contain a token'
);
-- Create an anonymous token (no user_id)
do $$
declare
token_result jsonb;
begin
token_result := public.create_nonce(
null,
'user-invitation',
7200,
'{"team_id": "123456"}'::jsonb
);
-- Store the anonymous token for later verification
perform set_config('app.settings.anonymous_token', token_result->>'token', false);
perform set_config('app.settings.anonymous_token_id', token_result->>'id', false);
perform set_config('app.settings.anonymous_token_json', token_result::text, false);
end $$;
-- Check anonymous token result properties
select ok(
(current_setting('app.settings.anonymous_token_json', false)::jsonb) ? 'id',
'Anonymous token result should contain an id'
);
select ok(
(current_setting('app.settings.anonymous_token_json', false)::jsonb) ? 'token',
'Anonymous token result should contain a token'
);
-- ==========================================
-- Test 2: Verify Tokens
-- ==========================================
-- Test 2.1: Authenticated users can verify tokens
select tests.authenticate_as('token_creator');
-- Verify token and store result
do $$
declare
test_token text;
verification_result jsonb;
begin
test_token := current_setting('app.settings.test_token', false);
verification_result := public.verify_nonce(
test_token,
'password-reset'
);
perform set_config('app.settings.verification_result', verification_result::text, false);
end $$;
-- Check verification result
select is(
(current_setting('app.settings.verification_result', false)::jsonb)->>'valid',
'true',
'Token should be valid'
);
select ok(
(current_setting('app.settings.verification_result', false)::jsonb) ? 'metadata',
'Result should contain metadata'
);
select ok(
(current_setting('app.settings.verification_result', false)::jsonb) ? 'scopes',
'Result should contain scopes'
);
-- Test 2.2: Users can verify tokens assigned to them
do $$
declare
user_token text;
verification_result jsonb;
user_id uuid;
begin
user_token := current_setting('app.settings.user_token', false);
set local role postgres;
user_id := makerkit.get_id_by_identifier('token_creator');
perform tests.authenticate_as('token_creator');
verification_result := public.verify_nonce(
user_token,
'email-verification',
user_id
);
perform set_config('app.settings.user_verification_result', verification_result::text, false);
end $$;
-- Check user verification result
select is(
(current_setting('app.settings.user_verification_result', false)::jsonb)->>'valid',
'true',
'User-specific token should be valid'
);
select isnt(
(current_setting('app.settings.user_verification_result', false)::jsonb)->>'user_id',
null,
'User-specific token should have user_id'
);
-- Test 2.3: Verify token with scopes
set local role service_role;
-- Create token with scopes
do $$
declare
scope_token_result jsonb;
begin
-- Create token with scopes
scope_token_result := public.create_nonce(
null,
'api-access',
3600,
'{"permissions": "read-only"}'::jsonb,
ARRAY['read:profile', 'read:posts']
);
-- Store for verification
perform set_config('app.settings.scope_token', scope_token_result->>'token', false);
end $$;
-- Verify with correct scope
do $$
declare
scope_token text;
verification_result jsonb;
user_id uuid;
begin
set local role postgres;
scope_token := current_setting('app.settings.scope_token', false);
user_id := makerkit.get_id_by_identifier('token_verifier');
perform tests.authenticate_as('token_verifier');
-- Verify with correct required scope
verification_result := public.verify_nonce(
scope_token,
'api-access',
null,
ARRAY['read:profile']
);
perform set_config('app.settings.correct_scope_result', verification_result::text, false);
-- Verify with incorrect required scope
verification_result := public.verify_nonce(
scope_token,
'api-access',
null,
ARRAY['write:posts']
);
perform set_config('app.settings.incorrect_scope_result', verification_result::text, false);
end $$;
-- Check scope verification results
select is(
(current_setting('app.settings.correct_scope_result', false)::jsonb)->>'valid',
'true',
'Token with correct scope should be valid'
);
select is(
(current_setting('app.settings.incorrect_scope_result', false)::jsonb)->>'valid',
'false',
'Token with incorrect scope should be invalid'
);
-- Test 2.4: Once used, token becomes invalid
do $$
declare
token_result jsonb;
first_verification jsonb;
second_verification jsonb;
begin
-- Use service role to create a token
set local role service_role;
-- Create a token
token_result := public.create_nonce(
null,
'one-time-action',
3600
);
set local role authenticated;
-- Verify it once (uses it)
first_verification := public.verify_nonce(
token_result->>'token',
'one-time-action'
);
-- Try to verify again
second_verification := public.verify_nonce(
token_result->>'token',
'one-time-action'
);
perform set_config('app.settings.first_verification', first_verification::text, false);
perform set_config('app.settings.second_verification', second_verification::text, false);
end $$;
-- Check first and second verification results
select is(
(current_setting('app.settings.first_verification', false)::jsonb)->>'valid',
'true',
'First verification should succeed'
);
select is(
(current_setting('app.settings.second_verification', false)::jsonb)->>'valid',
'false',
'Token should not be valid on second use'
);
-- Test 2.5: Verify with incorrect purpose
do $$
declare
token_result jsonb;
verification_result jsonb;
begin
-- Use service role to create a token
set local role service_role;
-- Create a token
token_result := public.create_nonce(
null,
'specific-purpose',
3600
);
set local role authenticated;
-- Verify with wrong purpose
verification_result := public.verify_nonce(
token_result->>'token',
'different-purpose'
);
perform set_config('app.settings.wrong_purpose_result', verification_result::text, false);
end $$;
-- Check wrong purpose verification result
select is(
(current_setting('app.settings.wrong_purpose_result', false)::jsonb)->>'valid',
'false',
'Token with incorrect purpose should be invalid'
);
-- ==========================================
-- Test 3: Revoke Tokens
-- ==========================================
-- Test 3.1: Only service_role can revoke tokens
select tests.authenticate_as('token_creator');
select
has_function(
'public',
'revoke_nonce',
ARRAY['uuid', 'text'],
'revoke_nonce function should exist'
);
select throws_ok(
$$ select public.revoke_nonce('00000000-0000-0000-0000-000000000000'::uuid, 'test reason') $$,
'permission denied for function revoke_nonce',
'Regular users should not be able to revoke tokens'
);
-- Test 3.2: Service role can revoke tokens
set local role service_role;
do $$
declare
token_result jsonb;
revoke_result boolean;
verification_result jsonb;
token_id uuid;
begin
-- Create a token
token_result := public.create_nonce(
null,
'revokable-action',
3600
);
token_id := token_result->>'id';
-- Revoke the token
revoke_result := public.revoke_nonce(
token_id,
'Security concern'
);
-- Switch to regular user to try to verify the revoked token
set local role authenticated;
-- Try to verify the revoked token
verification_result := public.verify_nonce(
token_result->>'token',
'revokable-action'
);
perform set_config('app.settings.revoke_result', revoke_result::text, false);
perform set_config('app.settings.revoked_verification', verification_result::text, false);
end $$;
-- Check revocation results
select is(
current_setting('app.settings.revoke_result', false)::boolean,
true,
'Token revocation should succeed'
);
select is(
(current_setting('app.settings.revoked_verification', false)::jsonb)->>'valid',
'false',
'Revoked token should be invalid'
);
-- ==========================================
-- Test 4: Get Token Status
-- ==========================================
-- Test 4.1: Verify permission on get_nonce_status
select tests.authenticate_as('token_creator');
select throws_ok(
$$ select public.get_nonce_status('00000000-0000-0000-0000-000000000000'::uuid) $$,
'permission denied for function get_nonce_status',
'Regular users should not be able to check token status'
);
-- Test 4.2: Service role can check token status
set local role service_role;
select
has_function(
'public',
'get_nonce_status',
ARRAY['uuid'],
'get_nonce_status function should exist'
);
do $$
declare
token_result jsonb;
status_result jsonb;
token_id uuid;
begin
-- Create a token
token_result := public.create_nonce(
null,
'status-check-test',
3600
);
token_id := token_result->>'id';
-- Get status
status_result := public.get_nonce_status(token_id);
perform set_config('app.settings.status_result', status_result::text, false);
end $$;
-- Check status result
select is(
(current_setting('app.settings.status_result', false)::jsonb)->>'exists',
'true',
'Token should exist'
);
select is(
(current_setting('app.settings.status_result', false)::jsonb)->>'purpose',
'status-check-test',
'Purpose should match'
);
select is(
(current_setting('app.settings.status_result', false)::jsonb)->>'is_valid',
'true',
'Token should be valid'
);
select is(
(current_setting('app.settings.status_result', false)::jsonb)->>'verification_attempts',
'0',
'New token should have 0 verification attempts'
);
select is(
(current_setting('app.settings.status_result', false)::jsonb)->>'used_at',
null,
'New token should not be used'
);
select is(
(current_setting('app.settings.status_result', false)::jsonb)->>'revoked',
'false',
'New token should not be revoked'
);
-- ==========================================
-- Test 5: Cleanup Expired Tokens
-- ==========================================
-- Test 5.1: Regular users cannot access cleanup function
select tests.authenticate_as('token_creator');
select throws_ok(
$$ select kit.cleanup_expired_nonces() $$,
'permission denied for schema kit',
'Regular users should not be able to clean up tokens'
);
-- Test 5.2: Postgres can clean up expired tokens
set local role postgres;
select
has_function(
'kit',
'cleanup_expired_nonces',
ARRAY['integer', 'boolean', 'boolean'],
'cleanup_expired_nonces function should exist'
);
do $$
declare
token_result jsonb;
cleanup_result integer;
token_count integer;
begin
-- Create an expired token (expiring in -10 seconds from now)
token_result := public.create_nonce(
null,
'expired-token-test',
-10 -- Negative value to create an already expired token
);
-- Run cleanup
cleanup_result := kit.cleanup_expired_nonces();
-- Verify the token is gone
select count(*) into token_count from public.nonces where id = (token_result->>'id')::uuid;
perform set_config('app.settings.cleanup_result', cleanup_result::text, false);
perform set_config('app.settings.token_count_after_cleanup', token_count::text, false);
end $$;
-- Check cleanup results
select cmp_ok(
current_setting('app.settings.cleanup_result', false)::integer,
'>=',
1,
'Cleanup should remove at least one expired token'
);
select is(
current_setting('app.settings.token_count_after_cleanup', false)::integer,
0,
'Expired token should be removed after cleanup'
);
-- ==========================================
-- Test 6: Security Tests
-- ==========================================
-- Test 6.1: Regular users cannot view tokens directly from the nonces table
select tests.authenticate_as('token_creator');
set local role postgres;
do $$
declare
creator_id uuid;
token_id uuid;
begin
-- Get the user id
creator_id := makerkit.get_id_by_identifier('token_creator');
-- Create a token for this user
token_id := (public.create_nonce(creator_id, 'security-test', 3600))->>'id';
perform set_config('app.settings.security_test_token_id', token_id::text, false);
end $$;
select tests.authenticate_as('token_creator');
do $$
declare
token_id uuid;
token_count integer;
begin
-- Get the token ID created by service role
token_id := (current_setting('app.settings.security_test_token_id', false))::uuid;
-- Try to view token directly from nonces table
select count(*) into token_count from public.nonces where id = token_id;
perform set_config('app.settings.creator_token_count', token_count::text, false);
end $$;
-- Check creator can see their own token
select is(
current_setting('app.settings.creator_token_count', false)::integer,
1,
'User should be able to see their own tokens in the table'
);
-- Test 6.2: Users cannot see tokens belonging to other users
select tests.authenticate_as('token_verifier');
do $$
declare
token_id uuid;
token_count integer;
begin
-- Get the token ID created for the creator user
token_id := (current_setting('app.settings.security_test_token_id', false))::uuid;
-- Verifier tries to view token created for creator
select count(*) into token_count from public.nonces where id = token_id;
perform set_config('app.settings.verifier_token_count', token_count::text, false);
end $$;
-- Check verifier cannot see creator's token
select is(
current_setting('app.settings.verifier_token_count', false)::integer,
0,
'User should not be able to see tokens belonging to other users'
);
-- ==========================================
-- Test 7: Auto-Revocation of Previous Tokens
-- ==========================================
-- Test 7.1: Creating a new token should revoke previous tokens with the same purpose by default
set local role postgres;
do $$
declare
auth_user_id uuid;
first_token_result jsonb;
second_token_result jsonb;
first_token_id uuid;
first_token_status jsonb;
begin
-- Get user ID
auth_user_id := makerkit.get_id_by_identifier('token_creator');
-- Create first token
first_token_result := public.create_nonce(
auth_user_id,
'password-reset',
3600,
'{"first": true}'::jsonb
);
first_token_id := first_token_result->>'id';
-- Verify first token is valid
first_token_status := public.get_nonce_status(first_token_id);
-- Create second token with same purpose
second_token_result := public.create_nonce(
auth_user_id,
'password-reset',
3600,
'{"second": true}'::jsonb
);
-- Check that first token is now revoked
first_token_status := public.get_nonce_status(first_token_id);
perform set_config('app.settings.first_token_valid_before', 'true', false);
perform set_config('app.settings.revoked_previous_count', (second_token_result->>'revoked_previous_count')::text, false);
perform set_config('app.settings.first_token_revoked', (first_token_status->>'revoked')::text, false);
perform set_config('app.settings.first_token_revoked_reason', (first_token_status->>'revoked_reason')::text, false);
perform set_config('app.settings.first_token_valid_after', (first_token_status->>'is_valid')::text, false);
end $$;
-- Check auto-revocation results
select is(
current_setting('app.settings.first_token_valid_before', false),
'true',
'First token should be valid initially'
);
select is(
current_setting('app.settings.revoked_previous_count', false)::integer,
1,
'Should report one revoked token'
);
select is(
current_setting('app.settings.first_token_revoked', false),
'true',
'First token should be revoked'
);
select is(
current_setting('app.settings.first_token_revoked_reason', false),
'Superseded by new token with same purpose',
'Revocation reason should be set'
);
select is(
current_setting('app.settings.first_token_valid_after', false),
'false',
'First token should be invalid'
);
-- ==========================================
-- Test 8: Maximum Verification Attempts
-- ==========================================
-- Test 8.1: Token should be revoked after exceeding max verification attempts
set local role service_role;
do $$
declare
token_result jsonb;
verification_result jsonb;
status_result jsonb;
token_id uuid;
token_text text;
begin
-- Create a token
token_result := public.create_nonce(
null,
'max-attempts-test',
3600
);
token_id := token_result->>'id';
token_text := token_result->>'token';
-- Manually set verification_attempts to just below the limit (3)
UPDATE public.nonces
SET verification_attempts = 3
WHERE id = token_id;
-- Get status after manual update
status_result := public.get_nonce_status(token_id);
-- Now perform a verification with an incorrect token - this should trigger max attempts exceeded
verification_result := public.verify_nonce(
'wrong-token', -- Wrong token
'max-attempts-test', -- Correct purpose,
NULL, -- No user id
NULL, -- No required scopes
3 -- Max 3 attempts
);
-- The above won't increment the counter, so we need to make one more attempt with the correct token
verification_result := public.verify_nonce(
token_text, -- Correct token
'max-attempts-test', -- Correct purpose,
NULL, -- No user id
NULL, -- No required scopes
3 -- Max 3 attempts
);
-- Check token status to verify it was revoked
status_result := public.get_nonce_status(token_id);
-- Store results for assertions outside the DO block
perform set_config('app.settings.max_attempts_verification_result', verification_result::text, false);
perform set_config('app.settings.max_attempts_status_result', status_result::text, false);
end $$;
-- Check max attempts results outside the DO block
select is(
(current_setting('app.settings.max_attempts_verification_result', false)::jsonb)->>'valid',
'false',
'Token should be invalid after exceeding max attempts'
);
select is(
(current_setting('app.settings.max_attempts_verification_result', false)::jsonb)->>'max_attempts_exceeded',
'true',
'Max attempts exceeded flag should be set'
);
select is(
(current_setting('app.settings.max_attempts_status_result', false)::jsonb)->>'revoked',
'true',
'Token should be revoked after exceeding max attempts'
);
select is(
(current_setting('app.settings.max_attempts_status_result', false)::jsonb)->>'revoked_reason',
'Maximum verification attempts exceeded',
'Revocation reason should indicate max attempts exceeded'
);
-- Test 8.2: Setting max attempts to 0 should disable the limit
do $$
declare
token_result jsonb;
verification_result jsonb;
status_result jsonb;
token_id uuid;
token_text text;
begin
-- Create a token
token_result := public.create_nonce(
null,
'unlimited-attempts-test',
3600
);
token_id := token_result->>'id';
token_text := token_result->>'token';
-- Manually set verification_attempts to a high number
UPDATE public.nonces
SET verification_attempts = 10
WHERE id = token_id;
-- Get status after manual update
status_result := public.get_nonce_status(token_id);
-- Now perform a verification with the correct token and unlimited attempts
verification_result := public.verify_nonce(
token_text, -- Correct token
'unlimited-attempts-test', -- Correct purpose,
NULL, -- No user id
NULL, -- No required scopes
0 -- Unlimited attempts (disabled)
);
-- Check token status to verify it was not revoked
status_result := public.get_nonce_status(token_id);
-- Store results for assertions outside the DO block
perform set_config('app.settings.unlimited_attempts_status', status_result::text, false);
end $$;
-- Check unlimited attempts results outside the DO block
select is(
(current_setting('app.settings.unlimited_attempts_status', false)::jsonb)->>'revoked',
'false',
'Token should not be revoked when max attempts is disabled'
);
select cmp_ok(
(current_setting('app.settings.unlimited_attempts_status', false)::jsonb)->>'verification_attempts',
'>=',
'10',
'Token should record at least 10 verification attempts'
);
-- Finish tests
select * from finish();
rollback;

View File

@@ -44,6 +44,16 @@ select
$$, row ('owner'::varchar),
'The primary owner should have the owner role for the team account');
select is(
public.is_account_owner((select
id
from public.accounts
where
slug = 'test')),
true,
'The current user should be the owner of the team account'
);
-- Should be able to see the team account
select
isnt_empty($$
@@ -58,6 +68,16 @@ select
select
tests.authenticate_as('test2');
select is(
public.is_account_owner((select
id
from public.accounts
where
slug = 'test')),
false,
'The current user should not be the owner of the team account'
);
select
is_empty($$
select