Skip to content
Merged
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,24 @@ NUXT_PUBLIC_SITE_URL=http://localhost:3000

# Display name for the SSO button (default: "SSO")
# OIDC_PROVIDER_NAME=Company SSO

# ─── Optional: Social Sign-In (Google, GitHub, Microsoft) ────────────────────
# Enable social login buttons on the sign-in and sign-up pages.
# Each provider requires both CLIENT_ID and CLIENT_SECRET to be set.
# When configured, "Continue with <Provider>" buttons appear on the auth pages.

# Google — Create credentials at https://console.cloud.google.com/apis/credentials
# Redirect URI: https://yourdomain.com/api/auth/callback/google
# AUTH_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
# AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-your-google-client-secret

# GitHub — Create an OAuth App at https://github.com/settings/developers
# Redirect URI: https://yourdomain.com/api/auth/callback/github
# AUTH_GITHUB_CLIENT_ID=your-github-client-id
# AUTH_GITHUB_CLIENT_SECRET=your-github-client-secret

# Microsoft — Register an app at https://portal.azure.com → App registrations
# Redirect URI: https://yourdomain.com/api/auth/callback/microsoft
# AUTH_MICROSOFT_CLIENT_ID=your-microsoft-client-id
# AUTH_MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret
# AUTH_MICROSOFT_TENANT_ID=common
95 changes: 90 additions & 5 deletions app/pages/auth/sign-in.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,27 @@ const email = ref("");
const password = ref("");
const error = ref("");
const isLoading = ref(false);
const socialLoading = ref<string | null>(null);
const ssoRedirecting = ref(false);
const route = useRoute();
const config = useRuntimeConfig();
const localePath = useLocalePath();
const { track } = useTrack();
const oidcEnabled = computed(() => config.public.oidcEnabled as boolean);

const { data: authProviders } = await useFetch('/api/auth/providers');
const oidcEnabled = computed(() => authProviders.value?.oidc ?? false);
const oidcProviderName = computed(
() => (config.public.oidcProviderName as string) || "SSO",
() => authProviders.value?.oidcProviderName || "SSO",
);

const socialProviders = computed(() => {
const providers: { id: string; name: string }[] = [];
if (authProviders.value?.google) providers.push({ id: "google", name: "Google" });
if (authProviders.value?.github) providers.push({ id: "github", name: "GitHub" });
if (authProviders.value?.microsoft) providers.push({ id: "microsoft", name: "Microsoft" });
return providers;
});

onMounted(() => track("signin_page_viewed"));

if (route.query.live === "1") {
Expand Down Expand Up @@ -163,6 +174,31 @@ async function handleEnterpriseSso() {
ssoRedirecting.value = false;
}
}

/**
* Social sign-in — Google, GitHub, Microsoft.
* Uses better-auth's built-in signIn.social() which handles the full OAuth redirect flow.
*/
async function handleSocialSignIn(providerId: string) {
socialLoading.value = providerId;
error.value = "";
const pendingInvitation = route.query.invitation as string | undefined;
const callbackURL = pendingInvitation
? localePath(`/auth/accept-invitation/${pendingInvitation}`)
: localePath("/dashboard");
try {
await authClient.signIn.social({
provider: providerId as "google" | "github" | "microsoft",
callbackURL,
});
} catch (e: unknown) {
error.value =
e instanceof Error
? e.message
: "Social sign-in failed. Please try again.";
socialLoading.value = null;
}
}
</script>

