Authentifizierung

Owlat nutzt BetterAuth mit dem Convex-Adapter für Authentifizierung und die Verwaltung von Organisationen (Teams).

Owlat nutzt BetterAuth mit dem Convex-Adapter für Authentifizierung und die Verwaltung von Organisationen (Teams).

Überblick

  • BetterAuth übernimmt Registrierung, Login und Sessions der Nutzer
  • Organization Plugin verwaltet die Team-Mitgliedschaft innerhalb der einen Organisation
  • Session-basierter Kontext löst Nutzer, Rolle und aktive Org aus dem Request auf — es wird kein teamId-Parameter herumgereicht

Eine Organisation pro Instanz. Jede Owlat-Installation beherbergt genau eine Organisation. Die Org wird zur Installationszeit von owlat quickstart gebootstrappt (siehe Setup-CLI & Installer), das den ersten Admin über die Backend-Action POST /seed/admin (apps/api/convex/seedAdmin.ts) erzeugt. Der BetterAuth-Endpoint auth/organization/create ist auf Plugin-Ebene deaktiviert (allowUserToCreateOrganization: false), der Auth-Client re-exportiert organization.create/organization.delete nicht, und eine Laufzeit-Invariante in getBetterAuthSessionWithRole weist jeden Request zurück, dessen Session auf eine Nicht-Singleton-Org zeigt. Mandantenfähiges SaaS-Hosting übernimmt eine separate Control Plane (eine private Codebase), die pro Mandant eine dedizierte Owlat-Instanz bereitstellt.

Architektur

Frontend
useAuth()
sessionuserorgId
useOrganization()
membersinvitationsrole management
BetterAuthConvex
Userauto-created
Sessionauto-created
Organizationauto-created
Memberauto-created
Invitationauto-created

Konfiguration

Server (auth.ts)

apps/api/convex/auth/auth.ts baut die BetterAuth-Instanz aus einem Convex- ActionCtx auf. Die reale Konfiguration verdrahtet vier Plugins und eigene Rollen und stellt sämtliche Auth-E-Mails über den konfigurierten System-E-Mail-Transport zu (internal.systemMail.sendSystemEmail), der zu dem Zustellanbieter routet, der über EMAIL_PROVIDER (oder eine Provider-Route pro Org) gesetzt ist — der eingebaute MTA, Amazon SES, Resend, ein generisches SMTP-Relay oder Mailchimp Transactional. Es gibt keinen impliziten Default: Ist keiner davon konfiguriert, wird Auth-Mail abgelehnt statt über den MTA verschickt.

import { betterAuth } from 'better-auth';
import { organization, oneTimeToken } from 'better-auth/plugins';
import { createAccessControl } from 'better-auth/plugins/access';
import { convex, crossDomain } from '@convex-dev/better-auth/plugins';
import { getOptional } from '../lib/env';

// Custom access control: rename BetterAuth's default 'member' role to 'editor'.
const ac = createAccessControl({ ...defaultStatements });
const owner = ac.newRole({ ...ownerAc.statements });
const admin = ac.newRole({ ...adminAc.statements });
const editor = ac.newRole({ ...memberAc.statements });

