poe-code 4.0.20 → 4.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poe-code",
3
- "version": "4.0.20",
3
+ "version": "4.0.21",
4
4
  "description": "CLI tool to configure Poe API for developer workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,8 @@
1
1
  export { checkAuth } from "./check-auth.js";
2
- export { createOAuthClient } from "./oauth-client.js";
2
+ export { createOAuthAuthorizationUrl, createOAuthClient, exchangeOAuthCode } from "./oauth-client.js";
3
+ export { validateOAuthAuthorizationCallback } from "./loopback-authorization.js";
4
+ export { generateCodeChallenge, generateCodeVerifier } from "./pkce.js";
3
5
  export type { OAuthLandingPage } from "./loopback-authorization.js";
4
6
  export type { AuthIdentity, CheckAuthOptions } from "./check-auth.js";
5
- export type { OAuthClient, OAuthClientConfig, OAuthResult, OAuthAuthorization } from "./oauth-client.js";
7
+ export type { OAuthClient, OAuthClientConfig, OAuthResult, OAuthAuthorization, CreateOAuthAuthorizationUrlOptions, ExchangeOAuthCodeOptions } from "./oauth-client.js";
8
+ export type { ValidateOAuthAuthorizationCallbackOptions } from "./loopback-authorization.js";
@@ -1,2 +1,4 @@
1
1
  export { checkAuth } from "./check-auth.js";
2
- export { createOAuthClient } from "./oauth-client.js";
2
+ export { createOAuthAuthorizationUrl, createOAuthClient, exchangeOAuthCode } from "./oauth-client.js";
3
+ export { validateOAuthAuthorizationCallback } from "./loopback-authorization.js";
4
+ export { generateCodeChallenge, generateCodeVerifier } from "./pkce.js";
@@ -15,6 +15,13 @@ export interface LoopbackAuthorizationSession {
15
15
  waitForCode(authorizationUrl: string): Promise<string>;
16
16
  close(): void;
17
17
  }
18
+ export interface ValidateOAuthAuthorizationCallbackOptions {
19
+ callbackUrl: string | URL;
20
+ expectedState: string;
21
+ expectedIssuer?: string;
22
+ requireIssuer?: boolean;
23
+ }
24
+ export declare function validateOAuthAuthorizationCallback(options: ValidateOAuthAuthorizationCallbackOptions): string;
18
25
  export declare function createLoopbackAuthorizationSession(options?: LoopbackAuthorizationOptions): Promise<LoopbackAuthorizationSession>;
19
26
  export declare function extractCodeFromInput(input: string): string | null;
20
27
  export declare function buildSuccessPage(landingPage?: OAuthLandingPage): string;
@@ -1,5 +1,39 @@
1
1
  import http from "node:http";
2
2
  import { parseAuthorizationState } from "./authorization-state.js";
