NewWacht Bench is live — AI-assisted development for Wacht
GuidesOAuth Apps

Sign In from Desktop and CLI Apps

Use the OAuth 2.0 loopback flow (RFC 8252) to authenticate users from a Tauri, Electron, or CLI app against your Wacht deployment.

Sign In from Desktop and CLI Apps

Native apps — Tauri, Electron, a CLI like gh, anything running on a user's machine — can't keep a client_secret and can't reliably receive an HTTPS browser redirect. The OAuth 2.0 standard for this case is Authorization Code with PKCE on a loopback redirect, defined by RFC 8252. Wacht supports this end-to-end.

The flow:

  1. Your app starts a tiny HTTP server on http://127.0.0.1:<random_port>/callback.
  2. Your app opens the user's system browser to https://<oauth-fqdn>/oauth/authorize?...&redirect_uri=http://127.0.0.1:<random_port>/callback.
  3. The user signs in. Wacht redirects the browser to the loopback URL with ?code=....
  4. Your app's loopback server receives the code, then POSTs it to /oauth/token with the PKCE verifier.
  5. You get back an access_token, refresh_token, and (if openid scope was requested) id_token.

You can use any standards-compliant OAuth/OIDC library. We do not ship a custom helper for this, because well-maintained libraries already exist and the flow is a public standard. Recommended:

  • Node / Tauri-with-Node-sidecar / CLI in TypeScript: openid-client
  • Tauri's Rust backend / Rust CLI: oauth2 crate

Setup

Create an OAuth app and a public OAuth client on your deployment. The full guide is at Create OAuth Apps and Clients. The native-specific settings:

  • client_auth_method: none — no client_secret, PKCE is mandatory.
  • grant_types: authorization_code, refresh_token.
  • redirect_uris: register one loopback URI per host you'll use, with no port. For example:
    • http://127.0.0.1/callback
    • http://[::1]/callback if you want IPv6 support
    • http://localhost/callback if you specifically want the localhost hostname
  • post_logout_redirect_uris: same shape, optional, only needed for RP-initiated logout.

Why no port in the registered URI?

RFC 8252 §7.3 requires servers to allow any port on loopback redirect URIs, because native apps obtain an ephemeral port from the OS at request time. Wacht enforces this: a registered redirect URI of http://127.0.0.1/callback matches any incoming http://127.0.0.1:54321/callback as long as the scheme, host, path, and query match.

You can register a URI with a port, and the exact-match will still work, but the no-port form is more flexible and is what we recommend.

Host normalization

Wacht does not treat 127.0.0.1, localhost, and ::1 as interchangeable. Register the exact host you'll use. If your app might run as both IPv4 and IPv6, register both.

Node / TypeScript example

Install openid-client:

npm install openid-client

A complete flow:

import * as client from "openid-client";
import { createServer } from "node:http";
import { spawn } from "node:child_process";

const config = await client.discovery(
  new URL("https://<your-oauth-fqdn>"),
  "<your-client-id>",
);

const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();

const tokens = await new Promise<client.TokenEndpointResponse>((resolve, reject) => {
  const server = createServer(async (req, res) => {
    try {
      const url = new URL(req.url ?? "/", `http://127.0.0.1`);
      if (url.pathname !== "/callback") {
        res.writeHead(404).end();
        return;
      }
      res.writeHead(200, { "content-type": "text/html" }).end(
        "<p>You can close this window.</p><script>window.close()</script>",
      );
      const result = await client.authorizationCodeGrant(config, url, {
        pkceCodeVerifier: codeVerifier,
        expectedState: state,
      });
      server.close();
      resolve(result);
    } catch (err) {
      reject(err);
    }
  });

  server.listen(0, "127.0.0.1", () => {
    const { port } = server.address() as { port: number };
    const redirectUri = `http://127.0.0.1:${port}/callback`;
    const authorizeUrl = client.buildAuthorizationUrl(config, {
      redirect_uri: redirectUri,
      scope: "openid profile email offline_access",
      state,
      code_challenge: codeChallenge,
      code_challenge_method: "S256",
    });
    openBrowser(authorizeUrl.toString());
  });

  setTimeout(() => {
    server.close();
    reject(new Error("auth flow timed out"));
  }, 90_000);
});

console.log("access_token", tokens.access_token);
console.log("refresh_token", tokens.refresh_token);

function openBrowser(url: string): void {
  const cmd =
    process.platform === "darwin" ? "open" :
    process.platform === "win32" ? "start" : "xdg-open";
  spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
}