export const createAuthOptions = (ctx: ActionCtx) => ({
    database: authComponent.adapter(ctx),
    secret: getOptional('BETTER_AUTH_SECRET'),
    baseURL: getOptional('SITE_URL'),
    emailAndPassword: {
        enabled: true,
        minPasswordLength: 10,
        maxPasswordLength: 128,
        sendResetPassword: async ({ user, token }) => {
            await sendViaMta({ to: user.email, /* …reset email… */ });
        },
    },
    session: {
        expiresIn: 60 * 60 * 24 * 3, // 3-day sessions
        updateAge: 60 * 60 * 12,
        cookieCache: { enabled: true, maxAge: 5 * 60 },
    },
    plugins: [
        // Cookieless cross-origin auth for the Tauri desktop app.
        crossDomain({ siteUrl: getOptional('SITE_URL') }),
        // Short-lived token redeemed by the desktop deep-link handshake.
        oneTimeToken(),
        // Provides /convex/token + /convex/jwks; mints the JWT Convex verifies.
        // The JWT payload carries `activeOrganizationId` so the backend can
        // resolve the active org from the claim without a session-table read.
        convex({ authConfig, jwt: { definePayload: ({ user, session }) => ({
            ...user,
            activeOrganizationId: session.activeOrganizationId ?? null,
        }) } }),
        organization({
            ac,
            roles: { owner, admin, editor },
            // Single-org-per-instance: the one org is bootstrapped by /seed/admin;
            // users cannot create more.
            allowUserToCreateOrganization: false,
            creatorRole: 'owner',
            membershipLimit: 50,
            invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days
            // Invitation emails are sent through the instance MTA.
            sendInvitationEmail: async ({ email, organization: org, inviter, invitation }) => {
                const acceptUrl = `${getOptional('SITE_URL')}/invite/accept?id=${invitation.id}`;
                await sendViaMta({ to: email, /* …invitation email… */ });
            },
        }),
    ],
});
E-Mail wird über den konfigurierten Transport versendet

Wenn der aufgelöste Provider der eingebaute MTA ist (EMAIL_PROVIDER=mta), sendet sendViaMta letztlich einen POST an ${MTA_API_URL}/send mit einem Bearer ${MTA_API_KEY}-Header — derselbe MTA, der Ihre Kampagnen zustellt. Jede andere Art, die der Katalog deklariert, trägt Einladungs-, Passwort-Reset- und E-Mail-Änderungsnachrichten genauso gut, ausgewählt über EMAIL_PROVIDER oder eine Provider-Route.

Client (auth-client.ts)

apps/web/app/lib/auth-client.ts baut den BetterAuth-Vue-Client. Es gibt eine Trennung zwischen Web und Desktop: Der Web-Build proxyt Auth-Requests same-origin (Nitro) über window.location.origin (mit Rückfall auf NUXT_PUBLIC_SITE_URL während des SSR), während der Tauri-Desktop-Build direkt mit der Convex-Site-URL des aktiven Workspace spricht und die Session über den crossDomainClient in einem Header mitführt (persistiert im Schlüsselbund des Betriebssystems).

import { createAuthClient } from 'better-auth/vue';
import { convexClient, crossDomainClient } from '@convex-dev/better-auth/client/plugins';
import { organizationClient } from 'better-auth/client/plugins';

function createWebAuthClient() {
    const siteUrl =
        typeof window !== 'undefined'
            ? window.location.origin
            : (globalThis.process?.env?.NUXT_PUBLIC_SITE_URL ?? 'http://localhost:3000');
    return createAuthClient({
        baseURL: siteUrl,
        plugins: [convexClient(), organizationClient()],
    });
}

// Desktop (Tauri) adds the cross-domain plugin + keychain-backed storage.
export const authClient = isDesktopRuntime()
    ? createAuthClient({
            baseURL: getActiveWorkspace()?.convexSiteUrl ?? 'http://localhost:3211',
            plugins: [convexClient(), organizationClient(), crossDomainClient({ storage: keychainStorage })],
        })
    : createWebAuthClient();

// Auth methods.
export const { signIn, signUp, signOut, useSession, getSession } = authClient;

// Organization methods. `organization.create`/`organization.delete` are
// intentionally NOT re-exported — Owlat is single-org-per-instance.
export const {
    organization: {
        update: updateOrganization,
        getFullOrganization,
        list: listOrganizations,
        setActive: setActiveOrganization,
        checkSlug: checkOrgSlug,
        inviteMember,
        acceptInvitation,
        rejectInvitation,
        cancelInvitation,
        removeMember,
        updateMemberRole,
        getActiveMember,
        listMembers,
        listInvitations,
        leave: leaveOrganization,
    },
    useListOrganizations,
    useActiveOrganization,
} = authClient;

