check if github token is valid on page load and trigger re-auth flow if not (#120)

This commit is contained in:
Anastasiia Solop
2025-07-10 16:58:02 +02:00
committed by GitHub
parent 35b2631ba6
commit f8af65189f
6 changed files with 118 additions and 13 deletions

View File

@@ -2,7 +2,7 @@ use axum::{
extract::{Request, State},
middleware::Next,
response::{Json as ResponseJson, Response},
routing::post,
routing::{get, post},
Json, Router,
};
@@ -12,6 +12,7 @@ pub fn auth_router() -> Router<AppState> {
Router::new()
.route("/auth/github/device/start", post(device_start))
.route("/auth/github/device/poll", post(device_poll))
.route("/auth/github/check", get(github_check_token))
}
#[derive(serde::Deserialize)]
@@ -263,6 +264,40 @@ async fn device_poll(
}
}
/// GET /auth/github/check
async fn github_check_token(State(app_state): State<AppState>) -> ResponseJson<ApiResponse<()>> {
let config = app_state.get_config().read().await;
let token = config.github.token.clone();
drop(config);
if let Some(token) = token {
let client = reqwest::Client::new();
let res = client
.get("https://api.github.com/user")
.bearer_auth(&token)
.header("User-Agent", "vibe-kanban-app")
.send()
.await;
match res {
Ok(r) if r.status().is_success() => ResponseJson(ApiResponse {
success: true,
data: None,
message: Some("GitHub token is valid".to_string()),
}),
_ => ResponseJson(ApiResponse {
success: false,
data: None,
message: Some("github_token_invalid".to_string()),
}),
}
} else {
ResponseJson(ApiResponse {
success: false,
data: None,
message: Some("github_token_invalid".to_string()),
})
}
}
/// Middleware to set Sentry user context for every request
pub async fn sentry_user_context_middleware(
State(app_state): State<AppState>,

View File

@@ -407,6 +407,9 @@ pub async fn create_github_pr(
e
);
let message = match &e {
crate::models::task_attempt::TaskAttemptError::GitHubService(
crate::services::GitHubServiceError::TokenInvalid,
) => Some("github_token_invalid".to_string()),
crate::models::task_attempt::TaskAttemptError::Git(err)
if err.message().contains("status code: 403") =>
{

View File

@@ -12,6 +12,7 @@ pub enum GitHubServiceError {
Repository(String),
PullRequest(String),
Branch(String),
TokenInvalid,
}
impl std::fmt::Display for GitHubServiceError {
@@ -22,6 +23,7 @@ impl std::fmt::Display for GitHubServiceError {
GitHubServiceError::Repository(e) => write!(f, "Repository error: {}", e),
GitHubServiceError::PullRequest(e) => write!(f, "Pull request error: {}", e),
GitHubServiceError::Branch(e) => write!(f, "Branch error: {}", e),
GitHubServiceError::TokenInvalid => write!(f, "GitHub token is invalid or expired."),
}
}
}
@@ -30,7 +32,22 @@ impl std::error::Error for GitHubServiceError {}
impl From<octocrab::Error> for GitHubServiceError {
fn from(err: octocrab::Error) -> Self {
GitHubServiceError::Client(err)
match &err {
octocrab::Error::GitHub { source, .. } => {
let status = source.status_code.as_u16();
let msg = source.message.to_ascii_lowercase();
if status == 401
|| status == 403
|| msg.contains("bad credentials")
|| msg.contains("token expired")
{
GitHubServiceError::TokenInvalid
} else {
GitHubServiceError::Client(err)
}
}
_ => GitHubServiceError::Client(err),
}
}
}
@@ -161,11 +178,27 @@ impl GitHubService {
.send()
.await
.map_err(|e| match e {
octocrab::Error::GitHub { source, .. } => GitHubServiceError::PullRequest(format!(
"GitHub API error: {} (status: {})",
source.message,
source.status_code.as_u16()
)),
octocrab::Error::GitHub { source, .. } => {
if source.status_code.as_u16() == 401
|| source.status_code.as_u16() == 403
|| source
.message
.to_ascii_lowercase()
.contains("bad credentials")
|| source
.message
.to_ascii_lowercase()
.contains("token expired")
{
GitHubServiceError::TokenInvalid
} else {
GitHubServiceError::PullRequest(format!(
"GitHub API error: {} (status: {})",
source.message,
source.status_code.as_u16()
))
}
}
_ => GitHubServiceError::PullRequest(format!("Failed to create PR: {}", e)),
})?;

View File

@@ -22,7 +22,7 @@ import { GitHubLoginDialog } from '@/components/GitHubLoginDialog';
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
function AppContent() {
const { config, updateConfig, loading } = useConfig();
const { config, updateConfig, loading, githubTokenInvalid } = useConfig();
const [showDisclaimer, setShowDisclaimer] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false);
const [showGitHubLogin, setShowGitHubLogin] = useState(false);
@@ -36,9 +36,12 @@ function AppContent() {
}
const notAuthenticated =
!config.github?.username || !config.github?.token;
setShowGitHubLogin(notAuthenticated);
setShowGitHubLogin(notAuthenticated || githubTokenInvalid);
}
}, [config]);
if (githubTokenInvalid) {
setShowGitHubLogin(true);
}
}, [config, githubTokenInvalid]);
const handleDisclaimerAccept = async () => {
if (!config) return;

View File

@@ -18,7 +18,7 @@ export function GitHubLoginDialog({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { config, loading } = useConfig();
const { config, loading, githubTokenInvalid } = useConfig();
const [fetching, setFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deviceState, setDeviceState] = useState<null | {
@@ -31,7 +31,9 @@ export function GitHubLoginDialog({
const [polling, setPolling] = useState(false);
const [copied, setCopied] = useState(false);
const isAuthenticated = !!(config?.github?.username && config?.github?.token);
const isAuthenticated =
!!(config?.github?.username && config?.github?.token) &&
!githubTokenInvalid;
const handleLogin = async () => {
setFetching(true);

View File

@@ -14,6 +14,7 @@ interface ConfigContextType {
updateAndSaveConfig: (updates: Partial<Config>) => void;
saveConfig: () => Promise<boolean>;
loading: boolean;
githubTokenInvalid: boolean;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
@@ -25,6 +26,7 @@ interface ConfigProviderProps {
export function ConfigProvider({ children }: ConfigProviderProps) {
const [config, setConfig] = useState<Config | null>(null);
const [loading, setLoading] = useState(true);
const [githubTokenInvalid, setGithubTokenInvalid] = useState(false);
useEffect(() => {
const loadConfig = async () => {
@@ -45,6 +47,26 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
loadConfig();
}, []);
// Check GitHub token validity after config loads
useEffect(() => {
if (loading) return;
const checkToken = async () => {
try {
const response = await fetch('/api/auth/github/check');
const data: ApiResponse<null> = await response.json();
if (!data.success && data.message === 'github_token_invalid') {
setGithubTokenInvalid(true);
} else {
setGithubTokenInvalid(false);
}
} catch (err) {
// If the check fails, assume token is invalid
setGithubTokenInvalid(true);
}
};
checkToken();
}, [loading]);
const updateConfig = useCallback((updates: Partial<Config>) => {
setConfig((prev) => (prev ? { ...prev, ...updates } : null));
}, []);
@@ -100,7 +122,14 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
return (
<ConfigContext.Provider
value={{ config, updateConfig, saveConfig, loading, updateAndSaveConfig }}
value={{
config,
updateConfig,
saveConfig,
loading,
updateAndSaveConfig,
githubTokenInvalid,
}}
>
{children}
</ConfigContext.Provider>