Skip to content

Commit b819bc4

Browse files
committed
feat(provider): add Meta (muse) OAuth provider integration
1 parent c861890 commit b819bc4

36 files changed

Lines changed: 1044 additions & 37 deletions

‎bun.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"dependencies": {
8989
"@anthropic-ai/sandbox-runtime": "0.0.79",
9090
"@corbits/codex-provider": "0.1.2",
91+
"@corbits/meta-provider": "github:corbitsdev/corbits-meta-provider#8c153bf",
9192
"@corbits/oauth-core": "0.3.0",
9293
"@corbits/openai-responses": "0.2.1",
9394
"@corbits/xai-provider": "0.1.2",

‎packages/first-class-providers/src/providers.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [
8888
auth: "oauth",
8989
oauth: "xai",
9090
},
91+
{
92+
id: "meta",
93+
label: "Meta Muse",
94+
auth: "oauth",
95+
oauth: "meta",
96+
},
9197
{
9298
id: OPENCODE_GO_PROVIDER_ID,
9399
label: OPENCODE_GO_DISPLAY_NAME,

‎packages/first-class-providers/src/types.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export type FirstClassAuthKind =
55
| "chooser"
66
| "custom";
77

8-
export type FirstClassOAuthProvider = "codex" | "xai";
8+
export type FirstClassOAuthProvider = "codex" | "xai" | "meta";
99

1010
export type FirstClassBillingProduct = "subscription" | "credits";
1111

‎src/auth/credential-surface-coverage.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
credentialFileDescriptors,
1010
} from "./credential-surface.js";
1111
import { MCP_AUTH_DIRNAME } from "../mcp/auth-store.js";
12+
import { META_AUTH_FILENAME } from "./meta/store.js";
1213
import { XAI_AUTH_FILENAME } from "./xai/store.js";
1314

1415
const here = dirname(fileURLToPath(import.meta.url));
@@ -18,6 +19,7 @@ const here = dirname(fileURLToPath(import.meta.url));
1819
// scan excludes, so fixture names can never become denylist patterns.
1920
const STORE_FILES = [
2021
join(here, "codex", "store.ts"),
22+
join(here, "meta", "store.ts"),
2123
join(here, "xai", "store.ts"),
2224
join(here, "..", "mcp", "auth-store.ts"),
2325
];
@@ -62,7 +64,7 @@ describe("CL-7789 credential-surface coverage", () => {
6264
}
6365
}
6466
expect([...seen].sort()).toEqual(
65-
[CODEX_AUTH_FILENAME, XAI_AUTH_FILENAME].sort(),
67+
[CODEX_AUTH_FILENAME, META_AUTH_FILENAME, XAI_AUTH_FILENAME].sort(),
6668
);
6769
});
6870