<template>
Expand All @@ -180,12 +216,61 @@ async function handleEnterpriseSso() {
{{ error }}
</div>

<!-- Social sign-in providers (Google, GitHub, Microsoft) -->
<template v-if="socialProviders.length">
<div class="flex flex-col gap-2.5">
<button
v-for="provider in socialProviders"
:key="provider.id"
type="button"
:disabled="!!socialLoading || isLoading || ssoRedirecting"
class="relative px-4 py-2.5 rounded-lg text-sm font-medium shadow-sm transition-all flex items-center justify-center gap-3 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-800 dark:text-surface-200 hover:bg-surface-50 dark:hover:bg-surface-700 hover:border-surface-300 dark:hover:border-surface-600"
@click="handleSocialSignIn(provider.id)"
>
<template v-if="socialLoading === provider.id">
<svg class="animate-spin size-4 text-surface-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /></svg>
Redirecting…
</template>
<template v-else>
<!-- Google icon -->
<svg v-if="provider.id === 'google'" class="size-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
<!-- GitHub icon -->
<svg v-else-if="provider.id === 'github'" class="size-5 text-surface-900 dark:text-surface-100" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<!-- Microsoft icon -->
<svg v-else-if="provider.id === 'microsoft'" class="size-5" viewBox="0 0 23 23" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="10" height="10" fill="#F25022"/>
<rect x="12" y="1" width="10" height="10" fill="#7FBA00"/>
<rect x="1" y="12" width="10" height="10" fill="#00A4EF"/>
<rect x="12" y="12" width="10" height="10" fill="#FFB900"/>
</svg>
Continue with {{ provider.name }}
</template>
</button>
</div>

<div v-if="!oidcEnabled" class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-surface-200 dark:border-surface-700" />
</div>
<div class="relative flex justify-center text-xs">
<span class="bg-white dark:bg-surface-900 px-2 text-surface-400">or continue with email</span>
</div>
</div>
</template>

<!-- Self-hosted OIDC SSO — only shown when global OIDC is configured via environment variables -->
<template v-if="oidcEnabled">
<button
type="button"
:disabled="isLoading"
class="px-4 py-2.5 bg-surface-900 dark:bg-white text-white dark:text-surface-900 rounded-lg text-sm font-semibold shadow-md hover:bg-surface-800 dark:hover:bg-surface-100 disabled:opacity-60 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2.5 ring-1 ring-surface-700 dark:ring-surface-300"
class="px-4 py-2.5 bg-surface-900 dark:bg-white text-white dark:text-surface-900 rounded-lg text-sm font-semibold shadow-md cursor-pointer hover:bg-surface-800 dark:hover:bg-surface-100 disabled:opacity-60 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2.5 ring-1 ring-surface-700 dark:ring-surface-300"
@click="handleSelfHostedSso"
>
<template v-if="isLoading">Redirecting…</template>
Expand Down Expand Up @@ -240,7 +325,7 @@ async function handleEnterpriseSso() {
<button
type="submit"
:disabled="isLoading"
class="mt-2 px-4 py-2.5 bg-brand-600 text-white rounded-md text-sm font-medium hover:bg-brand-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
class="mt-2 px-4 py-2.5 bg-brand-600 text-white rounded-md text-sm font-medium cursor-pointer hover:bg-brand-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
>
{{ isLoading ? "Signing in…" : "Sign in" }}
</button>
Expand All @@ -264,7 +349,7 @@ async function handleEnterpriseSso() {
<button
type="button"
:disabled="ssoRedirecting"
class="px-4 py-2.5 bg-surface-900 dark:bg-white text-white dark:text-surface-900 rounded-lg text-sm font-semibold shadow-md hover:bg-surface-800 dark:hover:bg-surface-100 disabled:opacity-60 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2.5 ring-1 ring-surface-700 dark:ring-surface-300"
class="px-4 py-2.5 bg-surface-900 dark:bg-white text-white dark:text-surface-900 rounded-lg text-sm font-semibold shadow-md cursor-pointer hover:bg-surface-800 dark:hover:bg-surface-100 disabled:opacity-60 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2.5 ring-1 ring-surface-700 dark:ring-surface-300"
@click="handleEnterpriseSso"
>
<ShieldCheck class="size-4" />
Expand Down
93 changes: 88 additions & 5 deletions app/pages/auth/sign-up.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,20 @@ const error = ref("");
const isLoading = ref(false);
const localePath = useLocalePath();
const { track } = useTrack();
const config = useRuntimeConfig();
const oidcEnabled = computed(() => config.public.oidcEnabled as boolean);
const { data: authProviders } = await useFetch('/api/auth/providers');
const oidcEnabled = computed(() => authProviders.value?.oidc ?? false);
const oidcProviderName = computed(
() => (config.public.oidcProviderName as string) || "SSO",
() => authProviders.value?.oidcProviderName || "SSO",
);
const socialLoading = ref<string | null>(null);

const socialProviders = computed(() => {
const providers: { id: string; name: string }[] = [];
if (authProviders.value?.google) providers.push({ id: "google", name: "Google" });
if (authProviders.value?.github) providers.push({ id: "github", name: "GitHub" });
if (authProviders.value?.microsoft) providers.push({ id: "microsoft", name: "Microsoft" });
return providers;
});

onMounted(() => track("signup_page_viewed"));

Expand Down Expand Up @@ -108,6 +117,31 @@ async function handleSsoSignUp() {
isLoading.value = false;
}
}

/**
* Social sign-up — Google, GitHub, Microsoft.
* Uses better-auth's built-in signIn.social() which handles the full OAuth redirect flow.
* New users are auto-registered on first social login.
*/
async function handleSocialSignUp(providerId: string) {
socialLoading.value = providerId;
error.value = "";
const callbackURL = pendingInvitation.value
? localePath(`/auth/accept-invitation/${pendingInvitation.value}`)
: localePath("/onboarding/create-org");
try {
await authClient.signIn.social({
provider: providerId as "google" | "github" | "microsoft",
callbackURL,
});
} catch (e: unknown) {
error.value =
e instanceof Error
? e.message
: "Social sign-up failed. Please try again.";
socialLoading.value = null;
}
}
</script>

<template>
Expand All @@ -125,12 +159,61 @@ async function handleSsoSignUp() {
{{ error }}
</div>

<!-- Social sign-up providers (Google, GitHub, Microsoft) -->
<template v-if="socialProviders.length">
<div class="flex flex-col gap-2.5">
<button
v-for="provider in socialProviders"
:key="provider.id"
type="button"
:disabled="!!socialLoading || isLoading"
class="relative px-4 py-2.5 rounded-lg text-sm font-medium shadow-sm transition-all flex items-center justify-center gap-3 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-800 dark:text-surface-200 hover:bg-surface-50 dark:hover:bg-surface-700 hover:border-surface-300 dark:hover:border-surface-600"
@click="handleSocialSignUp(provider.id)"
>
<template v-if="socialLoading === provider.id">
<svg class="animate-spin size-4 text-surface-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /></svg>
Redirecting…
</template>
<template v-else>
<!-- Google icon -->
<svg v-if="provider.id === 'google'" class="size-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
<!-- GitHub icon -->
<svg v-else-if="provider.id === 'github'" class="size-5 text-surface-900 dark:text-surface-100" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<!-- Microsoft icon -->
<svg v-else-if="provider.id === 'microsoft'" class="size-5" viewBox="0 0 23 23" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="10" height="10" fill="#F25022"/>
<rect x="12" y="1" width="10" height="10" fill="#7FBA00"/>
<rect x="1" y="12" width="10" height="10" fill="#00A4EF"/>
<rect x="12" y="12" width="10" height="10" fill="#FFB900"/>
</svg>
Continue with {{ provider.name }}
</template>
</button>
</div>

<div v-if="!oidcEnabled" class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-surface-200 dark:border-surface-700" />
</div>
<div class="relative flex justify-center text-xs">
<span class="bg-white dark:bg-surface-900 px-2 text-surface-400">or continue with email</span>
</div>
</div>
</template>

<!-- SSO sign-up — only shown when OIDC is configured via environment variables -->
<template v-if="oidcEnabled">
<button
type="button"
:disabled="isLoading"
class="px-4 py-2.5 bg-surface-800 dark:bg-surface-200 text-white dark:text-surface-900 rounded-md text-sm font-medium hover:bg-surface-900 dark:hover:bg-surface-300 disabled:opacity-60 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
class="px-4 py-2.5 bg-surface-800 dark:bg-surface-200 text-white dark:text-surface-900 rounded-md text-sm font-medium cursor-pointer hover:bg-surface-900 dark:hover:bg-surface-300 disabled:opacity-60 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
@click="handleSsoSignUp"
>
<template v-if="isLoading">Redirecting…</template>
Expand Down Expand Up @@ -211,7 +294,7 @@ async function handleSsoSignUp() {
<button
type="submit"
:disabled="isLoading"
class="mt-2 px-4 py-2.5 bg-brand-600 text-white rounded-md text-sm font-medium hover:bg-brand-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
class="mt-2 px-4 py-2.5 bg-brand-600 text-white rounded-md text-sm font-medium cursor-pointer hover:bg-brand-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
>
{{ isLoading ? "Creating account…" : "Sign up" }}
</button>
Expand Down
15 changes: 15 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,21 @@ export default defineNuxtConfig({
),
/** Display name for the SSO provider button */
oidcProviderName: process.env.OIDC_PROVIDER_NAME || "SSO",
/** Whether Google social sign-in is enabled */
authGoogleEnabled: !!(
process.env.AUTH_GOOGLE_CLIENT_ID &&
process.env.AUTH_GOOGLE_CLIENT_SECRET
),
/** Whether GitHub social sign-in is enabled */
authGithubEnabled: !!(
process.env.AUTH_GITHUB_CLIENT_ID &&
process.env.AUTH_GITHUB_CLIENT_SECRET
),
/** Whether Microsoft social sign-in is enabled */
authMicrosoftEnabled: !!(
process.env.AUTH_MICROSOFT_CLIENT_ID &&
process.env.AUTH_MICROSOFT_CLIENT_SECRET
),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Nuxt 4, if runtimeConfig.public values are computed from process.env.AUTH_* inside nuxt.config.ts, are those values fixed at config/build evaluation unless overridden by NUXT_PUBLIC_* runtime env vars?

💡 Result:

Yes, in Nuxt 4, if runtimeConfig.public values are computed from process.env.AUTH_* (e.g., process.env.AUTH_SECRET) inside nuxt.config.ts, those values are fixed at build/config evaluation time. They can only be overridden at runtime by the corresponding NUXT_PUBLIC_* environment variables (e.g., NUXT_PUBLIC_AUTH_SECRET for public.runtimeConfig.authSecret). The official Nuxt documentation explicitly states: "Setting the default of runtimeConfig values to differently named environment variables (for example setting myVar to process.env.OTHER_VARIABLE) will only work during build-time and will break on runtime." Runtime overrides only work with matching NUXT_ prefixed variables that follow the runtimeConfig structure.

Citations:


🏁 Script executed:

cat -n nuxt.config.ts | sed -n '220,245p'

Repository: reqcore-inc/reqcore

Length of output: 1072


🏁 Script executed:

find . -name "auth.ts" -type f | head -20

Repository: reqcore-inc/reqcore

Length of output: 143


🏁 Script executed:

cat -n server/utils/auth.ts

Repository: reqcore-inc/reqcore

Length of output: 13558


Public social flags are fixed at build time while server-side providers are evaluated at runtime, causing UI/backend mismatch in runtime-only deployments.

Lines 225-239 compute authGoogleEnabled, authGithubEnabled, and authMicrosoftEnabled from process.env.AUTH_* inside nuxt.config.ts. In Nuxt 4, these build-time values are frozen into runtimeConfig.public and cannot be changed at runtime unless overridden by corresponding NUXT_PUBLIC_* environment variables.

However, server/utils/auth.ts (lines 167–193) evaluates the same AUTH_* variables at runtime when getAuth() is first called. This creates a critical mismatch:

  • Scenario A: If AUTH_* vars are not set at build time but injected at runtime (common in Railway and similar platforms), the UI flags remain false while the server can enable providers — users won't see the buttons despite functional backends.
  • Scenario B: If AUTH_* vars are set at build time but removed at runtime, the UI shows buttons for disabled providers — users encounter auth failures.

Fix: Use NUXT_PUBLIC_AUTH_* environment variables for the runtimeConfig.public flags, or move the flag computation logic to a runtime-evaluated utility that reads directly from env at request time.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nuxt.config.ts` around lines 225 - 239, The public auth feature flags
(authGoogleEnabled, authGithubEnabled, authMicrosoftEnabled) in nuxt.config.ts
are being computed from process.env at build time and frozen into
runtimeConfig.public, causing UI/backend mismatches versus the runtime
evaluation in server/utils/auth.ts's getAuth(); change those public flags to
read from NUXT_PUBLIC_AUTH_* environment variables
(NUXT_PUBLIC_AUTH_GOOGLE_CLIENT_ID / SECRET etc.) so they can be overridden at
runtime, or alternatively move the flag computation out of nuxt.config.ts into a
runtime-evaluated utility used by both the UI and server (keeping getAuth() as
the single source of truth) to ensure consistent runtime behavior.

},
},

Expand Down
Loading
Loading