feat: owner can test non accepted OAuth client (#27525)
* refactor: combine exchange and refresh into token endpoint * refactor: controller error handling * refactor: use snake_case * refactor: use snake_case * refactor: use snake case * refactor: token endpoint accepts application/x-www-form-urlencoded * refactor: token endpoint accepts application/x-www-form-urlencoded * refactor: flat token response data * refactor: error structure * refactor: client_id in the body * fix: address Cubic AI review feedback on OAuth2 endpoints - Fix getClient endpoint to use proper REST API error format instead of OAuth token error format (confidence 9/10) - Add missing space after comma in error format string in token.input.pipe.ts (confidence 9/10) - Support both camelCase and snake_case inputs in authorize endpoint for backward compatibility (confidence 10/10) - Restore legacy /exchange and /refresh endpoints alongside new /token endpoint for backward compatibility (confidence 10/10) - Add OAuth2TokensResponseDto for legacy endpoint wrapped responses - Add OAuth2LegacyExchangeInput and OAuth2LegacyRefreshInput for legacy endpoints Co-Authored-By: unknown <> * fix: address additional Cubic AI feedback on OAuth2 endpoints - Log errors when status code >= 500 in handleClientError (confidence 9/10) - Add Cache-Control: no-store and Pragma: no-cache headers to legacy /exchange and /refresh endpoints (confidence 9/10) Co-Authored-By: unknown <> * docs * Revert "fix: address additional Cubic AI feedback on OAuth2 endpoints" This reverts commit 39cc4aa3ebb9e59a171541d7010398425995ed89. * Revert "fix: address Cubic AI review feedback on OAuth2 endpoints" This reverts commit 97bf593186db04c0859f9ca30950c9e3e524019d. * docs * fix: address Cubic AI review feedback on OAuth2 endpoints - Fix getClient to use handleClientError instead of handleTokenError (confidence 10) - Restore legacy /exchange and /refresh endpoints for backward compatibility (confidence 9) - Fix RFC 6749 error format: use human-readable messages in error_description (confidence 9) - Fix errorDescription in OAuthService to use OAUTH_ERROR_REASONS mapping (confidence 9) Co-Authored-By: unknown <> * fix: address additional Cubic AI feedback on OAuth2 endpoints - Fix security issue: Replace 'CALENDSO_ENCRYPTION_KEY is not set' with generic 'Internal server configuration error' message (confidence 10/10) - Fix backward compatibility: Create OAuth2LegacyTokensDto with camelCase properties for legacy /exchange and /refresh endpoints (confidence 9/10) - Skipped: RFC 6749 error field issue (confidence 8/10, below threshold) Co-Authored-By: unknown <> * e2e * Revert "fix: address additional Cubic AI feedback on OAuth2 endpoints" This reverts commit a080e93f07aaf5a7dcf81fe605012cb7ebcdc192. * Revert "fix: address Cubic AI review feedback on OAuth2 endpoints" This reverts commit 04986a16c981521ca97069152457bf521a9ee45f. * fix: re-apply Cubic AI review feedback on OAuth2 endpoints - Restore OAuth2LegacyExchangeInput and OAuth2LegacyRefreshInput classes - Restore legacy /exchange and /refresh endpoints in OAuth2Controller - Restore OAuth2LegacyTokensDto and OAuth2TokensResponseDto classes - Restore OAUTH_ERROR_DESCRIPTIONS mapping in oauth2-error.service.ts - Restore OAUTH_ERROR_REASONS lookup in OAuthService.ts mapErrorToOAuthError - Fix encryption_key_missing error to not expose internal env var name Addresses Cubic AI feedback with confidence >= 9/10: - Comment 32 (9/10): Legacy endpoints and input classes - Comment 34 (9/10): Error description mapping in OAuthService - Comment 35 (10/10): OAUTH_ERROR_DESCRIPTIONS in error service Skipped (confidence < 9/10): - Comment 33 (8/10): getClient handleTokenError vs handleClientError Co-Authored-By: unknown <> * Revert "fix: re-apply Cubic AI review feedback on OAuth2 endpoints" This reverts commit 416bef9c931d9a7ed78c65a70a3425550d61b151. * delete unused file * fix: e2e tests * address cubic review * fix: address Cubic AI review feedback on OAuth2 exception filter - Fix header case sensitivity: use lowercase 'x-request-id' instead of 'X-Request-Id' since Express lowercases all request headers - Redact request body in error logs to prevent exposing sensitive OAuth2 credentials like client_secret, password, and refresh_token Co-Authored-By: unknown <> * docs: api v2 oauth controller docs * chore: remove authorize endpoint * feat: owner can test non accepted OAuth client * fix: remove sensitive data from OAuth2 exception logs Remove Authorization header and userEmail from error logs in OAuth2HttpExceptionFilter to avoid logging sensitive information. Addresses Cubic AI review feedback (confidence 9/10). Co-Authored-By: unknown <> * fix: e2e --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
unknown <>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
0eb2c15b39
commit
c321a6c07a
@@ -1,11 +1,12 @@
|
||||
import { generateSecret } from "@calcom/platform-libraries";
|
||||
import type { Membership, Team, User } from "@calcom/prisma/client";
|
||||
import { AccessScope, OAuthClientType } from "@calcom/prisma/enums";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { AccessScope, OAuthClientStatus, OAuthClientType } from "@calcom/prisma/enums";
|
||||
import type { INestApplication } from "@nestjs/common";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import type { TestingModule } from "@nestjs/testing";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuth2ClientRepositoryFixture } from "test/fixtures/repository/oauth2-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
@@ -21,20 +22,16 @@ import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
|
||||
// Mock next-auth/jwt getToken for ApiAuthStrategy NEXT_AUTH authentication
|
||||
// Mock next-auth/jwt getToken so the NextAuth fallback in ApiAuthStrategy doesn't crash in tests
|
||||
jest.mock("next-auth/jwt", () => ({
|
||||
getToken: jest.fn(),
|
||||
getToken: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
const mockGetToken = getToken as jest.MockedFunction<typeof getToken>;
|
||||
|
||||
describe("OAuth2 Controller Endpoints", () => {
|
||||
describe("User Not Authenticated", () => {
|
||||
let appWithoutAuth: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Mock getToken to return null for unauthenticated tests
|
||||
mockGetToken.mockResolvedValue(null);
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
|
||||
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
|
||||
@@ -69,20 +66,23 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Authenticated", () => {
|
||||
describe("User Authenticated (non-owner)", () => {
|
||||
let app: INestApplication;
|
||||
let moduleRef: TestingModule;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
|
||||
let oAuthService: OAuthService;
|
||||
|
||||
let user: User;
|
||||
let clientOwner: User;
|
||||
let authenticatedUser: User;
|
||||
let team: Team;
|
||||
let membership: Membership;
|
||||
let oAuthClient: { clientId: string };
|
||||
let apiKeyString: string;
|
||||
let refreshToken: string;
|
||||
|
||||
const testClientId = `test-oauth-client-${randomString()}`;
|
||||
@@ -96,7 +96,7 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
): Promise<string> {
|
||||
const result = await oAuthService.generateAuthorizationCode(
|
||||
testClientId,
|
||||
user.id,
|
||||
authenticatedUser.id,
|
||||
testRedirectUri,
|
||||
scopes,
|
||||
undefined,
|
||||
@@ -107,12 +107,6 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const userEmail = `oauth2-e2e-user-${randomString()}@api.com`;
|
||||
|
||||
// Mock getToken to return the user email for authenticated tests
|
||||
// This is needed because ApiAuthGuard uses ApiAuthStrategy which calls getToken from next-auth/jwt
|
||||
mockGetToken.mockResolvedValue({ email: userEmail });
|
||||
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
|
||||
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
|
||||
@@ -125,20 +119,28 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
|
||||
oAuthService = moduleRef.get(OAuthService);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
clientOwner = await userRepositoryFixture.create({
|
||||
email: `oauth2-e2e-owner-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
authenticatedUser = await userRepositoryFixture.create({
|
||||
email: `oauth2-e2e-user-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(authenticatedUser.id, null);
|
||||
apiKeyString = keyString;
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: `oauth2-e2e-team-${randomString()}`,
|
||||
slug: `oauth2-e2e-team-${randomString()}`,
|
||||
});
|
||||
|
||||
membership = await membershipRepositoryFixture.create({
|
||||
user: { connect: { id: user.id } },
|
||||
user: { connect: { id: authenticatedUser.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
@@ -151,6 +153,7 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
redirectUri: testRedirectUri,
|
||||
clientSecret: hashedSecret,
|
||||
clientType: OAuthClientType.CONFIDENTIAL,
|
||||
userId: clientOwner.id,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,6 +162,7 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
it("should return OAuth client info for valid client ID", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/auth/oauth2/clients/${testClientId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe("success");
|
||||
@@ -174,6 +178,7 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
it("should return 404 for non-existent client ID", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/api/v2/auth/oauth2/clients/non-existent-client-id")
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
@@ -434,7 +439,238 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
await oAuthClientFixture.delete(oAuthClient.clientId);
|
||||
await membershipRepositoryFixture.delete(membership.id);
|
||||
await teamRepositoryFixture.delete(team.id);
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
await userRepositoryFixture.delete(authenticatedUser.id);
|
||||
await userRepositoryFixture.delete(clientOwner.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Owner can use non-approved OAuth client", () => {
|
||||
let app: INestApplication;
|
||||
let moduleRef: TestingModule;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
|
||||
let oAuthService: OAuthService;
|
||||
|
||||
let owner: User;
|
||||
let team: Team;
|
||||
let membership: Membership;
|
||||
let pendingClient: { clientId: string };
|
||||
let rejectedClient: { clientId: string };
|
||||
let apiKeyString: string;
|
||||
|
||||
const pendingClientId = `test-pending-client-${randomString()}`;
|
||||
const rejectedClientId = `test-rejected-client-${randomString()}`;
|
||||
const testClientSecret = "test-secret-456";
|
||||
const testRedirectUri = "https://example.com/callback";
|
||||
|
||||
/** Generate a user-scoped authorization code (no teamSlug) so the owner's userId is in the token. */
|
||||
async function generateAuthCodeForClient(
|
||||
clientId: string,
|
||||
scopes: AccessScope[] = [AccessScope.READ_BOOKING]
|
||||
): Promise<string> {
|
||||
const result = await oAuthService.generateAuthorizationCode(
|
||||
clientId,
|
||||
owner.id,
|
||||
testRedirectUri,
|
||||
scopes
|
||||
);
|
||||
const redirectUrl = new URL(result.redirectUrl);
|
||||
return redirectUrl.searchParams.get("code") as string;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
|
||||
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
|
||||
oAuthService = moduleRef.get(OAuthService);
|
||||
|
||||
owner = await userRepositoryFixture.create({
|
||||
email: `oauth2-owner-e2e-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(owner.id, null);
|
||||
apiKeyString = keyString;
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: `oauth2-owner-team-${randomString()}`,
|
||||
slug: `oauth2-owner-team-${randomString()}`,
|
||||
});
|
||||
|
||||
membership = await membershipRepositoryFixture.create({
|
||||
user: { connect: { id: owner.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const [hashedSecret] = generateSecret(testClientSecret);
|
||||
|
||||
pendingClient = await oAuthClientFixture.create({
|
||||
clientId: pendingClientId,
|
||||
name: "Pending OAuth Client",
|
||||
redirectUri: testRedirectUri,
|
||||
clientSecret: hashedSecret,
|
||||
clientType: OAuthClientType.CONFIDENTIAL,
|
||||
status: OAuthClientStatus.PENDING,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
rejectedClient = await oAuthClientFixture.create({
|
||||
clientId: rejectedClientId,
|
||||
name: "Rejected OAuth Client",
|
||||
redirectUri: testRedirectUri,
|
||||
clientSecret: hashedSecret,
|
||||
clientType: OAuthClientType.CONFIDENTIAL,
|
||||
status: OAuthClientStatus.REJECTED,
|
||||
userId: owner.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return PENDING client info when authenticated as owner", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/auth/oauth2/clients/${pendingClientId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe("success");
|
||||
expect(response.body.data.client_id).toBe(pendingClientId);
|
||||
expect(response.body.data.name).toBe("Pending OAuth Client");
|
||||
});
|
||||
|
||||
it("should exchange authorization code for tokens with PENDING client owned by user", async () => {
|
||||
const code = await generateAuthCodeForClient(pendingClientId);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/auth/oauth2/token")
|
||||
.type("form")
|
||||
.send({
|
||||
client_id: pendingClientId,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_secret: testClientSecret,
|
||||
redirect_uri: testRedirectUri,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.access_token).toBeDefined();
|
||||
expect(response.body.refresh_token).toBeDefined();
|
||||
expect(response.body.token_type).toBe("bearer");
|
||||
});
|
||||
|
||||
it("should refresh tokens with PENDING client owned by user", async () => {
|
||||
const code = await generateAuthCodeForClient(pendingClientId);
|
||||
|
||||
const tokenResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/auth/oauth2/token")
|
||||
.type("form")
|
||||
.send({
|
||||
client_id: pendingClientId,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_secret: testClientSecret,
|
||||
redirect_uri: testRedirectUri,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/auth/oauth2/token")
|
||||
.type("form")
|
||||
.send({
|
||||
client_id: pendingClientId,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: tokenResponse.body.refresh_token,
|
||||
client_secret: testClientSecret,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.access_token).toBeDefined();
|
||||
expect(response.body.refresh_token).toBeDefined();
|
||||
expect(response.body.token_type).toBe("bearer");
|
||||
});
|
||||
|
||||
it("should reject authorization code generation for REJECTED client even as owner", async () => {
|
||||
await expect(generateAuthCodeForClient(rejectedClientId)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should reject token exchange when client becomes rejected", async () => {
|
||||
const code = await generateAuthCodeForClient(pendingClientId);
|
||||
|
||||
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.REJECTED);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/auth/oauth2/token")
|
||||
.type("form")
|
||||
.send({
|
||||
client_id: pendingClientId,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_secret: testClientSecret,
|
||||
redirect_uri: testRedirectUri,
|
||||
})
|
||||
.expect(401);
|
||||
|
||||
expect(response.body.error).toBe("unauthorized_client");
|
||||
expect(response.body.error_description).toBe("client_rejected");
|
||||
|
||||
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.PENDING);
|
||||
});
|
||||
|
||||
it("should reject token refresh when client becomes rejected", async () => {
|
||||
const code = await generateAuthCodeForClient(pendingClientId);
|
||||
|
||||
const tokenResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/auth/oauth2/token")
|
||||
.type("form")
|
||||
.send({
|
||||
client_id: pendingClientId,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_secret: testClientSecret,
|
||||
redirect_uri: testRedirectUri,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.REJECTED);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/auth/oauth2/token")
|
||||
.type("form")
|
||||
.send({
|
||||
client_id: pendingClientId,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: tokenResponse.body.refresh_token,
|
||||
client_secret: testClientSecret,
|
||||
})
|
||||
.expect(401);
|
||||
|
||||
expect(response.body.error).toBe("unauthorized_client");
|
||||
expect(response.body.error_description).toBe("client_rejected");
|
||||
|
||||
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.PENDING);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oAuthClientFixture.delete(pendingClient.clientId);
|
||||
await oAuthClientFixture.delete(rejectedClient.clientId);
|
||||
await membershipRepositoryFixture.delete(membership.id);
|
||||
await teamRepositoryFixture.delete(team.id);
|
||||
await userRepositoryFixture.delete(owner.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,15 +16,16 @@ export class OAuth2HttpExceptionFilter implements ExceptionFilter<OAuth2HttpExce
|
||||
const requestId = request.headers["x-request-id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
const { userEmail: _userEmail, ...safeUserContext } = extractUserContext(request);
|
||||
const { Authorization: _auth, ...safeHeaders } = filterReqHeaders(request.headers);
|
||||
this.logger.error(`OAuth2 Http Exception: ${exception.oAuthErrorData.error}`, {
|
||||
exception,
|
||||
body: "[REDACTED]",
|
||||
headers: filterReqHeaders(request.headers),
|
||||
headers: safeHeaders,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
...safeUserContext,
|
||||
});
|
||||
|
||||
response.status(exception.getStatus()).json(exception.oAuthErrorData);
|
||||
|
||||
Reference in New Issue
Block a user