Frontend-Composables

useAuth()

Das zentrale Authentifizierungs-Composable (apps/web/app/composables/useAuth.ts). Es kapselt authClient.useSession() und stellt eine reaktive Session sowie die Auth-Aktionen bereit.

const {
    sessionData, // Full BetterAuth session payload | null
    user, // Current user | null
    currentSession, // session row (carries activeOrganizationId)
    status, // 'pending' | 'authenticated' | 'unauthenticated' | 'error'
    isAuthenticated, // Boolean (status === 'authenticated')
    isPending, // Boolean (status === 'pending')
    error, // Error | null
    activeOrganizationId, // Active org ID from the session
    hasActiveOrganization, // Boolean
    signInWithEmail, // (email, password) => Promise
    signUpWithEmail, // (email, password, name) => Promise
    signOut, // () => Promise
    forgotPassword, // (email) => Promise
    resetPassword, // (newPassword, token) => Promise
    refetch, // force-refresh the session
    waitUntilReady, // await until status leaves 'pending'
} = useAuth();

Die Rolle des Aufrufers kommt nicht aus useAuth() — sie liegt auf useOrganization().currentMemberRole.

useOrganization()

Verwaltung der Organisationsmitgliedschaft (apps/web/app/composables/useOrganization.ts). Unterlegt mit einem gemeinsamen useState, sodass sich alle Aufrufer pro Org-Wechsel einen Satz HTTP-Requests teilen.

const {
    // State
    organization, // active org | null
    organizationId, // active org ID | null
    organizations, // list of orgs the user belongs to
    members, // Ref<OrganizationMember[]>
    invitations, // Ref<OrganizationInvitation[]> (pending only)
    currentMemberRole, // 'owner' | 'admin' | 'editor' | null
    isLoading,
    isLoadingMembers,

    // Permission checks
    canManageMembers, // owner or admin
    isOwner,

    // Actions
    fetchMembers, // ({ force }?) => Promise
    invite, // (email, role, mailbox?) => Promise
    remove, // (memberIdOrEmail) => Promise
    updateRole, // (memberId, role) => Promise
    cancelInvite, // (invitationId) => Promise
    setActive, // (orgId) => Promise
    update, // ({ name?, slug?, logo? }) => Promise
    getFullOrganization, // () => Promise
} = useOrganization();

Das Rollenvokabular der App ist owner | admin | editor. BetterAuth speichert editor intern als seine eingebaute Rolle memberuseOrganization bildet an der Grenze zwischen beiden ab, sodass Sie im App-Code immer editor sehen.

Authentifizierungsablauf

Registrierung

Die öffentliche Registrierung ist ausschließlich auf Einladung möglich. Die Registrierungsseite (apps/web/app/pages/auth/register.vue) zeigt den Hinweis "Registration is disabled", sofern sie nicht über einen Redirect von /invite/accept?id=… erreicht wurde. Es gibt keinen Pfad, der bei der Registrierung eine neue Organisation anlegt. Die erste Organisation wird einmalig während der Installation von POST /seed/admin erzeugt (siehe apps/api/convex/seedAdmin.ts); jeder weitere Nutzer tritt dieser Singleton-Org über den Einladungsablauf als Editor/Admin/Owner bei.

<script setup>
import { api } from '@owlat/api';

const { signUpWithEmail } = useAuth();
const { run: createUserProfile } = useBackendOperation(api.auth.userProfiles.create);
const route = useRoute();

async function register() {
    // 1. Create the BetterAuth user (also refreshes the session).
    const result = await signUpWithEmail(email.value, password.value, name.value);

    // 2. Create the Convex-side user profile (non-blocking; failures are toasted).
    if (result?.user?.id) {
        await createUserProfile({
            authUserId: result.user.id,
            email: email.value,
            name: name.value,
        });
    }

    // 3. Continue to the invite-accept page (or dashboard); BetterAuth attaches
    //    the new user to the singleton org with the invited role.
    await navigateTo((route.query.redirect as string) || '/dashboard');
}
</script>

