Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions lib/auth/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { generatePKCE } from "@openauthjs/openauth/pkce";
import { randomBytes } from "node:crypto";
import { randomBytes, webcrypto } from "node:crypto";
import type { PKCEPair, AuthorizationFlow, TokenResult, ParsedAuthInput, JWTPayload } from "../types.js";
import { logError } from "../logger.js";
import {
Expand Down Expand Up @@ -206,13 +205,41 @@ export interface AuthorizationFlowOptions {
forceNewLogin?: boolean;
}

/**
* Generate an RFC 7636 S256 PKCE pair.
*
* Previously `generatePKCE` from `@openauthjs/openauth/pkce`. That package was
* pulled in for this one function and dragged `hono` into the production tree
* as a peer dependency - which is the only reason `hono` was a direct
* dependency and an override here - carrying advisories that could not be
* cleared without it.
*
* Semantics are preserved exactly:
* - 64 random bytes, base64url-encoded, giving an 86-character verifier
* (RFC 7636 allows 43-128).
* - challenge = base64url(SHA-256(ASCII(verifier))).
* - The upstream helper also returned `method: "S256"`; nothing read it.
* {@link PKCEPair} is `{ challenge, verifier }` and the request hardcodes
* `code_challenge_method=S256` below.
*
* Both encoders emit unpadded base64url, so the wire format is unchanged.
*/
async function generatePKCE(): Promise<PKCEPair> {
const verifier = randomBytes(64).toString("base64url");
const digest = await webcrypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
return { verifier, challenge: Buffer.from(digest).toString("base64url") };
}

/**
* Create OAuth authorization flow
* @param options - Optional configuration for the flow
* @returns Authorization flow details
*/
export async function createAuthorizationFlow(options?: AuthorizationFlowOptions): Promise<AuthorizationFlow> {
const pkce = (await generatePKCE()) as PKCEPair;
const pkce = await generatePKCE();
const state = createState();

const url = new URL(AUTHORIZE_URL);
Expand Down
122 changes: 6 additions & 116 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 2 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,9 @@
},
"dependencies": {
"@napi-rs/keyring": "~1.2.0",
"@openauthjs/openauth": "^0.4.3",
"@opencode-ai/plugin": "^1.14.22",
"@opentui/core": "0.2.6",
"@opentui/solid": "0.2.6",
"hono": "4.12.32",
"proper-lockfile": "^4.1.2",
"solid-js": "^1.9.11",
"web-tree-sitter": "^0.25.10",
Expand All @@ -133,17 +131,17 @@
"overrides": {
"esbuild": "^0.28.1",
"flatted": "3.4.2",
"hono": "4.12.32",
"rollup": "4.60.0",
"vite": "^7.3.5",
"yaml": "^2.8.3",
"@babel/core": "^7.29.6",
"ajv@<6.14.0": "^6.14.0",
"brace-expansion": "^5.0.8",
"brace-expansion": "^5.0.9",
"seroval@<=1.5.2": "^1.5.6",
"seroval-plugins@<=1.5.2": "^1.5.6",
"minimatch@<9.0.7": "^9.0.7",
"minimatch@>=10.0.0 <10.2.3": "^10.2.3",
"nanoid": "^3.3.18",
"picomatch@<2.3.2": "^2.3.2",
"picomatch@>=4.0.0 <4.0.4": "^4.0.4",
"postcss@<8.5.24": "^8.5.24",
Expand Down
32 changes: 32 additions & 0 deletions test/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { createHash } from 'node:crypto';
import {
createState,
parseAuthorizationInput,
Expand Down Expand Up @@ -224,6 +225,37 @@ describe('Auth Module', () => {
expect(flow1.pkce.verifier).not.toBe(flow2.pkce.verifier);
expect(flow1.url).not.toBe(flow2.url);
});

// The PKCE pair was produced by @openauthjs/openauth until that package
// was dropped. Every assertion above passes for a challenge that is not
// actually derived from the verifier - the server would reject the token
// exchange and nothing here would notice. Pin the derivation itself,
// recomputed through a different crypto API than the implementation uses.
it('derives the challenge as base64url(SHA-256(verifier))', async () => {
const { pkce } = await createAuthorizationFlow();

const expected = createHash('sha256')
.update(pkce.verifier, 'ascii')
.digest('base64url');

expect(pkce.challenge).toBe(expected);
});

it('emits an RFC 7636-conformant verifier and challenge', async () => {
const { pkce } = await createAuthorizationFlow();

// Unpadded base64url only - no "+", "/" or "=".
expect(pkce.verifier).toMatch(/^[A-Za-z0-9_-]+$/);
expect(pkce.challenge).toMatch(/^[A-Za-z0-9_-]+$/);

// 64 random bytes -> 86 chars, inside the 43-128 the RFC allows.
expect(pkce.verifier).toHaveLength(86);
expect(pkce.verifier.length).toBeGreaterThanOrEqual(43);
expect(pkce.verifier.length).toBeLessThanOrEqual(128);

// A SHA-256 digest is 32 bytes -> 43 unpadded base64url chars.
expect(pkce.challenge).toHaveLength(43);
});
});

describe('exchangeAuthorizationCode', () => {
Expand Down