‎src/auth/credential-surface.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface CredentialDirDescriptor {
1717
export const credentialFileDescriptors: CredentialFileDescriptor[] = [
1818
{ settingsDirName: SETTINGS_DIR_NAME, filename: "codex-auth.json" },
1919
{ settingsDirName: SETTINGS_DIR_NAME, filename: "xai-auth.json" },
20+
{ settingsDirName: SETTINGS_DIR_NAME, filename: "meta-auth.json" },
2021
];
2122

2223
export const credentialDirDescriptors: CredentialDirDescriptor[] = [

‎src/auth/meta/constants.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {
2+
META_API_KEY_LIFETIME_MS,
3+
META_BASE_URL,
4+
META_DEFAULT_MODELS,
5+
} from "@corbits/meta-provider";
6+
7+
export { META_BASE_URL, META_DEFAULT_MODELS, META_API_KEY_LIFETIME_MS };
8+
9+
// Meta's minted Model API key is valid about 24h. The refresh path re-mints
10+
// from the stored identity token, so the skew just needs to stay comfortably
11+
// below the hard 24h lifetime to avoid last-second races.
12+
export const META_REFRESH_SKEW_MS = 5 * 60 * 1000;

‎src/auth/meta/login.test.ts‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { test, expect, describe } from "bun:test";
2+
import { mkdtemp, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { startMetaLogin } from "./login.js";
6+
import { loadMetaProfile } from "../../config/oauth-stores.js";
7+
8+
const AUTH_URL = "https://auth.meta.com/oidc/device/authorization/";
9+
const TOKEN_URL = "https://auth.meta.com/oidc/device/token/";
10+
const MINT_URL = "https://api.meta.ai/muse-code/key";
11+
12+
function jsonResponse(body: unknown, status = 200): Response {
13+
return new Response(JSON.stringify(body), {
14+
status,
15+
headers: { "content-type": "application/json" },
16+
});
17+
}
18+
19+
function noopSleep(): Promise<void> {
20+
return Promise.resolve();
21+
}
22+
23+
async function withHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
24+
const home = await mkdtemp(join(tmpdir(), "meta-login-"));
25+
try {
26+
return await fn(home);
27+
} finally {
28+
await rm(home, { recursive: true, force: true });
29+
}
30+
}
31+
32+
describe("startMetaLogin (device flow)", () => {
33+
test("authorize → poll pending → complete → mint → saved profile", async () => {
34+
await withHome(async (home) => {
35+
const requests: string[] = [];
36+
const fetchImpl = (async (input: RequestInfo | URL) => {
37+
const url = String(input);
38+
requests.push(url);
39+
if (url === AUTH_URL) {
40+
return jsonResponse({
41+
device_code: "dc-1",
42+
user_code: "ABCD-1234",
43+
verification_uri_complete:
44+
"https://auth.meta.com/device?code=ABCD-1234",
45+
interval: 1,
46+
expires_in: 1800,
47+
});
48+
}
49+
if (url === TOKEN_URL) {
50+
return jsonResponse({
51+
access_token: "identity-token",
52+
token_type: "bearer",
53+
});
54+
}
55+
if (url === MINT_URL) {
56+
return jsonResponse({ api_key: "LLM|minted" });
57+
}
58+
throw new Error(`unexpected fetch ${url}`);
59+
}) as unknown as typeof fetch;
60+
61+
let notified: { verificationUri: string; userCode: string } | undefined;
62+
const controller = new AbortController();
63+
const handle = startMetaLogin({
64+
profile: "work",
65+
signal: controller.signal,
66+
home,
67+
fetchImpl,
68+
sleep: noopSleep,
69+
notify: (event) => {
70+
notified = {
71+
verificationUri: event.verificationUri,
72+
userCode: event.userCode,
73+
};
74+
},
75+
});
76+
77+
const staged = await handle.completed;
78+
expect(notified).toEqual({
79+
verificationUri: "https://auth.meta.com/device?code=ABCD-1234",
80+
userCode: "ABCD-1234",
81+
});
82+
expect(staged.profile.name).toBe("work");
83+
expect(staged.profile.tokens.access).toBe("LLM|minted");
84+
expect(staged.profile.tokens.refresh).toBe("identity-token");
85+
86+
await staged.commit();
87+
const saved = await loadMetaProfile("work", home);
88+
expect(saved?.tokens.access).toBe("LLM|minted");
89+
expect(saved?.tokens.refresh).toBe("identity-token");
90+
expect(requests).toEqual([AUTH_URL, TOKEN_URL, MINT_URL]);
91+
});
92+
});
93+
94+
test("abort rejects with the cancelled-login message", async () => {
95+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
96+
const url = String(input);
97+
if (url === AUTH_URL) {
98+
return jsonResponse({
99+
device_code: "dc-1",
100+
user_code: "ABCD-1234",
101+
verification_uri_complete: "https://auth.meta.com/device",
102+
interval: 1,
103+
expires_in: 1800,
104+
});
105+
}
106+
// Token poll parks until the caller aborts, then rejects like real fetch.
107+
return new Promise<Response>((_resolve, reject) => {
108+
init?.signal?.addEventListener(
109+
"abort",
110+
() => reject(new DOMException("aborted", "AbortError")),
111+
{ once: true },
112+
);
113+
});
114+
}) as unknown as typeof fetch;
115+
116+
const controller = new AbortController();
117+
const handle = startMetaLogin({
118+
profile: "work",
119+
signal: controller.signal,
120+
fetchImpl,
121+
sleep: noopSleep,
122+
});
123+
124+
const pending = handle.completed.catch((err: unknown) => err);
125+
setTimeout(() => controller.abort(), 0);
126+
const err = await pending;
127+
expect(err).toBeInstanceOf(Error);
128+
expect((err as Error).message).toBe("Login cancelled");
129+
});
130+
});

