import { FEED_BASE_URL, PROFILES_BASE_URL } from "./env"; import type { SessionPerson } from "./session-store"; // Talks to the Minnect profiles host to turn an email + password into the // tokens a session needs. Field-order extraction follows the build contract // (docs spec, "Login"). Never logs or echoes the Minnect error body. /** * A login failure with a message that is safe to show the browser. * The raw Minnect error is never attached, so an unknown email and a wrong * password are indistinguishable to the client (spec requirement). */ export class LoginError extends Error { readonly status: number; readonly publicMessage: string; constructor(status: number, publicMessage: string) { super(publicMessage); this.name = "LoginError"; this.status = status; this.publicMessage = publicMessage; } } const GENERIC_SIGN_IN_ERROR = "Email or password is incorrect."; const FEED_ERROR = "Could not open the feed."; export interface LoginTokens { profileAccessToken: string; profileAccessTokenExpiresAt: number | null; profileRefreshToken: string; feedAccessToken: string; feedAccessTokenExpiresAt: number | null; } /** Profile tokens from a fresh login or a refresh, with the access-token expiry resolved to an epoch. */ export interface ProfileTokens { profileAccessToken: string; profileAccessTokenExpiresAt: number | null; profileRefreshToken: string; } /** The feed access token and its expiry (null when the body does not carry one). */ export interface FeedToken { feedAccessToken: string; feedAccessTokenExpiresAt: number | null; } export interface LoginResult { person: SessionPerson; tokens: LoginTokens; } export interface LoginOptions { /** Injectable fetch, for testing. Defaults to the global fetch. */ fetchImpl?: typeof fetch; } type JsonBody = Record | null; function jsonHeaders(token?: string): Record { const headers: Record = { "Content-Type": "application/json", Accept: "application/json", }; if (token) headers.Authorization = `Bearer ${token}`; return headers; } async function parseJson(res: Response): Promise { try { return (await res.json()) as JsonBody; } catch { return null; } } function asRecord(value: unknown): Record { return value && typeof value === "object" ? (value as Record) : {}; } function asString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } // Profile access/refresh tokens, in the spec's documented field order: // 1. data.accessToken / data.refreshToken // 2. accessToken / refreshToken // expiresIn (seconds) from data.expiresIn, then expiresIn. function readProfileTokens(body: JsonBody): { accessToken?: string; refreshToken?: string; expiresIn?: number; } { const root = asRecord(body); const data = asRecord(root.data); const accessToken = asString(data.accessToken) ?? asString(root.accessToken); const refreshToken = asString(data.refreshToken) ?? asString(root.refreshToken); const rawExpires = data.expiresIn ?? root.expiresIn; const expiresIn = typeof rawExpires === "number" ? rawExpires : undefined; return { accessToken, refreshToken, expiresIn }; } // Feed access token, in the spec's documented field order: // 1. accessToken 2. data.accessToken 3. token 4. data.token function readFeedToken(body: JsonBody): string | undefined { const root = asRecord(body); const data = asRecord(root.data); return ( asString(root.accessToken) ?? asString(data.accessToken) ?? asString(root.token) ?? asString(data.token) ); } // expiresIn (seconds) -> absolute epoch ms, or null when it is not a number. function expiresAtFrom(expiresIn: unknown, now: number): number | null { return typeof expiresIn === "number" ? now + expiresIn * 1000 : null; } /** * Exchange a profile access token for a feed token. * `POST /api/v4/auth/external-services/feed` with body `{}`. * Used by sign-in and by refresh. Throws LoginError(FEED_ERROR) on failure. */ export async function exchangeFeedToken( profileAccessToken: string, options: LoginOptions = {}, ): Promise { const doFetch = options.fetchImpl ?? fetch; const res = await doFetch( `${PROFILES_BASE_URL}/api/v4/auth/external-services/feed`, { method: "POST", headers: jsonHeaders(profileAccessToken), body: JSON.stringify({}), }, ); if (res.status !== 200) { throw new LoginError(502, FEED_ERROR); } const body = await parseJson(res); const feedAccessToken = readFeedToken(body); if (!feedAccessToken) { throw new LoginError(502, FEED_ERROR); } // Feed-token expiry is read opportunistically: the spec does not require the // body to carry it. When it is absent, the feed-call-returns-401 path (the // feed route, a later task) is the backstop for a stale feed token. const root = asRecord(body); const rawExpires = asRecord(root.data).expiresIn ?? root.expiresIn; return { feedAccessToken, feedAccessTokenExpiresAt: expiresAtFrom(rawExpires, Date.now()), }; } /** * Exchange a profile refresh token for a new profile access + refresh token. * `POST /api/v4/auth/refresh-token` with `{ refreshToken }`. The new tokens are * read with the same field order as sign-in. No password is involved. Throws a * plain Error on any failure; the caller treats that as "refresh failed". */ export async function refreshProfileTokens( refreshToken: string, options: LoginOptions = {}, ): Promise { const doFetch = options.fetchImpl ?? fetch; const res = await doFetch( `${PROFILES_BASE_URL}/api/v4/auth/refresh-token`, { method: "POST", headers: jsonHeaders(), body: JSON.stringify({ refreshToken }), }, ); if (res.status !== 200) { throw new Error("Profile token refresh failed."); } const { accessToken, refreshToken: newRefreshToken, expiresIn, } = readProfileTokens(await parseJson(res)); if (!accessToken || !newRefreshToken) { throw new Error("Profile token refresh returned no tokens."); } return { profileAccessToken: accessToken, profileAccessTokenExpiresAt: expiresAtFrom(expiresIn, Date.now()), profileRefreshToken: newRefreshToken, }; } /** * Revoke a profile refresh token on sign-out. * `POST /api/v4/auth/revoke-token` with `{ refreshToken }`. Best-effort: any * error (network or non-200) is swallowed so that sign-out still succeeds. */ export async function revokeToken( refreshToken: string, options: LoginOptions = {}, ): Promise { const doFetch = options.fetchImpl ?? fetch; try { await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/revoke-token`, { method: "POST", headers: jsonHeaders(), body: JSON.stringify({ refreshToken }), }); } catch { // Sign-out still succeeds if revoke fails (spec, "Sign out"). } } /** `GET /api/v1/auth/me` on the feed host. Returns data.id, or null on any failure. */ async function fetchFeedUserId(feedToken: string, doFetch: typeof fetch): Promise { try { const res = await doFetch(`${FEED_BASE_URL}/api/v1/auth/me`, { method: "GET", headers: jsonHeaders(feedToken), }); if (res.status !== 200) return null; const id = asRecord(asRecord(await parseJson(res)).data).id; if (typeof id === "string" && id.length > 0) return id; if (typeof id === "number" && Number.isFinite(id)) return String(id); return null; } catch { return null; } } /** * Runs the three-call sign-in: login -> auth/me -> feed-token exchange. * Returns the person to show and the tokens to store server-side. * Throws LoginError on any failure; it never creates a session itself. */ export async function loginToMinnect( email: string, password: string, options: LoginOptions = {}, ): Promise { const doFetch = options.fetchImpl ?? fetch; // 1. Sign in. const loginRes = await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/login`, { method: "POST", headers: jsonHeaders(), body: JSON.stringify({ email, password }), }); if (loginRes.status !== 200) { // Wrong password or unknown email both land here with one generic message. throw new LoginError(401, GENERIC_SIGN_IN_ERROR); } const loginBody = await parseJson(loginRes); const { accessToken: profileAccessToken, refreshToken: profileRefreshToken, expiresIn, } = readProfileTokens(loginBody); if (!profileAccessToken || !profileRefreshToken) { throw new LoginError(502, GENERIC_SIGN_IN_ERROR); } const profileAccessTokenExpiresAt = expiresAtFrom(expiresIn, Date.now()); // 2. Who the person is. const meRes = await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/me`, { method: "GET", headers: jsonHeaders(profileAccessToken), }); if (meRes.status !== 200) { throw new LoginError(502, GENERIC_SIGN_IN_ERROR); } const meBody = await parseJson(meRes); const user = asRecord(asRecord(meBody).data); const userId = asString(user.userId); const personEmail = asString(user.email); if (!userId || !personEmail) { throw new LoginError(502, GENERIC_SIGN_IN_ERROR); } const name = [user.firstName, user.lastName] .map((p) => (typeof p === "string" ? p.trim() : "")) .filter((p) => p.length > 0) .join(" ") || personEmail; const pictureUrl = asString(user.profilePictureUrl) ?? null; // 3. Feed token exchange. const { feedAccessToken, feedAccessTokenExpiresAt } = await exchangeFeedToken( profileAccessToken, options, ); // 4. The person's id on the feed, used to decide who may delete a post. // Best-effort: without it no delete control is offered. const feedUserId = await fetchFeedUserId(feedAccessToken, doFetch); return { person: { userId, name, email: personEmail, pictureUrl, feedUserId }, tokens: { profileAccessToken, profileAccessTokenExpiresAt, profileRefreshToken, feedAccessToken, feedAccessTokenExpiresAt, }, }; }