Skip to content

feat(stage-tauri): add native OIDC auth flow#80

Open
vi70x4 wants to merge 3 commits into
mainfrom
vi/feat/auth-oidc-flow
Open

feat(stage-tauri): add native OIDC auth flow#80
vi70x4 wants to merge 3 commits into
mainfrom
vi/feat/auth-oidc-flow

Conversation

@vi70x4

@vi70x4 vi70x4 commented Jul 7, 2026

Copy link
Copy Markdown
Member

Summary

  • add Stage Tauri native OIDC loopback auth commands and renderer session wiring
  • use build-time VITE auth config in Rust while preserving runtime env overrides
  • route shared needsLogin requests through the native Tauri auth bridge instead of web redirects

Tests

  • cargo test --manifest-path apps/stage-tauri/Cargo.toml commands::auth -- --test-threads=1
  • pnpm -F @proj-airi/stage-tauri exec vitest run src/auth-session.test.ts
  • pnpm -F @proj-airi/stage-ui exec vitest run src/stores/auth.test.ts
  • pnpm -F @proj-airi/stage-tauri typecheck
  • pnpm -F @proj-airi/stage-ui typecheck

Notes

  • stage-tauri typecheck required rebuilding ignored @proj-airi/tauri-eventa dist output locally before running, because the worktree had stale generated contract types.

vi70x3 added 3 commits July 7, 2026 03:39
Implements the Tauri OIDC login/logout command path, auth Eventa contracts, and the Stage Tauri settings account UI.

Tests: cargo fmt --manifest-path apps/stage-tauri/Cargo.toml --check; cargo test --manifest-path apps/stage-tauri/Cargo.toml auth; cargo build --manifest-path apps/stage-tauri/Cargo.toml; pnpm -F @proj-airi/tauri-eventa exec vitest run src/tauri/index.test.ts; pnpm -F @proj-airi/tauri-eventa typecheck; pnpm -F @proj-airi/stage-tauri exec vitest run src/window-routes.test.ts src/auth-session.test.ts; pnpm -F @proj-airi/stage-tauri typecheck; AIRI_LIVE2D_SDK_CACHE_DIR=/tmp/airi-live2d-sdk-auth-oidc-flow pnpm -F @proj-airi/stage-tauri build; git diff --check.
Records auth-oidc-flow as implementation-complete-runtime-evidence-pending at 43093934e and advances the mission pointer to end-to-end-user-session.

Tests: jq empty docs/missions/d74b01dc-52da-4992-8a61-ea0a07bd800e/features.json docs/missions/d74b01dc-52da-4992-8a61-ea0a07bd800e/state.json docs/missions/d74b01dc-52da-4992-8a61-ea0a07bd800e/validation-state.json docs/missions/d74b01dc-52da-4992-8a61-ea0a07bd800e/handoffs/2026-07-06T02-17-08-237Z__auth-oidc-flow__codex-orchestrator-2026-07-06.json; jq -c . docs/missions/d74b01dc-52da-4992-8a61-ea0a07bd800e/progress_log.jsonl >/dev/null.
Use build-time OIDC config in Rust with runtime env override fallback.

Route shared needsLogin through the Stage Tauri native auth bridge and keep Tauri out of the shared web redirect path.

Tests: cargo test --manifest-path apps/stage-tauri/Cargo.toml commands::auth -- --test-threads=1

Tests: pnpm -F @proj-airi/stage-tauri exec vitest run src/auth-session.test.ts

Tests: pnpm -F @proj-airi/stage-ui exec vitest run src/stores/auth.test.ts

Tests: pnpm -F @proj-airi/stage-tauri typecheck

Tests: pnpm -F @proj-airi/stage-ui typecheck
@deepsource-io

deepsource-io Bot commented Jul 7, 2026

Copy link
Copy Markdown

DeepSource Code Review

We reviewed changes in 4f77c23...227580c on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Coverage  

Code Review Summary

Analyzer Status Updated (UTC) Details
CSS Jul 7, 2026 1:49a.m. Review ↗
Rust Jul 7, 2026 1:49a.m. Review ↗
JavaScript Jul 7, 2026 1:49a.m. Review ↗
Shell Jul 7, 2026 1:49a.m. Review ↗
C & C++ Jul 7, 2026 1:49a.m. Review ↗
Code coverage Jul 7, 2026 1:49a.m. Review ↗

Code Coverage Summary

Language Line Coverage (New Code) Line Coverage (Overall)
Aggregate
0%
39.2%
Javascript
0%
39.2%

➟ Additional coverage metrics may have been reported. See full coverage report ↗


Important

AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.