‎src/auth/meta/login.ts‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { metaDeviceLogin, type MetaOAuthTokens } from "@corbits/meta-provider";
2+
3+
import type { AuthProfile } from "../store.js";
4+
import { saveMetaProfile } from "../../config/oauth-stores.js";
5+
6+
// Meta's device flow is a direct RFC 8628 exchange: there is no PKCE loopback
7+
// server and no authorization-code round trip. `startMetaLogin` adapts the
8+
// self-contained `metaDeviceLogin` into the TUI's `OAuthLoginHandle` shape so
9+
// the sign-in step can wait on a single promise.
10+
export type MetaLoginHandle = {
11+
authorizeUrl: string;
12+
completed: Promise<{
13+
profile: AuthProfile<MetaOAuthTokens>;
14+
commit: () => Promise<void>;
15+
}>;
16+
cancel: () => void;
17+
};
18+
19+
export type StartMetaLoginOptions = {
20+
profile: string;
21+
signal: AbortSignal;
22+
home?: string;
23+
/** Injectable fetch for tests (device authorize / token poll / mint). */
24+
fetchImpl?: typeof fetch;
25+
/** Injectable sleep so tests skip real wall-clock waits. */
26+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
27+
/** Injectable clock for expiry math. */
28+
now?: () => number;
29+
/** Called with the device-code event so the TUI can surface verificationUri + userCode. */
30+
notify?: (event: {
31+
type: "device_code";
32+
verificationUri: string;
33+
userCode: string;
34+
}) => void;
35+
};
36+
37+
/**
38+
* Runs Meta's device login. `metaDeviceLogin` owns the authorize → poll →
39+
* mint sequence; `notify` surfaces the verification URI + user code so the
40+
* caller can print them for the operator (Meta has no loopback callback).
41+
* The returned handle resolves with the minted tokens once the flow
42+
* completes, and `cancel` aborts the in-flight request.
43+
*/
44+
export function startMetaLogin(opts: StartMetaLoginOptions): MetaLoginHandle {
45+
const controller = new AbortController();
46+
const abort = (): void => {
47+
controller.abort();
48+
};
49+
// The TUI's cancel is a soft abandon: it lets the underlying login reject
50+
// with the cancel message (the same shape the caller matches on).
51+
opts.signal.addEventListener("abort", abort, { once: true });
52+
53+
let committed: Promise<void> | undefined;
54+
const completed = (async (): Promise<{
55+
profile: AuthProfile<MetaOAuthTokens>;
56+
commit: () => Promise<void>;
57+
}> => {
58+
const tokens = await metaDeviceLogin(controller.signal, {
59+
...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
60+
...(opts.sleep !== undefined ? { sleep: opts.sleep } : {}),
61+
...(opts.now !== undefined ? { now: opts.now } : {}),
62+
notify: (event) => {
63+
if (event.type === "device_code") {
64+
opts.notify?.({
65+
type: "device_code",
66+
verificationUri: event.verificationUri,
67+
userCode: event.userCode,
68+
});
69+
}
70+
},
71+
});
72+
const profile: AuthProfile<MetaOAuthTokens> = {
73+
name: opts.profile,
74+
tokens,
75+
createdAt: opts.now?.() ?? Date.now(),
76+
};
77+
return {
78+
profile,
79+
commit: () => {
80+
if (committed !== undefined) return committed;
81+
const attempt = saveMetaProfile(profile, opts.home);
82+
committed = attempt;
83+
attempt.then(
84+
() => undefined,
85+
() => {
86+
if (committed === attempt) committed = undefined;
87+
},
88+
);
89+
return attempt;
90+
},
91+
};
92+
})();
93+
94+
return {
95+
// The verification URI is surfaced via `notify` (device flow); the
96+
// authorizeUrl slot is unused for Meta, but the TUI reads it for the
97+
// "open this url" login box. Keep it empty — the device code box shows
98+
// the real instructions instead.
99+
authorizeUrl: "",
100+
completed,
101+
cancel: abort,
102+
};
103+
}

0 commit comments

Comments
 (0)