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:
parent
a68e882092
commit
38aa5875d3
11
.env.example
11
.env.example
@ -15,6 +15,17 @@ NEXT_PUBLIC_API_URL=http://localhost:4000
|
|||||||
GITHUB_OAUTH_ID=
|
GITHUB_OAUTH_ID=
|
||||||
GITHUB_OAUTH_SECRET=
|
GITHUB_OAUTH_SECRET=
|
||||||
|
|
||||||
|
# ---- Google OAuth (optional — "Continue with Google") ----
|
||||||
|
# Create at https://console.cloud.google.com/apis/credentials
|
||||||
|
# Authorized redirect URI must be: <CONTROL_PLANE_PUBLIC_URL>/v1/auth/google/callback
|
||||||
|
# e.g. dev: http://localhost:4000/v1/auth/google/callback
|
||||||
|
# prod: https://api.buildmymcp.com/v1/auth/google/callback
|
||||||
|
GOOGLE_OAUTH_ID=
|
||||||
|
GOOGLE_OAUTH_SECRET=
|
||||||
|
|
||||||
|
# Public URL of this API, used to build the OAuth redirect URI.
|
||||||
|
CONTROL_PLANE_PUBLIC_URL=http://localhost:4000
|
||||||
|
|
||||||
# ---- Anthropic ----
|
# ---- Anthropic ----
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,8 @@ const Env = z.object({
|
|||||||
ADMIN_EMAIL: z.string().email().optional(),
|
ADMIN_EMAIL: z.string().email().optional(),
|
||||||
ADMIN_PASSWORD: z.string().min(8).optional(),
|
ADMIN_PASSWORD: z.string().min(8).optional(),
|
||||||
ADMIN_NAME: z.string().optional(),
|
ADMIN_NAME: z.string().optional(),
|
||||||
|
GOOGLE_OAUTH_ID: z.string().optional(),
|
||||||
|
GOOGLE_OAUTH_SECRET: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const config = Env.parse({
|
export const config = Env.parse({
|
||||||
@ -31,6 +33,8 @@ export const config = Env.parse({
|
|||||||
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
|
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
|
||||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
|
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
|
||||||
ADMIN_NAME: process.env.ADMIN_NAME,
|
ADMIN_NAME: process.env.ADMIN_NAME,
|
||||||
|
GOOGLE_OAUTH_ID: process.env.GOOGLE_OAUTH_ID,
|
||||||
|
GOOGLE_OAUTH_SECRET: process.env.GOOGLE_OAUTH_SECRET,
|
||||||
});
|
});
|
||||||
|
|
||||||
// INFRA-001: refuse to boot in production with the placeholder encryption key.
|
// INFRA-001: refuse to boot in production with the placeholder encryption key.
|
||||||
|
|||||||
@ -1,16 +1,50 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import crypto from 'node:crypto';
|
||||||
import { z } from 'zod';
|
|
||||||
import {
|
import {
|
||||||
consumeMagicLink,
|
consumeMagicLink,
|
||||||
destroySession,
|
destroySession,
|
||||||
getSession,
|
getSession,
|
||||||
issueMagicLink,
|
issueMagicLink,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
|
upsertOAuthLogin,
|
||||||
} from '@bmm/auth';
|
} from '@bmm/auth';
|
||||||
import { audit } from '../lib/audit.js';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
import { audit } from '../lib/audit.js';
|
||||||
|
|
||||||
const SESSION_COOKIE = 'bmm_session';
|
const SESSION_COOKIE = 'bmm_session';
|
||||||
|
const OAUTH_STATE_COOKIE = 'bmm_oauth_state';
|
||||||
|
|
||||||
|
const GoogleClaims = z.object({
|
||||||
|
iss: z.string(),
|
||||||
|
aud: z.string(),
|
||||||
|
exp: z.number(),
|
||||||
|
email: z.string().email(),
|
||||||
|
email_verified: z.union([z.boolean(), z.string()]).optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode (NOT signature-verify) a Google ID token payload. Signature verification
|
||||||
|
* is unnecessary here because the token is fetched directly from Google's token
|
||||||
|
* endpoint over TLS, authenticated with our client secret — an intermediary-free
|
||||||
|
* channel, per Google's own guidance. We still validate iss / aud / exp / email
|
||||||
|
* below as defense-in-depth.
|
||||||
|
*/
|
||||||
|
function decodeGoogleIdToken(idToken: string): z.infer<typeof GoogleClaims> {
|
||||||
|
const parts = idToken.split('.');
|
||||||
|
if (parts.length !== 3 || !parts[1]) throw new Error('malformed_id_token');
|
||||||
|
const json = Buffer.from(parts[1], 'base64url').toString('utf8');
|
||||||
|
return GoogleClaims.parse(JSON.parse(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleRedirectUri(): string {
|
||||||
|
return `${config.CONTROL_PLANE_PUBLIC_URL}/v1/auth/google/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleConfigured(): boolean {
|
||||||
|
return Boolean(config.GOOGLE_OAUTH_ID && config.GOOGLE_OAUTH_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.post('/v1/auth/magic-link', async (req, reply) => {
|
app.post('/v1/auth/magic-link', async (req, reply) => {
|
||||||
@ -132,4 +166,114 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Which third-party login providers are configured. Lets the UI hide the
|
||||||
|
// Google button when no credentials are set, instead of showing a dead button.
|
||||||
|
app.get('/v1/auth/providers', async (_req, reply) => {
|
||||||
|
return reply.send({ google: googleConfigured() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: hand the browser off to Google's consent screen.
|
||||||
|
app.get('/v1/auth/google', async (_req, reply) => {
|
||||||
|
if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) {
|
||||||
|
return reply.code(503).send({ error: 'google_oauth_not_configured' });
|
||||||
|
}
|
||||||
|
const state = crypto.randomBytes(16).toString('base64url');
|
||||||
|
reply.setCookie(OAUTH_STATE_COOKIE, state, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: config.NODE_ENV === 'production',
|
||||||
|
maxAge: 600,
|
||||||
|
});
|
||||||
|
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
||||||
|
url.searchParams.set('client_id', config.GOOGLE_OAUTH_ID);
|
||||||
|
url.searchParams.set('redirect_uri', googleRedirectUri());
|
||||||
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('scope', 'openid email profile');
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
url.searchParams.set('access_type', 'online');
|
||||||
|
url.searchParams.set('prompt', 'select_account');
|
||||||
|
return reply.redirect(url.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Google redirects back here with an auth code. Exchange it, verify
|
||||||
|
// the ID token, mint a session, drop the user on the dashboard.
|
||||||
|
app.get('/v1/auth/google/callback', async (req, reply) => {
|
||||||
|
const loginUrl = `${config.NEXT_PUBLIC_APP_URL}/login`;
|
||||||
|
const Query = z.object({
|
||||||
|
code: z.string().min(10).optional(),
|
||||||
|
state: z.string().min(8).optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
});
|
||||||
|
const q = Query.safeParse(req.query);
|
||||||
|
const cookieState = req.cookies[OAUTH_STATE_COOKIE];
|
||||||
|
reply.clearCookie(OAUTH_STATE_COOKIE, { path: '/' });
|
||||||
|
|
||||||
|
if (!q.success || q.data.error || !q.data.code || !q.data.state) {
|
||||||
|
return reply.redirect(`${loginUrl}?error=google_failed`);
|
||||||
|
}
|
||||||
|
// CSRF: the state echoed back by Google must match the one we set.
|
||||||
|
// Length-check first — timingSafeEqual throws on a length mismatch.
|
||||||
|
if (
|
||||||
|
!cookieState ||
|
||||||
|
cookieState.length !== q.data.state.length ||
|
||||||
|
!crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(q.data.state))
|
||||||
|
) {
|
||||||
|
return reply.redirect(`${loginUrl}?error=google_state`);
|
||||||
|
}
|
||||||
|
if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) {
|
||||||
|
return reply.redirect(`${loginUrl}?error=google_failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code: q.data.code,
|
||||||
|
client_id: config.GOOGLE_OAUTH_ID,
|
||||||
|
client_secret: config.GOOGLE_OAUTH_SECRET,
|
||||||
|
redirect_uri: googleRedirectUri(),
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!tokenRes.ok) throw new Error(`token_exchange_${tokenRes.status}`);
|
||||||
|
const tokens = (await tokenRes.json()) as { id_token?: string };
|
||||||
|
if (!tokens.id_token) throw new Error('no_id_token');
|
||||||
|
|
||||||
|
const claims = decodeGoogleIdToken(tokens.id_token);
|
||||||
|
if (claims.iss !== 'accounts.google.com' && claims.iss !== 'https://accounts.google.com') {
|
||||||
|
throw new Error('bad_iss');
|
||||||
|
}
|
||||||
|
if (claims.aud !== config.GOOGLE_OAUTH_ID) throw new Error('bad_aud');
|
||||||
|
if (claims.exp * 1000 < Date.now()) throw new Error('token_expired');
|
||||||
|
const verified = claims.email_verified === true || claims.email_verified === 'true';
|
||||||
|
if (!verified) throw new Error('email_unverified');
|
||||||
|
|
||||||
|
const session = await upsertOAuthLogin(
|
||||||
|
{ email: claims.email, name: claims.name ?? null },
|
||||||
|
{ ipAddress: req.ip, userAgent: req.headers['user-agent'] },
|
||||||
|
);
|
||||||
|
reply.setCookie(SESSION_COOKIE, session.sessionToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: config.NODE_ENV === 'production',
|
||||||
|
maxAge: 30 * 24 * 60 * 60,
|
||||||
|
});
|
||||||
|
await audit({
|
||||||
|
orgId: session.orgId,
|
||||||
|
userId: session.userId,
|
||||||
|
action: 'auth.login',
|
||||||
|
resourceType: 'session',
|
||||||
|
metadata: { email: session.email, provider: 'google' },
|
||||||
|
ipAddress: req.ip,
|
||||||
|
});
|
||||||
|
return reply.redirect(`${config.NEXT_PUBLIC_APP_URL}/dashboard`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ err }, 'google oauth callback failed');
|
||||||
|
return reply.redirect(`${loginUrl}?error=google_failed`);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,32 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Input, Label } from '@/components/input';
|
||||||
import { useState } from 'react';
|
|
||||||
import { Logo } from '@/components/logo';
|
import { Logo } from '@/components/logo';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input, Label } from '@/components/input';
|
import { apiFetch, apiUrl } from '@/lib/api';
|
||||||
import { apiFetch } from '@/lib/api';
|
import Link from 'next/link';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const ERROR_COPY: Record<string, string> = {
|
||||||
|
google_failed: 'Google sign-in could not be completed. Please try again.',
|
||||||
|
google_state: 'Google sign-in expired or was interrupted. Please try again.',
|
||||||
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [googleEnabled, setGoogleEnabled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<{ google: boolean }>('/v1/auth/providers')
|
||||||
|
.then((r) => setGoogleEnabled(r.google))
|
||||||
|
.catch(() => setGoogleEnabled(false));
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const err = params.get('error');
|
||||||
|
if (err && ERROR_COPY[err]) setError(ERROR_COPY[err]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -34,11 +50,30 @@ export default function LoginPage() {
|
|||||||
<Logo className="mb-10" />
|
<Logo className="mb-10" />
|
||||||
<h1 className="text-[20px] font-semibold tracking-tight">Sign in to your workspace</h1>
|
<h1 className="text-[20px] font-semibold tracking-tight">Sign in to your workspace</h1>
|
||||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||||
We'll email you a magic link. No password.
|
Continue with Google, or get a magic link by email.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{state !== 'sent' ? (
|
{state !== 'sent' ? (
|
||||||
<form onSubmit={submit} className="mt-7 space-y-3">
|
<>
|
||||||
|
{googleEnabled && (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={apiUrl('/v1/auth/google')}
|
||||||
|
className="mt-7 flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
|
||||||
|
>
|
||||||
|
<GoogleIcon />
|
||||||
|
Continue with Google
|
||||||
|
</a>
|
||||||
|
<div className="my-5 flex items-center gap-3">
|
||||||
|
<span className="h-px flex-1 bg-[--color-border]" />
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||||
|
or
|
||||||
|
</span>
|
||||||
|
<span className="h-px flex-1 bg-[--color-border]" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<form onSubmit={submit} className={googleEnabled ? 'space-y-3' : 'mt-7 space-y-3'}>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -62,6 +97,7 @@ export default function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
{error && <p className="text-[12px] text-[--color-danger]">{error}</p>}
|
{error && <p className="text-[12px] text-[--color-danger]">{error}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="panel mt-7 p-4">
|
<div className="panel mt-7 p-4">
|
||||||
<p className="text-[13px]">
|
<p className="text-[13px]">
|
||||||
@ -82,3 +118,26 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GoogleIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 18 18" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.997 8.997 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 7.294C4.672 5.167 6.656 3.58 9 3.58Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
|
||||||
|
|
||||||
export async function apiFetch<T = unknown>(
|
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
path: string,
|
|
||||||
init: RequestInit = {},
|
|
||||||
): Promise<T> {
|
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
@ -26,6 +23,11 @@ export async function apiFetch<T = unknown>(
|
|||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Absolute API URL for a path — use for full-page navigations (OAuth redirects). */
|
||||||
|
export function apiUrl(path: string): string {
|
||||||
|
return `${API_BASE}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function apiWebSocketURL(path: string): string {
|
export function apiWebSocketURL(path: string): string {
|
||||||
const httpBase = API_BASE;
|
const httpBase = API_BASE;
|
||||||
const wsBase = httpBase.replace(/^http/, 'ws');
|
const wsBase = httpBase.replace(/^http/, 'ws');
|
||||||
|
|||||||
@ -123,6 +123,60 @@ export async function consumeMagicLink(
|
|||||||
return { sessionToken, userId: user.id, orgId: membership.orgId, email: user.email };
|
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 {
|
export interface AuthedUser {
|
||||||
userId: string;
|
userId: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user