Skip to content

Commit 637f162

Browse files
committed
Add delegated user OAuth support and token refresh for GitHub integration
Introduces delegated user-to-server OAuth support for GitHub App integration in the task manager worker. Adds logic for handling delegated user tokens, including automatic refresh and fallback to installation tokens, and updates environment/configuration to support GitHub App OAuth credentials. Updates dependencies to include @octokit/oauth-methods and related packages.
1 parent af446e4 commit 637f162

6 files changed

Lines changed: 454 additions & 51 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"@babel/parser": "^7.26.9",
5656
"@babel/traverse": "7.26.9",
5757
"@hawk.so/nodejs": "^3.1.1",
58-
"@hawk.so/types": "^0.5.3",
58+
"@hawk.so/types": "^0.5.6",
5959
"@types/amqplib": "^0.8.2",
6060
"@types/jest": "^29.2.3",
6161
"@types/mongodb": "^3.5.15",

workers/task-manager/.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,8 @@ SIMULTANEOUS_TASKS=1
88

99
# GitHub App configuration
1010
GITHUB_APP_ID=your_github_app_id
11-
GITHUB_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----
11+
GITHUB_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
12+
# Client ID of GitHub app
13+
GITHUB_APP_CLIENT_ID=Iv23li65HEIkWZXsm6qO
14+
# Generated in GitHub app settings
15+
GITHUB_APP_CLIENT_SECRET=0663e20d484234e17b0871c1f070581739c14e04

