Skip to content

Commit

Permalink
N21-1456 improves logging for oauth calls
Browse files Browse the repository at this point in the history
  • Loading branch information
arnegns committed Nov 9, 2023
1 parent 96b199f commit f5f9e92
Show file tree
Hide file tree
Showing 10 changed files with 462 additions and 121 deletions.
390 changes: 284 additions & 106 deletions apps/server/src/infra/oauth-provider/hydra/hydra.adapter.spec.ts

Large diffs are not rendered by default.

41 changes: 29 additions & 12 deletions apps/server/src/infra/oauth-provider/hydra/hydra.adapter.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import { Configuration } from '@hpi-schul-cloud/commons/lib';
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios';
import { isAxiosError } from '@shared/common';
import { AxiosError, AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios';

Check failure on line 5 in apps/server/src/infra/oauth-provider/hydra/hydra.adapter.ts

View workflow job for this annotation

GitHub Actions / nest_lint

'AxiosError' is defined but never used
import QueryString from 'qs';
import { Observable, firstValueFrom } from 'rxjs';
import { firstValueFrom, Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { URL } from 'url';
import {
AcceptConsentRequestBody,
AcceptLoginRequestBody,
IntrospectResponse,
ProviderConsentResponse,
ProviderConsentSessionResponse,
ProviderLoginResponse,
ProviderOauthClient,
ProviderRedirectResponse,
RejectRequestBody,
} from '../dto';
import { ProviderConsentSessionResponse } from '../dto/response/consent-session.response';
import { HydraOauthLoggableException } from '../loggable';
import { OauthProviderService } from '../oauth-provider.service';

@Injectable()
Expand Down Expand Up @@ -160,15 +163,29 @@ export class HydraAdapter extends OauthProviderService {
data?: unknown,
additionalHeaders: RawAxiosRequestHeaders = {}
): Promise<T> {
const observable: Observable<AxiosResponse<T>> = this.httpService.request({
url,
method,
headers: {
'X-Forwarded-Proto': 'https',
...additionalHeaders,
},
data,
});
const observable: Observable<AxiosResponse<T>> = this.httpService
.request({
url,
method,
headers: {
'X-Forwarded-Proto': 'https',
...additionalHeaders,
},
data,
})
.pipe(
catchError((error: unknown) => {
if (isAxiosError(error)) {
const responseData = error.response?.data;
const errorMessage = (responseData as { error: { message: string } }).error.message;
const errorCode = (responseData as { error: { code: number } }).error.code;
throw new HydraOauthLoggableException(url, method, errorMessage, errorCode);
} else {
throw error;
}
})
);

const response: AxiosResponse<T> = await firstValueFrom(observable);
return response.data;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { HydraOauthLoggableException } from './hydra-oauth-loggable-exception';

describe(HydraOauthLoggableException.name, () => {
describe('getLogMessage', () => {
const setup = () => {
const url = 'url';
const method = 'method';
const errorMessage = 'thisIsTheMessage';
const errorStatusCode = 127;

const exception = new HydraOauthLoggableException(url, method, errorMessage, errorStatusCode);

return {
exception,
url,
method,
errorMessage,
errorStatusCode,
};
};

it('should return the correct log message', () => {
const { exception, url, method, errorMessage } = setup();

const message = exception.getLogMessage();

expect(message).toEqual({
type: 'HYDRA_OAUTH_ERROR',
message: 'Hydra oauth error occurred',
stack: expect.stringContaining(`HydraOauthLoggableException: ${errorMessage}`) as string,
data: {
url,
method,
},
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { HttpException } from '@nestjs/common/exceptions/http.exception';
import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger';

export class HydraOauthLoggableException extends HttpException implements Loggable {
constructor(
private readonly url: string,
private readonly method: string,
private readonly errorMessage: string,
private readonly errorStatusCode: number
) {
super(errorMessage, errorStatusCode);
}

getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage {
return {
type: 'HYDRA_OAUTH_ERROR',
message: 'Hydra oauth error occurred',
stack: this.stack,
data: {
url: this.url,
method: this.method,
},
};
}
}
1 change: 1 addition & 0 deletions apps/server/src/infra/oauth-provider/loggable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './hydra-oauth-loggable-exception';
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { HttpService } from '@nestjs/axios';
import { Test, TestingModule } from '@nestjs/testing';
import { axiosResponseFactory } from '@shared/testing';
import { AxiosError, AxiosResponse } from 'axios/index';
import { of, throwError } from 'rxjs';
import { OAuthGrantType } from '../interface/oauth-grant-type.enum';
import { OAuthSSOError } from '../loggable';
Expand Down Expand Up @@ -101,5 +102,47 @@ describe('OauthAdapterServive', () => {
await expect(resp).rejects.toEqual(new OAuthSSOError('Requesting token failed.', 'sso_auth_code_step'));
});
});

describe('when error got returned', () => {
describe('when error is a unknown error', () => {
const setup = () => {
httpService.post.mockReturnValueOnce(throwError(() => new Error('unknown error')));
};

it('should throw the default sso error', async () => {
setup();

const resp = service.sendAuthenticationCodeTokenRequest('tokenEndpoint', testPayload);

await expect(resp).rejects.toEqual(new OAuthSSOError('Requesting token failed.', 'sso_auth_code_step'));
});
});

describe('when error is a axios error', () => {
const setup = () => {
const axiosError: AxiosError = {
isAxiosError: true,
response: {
data: {
error: {
message: 'Some error message',
code: 123,
},
},
} as AxiosResponse,
} as AxiosError;

httpService.post.mockReturnValueOnce(throwError(() => axiosError));
};

it('should throw an error', async () => {
setup();

const resp = service.sendAuthenticationCodeTokenRequest('tokenEndpoint', testPayload);

await expect(resp).rejects.toEqual(new OAuthSSOError('Some error message', 'sso_auth_code_step'));
});
});
});
});
});
18 changes: 15 additions & 3 deletions apps/server/src/modules/oauth/service/oauth-adapter.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { HydraOauthLoggableException } from '@infra/oauth-provider/loggable';

Check failure on line 1 in apps/server/src/modules/oauth/service/oauth-adapter.service.ts

View workflow job for this annotation

GitHub Actions / nest_lint

'HydraOauthLoggableException' is defined but never used
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common/decorators';
import { AxiosResponse } from 'axios';
import { isAxiosError } from '@shared/common';
import { AxiosError, AxiosResponse } from 'axios';

Check failure on line 5 in apps/server/src/modules/oauth/service/oauth-adapter.service.ts

View workflow job for this annotation

GitHub Actions / nest_lint

'AxiosError' is defined but never used
import JwksRsa from 'jwks-rsa';
import QueryString from 'qs';
import { lastValueFrom, Observable } from 'rxjs';
Expand Down Expand Up @@ -37,10 +39,20 @@ export class OauthAdapterService {
private async resolveTokenRequest(
observable: Observable<AxiosResponse<OauthTokenResponse, unknown>>
): Promise<OauthTokenResponse> {
let responseToken: AxiosResponse<OauthTokenResponse>;
let responseToken: AxiosResponse<OauthTokenResponse> | undefined;
try {
responseToken = await lastValueFrom(observable);
} catch (error) {
} catch (error: unknown) {
if (isAxiosError(error)) {
const response = error.response as AxiosResponse;

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const errorMessage: string = (response?.data?.error?.message as string) ?? 'Requesting token failed.';
throw new OAuthSSOError(errorMessage, 'sso_auth_code_step');
}
}

if (!responseToken) {
throw new OAuthSSOError('Requesting token failed.', 'sso_auth_code_step');
}

Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/shared/common/utils/axios.util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { isAxiosError } from './axios.util';

describe('axios.util', () => {
describe('isAxiosError', () => {
it('should return true when error is an axios error', () => {
const axiosError = {
isAxiosError: true,
};
expect(isAxiosError(axiosError)).toBe(true);
});

it('should return false when error is not an axios error', () => {
const error = new Error('some error');
expect(isAxiosError(error)).toBe(false);
});

it('should return false when error is undefined', () => {
expect(isAxiosError(undefined)).toBe(false);
});
});
});
5 changes: 5 additions & 0 deletions apps/server/src/shared/common/utils/axios.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AxiosError } from 'axios';

export function isAxiosError(error: unknown): error is AxiosError {
return !!(error && typeof error === 'object' && 'isAxiosError' in error);
}
1 change: 1 addition & 0 deletions apps/server/src/shared/common/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './converter.util';
export * from './guard-against';
export * from './axios.util';

0 comments on commit f5f9e92

Please sign in to comment.