3
+ export function validateOAuthAuthorizationCallback(options) {
4
+ let url;
5
+ try {
6
+ url = typeof options.callbackUrl === "string"
7
+ ? new URL(options.callbackUrl)
8
+ : options.callbackUrl;
9
+ }
10
+ catch {
11
+ throw new Error("Poe OAuth callbackUrl must be an absolute URL.");
12
+ }
13
+ const callback = {
14
+ code: url.searchParams.get("code"),
15
+ state: url.searchParams.get("state"),
16
+ iss: url.searchParams.get("iss")
17
+ };
18
+ const expected = {
19
+ state: validateExpectedState(options.expectedState),
20
+ issuer: options.expectedIssuer ?? null,
21
+ requireIssuer: options.requireIssuer ?? false
22
+ };
23
+ validateAuthorizationCallbackBinding(callback, expected);
24
+ const authorizationError = url.searchParams.get("error");
25
+ if (authorizationError !== null) {
26
+ const description = url.searchParams.get("error_description");
27
+ const safeError = sanitizeAuthorizationError(authorizationError, expected.state);
28
+ const safeDescription = description === null
29
+ ? null
30
+ : sanitizeAuthorizationError(description, expected.state);
31
+ throw new Error(safeDescription === null
32
+ ? `OAuth authorization failed: ${safeError}`
33
+ : `OAuth authorization failed: ${safeError} — ${safeDescription}`);
34
+ }
35
+ return validateAuthorizationCallbackParameters(callback, expected);
36
+ }
3
37
  export async function createLoopbackAuthorizationSession(options = {}) {
4
38
  const callbackPath = options.callbackPath ?? "/callback";
5
39
  const server = options.createServer ? options.createServer() : http.createServer();
@@ -46,39 +80,26 @@ function waitForAuthorizationCode(server, authorizationUrl, options, callbackPat
46
80
  res.end("Not found");
47
81
  return;
48
82
  }
49
- const error = url.searchParams.get("error");
50
- if (error !== null) {
51
- try {
52
- validateAuthorizationCallbackBinding({
53
- state: url.searchParams.get("state"),
54
- iss: url.searchParams.get("iss")
55
- }, expectedAuthorization);
56
- }
57
- catch (validationError) {
58
- res.writeHead(400);
59
- res.end(validationError instanceof Error ? validationError.message : "Invalid OAuth callback");
60
- return;
61
- }
62
- const description = url.searchParams.get("error_description") ?? error;
63
- res.writeHead(400);
64
- res.end(`Authorization failed: ${description}`);
65
- settle(() => reject(new Error(`OAuth authorization failed: ${error} — ${description}`)));
66
- return;
67
- }
68
83
  try {
69
- const code = validateAuthorizationCallbackParameters({
70
- code: url.searchParams.get("code"),
71
- state: url.searchParams.get("state"),
72
- iss: url.searchParams.get("iss")
73
- }, expectedAuthorization);
84
+ const code = validateOAuthAuthorizationCallback({
85
+ callbackUrl: url,
86
+ expectedState: expectedAuthorization.state ?? "",
87
+ expectedIssuer: expectedAuthorization.issuer ?? undefined,
88
+ requireIssuer: expectedAuthorization.requireIssuer
89
+ });
74
90
  res.writeHead(200, { "Content-Type": "text/html" });
75
91
  res.end(buildSuccessPage(options.landingPage));
76
92
  settle(() => resolve(code));
77
93
  }
78
94
  catch (error) {
95
+ const validationError = error instanceof Error ? error : new Error(String(error));
79
96
  res.writeHead(400);
80
- res.end(error instanceof Error ? error.message : "Invalid OAuth callback");
81
- settle(() => reject(error instanceof Error ? error : new Error(String(error))));
97
+ res.end(validationError.message);
98
+ if (url.searchParams.has("error") &&
99
+ !validationError.message.startsWith("OAuth authorization failed:")) {
100
+ return;
101
+ }
102
+ settle(() => reject(validationError));
82
103
  }
83
104
  });
84
105
  if (options.readLine !== undefined) {
@@ -171,6 +192,18 @@ function validateAuthorizationCallbackBinding(callback, expected) {
171
192
  throw new Error("OAuth callback issuer mismatch");
172
193
  }
173
194
  }
