feat(auth): add "Continue with Google" OAuth 2.0 login
Server-side authorization-code flow: /v1/auth/google redirects to the consent screen with a CSRF state cookie; /v1/auth/google/callback exchanges the code, validates the ID token (iss/aud/exp/email_verified), and mints a 30-day session via upsertOAuthLogin. /v1/auth/providers lets the login UI hide the button until GOOGLE_OAUTH_ID/SECRET are set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,60 @@ export async function consumeMagicLink(
|
||||
return { sessionToken, userId: user.id, orgId: membership.orgId, email: user.email };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get-or-create a user from a verified third-party identity (Google, etc.) and
|
||||
* mint a session. The caller is responsible for verifying the identity provider's
|
||||
* token BEFORE calling this — `email` must already be proven to belong to the user.
|
||||
*/
|
||||
export async function upsertOAuthLogin(
|
||||
input: { email: string; name?: string | null },
|
||||
meta: { ipAddress?: string; userAgent?: string } = {},
|
||||
db: Database = createDb(),
|
||||
): Promise<ConsumedSession> {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
throw new Error('invalid_email');
|
||||
}
|
||||
|
||||
let user = (await db.select().from(users).where(eq(users.email, email)).limit(1))[0];
|
||||
if (!user) {
|
||||
[user] = await db
|
||||
.insert(users)
|
||||
.values({ email, emailVerified: true, name: input.name ?? undefined })
|
||||
.returning();
|
||||
if (!user) throw new Error('user_create_failed');
|
||||
const orgSlug = `${slugify(email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`;
|
||||
const [org] = await db
|
||||
.insert(organizations)
|
||||
.values({ slug: orgSlug, name: `${email.split('@')[0]}'s workspace` })
|
||||
.returning();
|
||||
if (!org) throw new Error('org_create_failed');
|
||||
await db.insert(memberships).values({ orgId: org.id, userId: user.id, role: 'owner' });
|
||||
} else if (!user.emailVerified) {
|
||||
await db.update(users).set({ emailVerified: true }).where(eq(users.id, user.id));
|
||||
}
|
||||
|
||||
const resolved = user;
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(memberships)
|
||||
.where(eq(memberships.userId, resolved.id))
|
||||
.limit(1);
|
||||
if (!membership) throw new Error('no_org_membership');
|
||||
|
||||
const sessionToken = randomToken(32);
|
||||
await db.insert(sessions).values({
|
||||
userId: resolved.id,
|
||||
tokenHash: sha256(sessionToken),
|
||||
expiresAt: new Date(Date.now() + SESSION_TTL_MS),
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, resolved.id));
|
||||
|
||||
return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email };
|
||||
}
|
||||
|
||||
export interface AuthedUser {
|
||||
userId: string;
|
||||
orgId: string;
|
||||
|
||||
Reference in New Issue
Block a user