Skip to content

Commit

Permalink
feat: support oidc
Browse files Browse the repository at this point in the history
  • Loading branch information
boris-w committed May 22, 2024
1 parent b987080 commit afc1104
Show file tree
Hide file tree
Showing 14 changed files with 184 additions and 28 deletions.
4 changes: 3 additions & 1 deletion apps/nestjs-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@
"@types/node": "20.9.0",
"@types/node-fetch": "2.6.11",
"@types/nodemailer": "6.4.14",
"@types/papaparse": "5.3.14",
"@types/passport": "1.0.16",
"@types/passport-openidconnect": "0.1.3",
"@types/passport-github2": "1.2.9",
"@types/passport-google-oauth20": "2.0.14",
"@types/passport-jwt": "4.0.1",
Expand Down Expand Up @@ -136,7 +138,6 @@
"@teable/db-main-prisma": "workspace:^",
"@teable/openapi": "workspace:^",
"@teamwork/websocket-json-stream": "2.0.0",
"@types/papaparse": "5.3.14",
"ajv": "8.12.0",
"axios": "1.6.8",
"bcrypt": "5.1.1",
Expand Down Expand Up @@ -179,6 +180,7 @@
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1",
"passport-local": "1.0.0",
"passport-openidconnect": "0.1.2",
"pause": "0.1.0",
"pino-http": "9.0.0",
"pino-pretty": "11.0.0",
Expand Down
10 changes: 10 additions & 0 deletions apps/nestjs-backend/src/configs/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ export const authConfig = registerAs('auth', () => ({
clientSecret: process.env.BACKEND_GOOGLE_CLIENT_SECRET,
callbackURL: process.env.BACKEND_GOOGLE_CALLBACK_URL,
},
oidc: {
issuer: process.env.BACKEND_OIDC_ISSUER,
authorizationURL: process.env.BACKEND_OIDC_AUTHORIZATION_URL,
tokenURL: process.env.BACKEND_OIDC_TOKEN_URL,
userInfoURL: process.env.BACKEND_OIDC_USER_INFO_URL,
clientID: process.env.BACKEND_OIDC_CLIENT_ID,
clientSecret: process.env.BACKEND_OIDC_CLIENT_SECRET,
callbackURL: process.env.BACKEND_OIDC_CALLBACK_URL,
other: process.env.BACKEND_OIDC_OTHER ? JSON.parse(process.env.BACKEND_OIDC_OTHER) : {},
},
}));

