mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 01:56:27 +08:00
feat(server): introduce user friendly server errors (#7111)
This commit is contained in:
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user