feat(server): introduce user friendly server errors (#7111)

This commit is contained in:
liuyi
2024-06-17 11:30:58 +08:00
committed by GitHub
parent 5307a55f8a
commit 54fc1197ad
65 changed files with 3170 additions and 924 deletions
@@ -1,17 +1,18 @@
import {
BadRequestException,
Controller,
Get,
Query,
Req,
Res,
} from '@nestjs/common';
import { Controller, Get, Query, Req, Res } from '@nestjs/common';
import { ConnectedAccount, PrismaClient } from '@prisma/client';
import type { Request, Response } from 'express';
import { AuthService, Public } from '../../core/auth';
import { UserService } from '../../core/user';
import { URLHelper } from '../../fundamentals';
import {
InvalidOauthCallbackState,
MissingOauthQueryParameter,
OauthAccountAlreadyConnected,
OauthStateExpired,
UnknownOauthProvider,
URLHelper,
WrongSignInMethod,
} from '../../fundamentals';
import { OAuthProviderName } from './config';
import { OAuthAccount, Tokens } from './providers/def';
import { OAuthProviderFactory } from './register';
@@ -35,12 +36,15 @@ export class OAuthController {
@Query('provider') unknownProviderName: string,
@Query('redirect_uri') redirectUri?: string
) {
if (!unknownProviderName) {
throw new MissingOauthQueryParameter({ name: 'provider' });
}
// @ts-expect-error safe
const providerName = OAuthProviderName[unknownProviderName];
const provider = this.providerFactory.get(providerName);
if (!provider) {
throw new BadRequestException('Invalid OAuth provider');
throw new UnknownOauthProvider({ name: unknownProviderName });
}
const state = await this.oauth.saveOAuthState({
@@ -60,29 +64,31 @@ export class OAuthController {
@Query('state') stateStr?: string
) {
if (!code) {
throw new BadRequestException('Missing query parameter `code`');
throw new MissingOauthQueryParameter({ name: 'code' });
}
if (!stateStr) {
throw new BadRequestException('Invalid callback state parameter');
throw new MissingOauthQueryParameter({ name: 'state' });
}
if (typeof stateStr !== 'string' || !this.oauth.isValidState(stateStr)) {
throw new InvalidOauthCallbackState();
}
const state = await this.oauth.getOAuthState(stateStr);
if (!state) {
throw new BadRequestException('OAuth state expired, please try again.');
throw new OauthStateExpired();
}
if (!state.provider) {
throw new BadRequestException(
'Missing callback state parameter `provider`'
);
throw new MissingOauthQueryParameter({ name: 'provider' });
}
const provider = this.providerFactory.get(state.provider);
if (!provider) {
throw new BadRequestException('Invalid provider');
throw new UnknownOauthProvider({ name: state.provider ?? 'unknown' });
}
const tokens = await provider.getToken(code);
@@ -154,15 +160,9 @@ export class OAuthController {
// we can't directly connect the external account with given email in sign in scenario for safety concern.
// let user manually connect in account sessions instead.
if (user.registered) {
throw new BadRequestException(
'The account with provided email is not register in the same way.'
);
throw new WrongSignInMethod();
}
await this.user.fulfillUser(externalAccount.email, {
emailVerifiedAt: new Date(),
registered: true,
});
await this.db.connectedAccount.create({
data: {
userId: user.id,
@@ -228,9 +228,7 @@ export class OAuthController {
if (connectedUser) {
if (connectedUser.id !== user.id) {
throw new BadRequestException(
'The third-party account has already been connected to another user.'
);
throw new OauthAccountAlreadyConnected();
}
} else {
await this.db.connectedAccount.create({
@@ -1,4 +1,4 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Config, URLHelper } from '../../../fundamentals';
import { OAuthProviderName } from '../config';
@@ -39,74 +39,60 @@ export class GithubOAuthProvider extends AutoRegisteredOAuthProvider {
}
async getToken(code: string) {
try {
const response = await fetch(
'https://github.com/login/oauth/access_token',
{
method: 'POST',
body: this.url.stringify({
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.url.link('/oauth/callback'),
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
if (response.ok) {
const ghToken = (await response.json()) as AuthTokenResponse;
return {
accessToken: ghToken.access_token,
scope: ghToken.scope,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
}, ${JSON.stringify(await response.json())}`
);
const response = await fetch(
'https://github.com/login/oauth/access_token',
{
method: 'POST',
body: this.url.stringify({
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.url.link('/oauth/callback'),
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
}
} catch (e) {
throw new HttpException(
`Failed to get access_token, err: ${(e as Error).message}`,
HttpStatus.BAD_REQUEST
);
if (response.ok) {
const ghToken = (await response.json()) as AuthTokenResponse;
return {
accessToken: ghToken.access_token,
scope: ghToken.scope,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
}, ${JSON.stringify(await response.json())}`
);
}
}
async getUser(token: string) {
try {
const response = await fetch('https://api.github.com/user', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
const response = await fetch('https://api.github.com/user', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const user = (await response.json()) as UserInfo;
if (response.ok) {
const user = (await response.json()) as UserInfo;
return {
id: user.login,
avatarUrl: user.avatar_url,
email: user.email,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
} ${await response.text()}`
);
}
} catch (e) {
throw new HttpException(
`Failed to get user information, err: ${(e as Error).stack}`,
HttpStatus.BAD_REQUEST
return {
id: user.login,
avatarUrl: user.avatar_url,
email: user.email,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
} ${await response.text()}`
);
}
}
@@ -1,4 +1,4 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Config, URLHelper } from '../../../fundamentals';
import { OAuthProviderName } from '../config';
@@ -44,77 +44,63 @@ export class GoogleOAuthProvider extends AutoRegisteredOAuthProvider {
}
async getToken(code: string) {
try {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: this.url.stringify({
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.url.link('/oauth/callback'),
grant_type: 'authorization_code',
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: this.url.stringify({
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.url.link('/oauth/callback'),
grant_type: 'authorization_code',
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
});
if (response.ok) {
const ghToken = (await response.json()) as GoogleOAuthTokenResponse;
if (response.ok) {
const ghToken = (await response.json()) as GoogleOAuthTokenResponse;
return {
accessToken: ghToken.access_token,
refreshToken: ghToken.refresh_token,
expiresAt: new Date(Date.now() + ghToken.expires_in * 1000),
scope: ghToken.scope,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
}, ${JSON.stringify(await response.json())}`
);
}
} catch (e) {
throw new HttpException(
`Failed to get access_token, err: ${(e as Error).message}`,
HttpStatus.BAD_REQUEST
return {
accessToken: ghToken.access_token,
refreshToken: ghToken.refresh_token,
expiresAt: new Date(Date.now() + ghToken.expires_in * 1000),
scope: ghToken.scope,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
}, ${JSON.stringify(await response.json())}`
);
}
}
async getUser(token: string) {
try {
const response = await fetch(
'https://www.googleapis.com/oauth2/v2/userinfo',
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (response.ok) {
const user = (await response.json()) as UserInfo;
return {
id: user.id,
avatarUrl: user.picture,
email: user.email,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
} ${await response.text()}`
);
const response = await fetch(
'https://www.googleapis.com/oauth2/v2/userinfo',
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
}
} catch (e) {
throw new HttpException(
`Failed to get user information, err: ${(e as Error).stack}`,
HttpStatus.BAD_REQUEST
);
if (response.ok) {
const user = (await response.json()) as UserInfo;
return {
id: user.id,
avatarUrl: user.picture,
email: user.email,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
} ${await response.text()}`
);
}
}
@@ -1,9 +1,4 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
OnModuleInit,
} from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { z } from 'zod';
import { Config, URLHelper } from '../../../fundamentals';
@@ -44,6 +39,8 @@ const OIDCConfigurationSchema = z.object({
type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
const logger = new Logger('OIDCClient');
class OIDCClient {
private static async fetch<T = any>(
url: string,
@@ -53,17 +50,8 @@ class OIDCClient {
const response = await fetch(url, options);
if (!response.ok) {
if (response.status >= 400 && response.status < 500) {
throw new BadRequestException(`Invalid OIDC configuration`, {
cause: await response.json(),
description: response.statusText,
});
} else {
throw new InternalServerErrorException(`Failed to configure client`, {
cause: await response.json(),
description: response.statusText,
});
}
logger.error('Failed to fetch OIDC configuration', await response.json());
throw new Error(`Failed to configure client`);
}
const data = await response.json();
return verifier.parse(data);
@@ -20,6 +20,10 @@ export class OAuthService {
private readonly cache: SessionCache
) {}
isValidState(stateStr: string) {
return stateStr.length === 36;
}
async saveOAuthState(state: OAuthState) {
const token = randomUUID();
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, state, {