Comment on lines +711 to +714
std::env::set_var(
"AIRI_TEST_RUNTIME_AUTH_CONFIG",
" https://runtime.airi.test/ ",
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using `std::env::set_var` with a string literal


Calls to the std::env functions that use string literals instead of a
static strings can lead to bugs due to spelling errors.

),
"https://runtime.airi.test"
);
std::env::remove_var("AIRI_TEST_RUNTIME_AUTH_CONFIG");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using `std::env::remove_var` with a string literal


Calls to the std::env functions that use string literals instead of a
static strings can lead to bugs due to spelling errors.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the OIDC authentication flow for the Stage Tauri application, introducing Rust commands for loopback-based PKCE login and token exchange, Tauri Eventa contracts, and a settings account UI in the frontend. The code review highlights several critical security and robustness improvements for the loopback listener in auth.rs. Specifically, the reviewer recommends validating the OIDC state inside the connection acceptance loop to prevent mismatched state requests from aborting the flow, adding a timeout and error handling to the loopback listener to mitigate Denial of Service (DoS) vulnerabilities, and configuring a timeout on the reqwest Client used for token exchange to avoid indefinite hangs.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +350 to +362
async fn complete_login_flow(
config: OidcConfig,
listener: TcpListener,
expected_state: String,
code_verifier: String,
) -> Result<AuthTokens, String> {
let callback = accept_loopback_callback(listener).await?;
if callback.state != expected_state {
return Err("State mismatch".to_string());
}

exchange_code(&config, &callback.code, &code_verifier).await
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass the expected_state parameter to accept_loopback_callback so that state validation can be performed directly inside the connection acceptance loop. This prevents malicious or mismatched state requests from aborting the entire OIDC login flow.

async fn complete_login_flow(
    config: OidcConfig,
    listener: TcpListener,
    expected_state: String,
    code_verifier: String,
) -> Result<AuthTokens, String> {
    let callback = accept_loopback_callback(listener, &expected_state).await?;
    exchange_code(&config, &callback.code, &code_verifier).await
}

Comment on lines +364 to +414
async fn accept_loopback_callback(listener: TcpListener) -> Result<LoopbackCallback, String> {
loop {
let (mut stream, _) = listener
.accept()
.await
.map_err(|error| format!("Failed to accept OIDC callback: {error}"))?;
let request = read_http_request(&mut stream).await?;

if is_options_callback(&request) {
write_loopback_response(&mut stream, "HTTP/1.1 204 No Content", "").await?;
continue;
}

match parse_loopback_callback_request(&request) {
Ok(callback) => {
write_loopback_response(
&mut stream,
"HTTP/1.1 200 OK",
"<html><body><h2>Authentication successful!</h2><p>You can close this window and return to the app.</p></body></html>",
)
.await?;
return Ok(callback);
}
Err(CallbackParseError::MissingParameters) => {
write_loopback_response(
&mut stream,
"HTTP/1.1 400 Bad Request",
"<html><body><h2>Missing parameters</h2></body></html>",
)
.await?;
}
Err(CallbackParseError::Oidc(description)) => {
write_loopback_response(
&mut stream,
"HTTP/1.1 200 OK",
"<html><body><h2>Authentication failed</h2><p>You can close this window.</p></body></html>",
)
.await?;
return Err(description);
}
Err(CallbackParseError::UnsupportedRequest) => {
write_loopback_response(
&mut stream,
"HTTP/1.1 404 Not Found",
"<html><body><h2>Not found</h2></body></html>",
)
.await?;
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The current loopback listener is highly fragile and vulnerable to denial-of-service or accidental aborts. Specifically:

  1. Any read error or connection reset in read_http_request immediately returns an Err via ?, aborting the entire login flow.
  2. Any write error in write_loopback_response also aborts the entire login flow.
  3. A single slow connection can block the loopback server indefinitely because requests are handled sequentially.
  4. Mismatched state checks are performed outside the loop, meaning a single request with an invalid state will terminate the listener before the genuine callback arrives.

To resolve these issues, we should:

  • Add a timeout (e.g., 5 seconds) to read_http_request using tokio::time::timeout.
  • Ignore read/write errors and continue the loop to accept subsequent connections.
  • Validate the state parameter inside the loop against expected_state and only return Ok(callback) when they match.
async fn accept_loopback_callback(
    listener: TcpListener,
    expected_state: &str,
) -> Result<LoopbackCallback, String> {
    loop {
        let (mut stream, _) = listener
            .accept()
            .await
            .map_err(|error| format!("Failed to accept OIDC callback: {error}"))?;

        let request = match tokio::time::timeout(Duration::from_secs(5), read_http_request(&mut stream)).await {
            Ok(Ok(req)) => req,
            _ => continue,
        };

        if is_options_callback(&request) {
            let _ = write_loopback_response(&mut stream, "HTTP/1.1 204 No Content", "").await;
            continue;
        }

        match parse_loopback_callback_request(&request) {
            Ok(callback) => {
                if callback.state != expected_state {
                    let _ = write_loopback_response(
                        &mut stream,
                        "HTTP/1.1 400 Bad Request",
                        "<html><body><h2>State mismatch</h2></body></html>",
                    )
                    .await;
                    continue;
                }
                let _ = write_loopback_response(
                    &mut stream,
                    "HTTP/1.1 200 OK",
                    "<html><body><h2>Authentication successful!</h2><p>You can close this window and return to the app.</p></body></html>",
                )
                .await;
                return Ok(callback);
            }
            Err(CallbackParseError::MissingParameters) => {
                let _ = write_loopback_response(
                    &mut stream,
                    "HTTP/1.1 400 Bad Request",
                    "<html><body><h2>Missing parameters</h2></body></html>",
                )
                .await;
            }
            Err(CallbackParseError::Oidc(description)) => {
                let _ = write_loopback_response(
                    &mut stream,
                    "HTTP/1.1 200 OK",
                    "<html><body><h2>Authentication failed</h2><p>You can close this window.</p></body></html>",
                )
                .await;
                return Err(description);
            }
            Err(CallbackParseError::UnsupportedRequest) => {
                let _ = write_loopback_response(
                    &mut stream,
                    "HTTP/1.1 404 Not Found",
                    "<html><body><h2>Not found</h2></body></html>",
                )
                .await;
            }
        }
    }
}

Comment on lines +527 to +533
let response = reqwest::Client::new()
.post(config.token_url())
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.map_err(|error| format!("Token exchange failed: {error}"))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Configure a timeout on the reqwest::Client used for token exchange. By default, reqwest::Client has no timeout, which can cause the login flow to hang indefinitely if the OIDC token endpoint is unresponsive.

    let response = reqwest::Client::builder()
        .timeout(Duration::from_secs(15))
        .build()
        .map_err(|error| format!("Failed to build HTTP client: {error}"))?
        .post(config.token_url())
        .header("Content-Type", "application/x-www-form-urlencoded")
        .body(body)
        .send()
        .await
        .map_err(|error| format!("Token exchange failed: {error}"))?;

]
}

function authErrorMessage(error: unknown): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

`authErrorMessage` has a cyclomatic complexity of 11 with "medium" risk


A function with high cyclomatic complexity can be hard to understand and
maintain. Cyclomatic complexity is a software metric that measures the number of
independent paths through a function. A higher cyclomatic complexity indicates
that the function has more decision points and is more complex.


it('applies callback tokens, stores the client id, schedules refresh, and fetches the session', async () => {
const authStore = createAuthStore()
const fetchSession = vi.fn(async () => true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found `async` function without any `await` expressions


A function that does not contain any await expressions should not be async (except for some edge cases in TypeScript which are discussed below). Asynchronous functions in JavaScript behave differently than other functions in two important ways:

Comment on lines +78 to +80
electronAuthLogout: async () => {
calls.push(`native:${authStore.token ?? 'cleared'}`)
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found `async` function without any `await` expressions


A function that does not contain any await expressions should not be async (except for some edge cases in TypeScript which are discussed below). Asynchronous functions in JavaScript behave differently than other functions in two important ways:

Comment on lines +81 to +84
signOut: async () => {
calls.push('signOut')
authStore.clearAllAuthState()
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found `async` function without any `await` expressions


A function that does not contain any await expressions should not be async (except for some edge cases in TypeScript which are discussed below). Asynchronous functions in JavaScript behave differently than other functions in two important ways:


it('consumes a shared needsLogin request by clearing the flag and starting native login', async () => {
const authStore = { isAuthenticated: false, needsLogin: true }
const startLogin = vi.fn(async () => {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected empty async arrow function


Having empty functions hurts readability, and is considered a code-smell. There's almost always a way to avoid using them. If you must use one, consider adding a comment to inform the reader of its purpose.


it('ignores needsLogin when already authenticated', async () => {
const authStore = { isAuthenticated: true, needsLogin: true }
const startLogin = vi.fn(async () => {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected empty async arrow function


Having empty functions hurts readability, and is considered a code-smell. There's almost always a way to avoid using them. If you must use one, consider adding a comment to inform the reader of its purpose.

return clientId || STAGE_TAURI_DEFAULT_OIDC_CLIENT_ID
}

export async function applyStageTauriAuthCallback(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

`applyStageTauriAuthCallback` has a cyclomatic complexity of 6 with "medium" risk


A function with high cyclomatic complexity can be hard to understand and
maintain. Cyclomatic complexity is a software metric that measures the number of
independent paths through a function. A higher cyclomatic complexity indicates
that the function has more decision points and is more complex.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants