Files
Oxicloud/frontend/src/lib/api/endpoints/auth.ts
T
2026-07-14 11:10:23 +02:00

269 lines
9.6 KiB
TypeScript

/**
* Auth endpoints. The 401-refresh/dedup behaviour lives in apiFetch; the auth
* primitives here intentionally bypass it (see client.ts) so a 401 surfaces as
* a genuine failure to the caller.
*/
import { ApiError, apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { AuthResponse, User } from '$lib/api/types';
/**
* Best-effort parse of the backend `ErrorResponse` shape
* (`{ status, error, message, error_type }`). Returns whatever it could
* extract; never throws — a malformed body just yields undefineds.
*/
async function parseErrorBody(res: Response): Promise<{ errorType?: string; message?: string }> {
try {
const body = (await res.clone().json()) as {
error_type?: unknown;
message?: unknown;
error?: unknown;
};
const errorType = typeof body.error_type === 'string' ? body.error_type : undefined;
const rawMessage =
(typeof body.message === 'string' ? body.message : undefined) ??
(typeof body.error === 'string' ? body.error : undefined);
return { errorType, message: rawMessage };
} catch {
return {};
}
}
const JSON_HEADERS = { 'Content-Type': 'application/json' };
/**
* Probe the current session. Uses the raw `fetch` (NOT apiFetch) on purpose:
* a 401 here just means "not logged in" and must not trigger the global
* refresh-and-redirect (which would bounce the app in a refresh loop on the
* unauthenticated initial load). Returns null when unauthenticated.
*/
export async function fetchMe(): Promise<User | null> {
const res = await fetch('/api/auth/me', { credentials: 'same-origin' });
if (res.status === 401) return null;
if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`);
return (await res.json()) as User;
}
/**
* Attempt a single token refresh (raw fetch, no interceptor). Returns whether
* it succeeded. Used by the startup probe; mid-session refresh is handled
* transparently by apiFetch for all other endpoints.
*/
export async function tryRefresh(): Promise<boolean> {
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: '{}'
});
return res.ok;
} catch {
return false;
}
}
export async function login(emailOrUsername: string, password: string): Promise<AuthResponse> {
const res = await apiFetch('/api/auth/login', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ username: emailOrUsername, password })
});
if (!res.ok) {
// Surface the backend `error_type` so the login page can offer
// specific UX: `EmailNotVerified` → "resend verification link",
// `PasswordLoginDisabled` → nudge toward magic-link / SSO, etc.
const { errorType, message } = await parseErrorBody(res);
throw new ApiError(res.status, res.statusText, '/api/auth/login', errorType, message);
}
return (await res.json()) as AuthResponse;
}
export interface OidcProviders {
enabled: boolean;
provider_name?: string;
password_login_enabled?: boolean;
/**
* True when the server accepts magic-link login requests. The backend
* composes three factors: SMTP wired, `OXICLOUD_AUTH_METHODS` allowlist
* includes `magic_link`, and OIDC is NOT enabled at the deployment
* (OIDC-enabled deployments must not offer magic-link — it would bypass
* any 2FA / step-up the IdP enforces).
*/
magic_link_login_enabled?: boolean;
/**
* True when `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set. The login page
* uses this to explain the `EmailNotVerified` login response and
* surface a "resend verification link" affordance.
*/
require_verified_email?: boolean;
authorize_endpoint?: string;
}
/** Public OIDC provider info for the login page. */
export async function getOidcProviders(): Promise<OidcProviders> {
try {
const res = await fetch('/api/auth/oidc/providers');
if (!res.ok) return { enabled: false };
return (await res.json()) as OidcProviders;
} catch {
return { enabled: false };
}
}
export interface AuthStatus {
initialized: boolean;
admin_count: number;
registration_allowed: boolean;
}
/**
* System bootstrap probe. When `initialized === false` no admin exists yet and
* the login page must offer the first-run admin-setup flow. Raw `fetch` (NOT
* apiFetch): this is unauthenticated and a non-2xx must not bounce through the
* refresh interceptor. Defaults to "initialized" on any failure so a transient
* error never strands operators on the setup wizard.
*/
export async function getAuthStatus(): Promise<AuthStatus> {
try {
const res = await fetch('/api/auth/status', { credentials: 'same-origin' });
if (!res.ok) return { initialized: true, admin_count: 1, registration_allowed: true };
return (await res.json()) as AuthStatus;
} catch {
return { initialized: true, admin_count: 1, registration_allowed: true };
}
}
/**
* First-run admin bootstrap. POSTs to `/api/setup`, which creates the admin
* user and marks the system initialized. Raw `fetch` (NOT apiFetch) so a 401
* surfaces as a genuine failure instead of triggering the refresh-and-redirect.
*/
export async function setupAdmin(email: string, password: string): Promise<void> {
const res = await fetch('/api/setup', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ username: 'admin', email, password })
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
throw new Error(e.error || e.message || `setup failed: ${res.status}`);
}
}
/**
* OIDC code-exchange fallback. When the IdP round-trip lands back on the login
* page with `?oidc_code=`, exchange it for a session (cookies are set
* server-side). Raw `fetch` (NOT apiFetch) — a 401 here is a genuine exchange
* failure, not an expired access token. Returns the user on success, null on
* any failure so the caller can fall through to the normal login UI.
*/
export async function exchangeOidcCode(code: string): Promise<User | null> {
try {
const res = await fetch('/api/auth/oidc/exchange', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ code })
});
if (!res.ok) return null;
const data = (await res.json()) as { user?: User };
return data.user ?? null;
} catch {
return null;
}
}
/**
* Register a new user. Since PR 18 both `username` and `password` are optional
* on the backend: an email-only signup is valid and mints a welcome magic-link.
* Raw `fetch` (NOT apiFetch) so a 401/validation failure surfaces to the caller
* instead of tripping the global refresh-and-redirect interceptor — mirrors
* the login primitive.
*/
export async function register(email: string, password?: string, username?: string): Promise<void> {
const body: Record<string, unknown> = { email, role: 'user' };
if (password) body.password = password;
if (username) body.username = username;
const res = await fetch('/api/auth/register', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify(body)
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
throw new Error(e.error || e.message || `register failed: ${res.status}`);
}
}
/**
* Convert the authenticated external user into a full internal account.
* Server flips `is_external` to false, provisions a personal drive via
* the lifecycle hook, and returns the updated `User`.
*
* Password is optional — see backend `UpgradeToInternalDto`:
* * If the deployment offers magic-link login, blank password is
* accepted (user remains magic-link-only after upgrade).
* * Otherwise a password is required — the backend refuses with 400
* `error_type = "PasswordRequired"` and the SPA surfaces the
* server message.
*
* Uses `apiFetch` (unlike register/login) because the caller IS
* authenticated; a 401 here IS a genuine "session expired" and the
* refresh interceptor is the right response.
*/
export async function upgradeToInternal(password?: string): Promise<User> {
const body: Record<string, unknown> = {};
if (password) body.password = password;
const res = await apiFetch('/api/auth/upgrade-to-internal', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify(body)
});
if (!res.ok) {
const { errorType, message } = await parseErrorBody(res);
throw new ApiError(
res.status,
res.statusText,
'/api/auth/upgrade-to-internal',
errorType,
message
);
}
return (await res.json()) as User;
}
export type MagicLinkResult = 'sent' | 'unavailable';
/**
* Anti-enumeration sign-in by email. Any 2xx resolves to `sent` with a uniform
* message regardless of whether the email maps to an account. 503 means SMTP
* isn't configured (`unavailable`) — operators need to see that. Other non-2xx
* throw so the caller can show a generic error. Raw `fetch` (NOT apiFetch):
* unauthenticated, must not enter the refresh interceptor.
*/
export async function sendMagicLink(email: string): Promise<MagicLinkResult> {
const res = await fetch('/api/auth/magic-link/send', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ email })
});
if (res.status === 503) return 'unavailable';
if (!res.ok) throw new Error(`magic-link failed: ${res.status}`);
return 'sent';
}
export async function logout(): Promise<void> {
await apiFetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: '{}'
});
}