313 lines
9.9 KiB
TypeScript
313 lines
9.9 KiB
TypeScript
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<string, unknown> | null;
|
|
|
|
function jsonHeaders(token?: string): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
};
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
return headers;
|
|
}
|
|
|
|
async function parseJson(res: Response): Promise<JsonBody> {
|
|
try {
|
|
return (await res.json()) as JsonBody;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object"
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
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<FeedToken> {
|
|
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<ProfileTokens> {
|
|
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<void> {
|
|
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<string | null> {
|
|
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<LoginResult> {
|
|
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,
|
|
},
|
|
};
|
|
}
|