From 7f0ed5ca7396ded69e6625aee24182d76e4db49e Mon Sep 17 00:00:00 2001 From: Meg Stepp Date: Thu, 28 Nov 2024 20:00:51 -0500 Subject: [PATCH] a user can create an account and sign in via Oauth --- apps/dashboard/app/auth/actions.ts | 13 +- .../app/auth/sign-in/oauth-signin.tsx | 10 +- .../app/auth/sign-up/email-signup.tsx | 33 ++--- .../app/auth/sign-up/oauth-signup.tsx | 74 +++++------ .../sso-callback/[[...sso-callback]]/page.tsx | 13 -- .../sso-callback/[[...sso-callback]]/route.ts | 20 +++ apps/dashboard/lib/auth/interface.ts | 53 +++----- apps/dashboard/lib/auth/workos.ts | 124 +++++++++++++++--- apps/dashboard/lib/env.ts | 2 +- apps/dashboard/middleware.ts | 4 +- 10 files changed, 205 insertions(+), 141 deletions(-) delete mode 100644 apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/page.tsx create mode 100644 apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/route.ts diff --git a/apps/dashboard/app/auth/actions.ts b/apps/dashboard/app/auth/actions.ts index 43a2c5da47..c34dd5b338 100644 --- a/apps/dashboard/app/auth/actions.ts +++ b/apps/dashboard/app/auth/actions.ts @@ -3,19 +3,14 @@ import { auth } from "@/lib/auth/index"; import { OAuthStrategy } from "@/lib/auth/interface"; -export async function initiateOAuthSignIn(provider: OAuthStrategy) { +// this just serves as a flyweight between the pure client-side and the pure server-side auth provider. +export async function initiateOAuthSignIn({provider, redirectUrlComplete} : {provider: OAuthStrategy, redirectUrlComplete: string}) { try { - // WorkOS - will redirect internally - // No Auth - - // X Provider - should handle internally, whether its a redirect to an authorization url or returning a Response - const response = auth.signInViaOAuth({ + return await auth.signInViaOAuth({ provider, - redirectUri: '/auth/sso-callback' + redirectUrlComplete }); - // If the provider's implementation returns a Response, use it - return response; - } catch (error) { console.error('OAuth initialization error:', error); return { error: error instanceof Error ? error.message : 'Authentication failed' }; diff --git a/apps/dashboard/app/auth/sign-in/oauth-signin.tsx b/apps/dashboard/app/auth/sign-in/oauth-signin.tsx index e398fd4865..e46fbe7be2 100644 --- a/apps/dashboard/app/auth/sign-in/oauth-signin.tsx +++ b/apps/dashboard/app/auth/sign-in/oauth-signin.tsx @@ -6,22 +6,22 @@ import { OAuthStrategy } from "@/lib/auth/interface"; import * as React from "react"; import { OAuthButton } from "../oauth-button"; import { LastUsed, useLastUsed } from "./last_used"; +import { useSearchParams } from "next/navigation"; import { initiateOAuthSignIn } from "../actions"; export const OAuthSignIn: React.FC = () => { const [isLoading, setIsLoading] = React.useState(null); const [lastUsed, setLastUsed] = useLastUsed(); + const searchParams = useSearchParams(); + const redirectUrlComplete = searchParams?.get("redirect") ?? "/apis"; const oauthSignIn = async (provider: OAuthStrategy) => { try { setIsLoading(provider); setLastUsed(provider); - const result = await initiateOAuthSignIn(provider); - - if (result?.error) { - throw new Error(result.error); - } + const authorizationURL = await initiateOAuthSignIn({provider, redirectUrlComplete}); + window.location.assign(authorizationURL); } catch (err) { console.error(err); diff --git a/apps/dashboard/app/auth/sign-up/email-signup.tsx b/apps/dashboard/app/auth/sign-up/email-signup.tsx index 9f78ea56ed..3d0835a477 100644 --- a/apps/dashboard/app/auth/sign-up/email-signup.tsx +++ b/apps/dashboard/app/auth/sign-up/email-signup.tsx @@ -89,22 +89,23 @@ export const EmailSignUp: React.FC = ({ setError, setVerification }) => { try { setIsLoading(true); - await auth - .signUpViaEmail(email) - .then(() => { - setIsLoading(false); - // set verification to true so we can show the code input - setVerification(true); - }) - .catch((err) => { - setIsLoading(false); - console.log("sign up via email errors", err); - // if (err.errors[0].code === "form_identifier_exists") { - // toast.error("It looks like you have an account. Please use sign in"); - // } else { - // toast.error("We couldn't sign you up. Please try again later"); - // } - }); + // TODO: `auth` is server-side, you have to call it through a server action dummy + // await auth + // .signUpViaEmail(email) + // .then(() => { + // setIsLoading(false); + // // set verification to true so we can show the code input + // setVerification(true); + // }) + // .catch((err: any) => { + // setIsLoading(false); + // console.log("sign up via email errors", err); + // // if (err.errors[0].code === "form_identifier_exists") { + // // toast.error("It looks like you have an account. Please use sign in"); + // // } else { + // // toast.error("We couldn't sign you up. Please try again later"); + // // } + // }); } catch (error) { setIsLoading(false); console.error(error); diff --git a/apps/dashboard/app/auth/sign-up/oauth-signup.tsx b/apps/dashboard/app/auth/sign-up/oauth-signup.tsx index e85f0ba9c8..e02bb51297 100644 --- a/apps/dashboard/app/auth/sign-up/oauth-signup.tsx +++ b/apps/dashboard/app/auth/sign-up/oauth-signup.tsx @@ -2,53 +2,49 @@ import { Loading } from "@/components/dashboard/loading"; import { GitHub, Google } from "@/components/ui/icons"; import { toast } from "@/components/ui/toaster"; -import { useSignUp } from "@clerk/nextjs"; -import type { OAuthStrategy } from "@clerk/types"; +import type { OAuthStrategy } from "@/lib/auth/interface" +import { useRouter } from 'next/navigation'; import * as React from "react"; import { OAuthButton } from "../oauth-button"; +import { initiateOAuthSignIn } from "../actions"; export function OAuthSignUp() { const [isLoading, setIsLoading] = React.useState(null); - - //TODO: check if auth provider is available, otherwise we can't do anything - // const { signUp, isLoaded: signupLoaded } = useSignUp(); + const router = useRouter(); + const redirectUrlComplete = "/new"; const oauthSignIn = async (provider: OAuthStrategy) => { - if (!signupLoaded) { - return null; - } try { setIsLoading(provider); - await signUp.authenticateWithRedirect({ - strategy: provider, - redirectUrl: "/auth/sso-callback", - redirectUrlComplete: "/new", - }); - } catch (cause) { - console.error(cause); - setIsLoading(null); - toast.error("Something went wrong, please try again."); - } - }; + + const authorizationURL = await initiateOAuthSignIn({provider, redirectUrlComplete}); + window.location.assign(authorizationURL); + + } catch (err) { + console.error(err); + setIsLoading(null); + toast.error((err as Error).message); + } + }; - return ( -
- oauthSignIn("oauth_github")}> - {isLoading === "oauth_github" ? ( - - ) : ( - - )} - GitHub - - oauthSignIn("oauth_google")}> - {isLoading === "oauth_google" ? ( - - ) : ( - - )} - Google - -
- ); + return ( +
+ oauthSignIn("github")}> + {isLoading === "github" ? ( + + ) : ( + + )} + GitHub + + oauthSignIn("google")}> + {isLoading === "google" ? ( + + ) : ( + + )} + Google + +
+ ); } diff --git a/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/page.tsx b/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/page.tsx deleted file mode 100644 index c1d3488ca4..0000000000 --- a/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -"use client"; - -import { Loading } from "@/components/dashboard/loading"; -import { AuthenticateWithRedirectCallback } from "@clerk/nextjs"; - -export default function SSOCallback() { - return ( -
- - -
- ); -} diff --git a/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/route.ts b/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/route.ts new file mode 100644 index 0000000000..8204623f4a --- /dev/null +++ b/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth/index' +import { cookies } from 'next/headers'; + +export async function GET(request: NextRequest) { + const authResult = await auth.completeOAuthSignIn(request); + + // Get base URL from request because Next.js wants it + const baseUrl = new URL(request.url).origin; + console.log("base url", baseUrl) + + const response = NextResponse.redirect(new URL(authResult.redirectTo, baseUrl)); + + // Set actual session cookies + for (const cookie of authResult.cookies) { + cookies().set(cookie.name, cookie.value, cookie.options); + } + + return response; +} \ No newline at end of file diff --git a/apps/dashboard/lib/auth/interface.ts b/apps/dashboard/lib/auth/interface.ts index 75a28376a4..61e8f22f57 100644 --- a/apps/dashboard/lib/auth/interface.ts +++ b/apps/dashboard/lib/auth/interface.ts @@ -1,11 +1,12 @@ -import { StringLike } from "@visx/scale"; -import { User } from "@workos-inc/node"; import { NextRequest, NextResponse } from "next/server"; +export const UNKEY_SESSION_COOKIE = "unkey-session"; + export type OAuthStrategy = "google" | "github"; export interface SignInViaOAuthOptions { - redirectUri?: string, + redirectUrl?: string, + redirectUrlComplete: string, provider: OAuthStrategy } @@ -27,8 +28,8 @@ export interface AuthSession { // Default middleware configuration export const DEFAULT_MIDDLEWARE_CONFIG: MiddlewareConfig = { enabled: true, - publicPaths: ['/auth/sign-in', '/auth/sign-up', '/favicon.ico'], - cookieName: 'auth_token', + publicPaths: ['/auth/sign-in', '/auth/sign-up', '/favicon.ico'], // TODO: allow glob matching + cookieName: UNKEY_SESSION_COOKIE, loginPath: '/auth/sign-in' }; @@ -50,7 +51,9 @@ export interface AuthProvider { // sign the user into a different workspace/organisation signIn(orgId?: string): Promise; - signInViaOAuth({}: SignInViaOAuthOptions): Response; + signInViaOAuth({}: SignInViaOAuthOptions): String; + + completeOAuthSignIn(callbackRequest: Request): void; signOut(): Promise; @@ -74,7 +77,9 @@ export abstract class BaseAuthProvider implements AuthProvider { abstract signUpViaEmail(email: string): Promise; - abstract signInViaOAuth({}: SignInViaOAuthOptions): Response; + abstract signInViaOAuth({}: SignInViaOAuthOptions): String; + + abstract completeOAuthSignIn(callbackRequest: Request): void; // sign the user into a different workspace/organisation abstract signIn(orgId?: string): Promise; @@ -134,34 +139,6 @@ export abstract class BaseAuthProvider implements AuthProvider { }; } - protected async handleMiddlewareRequest( - request: NextRequest, - config: MiddlewareConfig - ): Promise { - const { pathname } = request.nextUrl; - - // Add more detailed logging - console.debug('Handle middleware request:', { - pathname, - publicPaths: config.publicPaths, - isPublicPath: this.isPublicPath(pathname, config.publicPaths) - }); - - if (this.isPublicPath(pathname, config.publicPaths)) { - console.debug('Public path detected, proceeding'); - return NextResponse.next(); - } - - console.debug("Validating session"); - const session = await this.validateSession(request, config); - if (!session) { - console.debug("No session found, redirecting to login"); - return this.redirectToLogin(request, config); - } - - return NextResponse.next(); - } - protected isPublicPath(pathname: string, publicPaths: string[]): boolean { const isPublic = publicPaths.some(path => pathname.startsWith(path)); console.debug('Checking public path:', { pathname, publicPaths, isPublic }); @@ -169,10 +146,10 @@ export abstract class BaseAuthProvider implements AuthProvider { } protected async validateSession(request: NextRequest, config: MiddlewareConfig) { - const token = request.cookies.get(config.cookieName)?.value; - if (!token) return null; + const sessionData = request.cookies.get(config.cookieName)?.value; + if (!sessionData) return null; - return this.getSession(token); + return this.getSession(sessionData); } protected redirectToLogin(request: NextRequest, config: MiddlewareConfig): NextResponse { diff --git a/apps/dashboard/lib/auth/workos.ts b/apps/dashboard/lib/auth/workos.ts index fd6702a847..e7fcf55643 100644 --- a/apps/dashboard/lib/auth/workos.ts +++ b/apps/dashboard/lib/auth/workos.ts @@ -1,9 +1,29 @@ -import { type MagicAuth, User, WorkOS } from "@workos-inc/node"; -import { AuthSession, BaseAuthProvider, type SignInViaOAuthOptions } from "./interface"; -import { NextResponse } from "next/server"; +import { type MagicAuth, WorkOS } from "@workos-inc/node"; +import { AuthSession, BaseAuthProvider, UNKEY_SESSION_COOKIE, type SignInViaOAuthOptions } from "./interface"; +import { NextRequest, NextResponse } from "next/server"; import { env } from "@/lib/env"; const SSO_CALLBACK_URI = "/auth/sso-callback"; +const SIGN_IN_REDIRECT = "/apis"; +const SIGN_UP_REDIRECT = "/new"; +const SIGN_IN_URL = "/auth/sign-in"; + +interface OAuthResult { + success: boolean; + error?: any; + redirectTo: string; + cookies: Array<{ + name: string; + value: string; + options: { + secure?: boolean; + httpOnly?: boolean; + sameSite?: 'lax' | 'strict' | 'none'; + path?: string; + }; + }>; +} + export class WorkOSAuthProvider extends BaseAuthProvider { private static instance: WorkOSAuthProvider | null = null; private static provider: WorkOS; @@ -16,7 +36,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider { } WorkOSAuthProvider.clientId = config.clientId; - WorkOSAuthProvider.provider = new WorkOS(config.apiKey); + WorkOSAuthProvider.provider = new WorkOS(config.apiKey, {clientId: config.clientId}); WorkOSAuthProvider.instance = this; } @@ -49,9 +69,9 @@ export class WorkOSAuthProvider extends BaseAuthProvider { return { userId: user.id, - orgId: organizationId || '' + orgId: organizationId || '' // if they are a brand new user and they haven't hit the workspace creation flow, they won't have an orgId }; - + } else { console.debug('Authentication failed:', authResult.reason); return null; @@ -82,6 +102,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider { throw new Error("Method not implemented."); } + // WIP async signUpViaEmail(email: string): Promise { if (!email) { throw new Error("No email address provided."); @@ -97,28 +118,93 @@ export class WorkOSAuthProvider extends BaseAuthProvider { throw new Error("Method not implemented."); } - signInViaOAuth({ - redirectUri = SSO_CALLBACK_URI, // Default value - provider - }: SignInViaOAuthOptions): NextResponse { - try { - // Validate provider + public signInViaOAuth({ + redirectUrl = env().NEXT_PUBLIC_WORKOS_REDIRECT_URI, + provider, + redirectUrlComplete = SIGN_IN_REDIRECT + }: SignInViaOAuthOptions): String { if (!provider) { throw new Error('Provider is required'); } + // add the redirect as state to access it in the callback later + const state = encodeURIComponent(JSON.stringify({ + redirectUrlComplete + })); + const authorizationUrl = WorkOSAuthProvider.provider.userManagement.getAuthorizationUrl({ clientId: WorkOSAuthProvider.clientId, - redirectUri, - provider: provider === "github" ? "GitHubOAuth" : "GoogleOAuth" + redirectUri: redirectUrl, + provider: provider === "github" ? "GitHubOAuth" : "GoogleOAuth", + state }); + + return authorizationUrl; + } - // Redirect to the authorization URL - return NextResponse.redirect(authorizationUrl); + public async completeOAuthSignIn(callbackRequest: NextRequest): Promise { - } catch (error) { - console.error('OAuth initialization error:', error); - throw error; + const searchParams = callbackRequest.nextUrl.searchParams; + const code = searchParams.get('code'); + const state = searchParams.get('state'); + + const redirectUrlComplete = state + ? JSON.parse(decodeURIComponent(state)).redirectUrlComplete + : SIGN_IN_REDIRECT; // state *shouldn't* be null, but just in case + + if (!code) { + return { + success: false, + redirectTo: SIGN_IN_URL, + cookies: [], + error: new Error("No code provided") + }; + } + + try { + const { sealedSession } = await WorkOSAuthProvider.provider.userManagement.authenticateWithCode({ + clientId: WorkOSAuthProvider.clientId, + code, + session: { + sealSession: true, + cookiePassword: env().WORKOS_COOKIE_PASSWORD + } + }); + + // make Typescript happy because it can be undefined + // but only the session property from `authenticateWithCode` is not included + // we always need the session to come back, so if it doesn't, don't set a session cookie + if (!sealedSession) { + throw new Error('No sealed session returned from WorkOS'); + } + + // TODO: make cookies a single object? Originally was setting a user cookie + // but userId/orgId can be accessed from the session by unsealing it + // caveat: can only be unsealed server-side + return { + success: true, + redirectTo: redirectUrlComplete, + cookies: [ + { + name: UNKEY_SESSION_COOKIE, + value: sealedSession, + options: { + secure: true, + httpOnly: true, + } + } + ] + }; + + } + catch (error) { + console.error("Callback failed", error); + return { + success: false, + redirectTo: '/auth/sign-in', + cookies: [], + error + }; } } diff --git a/apps/dashboard/lib/env.ts b/apps/dashboard/lib/env.ts index 7c6bdbefc1..8a5666cba2 100644 --- a/apps/dashboard/lib/env.ts +++ b/apps/dashboard/lib/env.ts @@ -22,7 +22,7 @@ export const env = () => WORKOS_API_KEY: z.string().optional(), WORKOS_CLIENT_ID: z.string().optional(), - NEXT_PUBLIC_WORKOS_REDIRECT_URI: z.string().default("http://localhost:3000/callback"), + NEXT_PUBLIC_WORKOS_REDIRECT_URI: z.string().default("http://localhost:3000/auth/sso-callback"), WORKOS_COOKIE_PASSWORD: z.string().optional(), CLERK_WEBHOOK_SECRET: z.string().optional(), diff --git a/apps/dashboard/middleware.ts b/apps/dashboard/middleware.ts index 1b5dec16cf..2246db9afb 100644 --- a/apps/dashboard/middleware.ts +++ b/apps/dashboard/middleware.ts @@ -24,7 +24,9 @@ export default async function (request: NextRequest, evt: NextFetchEvent) { enabled: isEnabled(), publicPaths: [ '/auth/sign-in', - '/auth/sign-up', + '/auth/sign-up', + '/auth/sso-callback', + '/auth/oauth-sign-in', '/favicon.ico', '/_next',] })(request)