1515 * `movio/api_service/app/controller/user_v3.py`.
1616 */
1717
18- import { ErrApi , ErrUnauthenticated } from "./errors.js" ;
18+ import { ErrApi , ErrUnauthenticated , isAuthError } from "./errors.js" ;
1919import type { ResolvedCredential } from "./resolver.js" ;
2020
2121const DEFAULT_BASE_URL = "https://api.heygen.com" ;
@@ -63,27 +63,63 @@ export interface AuthClientOptions {
6363 baseUrl ?: string ;
6464 /** Inject a custom fetch (used by tests). */
6565 fetchImpl ?: typeof fetch ;
66+ /**
67+ * Hook for refreshing an OAuth credential on 401. The hook should
68+ * exchange the supplied refresh_token for new tokens, persist them,
69+ * and return the new bearer to retry with. Wired in by the auth
70+ * commands; injectable for tests.
71+ */
72+ onUnauthenticatedRefresh ?: ( refresh_token : string ) => Promise < string > ;
6673}
6774
6875export class AuthClient {
6976 private readonly base : string ;
7077 private readonly fetchImpl : typeof fetch ;
78+ private readonly onRefresh ?: ( refresh_token : string ) => Promise < string > ;
7179
7280 constructor ( opts : AuthClientOptions = { } ) {
7381 this . base = ( opts . baseUrl ?? apiBaseUrl ( ) ) . replace ( / \/ + $ / , "" ) ;
7482 this . fetchImpl = opts . fetchImpl ?? fetch ;
83+ this . onRefresh = opts . onUnauthenticatedRefresh ;
7584 }
7685
7786 /**
7887 * `GET /v3/users/me`. Throws `ErrUnauthenticated` on 401, `ErrApi`
7988 * on any other non-2xx or non-JSON body.
89+ *
90+ * On OAuth 401 with a refresh hook configured, the request is
91+ * retried once after refreshing the access token. The retry's
92+ * outcome is what the caller sees — if the refresh itself fails
93+ * (REFRESH_FAILED) or the retry still 401s, the user lands on a
94+ * "please log in again" path upstream.
8095 */
8196 async getCurrentUser ( credential : ResolvedCredential ) : Promise < UserInfo > {
8297 const url = `${ this . base } /v3/users/me` ;
98+ return await this . fetchUser ( url , credential , true ) ;
99+ }
100+
101+ // fallow-ignore-next-line complexity
102+ private async fetchUser (
103+ url : string ,
104+ credential : ResolvedCredential ,
105+ allowRefresh : boolean ,
106+ ) : Promise < UserInfo > {
83107 const headers = buildAuthHeaders ( credential ) ;
84108 const res = await this . fetchImpl ( url , { method : "GET" , headers } ) ;
85109
86110 if ( res . status === 401 ) {
111+ if (
112+ allowRefresh &&
113+ credential . type === "oauth" &&
114+ credential . refresh_token &&
115+ this . onRefresh
116+ ) {
117+ const refreshed = await this . tryRefresh ( credential . refresh_token ) ;
118+ if ( refreshed ) {
119+ const next : ResolvedCredential = { ...credential , access_token : refreshed } ;
120+ return await this . fetchUser ( url , next , false ) ;
121+ }
122+ }
87123 const detail = await safeText ( res ) ;
88124 throw ErrUnauthenticated ( detail || `${ res . status } ${ res . statusText } ` ) ;
89125 }
@@ -99,6 +135,19 @@ export class AuthClient {
99135 }
100136 return extractUserInfo ( payload ) ;
101137 }
138+
139+ private async tryRefresh ( refresh_token : string ) : Promise < string | null > {
140+ if ( ! this . onRefresh ) return null ;
141+ try {
142+ return await this . onRefresh ( refresh_token ) ;
143+ } catch ( err ) {
144+ // Refresh failure should be surfaced upstream by the caller via
145+ // the retry's 401, not by throwing here — so callers consistently
146+ // see "please log in again" rather than mixed error types.
147+ if ( isAuthError ( err ) && err . code === "REFRESH_FAILED" ) return null ;
148+ throw err ;
149+ }
150+ }
102151}
103152
104153export function buildAuthHeaders ( credential : ResolvedCredential ) : Record < string , string > {
0 commit comments