1- import axios , { AxiosInstance } from "axios" ;
1+ import { RedmineAuthenticationError } from "@/api/redmine/RedmineAuthenticationError" ;
2+ import { Settings } from "@/provider/SettingsProvider" ;
3+ import axios , { AxiosInstance , isAxiosError } from "axios" ;
24import { formatISO } from "date-fns" ;
35import qs from "qs" ;
6+ import { browser } from "wxt/browser" ;
47import { MissingRedmineConfigError } from "./MissingRedmineConfigError" ;
58import {
69 TCreateIssue ,
@@ -10,6 +13,7 @@ import {
1013 TIssueStatus ,
1114 TIssueTracker ,
1215 TMembership ,
16+ TOAuthTokenResponse ,
1317 TPaginatedResponse ,
1418 TProject ,
1519 TReference ,
@@ -26,12 +30,15 @@ import {
2630export class RedmineApiClient {
2731 private instance : AxiosInstance ;
2832 public id = crypto . randomUUID ( ) ;
33+ private auth ?: Settings [ "auth" ] ;
2934
30- constructor ( redmineURL : string , redmineApiKey : string ) {
35+ constructor ( redmineURL : string , auth ?: Settings [ "auth" ] ) {
36+ this . auth = auth ;
3137 this . instance = axios . create ( {
3238 baseURL : redmineURL ,
3339 headers : {
34- "X-Redmine-API-Key" : redmineApiKey ,
40+ ...( auth ?. method === "apiKey" && { "X-Redmine-API-Key" : auth . apiKey } ) ,
41+ ...( auth ?. method === "oauth2" && { Authorization : `Bearer ${ auth . oauth2 ?. accessToken } ` } ) ,
3542 "Cache-Control" : "no-cache, no-store, max-age=0" ,
3643 Expires : "0" ,
3744 } ,
@@ -40,8 +47,12 @@ export class RedmineApiClient {
4047 if ( ! config . baseURL ) {
4148 throw new MissingRedmineConfigError ( ) ;
4249 }
50+ if ( auth ?. method === "oauth2" && ! auth . oauth2 ?. accessToken && config . url !== "/oauth/token" ) {
51+ throw new RedmineAuthenticationError ( "Authorization required" ) ;
52+ }
4353 return config ;
4454 } ) ;
55+
4556 this . instance . interceptors . response . use (
4657 ( response ) => {
4758 const contentType = response . headers [ "content-type" ] ;
@@ -51,11 +62,15 @@ export class RedmineApiClient {
5162 return response ;
5263 } ,
5364 ( error ) => {
54- if ( error . response ?. status === 401 ) {
55- throw new Error ( "Unauthorized" ) ;
56- }
57- if ( error . response ?. status === 403 ) {
58- throw new Error ( "Forbidden" ) ;
65+ if ( isAxiosError ( error ) ) {
66+ if ( error . response ?. status === 401 ) {
67+ const message = error . response . headers [ "www-authenticate" ] . match ( / e r r o r _ d e s c r i p t i o n = " ( [ ^ " ] + ) " / ) ?. [ 1 ] ;
68+ throw new RedmineAuthenticationError ( message ) ;
69+ }
70+
71+ if ( error . response ?. status === 403 ) {
72+ throw new Error ( "Forbidden" ) ;
73+ }
5974 }
6075 return Promise . reject ( error ) ;
6176 }
@@ -295,4 +310,85 @@ export class RedmineApiClient {
295310 async getCurrentUser ( ) : Promise < TUser > {
296311 return this . instance . get ( "/users/current.json?include=memberships" ) . then ( ( res ) => res . data . user ) ;
297312 }
313+
314+ // Auth
315+ getAuthorizeUrl ( { clientId, redirectUri, scope } : { clientId : string ; redirectUri : string ; scope : string } ) : string {
316+ return `${ this . instance . defaults . baseURL } /oauth/authorize?${ qs . stringify ( {
317+ client_id : clientId ,
318+ redirect_uri : redirectUri ,
319+ response_type : "code" ,
320+ scope,
321+ } ) } `;
322+ }
323+
324+ async getAccessToken ( { code, redirectUri, clientId, clientSecret } : { code : string ; redirectUri : string ; clientId : string ; clientSecret : string } ) {
325+ return this . instance
326+ . post < TOAuthTokenResponse > ( "/oauth/token" , {
327+ grant_type : "authorization_code" ,
328+ code,
329+ redirect_uri : redirectUri ,
330+ client_id : clientId ,
331+ client_secret : clientSecret ,
332+ } )
333+ . then ( ( res ) => res . data ) ;
334+ }
335+
336+ async refreshAccessToken ( { refreshToken, clientId, clientSecret } : { refreshToken : string ; clientId : string ; clientSecret : string } ) {
337+ return this . instance
338+ . post < TOAuthTokenResponse > ( "/oauth/token" , {
339+ grant_type : "refresh_token" ,
340+ refresh_token : refreshToken ,
341+ client_id : clientId ,
342+ client_secret : clientSecret ,
343+ } )
344+ . then ( ( res ) => res . data ) ;
345+ }
346+
347+ async startOAuth2Authorization ( ) {
348+ if ( ! this . auth ?. oauth2 ?. clientId || ! this . auth ?. oauth2 ?. clientSecret ) {
349+ throw new Error ( "OAuth2 Client ID and Client Secret are required for OAuth2 authentication" ) ;
350+ }
351+
352+ const redirectUri = browser . identity . getRedirectURL ( ) ;
353+ const authorizeUrl = this . getAuthorizeUrl ( {
354+ clientId : this . auth . oauth2 . clientId ,
355+ redirectUri,
356+ scope : "view_project search_project view_members view_issues view_time_entries" ,
357+ } ) ;
358+
359+ // Authorize and get the code
360+ const redirectURLString = await browser . identity . launchWebAuthFlow ( {
361+ interactive : true ,
362+ url : authorizeUrl ,
363+ } ) ;
364+ if ( ! redirectURLString ) {
365+ throw new Error ( "No redirect URL received" ) ;
366+ }
367+ const redirectURL = new URL ( redirectURLString ) ;
368+ if ( redirectURL . searchParams . get ( "error" ) ) {
369+ if ( redirectURL . searchParams . get ( "error" ) === "access_denied" ) {
370+ throw new Error ( "Authorization was denied. Please allow access to connect your Redmine account." ) ;
371+ }
372+ const errorDescription = redirectURL . searchParams . get ( "error_description" ) || "Unknown error" ;
373+ throw new Error ( `Authorization error: ${ errorDescription } ` ) ;
374+ }
375+ const code = redirectURL . searchParams . get ( "code" ) ;
376+ if ( ! code ) {
377+ throw new Error ( "Authorization code not found" ) ;
378+ }
379+
380+ // Exchange the code for tokens
381+ const tokenResponse = await this . getAccessToken ( {
382+ code,
383+ redirectUri,
384+ clientId : this . auth . oauth2 . clientId ,
385+ clientSecret : this . auth . oauth2 . clientSecret ,
386+ } ) ;
387+
388+ return {
389+ accessToken : tokenResponse . access_token ,
390+ refreshToken : tokenResponse . refresh_token ,
391+ expiresAt : ( tokenResponse . created_at + tokenResponse . expires_in ) * 1000 ,
392+ } ;
393+ }
298394}
0 commit comments