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:
- Same provider login (provider_id already known) → return existing user
- Email match → link the new provider to the existing account
- 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.