export const AuthConfig = () => Inject(authConfig.KEY);
Expand Down
5 changes: 5 additions & 0 deletions apps/nestjs-backend/src/features/auth/guard/oidc.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class OIDCGuard extends AuthGuard('openidconnect') {}
5 changes: 4 additions & 1 deletion apps/nestjs-backend/src/features/auth/oauth/oauth.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export class OauthStoreService {

constructor(private readonly cacheService: CacheService) {}

async store(req: Request, callback: (err: unknown, stateId: string) => void) {
async store(req: Request, callback: (err: unknown, stateId: string) => void, ...args: unknown[]) {
if (args.length === 3 && typeof args[2] === 'function') {
callback = args[2] as (err: unknown, stateId: string) => void;
}
const random = getRandomString(16);
await this.cacheService.set(
`oauth2:${random}`,
Expand Down
17 changes: 17 additions & 0 deletions apps/nestjs-backend/src/features/auth/social/controller.adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Response } from 'express';
import type { IOauth2State } from '../../../cache/types';

export class ControllerAdapter {
// eslint-disable-next-line @typescript-eslint/no-empty-function
async authenticate() {}

async callback(req: Express.Request, res: Response) {
const user = req.user!;
// set cookie, passport login
await new Promise<void>((resolve, reject) => {
req.login(user, (err) => (err ? reject(err) : resolve()));
});
const redirectUri = (req.authInfo as { state: IOauth2State })?.state?.redirectUri;
return res.redirect(redirectUri || '/');
}
}
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import type { IOauth2State } from '../../../../cache/types';
import { Public } from '../../decorators/public.decorator';
import { GithubGuard } from '../../guard/github.guard';
import { ControllerAdapter } from '../controller.adapter';

@Controller('api/auth')
export class GithubController {
export class GithubController extends ControllerAdapter {
@Get('/github')
@Public()
@UseGuards(GithubGuard)
// eslint-disable-next-line @typescript-eslint/no-empty-function
async githubAuthenticate() {}
async githubAuthenticate() {
return super.authenticate();
}

@Get('/github/callback')
@Public()
@UseGuards(GithubGuard)
async githubCallback(@Req() req: Express.Request, @Res({ passthrough: true }) res: Response) {
const user = req.user!;
// set cookie, passport login
await new Promise<void>((resolve, reject) => {
req.login(user, (err) => (err ? reject(err) : resolve()));
});
const redirectUri = (req.authInfo as { state: IOauth2State })?.state?.redirectUri;
return res.redirect(redirectUri || '/');
return super.callback(req, res);
}
}
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import type { IOauth2State } from '../../../../cache/types';
import { Public } from '../../decorators/public.decorator';
import { GoogleGuard } from '../../guard/google.guard';
import { ControllerAdapter } from '../controller.adapter';

@Controller('api/auth')
export class GoogleController {
export class GoogleController extends ControllerAdapter {
@Get('/google')
@Public()
@UseGuards(GoogleGuard)
// eslint-disable-next-line @typescript-eslint/no-empty-function
async googleAuthenticate() {}
async googleAuthenticate() {
return super.authenticate();
}

@Get('/google/callback')
@Public()
@UseGuards(GoogleGuard)
async googleCallback(@Req() req: Express.Request, @Res({ passthrough: true }) res: Response) {
const user = req.user!;
// set cookie, passport login
await new Promise<void>((resolve, reject) => {
req.login(user, (err) => (err ? reject(err) : resolve()));
});
const redirectUri = (req.authInfo as { state: IOauth2State })?.state?.redirectUri;
return res.redirect(redirectUri || '/');
return super.callback(req, res);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { Public } from '../../decorators/public.decorator';
import { OIDCGuard } from '../../guard/oidc.guard';
import { ControllerAdapter } from '../controller.adapter';

@Controller('api/auth')
export class OIDCController extends ControllerAdapter {
@Get('/oidc')
@Public()
@UseGuards(OIDCGuard)
// eslint-disable-next-line @typescript-eslint/no-empty-function
async githubAuthenticate() {
return super.authenticate();
}

@Get('/oidc/callback')
@Public()
@UseGuards(OIDCGuard)
async githubCallback(@Req() req: Express.Request, @Res({ passthrough: true }) res: Response) {
return super.callback(req, res);
}
}
13 changes: 13 additions & 0 deletions apps/nestjs-backend/src/features/auth/social/oidc/oidc.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { UserModule } from '../../../user/user.module';
import { OauthStoreService } from '../../oauth/oauth.store';
import { OIDCStrategy } from '../../strategies/oidc.strategy';
import { OIDCController } from './oidc.controller';

@Module({
imports: [UserModule],
providers: [OIDCStrategy, OauthStoreService],
exports: [],
controllers: [OIDCController],
})
export class OIDCModule {}
4 changes: 4 additions & 0 deletions apps/nestjs-backend/src/features/auth/social/social.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConditionalModule } from '@nestjs/config';
import { GithubModule } from './github/github.module';
import { GoogleModule } from './google/google.module';
import { OIDCModule } from './oidc/oidc.module';

@Module({
imports: [
Expand All @@ -11,6 +12,9 @@ import { GoogleModule } from './google/google.module';
ConditionalModule.registerWhen(GoogleModule, (env) => {
return Boolean(env.SOCIAL_AUTH_PROVIDERS?.split(',')?.includes('google'));
}),
ConditionalModule.registerWhen(OIDCModule, (env) => {
return Boolean(env.SOCIAL_AUTH_PROVIDERS?.split(',')?.includes('oidc'));
}),
],
})
export class SocialModule {}
48 changes: 48 additions & 0 deletions apps/nestjs-backend/src/features/auth/strategies/oidc.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import type { Profile } from 'passport-openidconnect';
import { Strategy } from 'passport-openidconnect';
import { AuthConfig } from '../../../configs/auth.config';
import type { authConfig } from '../../../configs/auth.config';
import { UserService } from '../../user/user.service';
import { OauthStoreService } from '../oauth/oauth.store';
import { pickUserMe } from '../utils';

@Injectable()
export class OIDCStrategy extends PassportStrategy(Strategy, 'openidconnect') {
constructor(
@AuthConfig() readonly config: ConfigType<typeof authConfig>,
private usersService: UserService,
oauthStoreService: OauthStoreService
) {
const { other, ...rest } = config.oidc;
console.log('OIDCStrategy', other);
super({
...rest,
state: true,
store: oauthStoreService,
...other,
});
}

async validate(_issuer: string, profile: Profile) {
const { id, emails, displayName, photos } = profile;
const email = emails?.[0].value;
if (!email) {
throw new UnauthorizedException('No email provided from Google');
}
const user = await this.usersService.findOrCreateUser({
name: displayName,
email,
provider: 'oidc',
providerId: id,
type: 'oauth',
avatarUrl: photos?.[0].value,
});
if (!user) {
throw new UnauthorizedException('Failed to create user from Google profile');
}
return pickUserMe(user);
}
}
11 changes: 10 additions & 1 deletion apps/nextjs-app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,14 @@ BACKEND_GITHUB_CLIENT_SECRET=github_client_secret
BACKEND_GOOGLE_CLIENT_ID=google_client_id
BACKEND_GOOGLE_CLIENT_SECRET=google_client_secret
BACKEND_GOOGLE_CALLBACK_URL=https://app.teable.io/api/auth/google/callback
#oidc example google
BACKEND_OIDC_CLIENT_ID=google_client_id
BACKEND_OIDC_CLIENT_SECRET=google_client_secret
BACKEND_OIDC_CALLBACK_URL=https://app.teable.io/api/auth/oidc/callback
BACKEND_OIDC_USER_INFO_URL=https://openidconnect.googleapis.com/v1/userinfo
BACKEND_OIDC_TOKEN_URL=https://oauth2.googleapis.com/token
BACKEND_OIDC_AUTHORIZATION_URL=https://accounts.google.com/o/oauth2/auth
BACKEND_OIDC_ISSUER=https://accounts.google.com
BACKEND_OIDC_OTHER={"scope": ["email", "profile"]}
# separated by ','
SOCIAL_AUTH_PROVIDERS=github,google
SOCIAL_AUTH_PROVIDERS=github,google,oidc
7 changes: 6 additions & 1 deletion apps/nextjs-app/src/features/auth/components/SocialAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ const providersAll = [
Icon: GoogleLogo,
authUrl: '/api/auth/google',
},
{
id: 'oidc',
text: 'OIDC',
authUrl: '/api/auth/oidc',
},
];

export const SocialAuth = () => {
Expand Down Expand Up @@ -45,7 +50,7 @@ export const SocialAuth = () => {
<div className="space-y-2">
{providers.map(({ id, text, Icon, authUrl }) => (
<Button key={id} className="w-full" variant="outline" onClick={() => onClick(authUrl)}>
<Icon className="size-4" />
{Icon && <Icon className="size-4" />}
{text}
</Button>
))}
Expand Down
33 changes: 29 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit afc1104

Please sign in to comment.