@@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken';
22import { Octokit } from '@octokit/rest' ;
33import type { Endpoints } from '@octokit/types' ;
44import { 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 */
2728export 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