Skip to content

Commit 502472e

Browse files
implement refresh tokens
1 parent 1cd5bc5 commit 502472e

File tree

15 files changed

+761
-190
lines changed

15 files changed

+761
-190
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- CreateTable
2+
CREATE TABLE "OAuthRefreshToken" (
3+
"hash" TEXT NOT NULL,
4+
"clientId" TEXT NOT NULL,
5+
"userId" TEXT NOT NULL,
6+
"scope" TEXT NOT NULL DEFAULT '',
7+
"resource" TEXT,
8+
"expiresAt" TIMESTAMP(3) NOT NULL,
9+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
11+
CONSTRAINT "OAuthRefreshToken_pkey" PRIMARY KEY ("hash")
12+
);
13+
14+
-- CreateIndex
15+
CREATE INDEX "OAuthRefreshToken_clientId_userId_idx" ON "OAuthRefreshToken"("clientId", "userId");
16+
17+
-- AddForeignKey
18+
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
19+
20+
-- AddForeignKey
21+
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

packages/db/prisma/schema.prisma

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -365,8 +365,9 @@ model User {
365365
chats Chat[]
366366
sharedChats ChatAccess[]
367367
368-
oauthTokens OAuthToken[]
369-
oauthAuthCodes OAuthAuthorizationCode[]
368+
oauthTokens OAuthToken[]
369+
oauthAuthCodes OAuthAuthorizationCode[]
370+
oauthRefreshTokens OAuthRefreshToken[]
370371
371372
createdAt DateTime @default(now())
372373
updatedAt DateTime @updatedAt
@@ -501,8 +502,9 @@ model OAuthClient {
501502
redirectUris String[]
502503
createdAt DateTime @default(now())
503504
504-
authCodes OAuthAuthorizationCode[]
505-
tokens OAuthToken[]
505+
authCodes OAuthAuthorizationCode[]
506+
tokens OAuthToken[]
507+
refreshTokens OAuthRefreshToken[]
506508
}
507509

508510
/// A short-lived authorization code issued during the OAuth2 authorization code flow.
@@ -520,6 +522,21 @@ model OAuthAuthorizationCode {
520522
createdAt DateTime @default(now())
521523
}
522524

525+
/// An opaque OAuth2 refresh token. Single-use with rotation (RFC 6749 Section 10.4, OAuth 2.1 Section 4.3.1).
526+
model OAuthRefreshToken {
527+
hash String @id // hashSecret(rawToken secret portion)
528+
clientId String
529+
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
530+
userId String
531+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
532+
scope String @default("")
533+
resource String? // RFC 8707
534+
expiresAt DateTime
535+
createdAt DateTime @default(now())
536+
537+
@@index([clientId, userId])
538+
}
539+
523540
/// An opaque OAuth2 access token. The raw token is never stored — only its HMAC-SHA256 hash.
524541
model OAuthToken {
525542
hash String @id // hashSecret(rawToken secret portion)

packages/shared/src/constants.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ import { ConfigSettings, IdentityProviderType } from "./types.js";
33

44
export const SOURCEBOT_SUPPORT_EMAIL = 'team@sourcebot.dev';
55

6+
/**
7+
* @deprecated Use API_KEY_PREFIX instead.
8+
*/
9+
export const LEGACY_API_KEY_PREFIX = 'sourcebot-';
10+
11+
export const API_KEY_PREFIX = 'sbk_';
12+
export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_';
13+
export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_';
14+
615
export const SOURCEBOT_UNLIMITED_SEATS = -1;
716

817
/**

packages/shared/src/crypto.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { z } from 'zod';
44
import { env } from './env.server.js';
55
import { Token } from '@sourcebot/schemas/v3/shared.type';
66
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
7+
import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX } from './constants.js';
78

89
const algorithm = 'aes-256-cbc';
910
const ivLength = 16; // 16 bytes for CBC
@@ -35,7 +36,7 @@ export function generateApiKey(): { key: string; hash: string } {
3536
const hash = hashSecret(secret);
3637

3738
return {
38-
key: `sourcebot-${secret}`,
39+
key: `${API_KEY_PREFIX}${secret}`,
3940
hash,
4041
};
4142
}
@@ -45,7 +46,17 @@ export function generateOAuthToken(): { token: string; hash: string } {
4546
const hash = hashSecret(secret);
4647

4748
return {
48-
token: `sourcebot-oauth-${secret}`,
49+
token: `${OAUTH_ACCESS_TOKEN_PREFIX}${secret}`,
50+
hash,
51+
};
52+
}
53+
54+
export function generateOAuthRefreshToken(): { token: string; hash: string } {
55+
const secret = crypto.randomBytes(32).toString('hex');
56+
const hash = hashSecret(secret);
57+
58+
return {
59+
token: `${OAUTH_REFRESH_TOKEN_PREFIX}${secret}`,
4960
hash,
5061
};
5162
}

packages/shared/src/index.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export {
4646
hashSecret,
4747
generateApiKey,
4848
generateOAuthToken,
49+
generateOAuthRefreshToken,
4950
verifySignature,
5051
encryptOAuthToken,
5152
decryptOAuthToken,

packages/web/src/__mocks__/prisma.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_NAME } from '@/lib/constants';
2-
import { Account, ApiKey, OAuthToken, Org, PrismaClient, User } from '@prisma/client';
2+
import { Account, ApiKey, OAuthRefreshToken, OAuthToken, Org, PrismaClient, User } from '@prisma/client';
33
import { beforeEach, vi } from 'vitest';
44
import { mockDeep, mockReset } from 'vitest-mock-extended';
55

@@ -52,10 +52,21 @@ export const MOCK_OAUTH_TOKEN: OAuthToken & { user: User & { accounts: Account[]
5252
clientId: 'test-client-id',
5353
userId: MOCK_USER_WITH_ACCOUNTS.id,
5454
scope: '',
55-
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // 1 year from now
55+
resource: null,
56+
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour from now
5657
createdAt: new Date(),
5758
lastUsedAt: null,
5859
user: MOCK_USER_WITH_ACCOUNTS,
5960
}
6061

62+
export const MOCK_REFRESH_TOKEN: OAuthRefreshToken = {
63+
hash: 'refreshtoken',
64+
clientId: 'test-client-id',
65+
userId: MOCK_USER_WITH_ACCOUNTS.id,
66+
scope: '',
67+
resource: null,
68+
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days from now
69+
createdAt: new Date(),
70+
}
71+
6172
export const userScopedPrismaClientExtension = vi.fn();

packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const GET = apiHandler(async () => {
1515
registration_endpoint: `${issuer}/api/ee/oauth/register`,
1616
revocation_endpoint: `${issuer}/api/ee/oauth/revoke`,
1717
response_types_supported: ['code'],
18-
grant_types_supported: ['authorization_code'],
18+
grant_types_supported: ['authorization_code', 'refresh_token'],
1919
code_challenge_methods_supported: ['S256'],
2020
token_endpoint_auth_methods_supported: ['none'],
2121
service_documentation: 'https://docs.sourcebot.dev',

packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { revokeToken } from '@/features/oauth/server';
1+
import { revokeToken } from '@/ee/features/oauth/server';
22
import { apiHandler } from '@/lib/apiHandler';
33
import { hasEntitlement } from '@sourcebot/shared';
44
import { NextRequest } from 'next/server';
Lines changed: 72 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { verifyAndExchangeCode, ACCESS_TOKEN_TTL_SECONDS } from '@/features/oauth/server';
1+
import { verifyAndExchangeCode, verifyAndRotateRefreshToken, ACCESS_TOKEN_TTL_SECONDS } from '@/ee/features/oauth/server';
22
import { apiHandler } from '@/lib/apiHandler';
33
import { hasEntitlement } from '@sourcebot/shared';
44
import { NextRequest } from 'next/server';
@@ -17,45 +17,86 @@ export const POST = apiHandler(async (request: NextRequest) => {
1717
const formData = await request.formData();
1818

1919
const grantType = formData.get('grant_type');
20-
if (grantType !== 'authorization_code') {
21-
return Response.json(
22-
{ error: 'unsupported_grant_type', error_description: 'Only authorization_code is supported.' },
23-
{ status: 400 }
24-
);
25-
}
26-
27-
const code = formData.get('code');
2820
const clientId = formData.get('client_id');
29-
const redirectUri = formData.get('redirect_uri');
30-
const codeVerifier = formData.get('code_verifier');
3121
const resource = formData.get('resource');
3222

33-
if (!code || !clientId || !redirectUri || !codeVerifier) {
23+
if (!clientId) {
3424
return Response.json(
35-
{ error: 'invalid_request', error_description: 'Missing required parameters: code, client_id, redirect_uri, code_verifier.' },
25+
{ error: 'invalid_request', error_description: 'Missing required parameter: client_id.' },
3626
{ status: 400 }
3727
);
3828
}
3929

40-
const result = await verifyAndExchangeCode({
41-
rawCode: code.toString(),
42-
clientId: clientId.toString(),
43-
redirectUri: redirectUri.toString(),
44-
codeVerifier: codeVerifier.toString(),
45-
resource: resource ? resource.toString() : null,
46-
});
30+
if (grantType === 'authorization_code') {
31+
const code = formData.get('code');
32+
const redirectUri = formData.get('redirect_uri');
33+
const codeVerifier = formData.get('code_verifier');
4734

48-
if ('error' in result) {
49-
return Response.json(
50-
{ error: result.error, error_description: result.errorDescription },
51-
{ status: 400 }
52-
);
35+
if (!code || !redirectUri || !codeVerifier) {
36+
return Response.json(
37+
{ error: 'invalid_request', error_description: 'Missing required parameters: code, redirect_uri, code_verifier.' },
38+
{ status: 400 }
39+
);
40+
}
41+
42+
const result = await verifyAndExchangeCode({
43+
rawCode: code.toString(),
44+
clientId: clientId.toString(),
45+
redirectUri: redirectUri.toString(),
46+
codeVerifier: codeVerifier.toString(),
47+
resource: resource ? resource.toString() : null,
48+
});
49+
50+
if ('error' in result) {
51+
return Response.json(
52+
{ error: result.error, error_description: result.errorDescription },
53+
{ status: 400 }
54+
);
55+
}
56+
57+
return Response.json({
58+
access_token: result.token,
59+
refresh_token: result.refreshToken,
60+
token_type: 'Bearer',
61+
expires_in: ACCESS_TOKEN_TTL_SECONDS,
62+
scope: '',
63+
});
64+
}
65+
66+
if (grantType === 'refresh_token') {
67+
const rawRefreshToken = formData.get('refresh_token');
68+
69+
if (!rawRefreshToken) {
70+
return Response.json(
71+
{ error: 'invalid_request', error_description: 'Missing required parameter: refresh_token.' },
72+
{ status: 400 }
73+
);
74+
}
75+
76+
const result = await verifyAndRotateRefreshToken({
77+
rawRefreshToken: rawRefreshToken.toString(),
78+
clientId: clientId.toString(),
79+
resource: resource ? resource.toString() : null,
80+
});
81+
82+
if ('error' in result) {
83+
return Response.json(
84+
{ error: result.error, error_description: result.errorDescription },
85+
{ status: 400 }
86+
);
87+
}
88+
89+
return Response.json({
90+
access_token: result.token,
91+
refresh_token: result.refreshToken,
92+
token_type: 'Bearer',
93+
expires_in: ACCESS_TOKEN_TTL_SECONDS,
94+
scope: '',
95+
});
5396
}
5497

55-
return Response.json({
56-
access_token: result.token,
57-
token_type: 'Bearer',
58-
expires_in: ACCESS_TOKEN_TTL_SECONDS,
59-
scope: '',
60-
});
98+
return Response.json(
99+
{ error: 'unsupported_grant_type', error_description: 'Supported grant types: authorization_code, refresh_token.' },
100+
{ status: 400 }
101+
);
61102
});

packages/web/src/app/oauth/authorize/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { auth } from '@/auth';
2-
import { generateAndStoreAuthCode } from '@/features/oauth/server';
2+
import { generateAndStoreAuthCode } from '@/ee/features/oauth/server';
33
import { LogoutEscapeHatch } from '@/app/components/logoutEscapeHatch';
44
import { ClientIcon } from './components/clientIcon';
55
import { Button } from '@/components/ui/button';

0 commit comments

Comments
 (0)