diff --git a/packages/cli/src/commands/auth/login/command.ts b/packages/cli/src/commands/auth/login/command.ts index 673a21fcf3..d913d4b579 100644 --- a/packages/cli/src/commands/auth/login/command.ts +++ b/packages/cli/src/commands/auth/login/command.ts @@ -55,6 +55,7 @@ export default command({ config.auth = { accessToken: result.accessToken, + refreshToken: result.refreshToken, expiresAt: result.expiresAt, }; writeConfig(config); diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 966d07590f..91d99bebfd 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -5,12 +5,13 @@ import { env } from "./env"; const CLIENT_ID = "superset-cli"; const PASTE_REDIRECT_PATH = "/cli/auth/code"; -const SCOPE = "openid profile email"; +const SCOPE = "openid profile email offline_access"; const LOOPBACK_PORTS = [51789, 51790, 51791, 51792, 51793]; const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; export interface LoginResult { accessToken: string; + refreshToken?: string; expiresAt: number; } @@ -238,11 +239,51 @@ async function exchangeCodeForToken({ access_token: string; token_type: string; expires_in?: number; + refresh_token?: string; }; - const expiresIn = data.expires_in ?? 60 * 60 * 24 * 30; + const expiresIn = data.expires_in ?? 60 * 60; return { accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + expiresIn * 1000, + }; +} + +export async function refreshAccessToken( + refreshToken: string, +): Promise { + const apiUrl = env.SUPERSET_API_URL; + const response = await fetch(`${apiUrl}/api/auth/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + resource: apiUrl, + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new CLIError( + `Token refresh failed: ${response.status}`, + body || "Run `superset auth login` again.", + ); + } + + const data = (await response.json()) as { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + }; + + const expiresIn = data.expires_in ?? 60 * 60; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? refreshToken, expiresAt: Date.now() + expiresIn * 1000, }; } diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 5dcfcee786..1bf9536bc5 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -13,6 +13,7 @@ import { env } from "./env"; export type SupersetConfig = { auth?: { accessToken: string; + refreshToken?: string; expiresAt: number; }; organizationId?: string; diff --git a/packages/cli/src/lib/resolve-auth.ts b/packages/cli/src/lib/resolve-auth.ts index 182b8071ef..e98c31b41f 100644 --- a/packages/cli/src/lib/resolve-auth.ts +++ b/packages/cli/src/lib/resolve-auth.ts @@ -1,6 +1,7 @@ import { CLIError } from "@superset/cli-framework"; import { type ApiClient, createApiClient } from "./api-client"; -import { readConfig, type SupersetConfig } from "./config"; +import { refreshAccessToken } from "./auth"; +import { readConfig, type SupersetConfig, writeConfig } from "./config"; export type AuthSource = "flag" | "env" | "oauth"; @@ -11,10 +12,12 @@ export type ResolvedAuth = { authSource: AuthSource; }; +const REFRESH_LEEWAY_MS = 5 * 60 * 1000; + export async function resolveAuth( apiKeyOption: string | undefined, ): Promise { - const config = readConfig(); + let config = readConfig(); let bearer = apiKeyOption?.trim(); let authSource: AuthSource = bearer ? "flag" : "oauth"; @@ -30,11 +33,30 @@ export async function resolveAuth( "Run: superset auth login (or set SUPERSET_API_KEY)", ); } - const CLOCK_SKEW_MS = 5 * 60 * 1000; - if (config.auth.expiresAt + CLOCK_SKEW_MS < Date.now()) { - throw new CLIError("Session expired", "Run: superset auth login"); + + const auth = config.auth; + if (auth.expiresAt - REFRESH_LEEWAY_MS < Date.now()) { + if (!auth.refreshToken) { + throw new CLIError("Session expired", "Run: superset auth login"); + } + try { + const refreshed = await refreshAccessToken(auth.refreshToken); + config = { + ...config, + auth: { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + expiresAt: refreshed.expiresAt, + }, + }; + writeConfig(config); + bearer = refreshed.accessToken; + } catch { + throw new CLIError("Session expired", "Run: superset auth login"); + } + } else { + bearer = auth.accessToken; } - bearer = config.auth.accessToken; } const api = createApiClient({