workers/task-manager/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"license": "MIT",
66
"workerType": "hawk-worker-task-manager",
77
"dependencies": {
8+
"@octokit/oauth-methods": "^4.0.0",
89
"@octokit/rest": "^22.0.1",
910
"@octokit/types": "^16.0.0",
1011
"jsonwebtoken": "^9.0.3"

workers/task-manager/src/GithubService.ts

Lines changed: 169 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken';
22
import { Octokit } from '@octokit/rest';
33
import type { Endpoints } from '@octokit/types';
44
import { normalizeGitHubPrivateKey } from './utils/githubPrivateKey';
5+
import { refreshToken as refreshOAuthToken } from '@octokit/oauth-methods';
56

67
/**
78
* Type for GitHub Issue creation parameters
@@ -26,30 +27,45 @@ export type GitHubIssue = Pick<
2627
*/
2728
export class GitHubService {
2829
/**
29-
* GitHub App ID from environment variables (optional if using PAT)
30+
* GitHub App ID from environment variables
3031
*/
3132
private readonly appId?: string;
3233

34+
/**
35+
* GitHub App Client ID from environment variables (optional, needed for token refresh)
36+
*/
37+
private readonly clientId?: string;
38+
39+
/**
40+
* GitHub App Client Secret from environment variables (optional, needed for token refresh)
41+
*/
42+
private readonly clientSecret?: string;
43+
3344
/**
3445
* Default timeout for GitHub API requests (in milliseconds)
3546
*/
3647
private static readonly DEFAULT_TIMEOUT = 10000;
3748

3849
/**
3950
* Creates an instance of GitHubService
40-
* Supports both GitHub App authentication and Personal Access Token (PAT)
51+
* Requires GitHub App authentication
4152
*/
4253
constructor() {
54+
if (!process.env.GITHUB_APP_ID) {
55+
throw new Error('GITHUB_APP_ID environment variable must be set');
56+
}
57+
58+
this.appId = process.env.GITHUB_APP_ID;
59+
4360
/**
44-
* If GITHUB_PAT is set, use PAT authentication (no need for GITHUB_APP_ID)
45-
* Otherwise, require GITHUB_APP_ID for GitHub App authentication
61+
* Client ID and Secret are optional but needed for token refresh
4662
*/
47-
if (!process.env.GITHUB_PAT && !process.env.GITHUB_APP_ID) {
48-
throw new Error('Either GITHUB_PAT or GITHUB_APP_ID environment variable must be set');
63+
if (process.env.GITHUB_APP_CLIENT_ID) {
64+
this.clientId = process.env.GITHUB_APP_CLIENT_ID;
4965
}
5066

51-
if (process.env.GITHUB_APP_ID) {
52-
this.appId = process.env.GITHUB_APP_ID;
67+
if (process.env.GITHUB_APP_CLIENT_SECRET) {
68+
this.clientSecret = process.env.GITHUB_APP_CLIENT_SECRET;
5369
}
5470
}
5571

@@ -115,36 +131,15 @@ export class GitHubService {
115131
}
116132

117133
/**
118-
* Get Personal Access Token from environment variables
134+
* Get authentication token (installation access token)
119135
*
120-
* @returns {string | null} PAT if available, null otherwise
121-
*/
122-
private getPAT(): string | null {
123-
return process.env.GITHUB_PAT || null;
124-
}
125-
126-
/**
127-
* Get authentication token (PAT or installation access token)
128-
*
129-
* @param {string | null} installationId - GitHub App installation ID (optional if using PAT)
136+
* @param {string | null} installationId - GitHub App installation ID
130137
* @returns {Promise<string>} Authentication token
131138
* @throws {Error} If token creation fails
132139
*/
133140
private async getAuthToken(installationId: string | null): Promise<string> {
134-
/**
135-
* If PAT is available, use it directly
136-
*/
137-
const pat = this.getPAT();
138-
if (pat) {
139-
console.log('[GitHub API] Using Personal Access Token (PAT) for authentication');
140-
return pat;
141-
}
142-
143-
/**
144-
* Otherwise, use GitHub App authentication
145-
*/
146141
if (!installationId) {
147-
throw new Error('installationId is required when using GitHub App authentication');
142+
throw new Error('installationId is required for GitHub App authentication');
148143
}
149144

150145
console.log('[GitHub API] Using GitHub App authentication with installation ID:', installationId);
@@ -184,17 +179,19 @@ export class GitHubService {
184179
* Create a GitHub issue
185180
*
186181
* @param {string} repoFullName - Repository full name (owner/repo)
187-
* @param {string | null} installationId - GitHub App installation ID (optional if using PAT)
182+
* @param {string | null} installationId - GitHub App installation ID (optional if using delegatedUser)
188183
* @param {IssueData} issueData - Issue data (title, body, labels)
189184
* @param {boolean} assignAgent - Whether to assign Copilot agent (creates issue via GraphQL with assigneeIds)
185+
* @param {string | null} delegatedUserToken - User-to-server OAuth token (optional, preferred over installation token)
190186
* @returns {Promise<GitHubIssue>} Created issue
191187
* @throws {Error} If issue creation fails
192188
*/
193189
public async createIssue(
194190
repoFullName: string,
195191
installationId: string | null,
196192
issueData: IssueData,
197-
assignAgent: boolean = false
193+
assignAgent: boolean = false,
194+
delegatedUserToken: string | null = null
198195
): Promise<GitHubIssue> {
199196
const [owner, repo] = repoFullName.split('/');
200197

@@ -203,9 +200,16 @@ export class GitHubService {
203200
}
204201

205202
/**
206-
* Get authentication token (PAT or installation access token)
203+
* Get authentication token (delegatedUser token preferred, then installation access token)
207204
*/
208-
const accessToken = await this.getAuthToken(installationId);
205+
let accessToken: string;
206+
207+
if (delegatedUserToken) {
208+
console.log('[GitHub API] Using delegated user-to-server token for authentication');
209+
accessToken = delegatedUserToken;
210+
} else {
211+
accessToken = await this.getAuthToken(installationId);
212+
}
209213

210214
/**
211215
* Create Octokit instance with authentication token and configured timeout
@@ -415,4 +419,133 @@ export class GitHubService {
415419
}
416420
}
417421

422+
/**
423+
* Get valid access token with automatic refresh if needed
424+
* Checks if token is expired or close to expiration and refreshes if necessary
425+
*
426+
* @param {Object} tokenInfo - Current token information
427+
* @param {string} tokenInfo.accessToken - Current access token
428+
* @param {string} tokenInfo.refreshToken - Refresh token
429+
* @param {Date | null} tokenInfo.accessTokenExpiresAt - Access token expiration date
430+
* @param {Date | null} tokenInfo.refreshTokenExpiresAt - Refresh token expiration date
431+
* @param {Function} onRefresh - Callback to save refreshed tokens (called after successful refresh)
432+
* @returns {Promise<string>} Valid access token
433+
* @throws {Error} If token refresh fails or refresh token is expired
434+
*/
435+
public async getValidAccessToken(
436+
tokenInfo: {
437+
accessToken: string;
438+
refreshToken: string;
439+
accessTokenExpiresAt: Date | null;
440+
refreshTokenExpiresAt: Date | null;
441+
},
442+
onRefresh?: (newTokens: {
443+
accessToken: string;
444+
refreshToken: string;
445+
expiresAt: Date | null;
446+
refreshTokenExpiresAt: Date | null;
447+
}) => Promise<void>
448+
): Promise<string> {
449+
const now = new Date();
450+
const bufferTime = 5 * 60 * 1000; // 5 minutes buffer before expiration
451+
452+
/**
453+
* Check if access token is expired or close to expiration
454+
*/
455+
if (tokenInfo.accessTokenExpiresAt) {
456+
const timeUntilExpiration = tokenInfo.accessTokenExpiresAt.getTime() - now.getTime();
457+
458+
if (timeUntilExpiration <= bufferTime) {
459+
/**
460+
* Token is expired or close to expiration, need to refresh
461+
*/
462+
if (!tokenInfo.refreshToken) {
463+
throw new Error('Access token expired and no refresh token available');
464+
}
465+
466+
/**
467+
* Check if refresh token is expired
468+
*/
469+
if (tokenInfo.refreshTokenExpiresAt && tokenInfo.refreshTokenExpiresAt <= now) {
470+
throw new Error('Refresh token is expired');
471+
}
472+
473+
if (!this.clientId || !this.clientSecret) {
474+
throw new Error('GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET are required for token refresh');
475+
}
476+
477+
/**
478+
* Refresh the token
479+
*/
480+
const newTokens = await this.refreshUserToken(tokenInfo.refreshToken);
481+
482+
/**
483+
* Save refreshed tokens if callback provided
484+
*/
485+
if (onRefresh) {
486+
await onRefresh(newTokens);
487+
}
488+
489+
return newTokens.accessToken;
490+
}
491+
}
492+
493+
/**
494+
* Token is still valid, return it
495+
*/
496+
return tokenInfo.accessToken;
497+
}
498+
499+
/**
500+
* Refresh user-to-server access token using refresh token
501+
* Rotates refresh token if a new one is provided
502+
*
503+
* @param {string} refreshToken - OAuth refresh token
504+
* @returns {Promise<{ accessToken: string; refreshToken: string; expiresAt: Date | null; refreshTokenExpiresAt: Date | null }>} New tokens
505+
* @throws {Error} If token refresh fails
506+
*/
507+
public async refreshUserToken(refreshToken: string): Promise<{
508+
accessToken: string;
509+
refreshToken: string;
510+
expiresAt: Date | null;
511+
refreshTokenExpiresAt: Date | null;
512+
}> {
513+
if (!this.clientId || !this.clientSecret) {
514+
throw new Error('GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET are required for token refresh');
515+
}
516+
517+
try {
518+
const { authentication } = await refreshOAuthToken({
519+
clientType: 'github-app',
520+
clientId: this.clientId,
521+
clientSecret: this.clientSecret,
522+
refreshToken,
523+
});
524+
525+
if (!authentication.token) {
526+
throw new Error('No access token in refresh response');
527+
}
528+
529+
/**
530+
* refreshToken, expiresAt, and refreshTokenExpiresAt are only available in certain authentication types
531+
* Use type guards to safely access these properties
532+
*/
533+
const newRefreshToken = 'refreshToken' in authentication && authentication.refreshToken
534+
? authentication.refreshToken
535+
: refreshToken; // Use new refresh token if provided, otherwise keep old one
536+
537+
return {
538+
accessToken: authentication.token,
539+
refreshToken: newRefreshToken,
540+
expiresAt: 'expiresAt' in authentication && authentication.expiresAt
541+
? new Date(authentication.expiresAt)
542+
: null,
543+
refreshTokenExpiresAt: 'refreshTokenExpiresAt' in authentication && authentication.refreshTokenExpiresAt
544+
? new Date(authentication.refreshTokenExpiresAt)
545+
: null,
546+
};
547+
} catch (error) {
548+
throw new Error(`Failed to refresh user token: ${error instanceof Error ? error.message : String(error)}`);
549+
}
550+
}
418551
}

0 commit comments

Comments
 (0)