-
Notifications
You must be signed in to change notification settings - Fork 897
fix(cli): auto-refresh OAuth access token #4069
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<ResolvedAuth> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+42
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If two CLI commands run simultaneously and both see a near-expiry token, they each call Prompt To Fix With AIThis is a comment left during a code review.
Path: packages/cli/src/lib/resolve-auth.ts
Line: 42-53
Comment:
**Concurrent invocations can invalidate each other's refresh token**
If two CLI commands run simultaneously and both see a near-expiry token, they each call `refreshAccessToken` independently. With rotating refresh tokens, the second rotation invalidates the first command's refresh token. The config also gets written twice with a race on `expiresAt`. A file-lock or compare-and-swap on `expiresAt` before writing would mitigate this.
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new CLIError("Session expired", "Run: superset auth login"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+54
to
+56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
All errors from Only HTTP 4xx responses from the token endpoint genuinely mean the refresh token is revoked. For other failures, the original error should propagate. Prompt To Fix With AIThis is a comment left during a code review.
Path: packages/cli/src/lib/resolve-auth.ts
Line: 54-56
Comment:
**Catch block swallows non-auth errors**
All errors from `refreshAccessToken` — including transient network failures (e.g., `TypeError: fetch failed`) and server-side 5xx errors — are caught here and replaced with "Session expired / Run: superset auth login." A user experiencing a DNS outage or a momentary server error will be told their session expired and instructed to re-login, which won't fix the underlying issue.
Only HTTP 4xx responses from the token endpoint genuinely mean the refresh token is revoked. For other failures, the original error should propagate.
How can I resolve this? If you propose a fix, please make it concise.
Comment on lines
+38
to
+56
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t hard-fail before actual expiry when refresh is unavailable. Line 38 currently converts the 5-minute leeway into an effective hard expiry: if refresh is missing/fails (Lines 39-56), auth fails even when Proposed fix 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");
- }
+ const now = Date.now();
+ if (auth.expiresAt - REFRESH_LEEWAY_MS < now) {
+ if (auth.refreshToken) {
+ 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 {
+ if (auth.expiresAt <= now) {
+ throw new CLIError("Session expired", "Run: superset auth login");
+ }
+ bearer = auth.accessToken;
+ }
+ } else if (auth.expiresAt <= now) {
+ throw new CLIError("Session expired", "Run: superset auth login");
+ } else {
+ bearer = auth.accessToken;
+ }
} else {
bearer = auth.accessToken;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| bearer = auth.accessToken; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| bearer = config.auth.accessToken; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const api = createApiClient({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
scopeomitted from refresh bodyThe
refreshAccessTokenrequest omits ascopeparameter. While most OIDC servers re-issue the originally granted scopes silently, stricter authorization servers may not return a newrefresh_tokenwithout it. The fallbackdata.refresh_token ?? refreshTokenwill then reuse the old token until the server's rotation window closes.Adding the same scope string used at authorization time would be safer.
Prompt To Fix With AI