| 1 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | import * as Schema from "effect/Schema"; |
| 3 | import { providerAuthReturnUrl } from "./providerAuthReturnUrl.ts"; |
| 4 | |
| 5 | export const CodexAuthHandoff = Schema.Struct({ |
| 6 | authorizationUrl: Schema.String.check(Schema.isMaxLength(16_384)), |
| 7 | returnUrl: Schema.String.check(Schema.isMaxLength(4_096)), |
| 8 | environmentId: EnvironmentId, |
| 9 | instanceId: ProviderInstanceId, |
| 10 | flowId: Schema.NonEmptyString.check(Schema.isMaxLength(128)), |
| 11 | }); |
| 12 | export type CodexAuthHandoff = typeof CodexAuthHandoff.Type; |
| 13 | const Delivery = Schema.Struct({ |
| 14 | environmentId: EnvironmentId, |
| 15 | instanceId: ProviderInstanceId, |
| 16 | flowId: Schema.NonEmptyString.check(Schema.isMaxLength(128)), |
| 17 | callbackUrl: Schema.String.check(Schema.isMaxLength(16_384)), |
| 18 | returnHash: Schema.String.check(Schema.isMaxLength(128)), |
| 19 | }); |
| 20 | |
| 21 | const encodeHandoff = Schema.encodeSync(Schema.fromJsonString(CodexAuthHandoff)); |
| 22 | const decodeHandoff = Schema.decodeUnknownSync(Schema.fromJsonString(CodexAuthHandoff)); |
| 23 | const encodeDelivery = Schema.encodeSync(Schema.fromJsonString(Delivery)); |
| 24 | const decodeDelivery = Schema.decodeUnknownSync(Schema.fromJsonString(Delivery)); |
| 25 | |
| 26 | /** The helper only opens OpenAI's authorize endpoint and receives a loopback callback. */ |
| 27 | export function codexAuthorizationRequest(value: string) { |
| 28 | const url = new URL(value); |
| 29 | if ( |
| 30 | value.length > 16_384 || |
| 31 | url.origin !== "https://auth.openai.com" || |
| 32 | url.pathname !== "/api/accounts/authorize" || |
| 33 | url.username || |
| 34 | url.password || |
| 35 | url.hash |
| 36 | ) |
| 37 | throw new Error("Invalid ChatGPT sign-in request."); |
| 38 | const single = (key: string) => { |
| 39 | const values = url.searchParams.getAll(key); |
| 40 | if (values.length !== 1 || !values[0]) throw new Error("Invalid ChatGPT sign-in request."); |
| 41 | return values[0]; |
| 42 | }; |
| 43 | const redirectUri = single("redirect_uri"); |
| 44 | const redirect = new URL(redirectUri); |
| 45 | const state = single("state"); |
| 46 | if ( |
| 47 | !/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}\/auth\/callback$/u.test(redirectUri) || |
| 48 | Number(redirect.port) > 65_535 || |
| 49 | !/^[\w-]{16,128}$/u.test(state) || |
| 50 | single("response_type") !== "code" || |
| 51 | single("code_challenge_method") !== "S256" || |
| 52 | !/^[\w-]{43}$/u.test(single("code_challenge")) || |
| 53 | !/^(dynamic_agent_client|oaiapp_[\w-]+)$/u.test(single("client_id")) |
| 54 | ) |
| 55 | throw new Error("Invalid ChatGPT sign-in request."); |
| 56 | return { authorizationUrl: url.toString(), redirectUri, state }; |
| 57 | } |
| 58 | |
| 59 | /** Validation is shared by the desktop listener and the environment receiving the code. */ |
| 60 | export function codexCallbackUrl(value: string, redirectUri: string, state: string) { |
| 61 | const callback = new URL(value); |
| 62 | const expected = new URL(redirectUri); |
| 63 | const states = callback.searchParams.getAll("state"); |
| 64 | const codes = callback.searchParams.getAll("code"); |
| 65 | const errors = callback.searchParams.getAll("error"); |
| 66 | const clients = callback.searchParams.getAll("client_id"); |
| 67 | if ( |
| 68 | value.length > 16_384 || |
| 69 | callback.origin !== expected.origin || |
| 70 | callback.pathname !== expected.pathname || |
| 71 | callback.username || |
| 72 | callback.password || |
| 73 | callback.hash || |
| 74 | states.length !== 1 || |
| 75 | states[0] !== state || |
| 76 | clients.length > 1 || |
| 77 | (clients.length === 1 && !/^oaiapp_[\w-]+$/u.test(clients[0]!)) || |
| 78 | !( |
| 79 | (codes.length === 1 && Boolean(codes[0]) && errors.length === 0) || |
| 80 | (errors.length === 1 && Boolean(errors[0]) && codes.length === 0) |
| 81 | ) |
| 82 | ) |
| 83 | throw new Error("This redirect URL does not belong to the current sign-in."); |
| 84 | return callback; |
| 85 | } |
| 86 | |
| 87 | export function codexAuthHandoffUrl(input: CodexAuthHandoff, development = false) { |
| 88 | const url = new URL(`${development ? "t3code-dev" : "t3code"}://auth/codex`); |
| 89 | url.searchParams.set("request", encodeHandoff(input)); |
| 90 | return url.toString(); |
| 91 | } |
| 92 | |
| 93 | export function readCodexAuthHandoff(value: string, development: boolean) { |
| 94 | try { |
| 95 | const url = new URL(value); |
| 96 | if ( |
| 97 | value.length > 32_768 || |
| 98 | url.protocol !== (development ? "t3code-dev:" : "t3code:") || |
| 99 | url.host !== "auth" || |
| 100 | url.pathname !== "/codex" || |
| 101 | url.username || |
| 102 | url.password || |
| 103 | url.hash || |
| 104 | url.searchParams.getAll("request").length !== 1 |
| 105 | ) |
| 106 | return undefined; |
| 107 | const input = decodeHandoff(url.searchParams.get("request")); |
| 108 | codexAuthorizationRequest(input.authorizationUrl); |
| 109 | if (!providerAuthReturnUrl(input.returnUrl)) return undefined; |
| 110 | return input; |
| 111 | } catch { |
| 112 | return undefined; |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /** Codes travel in a fragment, never in hosted web requests or a token store on the helper. */ |
| 117 | export function codexAuthDeliveryUrl(input: CodexAuthHandoff, callbackUrl: string) { |
| 118 | const request = codexAuthorizationRequest(input.authorizationUrl); |
| 119 | codexCallbackUrl(callbackUrl, request.redirectUri, request.state); |
| 120 | const destination = providerAuthReturnUrl(input.returnUrl); |
| 121 | if (!destination) throw new Error("Invalid T3 Code return address."); |
| 122 | const url = new URL(destination); |
| 123 | const delivery = { |
| 124 | environmentId: input.environmentId, |
| 125 | instanceId: input.instanceId, |
| 126 | flowId: input.flowId, |
| 127 | callbackUrl, |
| 128 | returnHash: url.hash, |
| 129 | }; |
| 130 | url.hash = `codex-auth=${encodeURIComponent(encodeDelivery(delivery))}`; |
| 131 | return url.toString(); |
| 132 | } |
| 133 | |
| 134 | export function readCodexAuthDelivery(value: string) { |
| 135 | try { |
| 136 | const url = new URL(value); |
| 137 | if (!url.hash.startsWith("#codex-auth=") || url.hash.length > 32_768) return undefined; |
| 138 | const input = decodeDelivery(decodeURIComponent(url.hash.slice("#codex-auth=".length))); |
| 139 | const destination = providerAuthReturnUrl(value); |
| 140 | if (!destination) return undefined; |
| 141 | const returnUrl = new URL(destination); |
| 142 | returnUrl.hash = input.returnHash; |
| 143 | const sanitized = providerAuthReturnUrl(returnUrl.toString()); |
| 144 | if (!sanitized) return undefined; |
| 145 | return { ...input, returnUrl: sanitized }; |
| 146 | } catch { |
| 147 | return undefined; |
| 148 | } |
| 149 | } |