Skip to content

Commit 4747677

Browse files
authored
Merge pull request #158 from import-ai/feat/oauth
feat(oauth): add simple oauth module
2 parents 00e4f93 + d99e905 commit 4747677

File tree

7 files changed

+219
-2
lines changed

7 files changed

+219
-2
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ lerna-debug.log*
4646
.temp
4747
.tmp
4848

49+
.claude
50+
4951
# Runtime data
5052
pids
5153
*.pid

src/app/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { AddTagIdsToResources1755248141570 } from 'omniboxd/migrations/175524814
4242
import { CleanResourceNames1755396702021 } from 'omniboxd/migrations/1755396702021-clean-resource-names';
4343
import { UpdateAttachmentUrls1755499552000 } from 'omniboxd/migrations/1755499552000-update-attachment-urls';
4444
import { ScanResourceAttachments1755504936756 } from 'omniboxd/migrations/1755504936756-scan-resource-attachments';
45+
import { OAuthModule } from 'omniboxd/oauth2/oauth.module';
4546

4647
@Module({})
4748
export class AppModule implements NestModule {
@@ -81,6 +82,7 @@ export class AppModule implements NestModule {
8182
InvitationsModule,
8283
AttachmentsModule,
8384
SharesModule,
85+
OAuthModule,
8486
// CacheModule.registerAsync({
8587
// imports: [ConfigModule],
8688
// inject: [ConfigService],

src/auth/jwt.strategy.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
1313
});
1414
}
1515

16-
validate(payload: { sub: string; email: string }) {
17-
return { id: payload.sub, email: payload.email };
16+
validate(payload: { sub: string; email?: string; client_id?: string }) {
17+
return {
18+
id: payload.sub,
19+
email: payload.email,
20+
clientId: payload.client_id,
21+
};
1822
}
1923
}

src/oauth2/oauth.controller.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Public } from 'omniboxd/auth/decorators/public.auth.decorator';
2+
import { UserId } from 'omniboxd/decorators/user-id.decorator';
3+
import { OAuthService } from 'omniboxd/oauth2/oauth.service';
4+
import {
5+
Controller,
6+
Get,
7+
Post,
8+
Query,
9+
Body,
10+
BadRequestException,
11+
} from '@nestjs/common';
12+
13+
@Controller('api/v1/oauth2')
14+
export class OAuthController {
15+
constructor(private readonly oauthService: OAuthService) {}
16+
17+
@Get('authorize')
18+
authorize(
19+
@Query('response_type') responseType: string,
20+
@Query('client_id') clientId: string,
21+
@Query('redirect_uri') redirectUri: string,
22+
@Query('state') state: string,
23+
@UserId() userId: string,
24+
) {
25+
if (responseType !== 'code') {
26+
throw new BadRequestException(
27+
'Only authorization code flow is supported',
28+
);
29+
}
30+
31+
if (!clientId || !redirectUri) {
32+
throw new BadRequestException('client_id and redirect_uri are required');
33+
}
34+
35+
const code = this.oauthService.createAuthorizationCode(
36+
userId,
37+
clientId,
38+
redirectUri,
39+
);
40+
41+
const redirectUrl = new URL(redirectUri);
42+
redirectUrl.searchParams.set('code', code);
43+
if (state) {
44+
redirectUrl.searchParams.set('state', state);
45+
}
46+
47+
// Most browsers will block automatic redirects, so we perform the redirect manually
48+
return {
49+
redirectUrl: redirectUrl.toString(),
50+
};
51+
}
52+
53+
@Public()
54+
@Post('token')
55+
async token(
56+
@Body()
57+
body: {
58+
grant_type: string;
59+
code: string;
60+
client_id: string;
61+
},
62+
) {
63+
if (body.grant_type !== 'authorization_code') {
64+
throw new BadRequestException(
65+
'Only authorization_code grant type is supported',
66+
);
67+
}
68+
69+
const { code, client_id } = body;
70+
if (!code || !client_id) {
71+
throw new BadRequestException('Missing required parameters');
72+
}
73+
74+
return await this.oauthService.exchangeCodeForTokens(code, client_id);
75+
}
76+
}

