refactor: replace device auth flow with effect

This commit is contained in:
Max Koon
2025-11-17 17:17:54 -05:00
parent 9e11455db1
commit f17daa2c78
9 changed files with 617 additions and 190 deletions

136
apps/tui/src/auth.ts Normal file
View File

@@ -0,0 +1,136 @@
import { Context, Data, Effect, Layer, Schema, Console, Schedule, Match, Ref, Duration } from "effect";
import { FileSystem } from "@effect/platform";
import { config } from "./config";
import { AuthState } from "./schema";
import { authClient } from "@/lib/auth-client";
import type { BetterFetchResponse } from "@better-fetch/fetch";
const CLIENT_ID = "koon-family";
const getFromFromDisk = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const content = yield* fs.readFileString(config.authPath);
return yield* Schema.decode(Schema.parseJson(AuthState))(content);
});
class AuthClientErrorString extends Data.TaggedError("AuthClientErrorString")<{
errorString: string,
}> {};
type ErrorType<E> = { [key in keyof ((E extends Record<string, any> ? E : {
message?: string;
}) & {
status: number;
statusText: string;
})]: ((E extends Record<string, any> ? E : {
message?: string;
}) & {
status: number;
statusText: string;
})[key]; };
class AuthClientError<T> extends Data.TaggedError("AuthClientError")<{
error: T,
}> {};
export class AuthClient extends Context.Tag("AuthClient")<AuthClient, AuthClientImpl>() {};
export interface AuthClientImpl {
use: <T, E>(
fn: (client: typeof authClient) => Promise<BetterFetchResponse<T, E>>,
) => Effect.Effect<T, AuthClientError<ErrorType<E>> | AuthClientErrorString, never>
}
export const make = () =>
Effect.gen(function* () {
return AuthClient.of({
use: (fn) =>
Effect.gen(function* () {
const { data, error } = yield* Effect.tryPromise({
try: () => fn(authClient),
catch: () => new AuthClientErrorString({ errorString: "Bad" }),
});
if (error != null) return yield* Effect.fail(new AuthClientError({ error }));
if (data == null) return yield* Effect.fail(new AuthClientErrorString({ errorString: "No data" }));
return data;
})
})
})
export const layer = () => Layer.scoped(AuthClient, make());
const pollToken = ({ device_code }: { device_code: string }) => Effect.gen(function* () {
const auth = yield* AuthClient;
const intervalRef = yield* Ref.make(5);
const tokenEffect = auth.use(client => {
Console.debug("Fetching");
return client.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code,
client_id: CLIENT_ID,
fetchOptions: { headers: { "user-agent": "CLI" } },
})
}
);
return yield* tokenEffect
.pipe(
Effect.tapError(error =>
error._tag == "AuthClientError" && error.error.error == "slow_down"
? Ref.update(intervalRef, current => {
Console.debug("updating delay to ", current + 5);
return current + 5
})
: Effect.void
),
Effect.retry({
schedule: Schedule.addDelayEffect(
Schedule.recurWhile<Effect.Effect.Error<typeof tokenEffect>>(error =>
error._tag == "AuthClientError" &&
(error.error.error == "authorization_pending" || error.error.error == "slow_down")
),
() => Ref.get(intervalRef).pipe(Effect.map(Duration.seconds))
)
})
);
});
const requestAuth = Effect.gen(function* () {
const auth = yield* AuthClient;
const { device_code, user_code } = yield* auth.use(client => client.device.code({
client_id: CLIENT_ID,
scope: "openid profile email",
}));
console.log(`Please use the code: ${user_code}`);
const { access_token } = yield* pollToken({ device_code });
const sessionData = yield* auth.use(client => client.getSession({
fetchOptions: {
auth: {
type: "Bearer",
token: access_token,
}
}
}));
if (sessionData == null) return yield* Effect.fail("Session was null");
const fs = yield* FileSystem.FileSystem;
yield* fs.writeFileString(config.authPath, JSON.stringify(sessionData));
return sessionData;
});
export const getAuth = Effect.gen(function* () {
return yield* getFromFromDisk.pipe(
Effect.catchAll(() => requestAuth)
);
});