System Integration

Multi-Provider OAuth with Account Linking: GitHub, Google, and Apple Sign In

TermOnMac supports three OAuth providers: GitHub, Google, and Sign In with Apple. Users can link multiple providers to the same account based on email matching. The implementation is in relay_server/src/api-keys.ts and web-auth.ts.

Unified User Model

Each user has a user_id (32-character hex) that’s independent of any specific provider:

// relay_server/src/api-keys.ts
export interface ProfileRecord {
  user_id: string;
  email?: string;
  name?: string;
  avatar_url?: string;
  providers: { provider: string; provider_id: string }[];
  primary_provider: string;
  primary_provider_id: string;
  created_at: number;
  last_login: number;
  deleted_at?: number;
}

A profile holds a list of (provider, provider_id) pairs. The first one is the primary; additional providers can be linked later.

KV Schema

profile:{user_id}                       → ProfileRecord
user:{provider}:{provider_id}           → user_id          (provider lookup)
email:{lowercased_email}                → user_id          (email index for linking)
apikey:{key}                            → ApiKeyRecord
user_apikey:{user_id}                   → { api_key }
refresh:{token}                         → RefreshTokenRecord
user_refresh:{user_id}                  → { refresh_token }

The user:{provider}:{provider_id} key lets the server look up the user from any provider’s identifier. The email:{lowercased_email} key lets the server detect when a new provider login should be linked to an existing account.

findOrCreateUser Flow

export async function findOrCreateUser(
  kv: KVNamespace,
  provider: string,
  providerId: string,
  email?: string,
  name?: string,
): Promise<{ userId: string; apiKey: string; created: boolean; pendingDelete: boolean }> {
  // 1. Check provider lookup key
  const providerKey = `user:${provider}:${providerId}`;
  let userId = await kv.get(providerKey);

  // 2. Existing user — update last_login and return existing API key
  if (userId) {
    // ... return existing user
  }

  // 3. Account linking: check if another provider already has this email
  if (email) {
    const emailIndexKey = `email:${email.toLowerCase()}`;
    const existingUserId = await kv.get(emailIndexKey);
    if (existingUserId) {
      const profile: ProfileRecord = JSON.parse(profileJson);
      // Link new provider to existing user
      if (!profile.providers.some((p) =>
            p.provider === provider && p.provider_id === providerId)) {
        profile.providers.push({ provider, provider_id: providerId });
      }
      // ...
      return { userId: existingUserId, apiKey, created: false, pendingDelete };
    }
  }

  // 4. Brand new user
  userId = generateUserId();
  // ... create profile, API key, refresh token
}

The lookup priority:

  1. Same provider login (provider_id already known) → return existing user
  2. Email match → link the new provider to the existing account
  3. No match → create a new account

Account Linking by Email

If a user signs in with GitHub using alice@example.com, then later signs in with Google using the same email, the Google provider is linked to the existing GitHub account:

if (existingUserId) {
  if (!profile.providers.some((p) =>
        p.provider === provider && p.provider_id === providerId)) {
    profile.providers.push({ provider, provider_id: providerId });
  }
  profile.last_login = Date.now();
  await kv.put(`profile:${existingUserId}`, JSON.stringify(profile));
  await kv.put(providerKey, existingUserId);
  // ...
}

The same user_id is returned, with both providers now associated with it. The user has one account, accessible from either provider.

Pending Delete Reversal

If a user previously requested account deletion (a deleted_at timestamp is set on the profile and a pending_delete:{userId} key exists), signing back in cancels the deletion:

if (profile.deleted_at) {
  pendingDelete = true;
  delete profile.deleted_at;
  await kv.delete(`pending_delete:${userId}`);
}

The function returns pendingDelete: true so the caller can show a “your account deletion has been cancelled” message.

Email Index Backfill

Users created before the email index was added don’t have an email:{email} entry. The code backfills these on next login:

// Ensure email index exists for account linking (backfill for pre-index users)
const profileEmail = profile.email || email;
if (profileEmail) {
  const emailIndexKey = `email:${profileEmail.toLowerCase()}`;
  const existing = await kv.get(emailIndexKey);
  if (!existing) {
    await kv.put(emailIndexKey, userId);
  }
}

This ensures account linking works for all users, not just those created after the feature was added.