Login

<script setup>
const { signInWithEmail } = useAuth();

async function login() {
    await signInWithEmail(email.value, password.value);
    await navigateTo('/dashboard');
}
</script>

Geschützte Routen

<!-- In page component -->
<script setup>
definePageMeta({
    middleware: 'auth',
});
</script>

Einladung annehmen

<!-- pages/invite/accept.vue -->
<script setup>
const { isAuthenticated } = useAuth();
const invitationId = useRoute().query.id;

async function acceptInvitation() {
    await authClient.organization.acceptInvitation({
        invitationId,
    });
    await navigateTo('/dashboard');
}
</script>

<template>
    <div v-if="!isAuthenticated">
        Please <NuxtLink to="/auth/login">log in</NuxtLink> to accept this invitation.
    </div>
    <div v-else>
        <UiButton @click="acceptInvitation"> Accept Invitation </UiButton>
    </div>
</template>

Session-Kontext im Backend

Alle Session-Helfer des Backends liegen in apps/api/convex/lib/sessionOrganization.ts. Es gibt kein teamId irgendwo im Session-Kontext des Backends — das Single-Org-Refactoring hat das Konzept der Team-ID entfernt. Daten werden über die aktive Org gescopt, die die Helfer aus dem Claim activeOrganizationId des Convex-JWT lesen (mit Rückfall auf die BetterAuth-Session-Tabelle, solange ältere Tokens noch rotieren). Eine Laufzeit-Invariante (assertSingletonOrgInvariant) bestätigt, dass genau eine Org existiert und dass die aktive Org der Session mit ihr übereinstimmt.

Die meisten öffentlichen Funktionen rufen diese Helfer nie von Hand auf. Sie nutzen die Wrapper authedQuery / authedMutation / authedAction / adminMutation aus apps/api/convex/lib/authedFunctions.ts (siehe Convex-Backend), die die Session-/Rollenauflösung und das Berechtigungs-Gate für Sie ausführen. Greifen Sie zu den Helfern unten, wenn Sie innerhalb eines gewrappten Handlers feinere Kontrolle benötigen.

Session-Helfer

HelferRückgabeEinsatz
getBetterAuthSession(ctx){ userId, activeOrganizationId } | nullWeiches Lesen; null für anonyme Aufrufer
getBetterAuthSessionWithRole(ctx)ergänzt role: OrganizationRole | null; erzwingt die Singleton-Org-InvarianteWenn Sie die Rolle des Aufrufers brauchen
getUserIdFromSession(ctx)string (userId)Nur lesende Queries, die ausschließlich die User-ID benötigen; wirft, wenn nicht authentifiziert
requireAuthenticatedIdentity(ctx)Convex-UserIdentityAnonyme Aufrufer in Mutations/Actions abweisen
getMutationContext(ctx){ userId, role } (MutationSessionContext)Primärer Helfer für session-basierte Mutations; wirft, wenn nicht authentifiziert, keine aktive Org vorhanden oder kein Mitglied
import {
    getUserIdFromSession,
    getMutationContext,
} from './lib/sessionOrganization';

// In a query: just need the user.
export const list = authedQuery({
    handler: async (ctx) => {
        const userId = await getUserIdFromSession(ctx);
        // scope reads to the active org / userId
    },
});

// In a mutation: need the role too. Note there is no teamId.
export const create = authedMutation({
    args: { name: v.string() },
    handler: async (ctx, args) => {
        const { userId, role } = await getMutationContext(ctx);
        requirePermission(hasPermission(role, 'contacts:manage'), 'Only owners/admins can create contacts');
    },
});

Rollenbasierter Zugriff

Rollen

owner
  • Full access
  • Delete team
  • Transfer ownership
admin
  • Full access
  • Manage members
  • Cannot manage owners
editor
  • Create & edit content
  • No team management

Berechtigungsprüfungen