To refresh later:

const refreshed = await client.refreshTokenGrant(config, currentRefreshToken);

Rust example

Add deps:

[dependencies]
oauth2 = "5"
url = "2"
tiny_http = "0.12"
webbrowser = "1"
tokio = { version = "1", features = ["full"] }
use oauth2::{
    AuthUrl, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenUrl,
    basic::BasicClient, reqwest::async_http_client,
};
use std::time::Duration;
use url::Url;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let server = tiny_http::Server::http("127.0.0.1:0")?;
    let port = server.server_addr().to_ip().unwrap().port();
    let redirect_uri = format!("http://127.0.0.1:{port}/callback");

    let client = BasicClient::new(ClientId::new("<your-client-id>".into()))
        .set_auth_uri(AuthUrl::new("https://<your-oauth-fqdn>/oauth/authorize".into())?)
        .set_token_uri(TokenUrl::new("https://<your-oauth-fqdn>/oauth/token".into())?)
        .set_redirect_uri(RedirectUrl::new(redirect_uri.clone())?);

    let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();

    let (auth_url, csrf_token) = client
        .authorize_url(CsrfToken::new_random)
        .add_scope(Scope::new("openid".into()))
        .add_scope(Scope::new("profile".into()))
        .add_scope(Scope::new("email".into()))
        .add_scope(Scope::new("offline_access".into()))
        .set_pkce_challenge(pkce_challenge)
        .url();

    webbrowser::open(auth_url.as_ref())?;

    let request = server
        .recv_timeout(Duration::from_secs(90))?
        .ok_or("auth flow timed out")?;
    let url = Url::parse(&format!("http://127.0.0.1{}", request.url()))?;
    let code = url
        .query_pairs()
        .find(|(k, _)| k == "code")
        .map(|(_, v)| v.into_owned())
        .ok_or("missing code")?;
    let returned_state = url
        .query_pairs()
        .find(|(k, _)| k == "state")
        .map(|(_, v)| v.into_owned())
        .ok_or("missing state")?;
    if returned_state != *csrf_token.secret() {
        return Err("state mismatch".into());
    }
    let response = tiny_http::Response::from_string(
        "<p>You can close this window.</p><script>window.close()</script>",
    )
    .with_header("content-type: text/html".parse::<tiny_http::Header>().unwrap());
    request.respond(response)?;

    let tokens = client
        .exchange_code(oauth2::AuthorizationCode::new(code))
        .set_pkce_verifier(pkce_verifier)
        .request_async(async_http_client)
        .await?;

    println!("access_token: {}", tokens.access_token().secret());
    Ok(())
}

Token storage

Never write refresh tokens to disk in plaintext. Use the OS keyring:

  • Node: keytar (deprecated upstream but still works) or @napi-rs/keyring.
  • Rust: keyring crate, enable apple-native and windows-native features explicitly.

The access token lives in memory only and is re-minted from the refresh token at app start.

A common gotcha: Windows Credential Manager has a ~2560-character per-entry limit. If your refresh_token exceeds it, split storage or use a different backend.

Calling Wacht APIs with the access token

Once you have an access_token, call any Wacht endpoint with Authorization: Bearer <access_token>. The token is a standard OAuth bearer token. Token verification on the resource server side is covered in Access Token Verification.

RP-Initiated Logout

To end the user's Wacht session and revoke every token derived from it, send the browser to:

https://<oauth-fqdn>/oauth/logout
  ?id_token_hint=<the id_token you received>
  &post_logout_redirect_uri=http://127.0.0.1/logged-out
  &state=<optional>

The same loopback rules apply to post_logout_redirect_uri. Spin up a one-shot loopback server, open the URL, wait for the callback. See Use Wacht as an OpenID Connect Provider for details.

Common pitfalls

  1. Don't try to use deep links instead. Deep links (myapp://callback) work but are flaky across OS versions and require platform-specific registration. Loopback is the standard.
  2. Don't reuse the loopback server across runs. Spin it up, take one request, shut it down. Long-running loopback servers are an unnecessary attack surface.
  3. Don't poll Wacht to detect when the user finishes. The loopback callback is the signal. If your app exits before the user finishes, the loopback dies with it — that's fine; the user retries.
  4. Validate state on the callback. The libraries above do this for you; don't skip it.
  5. PKCE is required. It's not optional for public clients. Your library handles it; don't roll your own.

On this page