269 lines
9.6 KiB
TypeScript
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: '{}'
|
|
});
|
|
}
|