Berechtigungen sind ein typisiertes scope:verb-System, das ebenfalls in lib/sessionOrganization.ts definiert ist (es gibt kein Modul ./lib/permissions, und die alten Helfer isAdminRole/isOwnerRole wurden entfernt). hasPermission(role, permission) bildet eine Rolle auf einen Boolean ab; requirePermission(boolean, message?) ist das Assertion-Gate.

Die Permission-Union:

BerechtigungGewährt an
campaigns:send, campaigns:manage, campaigns:scheduleowner, admin
templates:manage, automations:manageowner, admin
topics:manage, segments:manageowner, admin
media:manage, shareLinks:manage, imports:manageowner, admin
contacts:manageowner, admin
organization:manage, settings:manageowner, admin
chat:manageowner, admin
organization:deletenur owner
emails:test, knowledge:read, chat:participatejedes Mitglied
import {
    hasPermission,
    requirePermission,
    requireAdminContext,
    requireOwnerContext,
    requireOrgPermission,
    getMutationContext,
} from './lib/sessionOrganization';

// Boolean check.
if (hasPermission(role, 'settings:manage')) {
    // ...
}

// Assertion: requirePermission takes a boolean + message (NOT a role).
const { role } = await getMutationContext(ctx);
requirePermission(hasPermission(role, 'settings:manage'), 'Only owners/admins can change settings');

Für häufige Fälle bevorzugen Sie die übergeordneten Gates, die den Kontext holen und in einem Aufruf prüfen:

// Admin (owner/admin) — wraps getMutationContext, mutation-only.
const session = await requireAdminContext(ctx);

// Owner only.
const session = await requireOwnerContext(ctx);

// Any typed permission, works in queries OR mutations (e.g. admin-gated reads).
const session = await requireOrgPermission(ctx, 'settings:manage');

Authentifizierung per API-Key

Öffentliche REST-Endpoints authentifizieren sich mit einem API-Key statt mit einer Session. Die Logik liegt in apps/api/convex/auth/apiAuth.ts. Keys sind mit lm_live_ präfigiert, werden ausschließlich als SHA-256-Hash gespeichert und sind auf 10 Requests pro Sekunde und Key rate-limitiert.

authenticateApiRequest(ctx, request) liefert eine diskriminierte Union zurück — der Diskriminator ist success, und im Erfolgsfall erhalten Sie eine keyId, die scopes des Keys und rateLimit (es gibt kein teamId; Keys sind key-gescopt):

type ApiAuthResponse =
    | { success: true; keyId: Id<'apiKeys'>; scopes: string[]; rateLimit: RateLimitHeaders }
    | { success: false; error: string; status: number; retryAfter?: number };

Der idiomatische Weg, einen Endpoint zu bauen, ist createAuthenticatedHandler: Er authentifiziert den Request, behandelt den CORS-Preflight, injiziert { keyId, scopes, rateLimit } und hängt Rate-Limit-Header an die Antwort. Keys tragen Scopes, die über requireScope(auth, scope) durchgesetzt werden:

import { createAuthenticatedHandler, requireScope } from './auth/apiAuth';

export const handleRequest = createAuthenticatedHandler(async (ctx, request, auth) => {
    // auth.keyId — the validated API key ID
    // auth.scopes — the scopes granted to this key
    // auth.rateLimit — { limit, remaining, reset }
    const denied = requireScope(auth, 'contacts:read', request.headers.get('Origin'));
    if (denied) return denied; // 403 when the key lacks the scope
    return jsonResponse({ ok: true });
});

Wenn Sie authenticateApiRequest direkt aufrufen, verzweigen Sie über success:

const auth = await authenticateApiRequest(ctx, request);
if (!auth.success) {
    return errorResponse(auth.status === 429 ? 'rate_limited' : 'unauthenticated', auth.error);
}
// auth.keyId, auth.rateLimit are now available

API-Keys werden im Dashboard erstellt und verwaltet — siehe API-Keys & Webhooks und die Referenz der Authentifizierungs-API.