Skip to content

Commit

Permalink
feat: Use the new authorization interrupts throughout our project
Browse files Browse the repository at this point in the history
  • Loading branch information
beverloo committed Jan 2, 2025
1 parent 1da6210 commit 17c1343
Show file tree
Hide file tree
Showing 20 changed files with 94 additions and 77 deletions.
11 changes: 9 additions & 2 deletions app/api/Action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { NextRequest } from 'next/server';
import { forbidden } from 'next/navigation';
import { serialize } from 'cookie';
import { z } from 'zod';

import type { User } from '@lib/auth/User';
import { type ActionProps, executeAction, noAccess } from './Action';
import { type ActionProps, executeAction } from './Action';
import { expectAuthenticationQuery } from '@lib/auth/AuthenticationTestHelpers';
import { kSessionCookieName, sealSession } from '@lib/auth/Session';
import { useMockConnection } from '@lib/database/Connection';
Expand Down Expand Up @@ -293,7 +294,13 @@ describe('Action', () => {
type ResponseType = z.infer<typeof interfaceDefinition>['response'];

async function MyAction(request: RequestType, props: ActionProps): Promise<ResponseType> {
noAccess();
// TODO: Replace this with `forbidden()` when Next.js 15.2 is released, and the new
// authorization interrupt feature is stable. Right now this depends on an experimental
// API that we cannot enable for testing purposes.
const error = new Error('NEXT_HTTP_ERROR_FALLBACK;403') as any;
error.digest = 'NEXT_HTTP_ERROR_FALLBACK;403';

throw error;
}

const request = createRequest('POST', { /* no payload */ });
Expand Down
23 changes: 5 additions & 18 deletions app/api/Action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import type { AnyZodObject, ZodObject, ZodRawShape, z } from 'zod';
import { NextRequest, NextResponse } from 'next/server';

import { getAccessFallbackHTTPStatus, isHTTPAccessFallbackError }
from 'next/dist/client/components/http-access-fallback/http-access-fallback';

import type { AuthenticationContext } from '@lib/auth/AuthenticationContext';
import type { User } from '@lib/auth/User';
import { AccessControl } from '@lib/auth/AccessControl';
Expand Down Expand Up @@ -70,11 +73,6 @@ export interface ActionProps {
export type Action<T extends ZodObject<ZodRawShape, any, any>> =
(request: z.infer<T>['request'], props: ActionProps) => Promise<z.infer<T>['response']>;

/**
* Error thrown when an HTTP 403 No Access response should be returned instead.
*/
class NoAccessError extends Error {}

/**
* Creates a response for the given `status` and `payload`. Necessary as the Jest test environment
* does not provide the latest Response.json() static method yet.
Expand Down Expand Up @@ -219,11 +217,8 @@ export async function executeAction<T extends ZodObject<ZodRawShape, any, any>>(
throw new Error(`Action response validation failed (${issues})`);

} catch (error: any) {
if (error instanceof NoAccessError)
return createResponse(403, { success: false });

if (Object.hasOwn(error, 'digest')) // fixme
return createResponse(404, { success: false });
if (isHTTPAccessFallbackError(error))
return createResponse(getAccessFallbackHTTPStatus(error), { success: false });

if (!process.env.JEST_WORKER_ID)
console.error(`Action(${request.nextUrl.pathname}) threw an Exception:`, error);
Expand All @@ -234,11 +229,3 @@ export async function executeAction<T extends ZodObject<ZodRawShape, any, any>>(
});
}
}

/**
* Aborts execution of the rest of the Action, and completes the API call with an HTTP 403 Forbidden
* response instead.
*/
export function noAccess(): never {
throw new NoAccessError;
}
5 changes: 2 additions & 3 deletions app/api/admin/program/requests/[[...id]]/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
// Copyright 2024 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { notFound } from 'next/navigation';
import { forbidden, notFound } from 'next/navigation';
import { z } from 'zod';

import { type DataTableEndpoints, createDataTableApi } from '@app/api/createDataTableApi';
import { LogSeverity, LogType, Log } from '@lib/Log';
import { executeAccessCheck } from '@lib/auth/AuthenticationContext';
import { noAccess } from '@app/api/Action';
import { getEventBySlug } from '@lib/EventLoader';
import db, { tActivities, tEventsTeams, tShifts, tUsers } from '@lib/database';

Expand Down Expand Up @@ -97,7 +96,7 @@ export const { GET, PUT } = createDataTableApi(kProgramRequestRowModel, kProgram
case 'create':
case 'delete':
case 'get':
noAccess();
forbidden();

case 'list':
executeAccessCheck(props.authenticationContext, {
Expand Down
5 changes: 2 additions & 3 deletions app/api/admin/retention/[[...id]]/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
// Copyright 2023 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { notFound } from 'next/navigation';
import { forbidden, notFound } from 'next/navigation';
import { z } from 'zod';

import { type DataTableEndpoints, createDataTableApi } from '@app/api/createDataTableApi';
import { LogSeverity, LogType, Log } from '@lib/Log';
import { RegistrationStatus } from '@lib/database/Types';
import { executeAccessCheck } from '@lib/auth/AuthenticationContext';
import { getEventBySlug } from '@lib/EventLoader';
import { noAccess } from '@app/api/Action';
import { readSetting } from '@lib/Settings';
import db, { tEvents, tRetention, tTeams, tUsersEvents, tUsers } from '@lib/database';

Expand Down Expand Up @@ -132,7 +131,7 @@ export const { GET, PUT } = createDataTableApi(kRetentionRowModel, kRetentionCon
case 'create':
case 'delete':
case 'get':
noAccess();
forbidden();

case 'list':
case 'update':
Expand Down
6 changes: 3 additions & 3 deletions app/api/admin/volunteerTeams.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Copyright 2023 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { notFound } from 'next/navigation';
import { forbidden, notFound } from 'next/navigation';
import { z } from 'zod';

import type { ActionProps } from '../Action';
import type { ApiDefinition, ApiRequest, ApiResponse } from '../Types';
import { type ActionProps, noAccess } from '../Action';
import { Log, LogType, LogSeverity } from '@lib/Log';
import { RegistrationStatus } from '@lib/database/Types';
import { SendEmailTask } from '@lib/scheduler/tasks/SendEmailTask';
Expand Down Expand Up @@ -188,7 +188,7 @@ export async function volunteerTeams(request: Request, props: ActionProps): Prom
const { subject, message } = request.update;
if (!subject || !message || /* null check= */ !props.user) {
if (!props.access.can('volunteer.silent'))
noAccess();
forbidden();

} else {
const username = await db.selectFrom(tUsers)
Expand Down
8 changes: 4 additions & 4 deletions app/api/application/updateApplication.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Copyright 2023 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { notFound } from 'next/navigation';
import { forbidden, notFound } from 'next/navigation';
import { z } from 'zod';

import { type ActionProps, noAccess } from '../Action';
import type { ActionProps } from '../Action';
import { LogSeverity, LogType, Log } from '@lib/Log';
import { RegistrationStatus } from '@lib/database/Types';
import { SendEmailTask } from '@lib/scheduler/tasks/SendEmailTask';
Expand Down Expand Up @@ -114,7 +114,7 @@ type Response = ApiResponse<typeof kUpdateApplicationDefinition>;
*/
export async function updateApplication(request: Request, props: ActionProps): Promise<Response> {
if (!props.user)
noAccess();
forbidden();

const requestContext = await db.selectFrom(tEventsTeams)
.innerJoin(tEvents)
Expand Down Expand Up @@ -283,7 +283,7 @@ export async function updateApplication(request: Request, props: ActionProps): P
const { subject, message } = request.status;
if (!subject || !message) {
if (!props.access.can('volunteer.silent'))
noAccess();
forbidden();

} else {
await SendEmailTask.Schedule({
Expand Down
6 changes: 3 additions & 3 deletions app/api/auth/passkeys/createChallenge.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
// Copyright 2023 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { forbidden, notFound } from 'next/navigation';
import { generateRegistrationOptions } from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import { notFound } from 'next/navigation';
import { z } from 'zod';

import type { ActionProps } from '../../Action';
import type { ApiDefinition, ApiRequest, ApiResponse } from '../../Types';
import { determineEnvironment } from '@lib/Environment';
import { noAccess, type ActionProps } from '../../Action';
import { determineRpID, retrieveCredentials, storeUserChallenge } from './PasskeyUtils';

/**
Expand Down Expand Up @@ -46,7 +46,7 @@ type Response = ApiResponse<typeof kCreateChallengeDefinition>;
*/
export async function createChallenge(request: Request, props: ActionProps): Promise<Response> {
if (!props.user || !props.user.username)
noAccess();
forbidden();

const environment = await determineEnvironment();
if (!environment)
Expand Down
5 changes: 3 additions & 2 deletions app/api/auth/passkeys/deletePasskey.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Copyright 2023 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { forbidden } from 'next/navigation';
import { z } from 'zod';

import type { ActionProps } from '../../Action';
import type { ApiDefinition, ApiRequest, ApiResponse } from '../../Types';
import { type ActionProps, noAccess } from '../../Action';
import { LogSeverity, LogType, Log } from '@lib/Log';
import { deleteCredential } from './PasskeyUtils';

Expand Down Expand Up @@ -41,7 +42,7 @@ type Response = ApiResponse<typeof kDeletePasskeyDefinition>;
*/
export async function deletePasskey(request: Request, props: ActionProps): Promise<Response> {
if (!props.user || !props.user.username)
noAccess();
forbidden();

const credentialDeleted = await deleteCredential(props.user, request.id);
if (credentialDeleted) {
Expand Down
6 changes: 3 additions & 3 deletions app/api/auth/passkeys/listPasskeys.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Copyright 2023 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { notFound } from 'next/navigation';
import { forbidden, notFound } from 'next/navigation';
import { z } from 'zod';

import type { ActionProps } from '../../Action';
import type { ApiDefinition, ApiRequest, ApiResponse } from '../../Types';
import { type ActionProps, noAccess } from '../../Action';
import { determineEnvironment } from '@lib/Environment';
import { formatDate } from '@lib/Temporal';
import { determineRpID, retrieveCredentials } from './PasskeyUtils';
Expand Down Expand Up @@ -59,7 +59,7 @@ type Response = ApiResponse<typeof kListPasskeysDefinition>;
*/
export async function listPasskeys(request: Request, props: ActionProps): Promise<Response> {
if (!props.user || !props.user.username)
noAccess();
forbidden();

const environment = await determineEnvironment();
if (!environment)
Expand Down
5 changes: 3 additions & 2 deletions app/api/auth/passkeys/registerPasskey.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// Copyright 2023 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { forbidden } from 'next/navigation';
import { verifyRegistrationResponse } from '@simplewebauthn/server';
import { z } from 'zod';

import type { ActionProps } from '../../Action';
import type { ApiDefinition, ApiRequest, ApiResponse } from '../../Types';
import { type ActionProps, noAccess } from '../../Action';
import { LogSeverity, LogType, Log } from '@lib/Log';
import { determineRpID, retrieveUserChallenge, storePasskeyRegistration, storeUserChallenge }
from './PasskeyUtils';
Expand Down Expand Up @@ -71,7 +72,7 @@ export async function getAllEnvironmentOrigins(): Promise<string[]> {
*/
export async function registerPasskey(request: Request, props: ActionProps): Promise<Response> {
if (!props.user || !props.user.username)
noAccess();
forbidden();

const expectedChallenge = await retrieveUserChallenge(props.user);
if (!expectedChallenge)
Expand Down
5 changes: 3 additions & 2 deletions app/api/auth/settings.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Copyright 2024 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { forbidden } from 'next/navigation';
import { z } from 'zod';

import type { ActionProps } from '../Action';
import type { ApiDefinition, ApiRequest, ApiResponse } from '../Types';
import { noAccess, type ActionProps } from '../Action';
import { writeUserSettings, type UserSettingsMap } from '@lib/UserSettings';

/**
Expand Down Expand Up @@ -46,7 +47,7 @@ type SettingStringType<Key extends keyof UserSettingsMap> =
*/
export async function settings(request: Request, props: ActionProps): Promise<Response> {
if (!props.user)
noAccess();
forbidden();

// User settings that are allowed to be updated using this interface.
const kAllowedUserSettings: { [k in keyof UserSettingsMap]: SettingStringType<k> } = {
Expand Down
6 changes: 3 additions & 3 deletions app/api/auth/updateAccount.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Copyright 2023 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { notFound } from 'next/navigation';
import { forbidden, notFound } from 'next/navigation';
import { z } from 'zod';

import type { ActionProps } from '../Action';
import type { ApiDefinition, ApiRequest, ApiResponse } from '../Types';
import { type ActionProps, noAccess } from '../Action';
import { LogType, Log } from '@lib/Log';
import { Temporal, formatDate } from '@lib/Temporal';
import db, { tUsers } from '@lib/database';
Expand Down Expand Up @@ -83,7 +83,7 @@ type Response = ApiResponse<typeof kUpdateAccountDefinition>;
*/
export async function updateAccount(request: Request, props: ActionProps): Promise<Response> {
if (!props.user)
return noAccess();
return forbidden();

const account = await db.selectFrom(tUsers)
.where(tUsers.userId.equals(props.user.userId))
Expand Down
7 changes: 4 additions & 3 deletions app/api/auth/updateAvatar.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// Copyright 2023 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { forbidden } from 'next/navigation';
import { z } from 'zod';

import type { ActionProps } from '../Action';
import type { ApiDefinition, ApiRequest, ApiResponse } from '../Types';
import { type ActionProps, noAccess } from '../Action';
import { FileType } from '@lib/database/Types';
import { LogType, Log } from '@lib/Log';
import { executeAccessCheck, or } from '@lib/auth/AuthenticationContext';
import { executeAccessCheck} from '@lib/auth/AuthenticationContext';
import { storeBlobData } from '@lib/database/BlobStore';
import db, { tUsers } from '@lib/database';

Expand Down Expand Up @@ -46,7 +47,7 @@ type Response = ApiResponse<typeof kUpdateAvatarDefinition>;
*/
export async function updateAvatar(request: Request, props: ActionProps): Promise<Response> {
if (!props.user)
return noAccess();
return forbidden();

let subjectUserId: number = props.user.userId;
if (request.overrideUserId && request.overrideUserId !== props.user.userId) {
Expand Down
7 changes: 4 additions & 3 deletions app/api/display/help-request/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { NextRequest } from 'next/server';
import { forbidden } from 'next/navigation';
import { z } from 'zod';

import type { ApiDefinition, ApiRequest, ApiResponse } from '../../Types';
import { DisplayHelpRequestStatus, DisplayHelpRequestTarget, SubscriptionType } from '@lib/database/Types';
import { Publish } from '@lib/subscriptions';
import { executeAction, noAccess, type ActionProps } from '../../Action';
import { executeAction, type ActionProps } from '../../Action';
import { getDisplayIdFromHeaders } from '@lib/auth/DisplaySession';
import { readSettings } from '@lib/Settings';
import db, { tActivitiesLocations, tDisplays, tDisplaysRequests, tEvents } from '@lib/database';
Expand Down Expand Up @@ -57,11 +58,11 @@ type Response = ApiResponse<typeof kHelpRequestDefinition>;
*/
async function helpRequest(request: Request, props: ActionProps): Promise<Response> {
if (!props.ip)
noAccess();
forbidden();

const displayId: number | undefined = await getDisplayIdFromHeaders(props.requestHeaders);
if (!displayId)
noAccess();
forbidden();

const dbInstance = db;
const configuration = await dbInstance.selectFrom(tDisplays)
Expand Down
5 changes: 3 additions & 2 deletions app/api/display/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { NextRequest } from 'next/server';
import { forbidden } from 'next/navigation';
import { z } from 'zod';

import type { ApiDefinition, ApiRequest, ApiResponse } from '../Types';
import { DisplayHelpRequestStatus, RegistrationStatus } from '@lib/database/Types';
import { Temporal, isBefore } from '@lib/Temporal';
import { executeAction, noAccess, type ActionProps } from '../Action';
import { executeAction, type ActionProps } from '../Action';
import { getDisplayIdFromHeaders, writeDisplayIdToHeaders } from '@lib/auth/DisplaySession';
import { readSettings } from '@lib/Settings';

Expand Down Expand Up @@ -204,7 +205,7 @@ function generateDisplayIdentifier(length: number): string {
*/
async function display(request: Request, props: ActionProps): Promise<Response> {
if (!props.ip)
noAccess();
forbidden();

const settings = await readSettings([
'display-check-in-rate-help-requested-seconds',
Expand Down
Loading

0 comments on commit 17c1343

Please sign in to comment.