src/oauth2/oauth.module.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Module } from '@nestjs/common';
2+
import { JwtModule } from '@nestjs/jwt';
3+
import { UserModule } from 'omniboxd/user/user.module';
4+
import { ConfigModule, ConfigService } from '@nestjs/config';
5+
import { OAuthController } from 'omniboxd/oauth2/oauth.controller';
6+
import { OAuthService } from 'omniboxd/oauth2/oauth.service';
7+
8+
@Module({
9+
imports: [
10+
UserModule,
11+
JwtModule.registerAsync({
12+
imports: [ConfigModule],
13+
inject: [ConfigService],
14+
useFactory: (config: ConfigService) => ({
15+
global: true,
16+
secret: config.get('OBB_JWT_SECRET'),
17+
signOptions: { expiresIn: config.get('OBB_JWT_EXPIRE') },
18+
}),
19+
}),
20+
],
21+
controllers: [OAuthController],
22+
providers: [OAuthService],
23+
exports: [OAuthService],
24+
})
25+
export class OAuthModule {}

src/oauth2/oauth.service.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { Injectable, BadRequestException } from '@nestjs/common';
2+
import { JwtService } from '@nestjs/jwt';
3+
import { nanoid } from 'nanoid';
4+
import { OAuthConfig, DEFAULT_OAUTH_CONFIG } from './types';
5+
import { UserService } from 'omniboxd/user/user.service';
6+
7+
interface AuthorizationCode {
8+
code: string;
9+
userId: string;
10+
clientId: string;
11+
redirectUri: string;
12+
expiresAt: Date;
13+
}
14+
15+
@Injectable()
16+
export class OAuthService {
17+
private config: OAuthConfig = DEFAULT_OAUTH_CONFIG;
18+
private authorizationCodes: Map<string, AuthorizationCode> = new Map();
19+
20+
constructor(
21+
private readonly jwtService: JwtService,
22+
private readonly userService: UserService,
23+
) {}
24+
25+
createAuthorizationCode(
26+
userId: string,
27+
clientId: string,
28+
redirectUri: string,
29+
) {
30+
const code = nanoid(32);
31+
const expiresAt = new Date();
32+
expiresAt.setMinutes(
33+
expiresAt.getMinutes() + this.config.authorizationCodeExpiryMinutes,
34+
);
35+
36+
const authCode: AuthorizationCode = {
37+
code,
38+
userId,
39+
clientId,
40+
redirectUri,
41+
expiresAt,
42+
};
43+
44+
this.cleanupExpiredCodes();
45+
this.authorizationCodes.set(code, authCode);
46+
return code;
47+
}
48+
49+
async exchangeCodeForTokens(code: string, clientId: string) {
50+
const authCode = this.authorizationCodes.get(code);
51+
52+
if (!authCode || authCode.expiresAt < new Date()) {
53+
throw new BadRequestException('Invalid or expired authorization code');
54+
}
55+
56+
if (authCode.clientId !== clientId) {
57+
throw new BadRequestException('Invalid authorization code');
58+
}
59+
60+
this.authorizationCodes.delete(code);
61+
this.cleanupExpiredCodes();
62+
return await this.generateAccessToken(authCode.userId, clientId);
63+
}
64+
65+
private async generateAccessToken(userId: string, clientId: string) {
66+
const user = await this.userService.find(userId);
67+
if (!user) {
68+
throw new BadRequestException('Invalid user');
69+
}
70+
const payload = {
71+
sub: userId,
72+
client_id: clientId,
73+
email: user.email,
74+
};
75+
76+
const accessToken = this.jwtService.sign(payload);
77+
78+
return {
79+
access_token: accessToken,
80+
};
81+
}
82+
83+
private cleanupExpiredCodes() {
84+
const now = new Date();
85+
for (const [code, authCode] of this.authorizationCodes.entries()) {
86+
if (authCode.expiresAt < now) {
87+
this.authorizationCodes.delete(code);
88+
}
89+
}
90+
}
91+
}

src/oauth2/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export interface AuthorizationCode {
2+
code: string;
3+
userId: string;
4+
clientId: string;
5+
redirectUri: string;
6+
expiresAt: Date;
7+
}
8+
9+
export interface OAuthConfig {
10+
supportedGrantTypes: string[];
11+
authorizationCodeExpiryMinutes: number;
12+
}
13+
14+
export const DEFAULT_OAUTH_CONFIG: OAuthConfig = {
15+
supportedGrantTypes: ['authorization_code'],
16+
authorizationCodeExpiryMinutes: 10,
17+
};

0 commit comments

Comments
 (0)