fix(auth): improve oauth flow, fix invite flow (with google), add copy invite link

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-12-30 20:09:25 +01:00
parent c12eb80867
commit c4e815b405
5 changed files with 330 additions and 376 deletions

View File

@@ -1,3 +1,4 @@
import { LogError } from '@/utils/errors';
import { import {
Arctic, Arctic,
type OAuth2Tokens, type OAuth2Tokens,
@@ -7,7 +8,7 @@ import {
google, google,
setSessionTokenCookie, setSessionTokenCookie,
} from '@openpanel/auth'; } from '@openpanel/auth';
import { type User, connectUserToOrganization, db } from '@openpanel/db'; import { type Account, connectUserToOrganization, db } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
@@ -37,66 +38,118 @@ async function getGithubEmail(githubAccessToken: string) {
return email; return email;
} }
export async function githubCallback( // New types and interfaces
req: FastifyRequest<{ type Provider = 'github' | 'google';
Querystring: { interface OAuthUser {
code: string; id: string;
state: string; email: string;
}; firstName: string;
}>, lastName?: string;
reply: FastifyReply, }
) {
const schema = z.object({ // Shared utility functions
code: z.string(), async function handleExistingUser({
state: z.string(), account,
inviteId: z.string().nullish(), oauthUser,
providerName,
reply,
}: {
account: Account;
oauthUser: OAuthUser;
providerName: Provider;
reply: FastifyReply;
}) {
const sessionToken = generateSessionToken();
const session = await createSession(sessionToken, account.userId);
await db.account.update({
where: { id: account.id },
data: {
provider: providerName,
providerId: oauthUser.id,
email: oauthUser.email,
},
}); });
const query = schema.safeParse(req.query); setSessionTokenCookie(
if (!query.success) { (...args) => reply.setCookie(...args),
req.log.error('invalid callback query params', { sessionToken,
error: query.error.message, session.expiresAt,
query: req.query, );
provider: 'github', return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
}); }
return reply.status(400).send(query.error.message);
async function handleNewUser({
oauthUser,
providerName,
inviteId,
reply,
}: {
oauthUser: OAuthUser;
providerName: Provider;
inviteId: string | undefined | null;
reply: FastifyReply;
}) {
const existingUser = await db.user.findFirst({
where: { email: oauthUser.email },
});
if (existingUser) {
throw new LogError(
'Please sign in using your original authentication method',
{
existingUser,
oauthUser,
providerName,
},
);
} }
const { code, state, inviteId } = query.data; const user = await db.user.create({
const storedState = req.cookies.github_oauth_state ?? null; data: {
email: oauthUser.email,
firstName: oauthUser.firstName,
lastName: oauthUser.lastName,
accounts: {
create: {
provider: providerName,
providerId: oauthUser.id,
},
},
},
});
if (code === null || state === null || storedState === null) { if (inviteId) {
req.log.error('missing oauth parameters', { try {
code: code === null, await connectUserToOrganization({ user, inviteId });
state: state === null, } catch (error) {
storedState: storedState === null, reply.log.error('error connecting user to organization', {
provider: 'github', error,
}); inviteId,
return reply.status(400).send('Please restart the process.'); user,
} });
if (state !== storedState) { }
req.log.error('oauth state mismatch', {
state,
storedState,
provider: 'github',
});
return reply.status(400).send('Please restart the process.');
} }
let tokens: OAuth2Tokens; const sessionToken = generateSessionToken();
try { const session = await createSession(sessionToken, user.id);
tokens = await github.validateAuthorizationCode(code); setSessionTokenCookie(
} catch (error) { (...args) => reply.setCookie(...args),
req.log.error('github authorization failed', { sessionToken,
error, session.expiresAt,
provider: 'github', );
}); return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
return reply.status(400).send('Please restart the process.'); }
// Provider-specific user fetching
async function fetchGithubUser(accessToken: string): Promise<OAuthUser> {
const email = await getGithubEmail(accessToken);
if (!email) {
throw new LogError('GitHub email not found or not verified');
} }
const githubAccessToken = tokens.accessToken();
const userRequest = new Request('https://api.github.com/user'); const userRequest = new Request('https://api.github.com/user');
userRequest.headers.set('Authorization', `Bearer ${githubAccessToken}`); userRequest.headers.set('Authorization', `Bearer ${accessToken}`);
const userResponse = await fetch(userRequest); const userResponse = await fetch(userRequest);
const userSchema = z.object({ const userSchema = z.object({
@@ -111,338 +164,197 @@ export async function githubCallback(
const userResult = userSchema.safeParse(userJson); const userResult = userSchema.safeParse(userJson);
if (!userResult.success) { if (!userResult.success) {
req.log.error('user schema error', { throw new LogError('Error fetching Github user', {
error: userResult.error.message, error: userResult.error,
userJson, githubUser: userJson,
provider: 'github',
}); });
return reply.status(400).send(userResult.error.message);
}
const githubUserId = userResult.data.id;
const email = await getGithubEmail(githubAccessToken);
const existingUser = await db.account.findFirst({
where: {
OR: [
{
provider: 'github',
providerId: String(githubUserId),
},
{
provider: 'github',
providerId: null,
email,
},
{
provider: 'oauth',
user: {
email: email ?? '',
},
},
],
},
});
if (existingUser !== null) {
const sessionToken = generateSessionToken();
const session = await createSession(sessionToken, existingUser.userId);
if (existingUser.provider === 'oauth') {
await db.account.update({
where: {
id: existingUser.id,
},
data: {
provider: 'github',
providerId: String(githubUserId),
},
});
} else if (existingUser.provider !== 'github') {
await db.account.create({
data: {
provider: 'github',
providerId: String(githubUserId),
user: {
connect: {
id: existingUser.userId,
},
},
},
});
} else if (existingUser.provider === 'github') {
await db.account.update({
where: {
id: existingUser.id,
},
data: {
providerId: String(githubUserId),
},
});
}
setSessionTokenCookie(
(...args) => reply.setCookie(...args),
sessionToken,
session.expiresAt,
);
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
} }
if (email === null) { return {
req.log.error('github email not found or not verified', { id: String(userResult.data.id),
githubUserId, email,
provider: 'github', firstName: userResult.data.name || userResult.data.login || '',
}); };
return reply.status(400).send('Please verify your GitHub email address.');
}
// (githubUserId, email, username);
const user = await await db.user.create({
data: {
email,
firstName: userResult.data.name || userResult.data.login || '',
accounts: {
create: {
provider: 'github',
providerId: String(githubUserId),
},
},
},
});
if (inviteId) {
try {
await connectUserToOrganization({ user, inviteId });
} catch (error) {
req.log.error(
error instanceof Error
? error.message
: 'Unknown error connecting user to projects',
{
inviteId,
email: user.email,
error,
},
);
}
}
const sessionToken = generateSessionToken();
const session = await createSession(sessionToken, user.id);
setSessionTokenCookie(
(...args) => reply.setCookie(...args),
sessionToken,
session.expiresAt,
);
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
} }
export async function googleCallback( async function fetchGoogleUser(tokens: OAuth2Tokens): Promise<OAuthUser> {
req: FastifyRequest<{ const claims = Arctic.decodeIdToken(tokens.idToken());
Querystring: {
code: string; const claimsSchema = z.object({
state: string; sub: z.string(),
}; email: z.string(),
}>, email_verified: z.boolean(),
reply: FastifyReply, given_name: z.string().optional(),
) { family_name: z.string().optional(),
});
const claimsResult = claimsSchema.safeParse(claims);
if (!claimsResult.success) {
throw new LogError('Error fetching Google user', {
error: claimsResult.error,
claims,
});
}
if (!claimsResult.data.email_verified) {
throw new LogError('Email not verified with Google');
}
return {
id: claimsResult.data.sub,
email: claimsResult.data.email,
firstName: claimsResult.data.given_name || '',
lastName: claimsResult.data.family_name || '',
};
}
interface ValidatedOAuthQuery {
code: string;
state: string;
}
async function validateOAuthCallback(
req: FastifyRequest,
provider: Provider,
): Promise<ValidatedOAuthQuery> {
const schema = z.object({ const schema = z.object({
code: z.string(), code: z.string(),
state: z.string(), state: z.string(),
inviteId: z.string().nullish(),
}); });
const query = schema.safeParse(req.query); const query = schema.safeParse(req.query);
if (!query.success) { if (!query.success) {
req.log.error('invalid callback query params', { throw new LogError('Invalid callback query params', {
error: query.error.message, error: query.error,
query: req.query, query: req.query,
provider: 'google', provider,
}); });
return reply.status(400).send(query.error.message);
} }
const { code, state, inviteId } = query.data; const { code, state } = query.data;
const storedState = req.cookies.google_oauth_state ?? null; const storedState = req.cookies[`${provider}_oauth_state`] ?? null;
const codeVerifier = req.cookies.google_code_verifier ?? null; const codeVerifier =
provider === 'google' ? (req.cookies.google_code_verifier ?? null) : null;
if ( if (
code === null || code === null ||
state === null || state === null ||
storedState === null || storedState === null ||
codeVerifier === null (provider === 'google' && codeVerifier === null)
) { ) {
req.log.error('missing oauth parameters', { throw new LogError('Missing oauth parameters', {
code: code === null, code: code === null,
state: state === null, state: state === null,
storedState: storedState === null, storedState: storedState === null,
codeVerifier: codeVerifier === null, codeVerifier: provider === 'google' ? codeVerifier === null : undefined,
provider: 'google', provider,
}); });
return reply.status(400).send('Please restart the process.');
} }
if (state !== storedState) { if (state !== storedState) {
req.log.error('oauth state mismatch', { throw new LogError('OAuth state mismatch', {
state, state,
storedState, storedState,
provider: 'google', provider,
}); });
return reply.status(400).send('Please restart the process.');
} }
let tokens: OAuth2Tokens; return { code, state };
try { }
tokens = await google.validateAuthorizationCode(code, codeVerifier);
} catch (error) { // Main callback handlers
req.log.error('google authorization failed', { export async function githubCallback(req: FastifyRequest, reply: FastifyReply) {
error, try {
provider: 'google', const { code } = await validateOAuthCallback(req, 'github');
}); const inviteId = req.cookies.inviteId;
return reply.status(400).send('Please restart the process.'); const tokens = await github.validateAuthorizationCode(code);
} const githubUser = await fetchGithubUser(tokens.accessToken());
const account = await db.account.findFirst({
const claims = Arctic.decodeIdToken(tokens.idToken()); where: {
OR: [
const claimsParser = z.object({ // To keep
sub: z.string(), { provider: 'github', providerId: githubUser.id },
given_name: z // During migration
.string() { provider: 'github', providerId: null, email: githubUser.email },
.nullish() { provider: 'oauth', user: { email: githubUser.email } },
.transform((val) => val || ''), ],
family_name: z },
.string() });
.nullish()
.transform((val) => val || ''), reply.clearCookie('github_oauth_state');
picture: z
.string() if (account) {
.nullish() return await handleExistingUser({
.transform((val) => val || ''), account,
email: z.string(), oauthUser: githubUser,
}); providerName: 'github',
reply,
const claimsResult = claimsParser.safeParse(claims); });
if (!claimsResult.success) { }
req.log.error('invalid claims format', {
error: claimsResult.error.message, return await handleNewUser({
claims, oauthUser: githubUser,
provider: 'google', providerName: 'github',
}); inviteId,
return reply.status(400).send(claimsResult.error.message); reply,
} });
} catch (error) {
const { sub: googleId, given_name, family_name, email } = claimsResult.data; req.log.error(error);
return redirectWithError(reply, error);
const existingAccount = await db.account.findFirst({ }
where: { }
OR: [
{ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
provider: 'google', try {
providerId: googleId, const { code } = await validateOAuthCallback(req, 'google');
}, const inviteId = req.cookies.inviteId;
{ const codeVerifier = req.cookies.google_code_verifier!;
provider: 'google', const tokens = await google.validateAuthorizationCode(code, codeVerifier);
providerId: null, const googleUser = await fetchGoogleUser(tokens);
email, const existingUser = await db.account.findFirst({
}, where: {
{ OR: [
provider: 'oauth', // To keep
user: { { provider: 'google', providerId: googleUser.id },
email, // During migration
}, { provider: 'google', providerId: null, email: googleUser.email },
}, { provider: 'oauth', user: { email: googleUser.email } },
], ],
}, },
}); });
if (existingAccount !== null) { reply.clearCookie('google_code_verifier');
const sessionToken = generateSessionToken(); reply.clearCookie('google_oauth_state');
const session = await createSession(sessionToken, existingAccount.userId);
if (existingUser) {
if (existingAccount.provider === 'oauth') { return await handleExistingUser({
await db.account.update({ account: existingUser,
where: { oauthUser: googleUser,
id: existingAccount.id, providerName: 'google',
}, reply,
data: { });
provider: 'google', }
providerId: googleId,
}, return await handleNewUser({
}); oauthUser: googleUser,
} else if (existingAccount.provider !== 'google') { providerName: 'google',
await db.account.create({ inviteId,
data: { reply,
provider: 'google', });
providerId: googleId, } catch (error) {
user: { req.log.error(error);
connect: { return redirectWithError(reply, error);
id: existingAccount.userId, }
}, }
},
}, function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
}); const url = new URL(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
} else if (existingAccount.provider === 'google') { url.pathname = '/login';
await db.account.update({ if (error instanceof LogError) {
where: { url.searchParams.set('error', error.message);
id: existingAccount.id, } else {
}, url.searchParams.set('error', 'An error occurred');
data: { }
providerId: googleId, url.searchParams.set('correlationId', reply.request.id);
}, return reply.redirect(url.toString());
});
}
setSessionTokenCookie(
(...args) => reply.setCookie(...args),
sessionToken,
session.expiresAt,
);
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
}
const user = await db.user.upsert({
where: {
email,
},
update: {
firstName: given_name,
lastName: family_name,
},
create: {
email,
firstName: given_name,
lastName: family_name,
accounts: {
create: {
provider: 'google',
providerId: googleId,
},
},
},
});
if (inviteId) {
try {
await connectUserToOrganization({ user, inviteId });
} catch (error) {
req.log.error(
error instanceof Error
? error.message
: 'Unknown error connecting user to projects',
{
inviteId,
email: user.email,
error,
},
);
}
}
const sessionToken = generateSessionToken();
const session = await createSession(sessionToken, user.id);
setSessionTokenCookie(
(...args) => reply.setCookie(...args),
sessionToken,
session.expiresAt,
);
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
} }

View File

@@ -0,0 +1,15 @@
export class LogError extends Error {
public readonly payload?: Record<string, unknown>;
constructor(
message: string,
payload?: Record<string, unknown>,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'LogError';
this.payload = payload;
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@@ -2,12 +2,20 @@ import { Or } from '@/components/auth/or';
import { SignInEmailForm } from '@/components/auth/sign-in-email-form'; import { SignInEmailForm } from '@/components/auth/sign-in-email-form';
import { SignInGithub } from '@/components/auth/sign-in-github'; import { SignInGithub } from '@/components/auth/sign-in-github';
import { SignInGoogle } from '@/components/auth/sign-in-google'; import { SignInGoogle } from '@/components/auth/sign-in-google';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { LinkButton } from '@/components/ui/button'; import { LinkButton } from '@/components/ui/button';
import { auth } from '@openpanel/auth/nextjs'; import { auth } from '@openpanel/auth/nextjs';
import { AlertCircle } from 'lucide-react';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
export default async function Page() { export default async function Page({
searchParams,
}: {
searchParams: { error?: string; correlationId?: string };
}) {
const session = await auth(); const session = await auth();
const error = searchParams.error;
const correlationId = searchParams.correlationId;
if (session.userId) { if (session.userId) {
return redirect('/'); return redirect('/');
@@ -16,6 +24,29 @@ export default async function Page() {
return ( return (
<div className="flex h-full center-center w-full"> <div className="flex h-full center-center w-full">
<div className="col gap-8 max-w-md w-full"> <div className="col gap-8 max-w-md w-full">
{error && (
<Alert variant="destructive" className="text-left bg-background">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
<p>{error}</p>
{correlationId && (
<>
<p>Correlation ID: {correlationId}</p>
<p className="mt-2">
Contact us if you have any issues.{' '}
<a
className="underline font-medium"
href={`mailto:hello@openpanel.dev?subject=Login%20Issue%20-%20Correlation%20ID%3A%20${correlationId}`}
>
hello[at]openpanel.dev
</a>
</p>
</>
)}
</AlertDescription>
</Alert>
)}
<div className="col md:row gap-4"> <div className="col md:row gap-4">
<SignInGithub type="sign-in" /> <SignInGithub type="sign-in" />
<SignInGoogle type="sign-in" /> <SignInGoogle type="sign-in" />
@@ -27,18 +58,6 @@ export default async function Page() {
<LinkButton variant={'outline'} size="lg" href="/onboarding"> <LinkButton variant={'outline'} size="lg" href="/onboarding">
No account? Sign up today No account? Sign up today
</LinkButton> </LinkButton>
<p className="text-sm text-muted-foreground leading-tight">
Having issues logging in?
<br />
Contact us at{' '}
<a
href="mailto:hello@openpanel.dev"
className="text-primary underline"
>
hello[at]openpanel.dev
</a>
. We're not using Clerk (auth provider) anymore.
</p>
</div> </div>
</div> </div>
); );

View File

@@ -15,12 +15,16 @@ import { pathOr } from 'ramda';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ACTIONS } from '@/components/data-table'; import { ACTIONS } from '@/components/data-table';
import { clipboard } from '@/utils/clipboard';
import type { IServiceInvite, IServiceProject } from '@openpanel/db'; import type { IServiceInvite, IServiceProject } from '@openpanel/db';
export function useColumns( export function useColumns(
projects: IServiceProject[], projects: IServiceProject[],
): ColumnDef<IServiceInvite>[] { ): ColumnDef<IServiceInvite>[] {
return [ return [
{
accessorKey: 'id',
},
{ {
accessorKey: 'email', accessorKey: 'email',
header: 'Mail', header: 'Mail',
@@ -99,6 +103,15 @@ function ActionCell({ row }: { row: Row<IServiceInvite> }) {
<Button icon={MoreHorizontalIcon} size="icon" variant={'outline'} /> <Button icon={MoreHorizontalIcon} size="icon" variant={'outline'} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
clipboard(
`${window.location.origin}/onboarding?inviteId=${row.original.id}`,
);
}}
>
Copy invite link
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-destructive" className="text-destructive"
onClick={() => { onClick={() => {

View File

@@ -42,6 +42,12 @@ export const authRouter = createTRPCRouter({
.mutation(({ input, ctx }) => { .mutation(({ input, ctx }) => {
const { provider } = input; const { provider } = input;
if (input.inviteId) {
ctx.setCookie('inviteId', input.inviteId, {
maxAge: 60 * 10,
});
}
if (provider === 'github') { if (provider === 'github') {
const state = Arctic.generateState(); const state = Arctic.generateState();
const url = github.createAuthorizationURL(state, [ const url = github.createAuthorizationURL(state, [
@@ -49,17 +55,6 @@ export const authRouter = createTRPCRouter({
'user:read', 'user:read',
]); ]);
// if we have an inviteId we want to add it to the redirect url
// so we have this information in the callback url later
if (input.inviteId) {
const redirectUri = url.searchParams.get('redirect_uri');
if (redirectUri) {
const redirectUrl = new URL(redirectUri);
redirectUrl.searchParams.set('inviteId', input.inviteId);
url.searchParams.set('redirect_uri', redirectUrl.toString());
}
}
ctx.setCookie('github_oauth_state', state, { ctx.setCookie('github_oauth_state', state, {
maxAge: 60 * 10, maxAge: 60 * 10,
}); });