v1.7.11: Switch to Authorization Code + PKCE (OAuth 2.1 standard)

Reverts implicit flow workaround now that IAM fork properly reads
OAuth params from the JSON body (hanzoai/iam@951e225a).

All platforms now use response_type=code with PKCE:
- Browser (Chrome/Firefox/Safari): code_challenge in authorize URL
- VS Code: code exchange with code_verifier in callback server
- CLI tools: same PKCE flow

Still handles implicit flow as fallback if code not present.
This commit is contained in:
Hanzo Dev
2026-03-05 16:17:12 -08:00
parent b6dc2c0a6d
commit 64d7782869
12 changed files with 87 additions and 64 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hanzo-ai-monorepo",
"version": "1.7.10",
"version": "1.7.11",
"private": true,
"description": "Hanzo AI Development Platform Monorepo",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/browser-extension",
"version": "1.7.10",
"version": "1.7.11",
"description": "Hanzo AI Browser Extension",
"main": "dist/background.js",
"scripts": {
+4 -5
View File
@@ -136,14 +136,13 @@ export async function login(): Promise<UserInfo> {
const state = generateState();
const redirectUri = getRedirectUri();
// Use response_type=token (implicit flow) — Casdoor returns the JWT
// directly in the redirect URL. The code flow (response_type=code) requires
// an empty-string grant_type that Casdoor's grant check doesn't support.
// We still generate PKCE params in case code flow is ever re-enabled.
// Authorization Code + PKCE (OAuth 2.1 standard for public clients)
const authorizeUrl = new URL(`${IAM_LOGIN}/login/oauth/authorize`);
authorizeUrl.searchParams.set('client_id', CLIENT_ID);
authorizeUrl.searchParams.set('response_type', 'token');
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
authorizeUrl.searchParams.set('scope', SCOPES);
authorizeUrl.searchParams.set('state', state);
+3 -1
View File
@@ -529,8 +529,10 @@ async function firefoxLogin(): Promise<any> {
const authorizeUrl = new URL(`${IAM_LOGIN}/login/oauth/authorize`);
authorizeUrl.searchParams.set('client_id', CLIENT_ID);
authorizeUrl.searchParams.set('response_type', 'token');
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
authorizeUrl.searchParams.set('scope', SCOPES);
authorizeUrl.searchParams.set('state', state);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Hanzo AI",
"version": "1.7.10",
"version": "1.7.11",
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
"permissions": [
"activeTab",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Hanzo AI",
"version": "1.7.10",
"version": "1.7.11",
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
"permissions": [
"activeTab",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/dxt",
"version": "1.7.10",
"version": "1.7.11",
"description": "Hanzo AI DXT Bundle",
"main": "dist/index.js",
"scripts": {
+1 -1
View File
@@ -4,7 +4,7 @@ pluginGroup = ai.hanzo
pluginName = Hanzo AI
pluginRepositoryUrl = https://github.com/hanzoai/hanzo-ai-jetbrains
# SemVer format -> https://semver.org
pluginVersion = 1.7.10
pluginVersion = 1.7.11
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 233
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/cli-tools",
"version": "1.7.10",
"version": "1.7.11",
"description": "Hanzo AI CLI Tools Integration",
"main": "dist/index.js",
"scripts": {
+4 -4
View File
@@ -172,15 +172,15 @@ export class HanzoAuth extends EventEmitter {
});
this.server.listen(port, () => {
// Build authorization URL
// Use response_type=token (implicit flow) — Casdoor returns the JWT
// directly in the redirect URL, avoiding the empty grant_type issue.
// Authorization Code + PKCE (OAuth 2.1 standard)
const authUrl = new URL('/login/oauth/authorize', this.config.iamLoginUrl);
authUrl.searchParams.set('client_id', this.config.clientId);
authUrl.searchParams.set('response_type', 'token');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`);
authUrl.searchParams.set('scope', this.config.scope);
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// Open browser
this.openBrowser(authUrl.toString());
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@hanzo/extension",
"displayName": "Hanzo AI",
"description": "The ultimate meta AI development platform. Manage and run all LLMs and CLI tools (Claude, Codex, Gemini, OpenHands, Aider) in one unified interface. Features MCP integration, async execution, git worktrees, and universal context sync.",
"version": "1.7.10",
"version": "1.7.11",
"publisher": "hanzo-ai",
"private": false,
"license": "MIT",
+68 -46
View File
@@ -162,19 +162,22 @@ export class StandaloneAuthManager {
const deviceId = this.getDeviceId();
const state = crypto.randomBytes(16).toString('hex');
// Build OAuth URL — login UI is on hanzo.id, not iam.hanzo.ai
// Use response_type=token (implicit flow) — Casdoor returns the JWT
// directly in the redirect URL, avoiding the empty grant_type issue.
// Build OAuth URL — Authorization Code + PKCE (OAuth 2.1 standard)
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
const authUrl = new URL(`${this.casdoorConfig.loginUrl}/login/oauth/authorize`);
authUrl.searchParams.append('client_id', this.casdoorConfig.clientId);
authUrl.searchParams.append('response_type', 'token');
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('redirect_uri', 'http://localhost:8765/callback');
authUrl.searchParams.append('scope', 'read write');
authUrl.searchParams.append('state', state);
authUrl.searchParams.append('code_challenge', codeChallenge);
authUrl.searchParams.append('code_challenge_method', 'S256');
authUrl.searchParams.append('device_id', deviceId);
// Start local callback server
const callbackResult = await this.startCallbackServer(state);
const callbackResult = await this.startCallbackServer(state, codeVerifier);
// Open browser
console.log('Opening browser for authentication...');
@@ -204,66 +207,85 @@ export class StandaloneAuthManager {
return false;
}
private async startCallbackServer(expectedState: string): Promise<{ accessToken: string; refreshToken?: string; expiresAt?: number } | null> {
private async startCallbackServer(expectedState: string, codeVerifier?: string): Promise<{ accessToken: string; refreshToken?: string; expiresAt?: number } | null> {
const endpoint = this.casdoorConfig.endpoint;
const clientId = this.casdoorConfig.clientId;
const clientSecret = this.casdoorConfig.clientSecret;
return new Promise((resolve) => {
const http = require('http');
const url = require('url');
const server = http.createServer((req: any, res: any) => {
const successHtml = `<html><body style="font-family:system-ui;text-align:center;padding:50px">
<h1 style="color:#10b981">Authentication Successful!</h1>
<p>You can close this window.</p>
<script>setTimeout(()=>window.close(),3000)</script></body></html>`;
const server = http.createServer(async (req: any, res: any) => {
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname !== '/callback') return;
if (parsedUrl.pathname === '/callback') {
const state = parsedUrl.query.state;
// Implicit flow: access_token comes as a query param
const accessToken = parsedUrl.query.access_token;
// Code flow fallback
const code = parsedUrl.query.code;
const state = parsedUrl.query.state;
if (state !== expectedState) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<h1>Authentication Failed</h1><p>Invalid state.</p>');
server.close();
resolve(null);
return;
}
if (state !== expectedState) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<h1>Authentication Failed</h1><p>Invalid state.</p>');
server.close();
resolve(null);
return;
}
const code = parsedUrl.query.code;
const directToken = parsedUrl.query.access_token;
if (accessToken) {
if (code && codeVerifier) {
// Authorization Code + PKCE exchange
try {
const body: Record<string, string> = {
grant_type: 'authorization_code',
client_id: clientId,
code: code as string,
redirect_uri: 'http://localhost:8765/callback',
code_verifier: codeVerifier,
};
if (clientSecret) body.client_secret = clientSecret;
const tokenResp = await axios.post(`${endpoint}/api/login/oauth/access_token`, body);
const tokens = tokenResp.data;
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<html><body style="font-family:system-ui;text-align:center;padding:50px">
<h1 style="color:#10b981">Authentication Successful!</h1>
<p>You can close this window.</p>
<script>setTimeout(()=>window.close(),3000)</script></body></html>`);
res.end(successHtml);
server.close();
const expiresIn = parsedUrl.query.expires_in ? parseInt(parsedUrl.query.expires_in, 10) : undefined;
resolve({
accessToken: accessToken as string,
refreshToken: parsedUrl.query.refresh_token as string | undefined,
expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
});
} else if (code) {
// Fallback: extract code for code exchange (handled upstream if needed)
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<html><body style="font-family:system-ui;text-align:center;padding:50px">
<h1 style="color:#10b981">Authentication Successful!</h1>
<p>You can close this window.</p></body></html>`);
server.close();
// For now, cannot exchange code here — implicit flow is preferred
resolve(null);
} else {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<h1>Authentication Failed</h1><p>No token received.</p>');
} catch (err: any) {
res.writeHead(500, { 'Content-Type': 'text/html' });
res.end(`<h1>Token Exchange Failed</h1><p>${err.message}</p>`);
server.close();
resolve(null);
}
} else if (directToken) {
// Implicit flow fallback
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(successHtml);
server.close();
const expiresIn = parsedUrl.query.expires_in ? parseInt(parsedUrl.query.expires_in, 10) : undefined;
resolve({
accessToken: directToken as string,
refreshToken: parsedUrl.query.refresh_token as string | undefined,
expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined,
});
} else {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<h1>Authentication Failed</h1><p>No code or token received.</p>');
server.close();
resolve(null);
}
});
server.listen(8765, 'localhost');
setTimeout(() => {
server.close();
resolve(null);
}, 300000);
setTimeout(() => { server.close(); resolve(null); }, 300000);
});
}