195
+ function validateExpectedState(state) {
196
+ if (state.length === 0 || state.trim() !== state) {
197
+ throw new Error("Poe OAuth expectedState must not be blank or contain surrounding whitespace.");
198
+ }
199
+ return state;
200
+ }
201
+ function sanitizeAuthorizationError(value, expectedState) {
202
+ if (value.includes(expectedState)) {
203
+ return "authorization_error";
204
+ }
205
+ return value;
206
+ }
174
207
  function escapeHtml(text) {
175
208
  return text
176
209
  .replaceAll("&", "&amp;")
@@ -21,4 +21,21 @@ export interface OAuthAuthorization {
21
21
  export interface OAuthClient {
22
22
  authorize(): Promise<OAuthAuthorization>;
23
23
  }
24
+ export interface CreateOAuthAuthorizationUrlOptions {
25
+ clientId: string;
26
+ redirectUri: string;
27
+ state: string;
28
+ codeChallenge: string;
29
+ authorizationEndpoint?: string;
30
+ }
31
+ export interface ExchangeOAuthCodeOptions {
32
+ clientId: string;
33
+ redirectUri: string;
34
+ code: string;
35
+ codeVerifier: string;
36
+ tokenEndpoint?: string;
37
+ fetch?: typeof globalThis.fetch;
38
+ }
24
39
  export declare function createOAuthClient(config: OAuthClientConfig): OAuthClient;
40
+ export declare function createOAuthAuthorizationUrl(options: CreateOAuthAuthorizationUrlOptions): string;
41
+ export declare function exchangeOAuthCode(options: ExchangeOAuthCodeOptions): Promise<OAuthResult>;
@@ -1,6 +1,6 @@
1
1
  import { createAuthorizationState } from "./authorization-state.js";
2
2
  import { createLoopbackAuthorizationSession } from "./loopback-authorization.js";
3
- import { generateCodeChallenge as generatePkceCodeChallenge, generateCodeVerifier as generatePkceCodeVerifier } from "./pkce.js";
3
+ import { generateCodeChallenge as generatePkceCodeChallenge, generateCodeVerifier as generatePkceCodeVerifier, validateCodeChallenge, validateCodeVerifier } from "./pkce.js";
4
4
  const DEFAULT_AUTHORIZATION_ENDPOINT = "https://poe.com/oauth/authorize";
5
5
  const DEFAULT_TOKEN_ENDPOINT = "https://api.poe.com/token";
6
6
  const MAX_VALID_EPOCH_MS = 8_640_000_000_000_000;
@@ -9,7 +9,9 @@ export function createOAuthClient(config) {
9
9
  const clientId = validateClientId(config.clientId);
10
10
  const normalizedConfig = {
11
11
  ...config,
12
- clientId
12
+ clientId,
13
+ authorizationEndpoint: validateHttpUrl(config.authorizationEndpoint ?? DEFAULT_AUTHORIZATION_ENDPOINT, "authorizationEndpoint"),
14
+ tokenEndpoint: validateHttpUrl(config.tokenEndpoint ?? DEFAULT_TOKEN_ENDPOINT, "tokenEndpoint")
13
15
  };
14
16
  return {
15
17
  authorize: () => startAuthorization(normalizedConfig, fetchFn)
@@ -33,8 +35,8 @@ async function startAuthorization(config, fetchFn) {
33
35
  }
34
36
  });
35
37
  const redirectUri = loopbackSession.redirectUri;
36
- const authorizationUrl = buildAuthorizationUrl({
37
- endpoint: authorizationEndpoint,
38
+ const authorizationUrl = createOAuthAuthorizationUrl({
39
+ authorizationEndpoint,
38
40
  clientId: config.clientId,
39
41
  redirectUri,
40
42
  codeChallenge,
@@ -48,13 +50,13 @@ async function startAuthorization(config, fetchFn) {
48
50
  resultPromise ??= (async () => {
49
51
  try {
50
52
  const code = await loopbackSession.waitForCode(authorizationUrl);
51
- return await exchangeCodeForApiKey({
53
+ return await exchangeOAuthCode({
52
54
  tokenEndpoint,
53
55
  code,
54
56
  codeVerifier,
55
57
  clientId: config.clientId,
56
58
  redirectUri,
57
- fetchFn
59
+ fetch: fetchFn
58
60
  });
59
61
  }
60
62
  finally {
@@ -65,41 +67,63 @@ async function startAuthorization(config, fetchFn) {
65
67
  };
66
68
  return { authorizationUrl, waitForResult };
67
69
  }
68
- function buildAuthorizationUrl(params) {
69
- const url = new URL(params.endpoint);
70
+ export function createOAuthAuthorizationUrl(options) {
71
+ const clientId = validateClientId(options.clientId);
72
+ const redirectUri = validateHttpUrl(options.redirectUri, "redirectUri");
73
+ const state = validateOpaqueValue(options.state, "state");
74
+ const codeChallenge = validateCodeChallenge(options.codeChallenge);
75
+ const authorizationEndpoint = validateHttpUrl(options.authorizationEndpoint ?? DEFAULT_AUTHORIZATION_ENDPOINT, "authorizationEndpoint");
76
+ const url = new URL(authorizationEndpoint);
70
77
  url.searchParams.set("response_type", "code");
71
- url.searchParams.set("client_id", params.clientId);
78
+ url.searchParams.set("client_id", clientId);
72
79
  url.searchParams.set("scope", "apikey:create");
73
- url.searchParams.set("code_challenge", params.codeChallenge);
80
+ url.searchParams.set("code_challenge", codeChallenge);
74
81
  url.searchParams.set("code_challenge_method", "S256");
75
- url.searchParams.set("redirect_uri", params.redirectUri);
76
- url.searchParams.set("state", params.state);
82
+ url.searchParams.set("redirect_uri", redirectUri);
83
+ url.searchParams.set("state", state);
77
84
  return url.toString();
78
85
  }
79
- async function exchangeCodeForApiKey(params) {
86
+ export async function exchangeOAuthCode(options) {
87
+ const clientId = validateClientId(options.clientId);
88
+ const redirectUri = validateHttpUrl(options.redirectUri, "redirectUri");
89
+ const code = validateOpaqueValue(options.code, "code");
90
+ const codeVerifier = validateCodeVerifier(options.codeVerifier);
91
+ const tokenEndpoint = validateHttpUrl(options.tokenEndpoint ?? DEFAULT_TOKEN_ENDPOINT, "tokenEndpoint");
92
+ const fetchFn = options.fetch ?? globalThis.fetch;
80
93
  const body = new URLSearchParams({
81
94
  grant_type: "authorization_code",
82
- code: params.code,
83
- code_verifier: params.codeVerifier,
84
- client_id: params.clientId,
85
- redirect_uri: params.redirectUri
86
- });
87
- const response = await params.fetchFn(params.tokenEndpoint, {
88
- method: "POST",
89
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
90
- body: body.toString()
95
+ code,
96
+ code_verifier: codeVerifier,
97
+ client_id: clientId,
98
+ redirect_uri: redirectUri
91
99
  });
100
+ let response;
101
+ try {
102
+ response = await fetchFn(tokenEndpoint, {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
105
+ body: body.toString()
106
+ });
107
+ }
108
+ catch {
109
+ throw new Error("Token exchange request failed");
110
+ }
92
111
  if (!response.ok) {
93
112
  const text = await response.text();
94
113
  const description = parseErrorDescription(text);
95
- throw new Error(description ?? `Token exchange failed (${response.status}): ${text}`);
114
+ if (description !== null &&
115
+ !description.includes(codeVerifier) &&
116
+ !description.includes(code)) {
117
+ throw new Error(description);
118
+ }
119
+ throw new Error(`Token exchange failed (${response.status})`);
96
120
  }
97
121
  let value;
98
122
  try {
99
123
  value = (await response.json());
100
124
  }
101
125
  catch (error) {
102
- throw new Error(`Token exchange failed: invalid JSON response from ${params.tokenEndpoint}`, {
126
+ throw new Error(`Token exchange failed: invalid JSON response from ${tokenEndpoint}`, {
103
127
  cause: error
104
128
  });
105
129
  }
@@ -157,6 +181,28 @@ function validateClientId(clientId) {
157
181
  }
158
182
  return clientId;
159
183
  }
184
+ function validateHttpUrl(value, field) {
185
+ if (value.trim() !== value || value.length === 0) {
186
+ throw new Error(`Poe OAuth ${field} must be an absolute HTTP(S) URL.`);
187
+ }
188
+ let url;
189
+ try {
190
+ url = new URL(value);
191
+ }
192
+ catch {
193
+ throw new Error(`Poe OAuth ${field} must be an absolute HTTP(S) URL.`);
194
+ }
195
+ if ((url.protocol !== "https:" && url.protocol !== "http:") || url.hash.length > 0) {
196
+ throw new Error(`Poe OAuth ${field} must be an absolute HTTP(S) URL without a fragment.`);
197
+ }
198
+ return url.toString();
199
+ }
200
+ function validateOpaqueValue(value, field) {
201
+ if (value.length === 0 || value.trim() !== value) {
202
+ throw new Error(`Poe OAuth ${field} must not be blank or contain surrounding whitespace.`);
203
+ }
204
+ return value;
205
+ }
160
206
  function isValidExpiresIn(value) {
161
207
  if (typeof value !== "number" ||
162
208
  !Number.isFinite(value) ||
@@ -1,2 +1,4 @@
1
1
  export declare function generateCodeVerifier(): string;
2
2
  export declare function generateCodeChallenge(verifier: string): string;
3
+ export declare function validateCodeVerifier(verifier: string): string;
4
+ export declare function validateCodeChallenge(challenge: string): string;
@@ -3,5 +3,31 @@ export function generateCodeVerifier() {
3
3
  return crypto.randomBytes(32).toString("base64url");
4
4
  }
5
5
  export function generateCodeChallenge(verifier) {
6
- return crypto.createHash("sha256").update(verifier).digest("base64url");
6
+ return crypto.createHash("sha256").update(validateCodeVerifier(verifier)).digest("base64url");
7
+ }
8
+ export function validateCodeVerifier(verifier) {
9
+ if (verifier.length < 43 ||
10
+ verifier.length > 128 ||
11
+ !hasOnlyPkceCharacters(verifier)) {
12
+ throw new Error("Poe OAuth codeVerifier must contain 43 to 128 URL-safe PKCE characters.");
13
+ }
14
+ return verifier;
15
+ }
16
+ export function validateCodeChallenge(challenge) {
17
+ if (challenge.length !== 43 || !hasOnlyPkceCharacters(challenge)) {
18
+ throw new Error("Poe OAuth codeChallenge must contain 43 URL-safe PKCE characters.");
19
+ }
20
+ return challenge;
21
+ }
22
+ function hasOnlyPkceCharacters(value) {
23
+ for (const character of value) {
24
+ const code = character.charCodeAt(0);
25
+ const isDigit = code >= 48 && code <= 57;
26
+ const isUppercase = code >= 65 && code <= 90;
27
+ const isLowercase = code >= 97 && code <= 122;
28
+ if (!isDigit && !isUppercase && !isLowercase && character !== "-" && character !== "_") {
29
+ return false;
30
+ }
31
+ }
32
+ return true;
7
33
  }