Skip to content

Authentication Methods ​

Overview ​

Collage Admin supports four sign-in methods, all producing the same JWT session on success:

  1. Password login — email + password, handled on pages/index.vue. Calls the Laravel /login endpoint via useAuth.login(). On success the JWT is written to the auth_token cookie (primary source of truth), mirrored into a reactive useState('auth-token-mem'), and also persisted to localStorage as a legacy fallback for sessions whose cookie never persisted.
  2. Google OAuth — redirects to Google consent screen; the backend callback redirects to pages/social-login.vue with ?access_token=. Available on the primary app domain only (hidden on white-label deployments).
  3. Microsoft OAuth — identical flow to Google. Provider is tagged via sessionStorage (login_provider = 'microsoft') because Microsoft's callback carries no provider param.
  4. Passkey (WebAuthn) — browser-native biometric / security-key ceremony via @simplewebauthn/browser. Available only on the primary domain and when browserSupportsWebAuthn() returns true. The bearer token is passed to /social-login through sessionStorage to keep it out of the URL.

Token state uses the auth_token cookie as the single source of truth — it is readable during SSR (request headers) and on the client, ensuring server and client seed identical state on first render. A reactive useState('auth-token-mem') reflects the cookie value in memory and is shared across all composable callers within a navigation. localStorage is a legacy fallback only, used for sessions whose cookie never persisted; it is reconciled after mount by app/plugins/auth-sync.client.ts.

Architecture ​

useAuth is the central composable. Every page and middleware calls it; a 30-second in-memory cache (useState('auth-user-fetch-cache')) and a per-NuxtApp in-flight WeakMap prevent duplicate /user fetches across concurrent middleware guards. Cookie read/write is delegated to useAuthCookies, which manages token, strategy, and workspace cookies in one place.

Passkey registration and management are separated into usePasskeyApi, which only covers the profile-settings side. The login ceremony lives entirely in pages/index.vue using @simplewebauthn/browser's startAuthentication().

Social and passkey logins both converge on pages/social-login.vue, which reads the token (from ?access_token or sessionStorage), calls fetchUser(), resolves the workspace, and calls nextStep() to navigate.

File Structure ​

Composable Files ​

  • app/composables/auth/useAuth.ts — primary auth composable

    • In-memory token state via useState('auth-token-mem')
    • Cookie token via useCookie('auth_token')
    • Per-NuxtApp in-flight fetch deduplication via WeakMap
    • 30-second user-fetch cache via useState('auth-user-fetch-cache')
    • Workspace resolution from accessibleWorkspaces
  • app/composables/auth/useAuthCookies.ts — centralised cookie layer

    • readToken, readStoredToken (cookie → localStorage fallback)
    • readStrategy, readWorkspace
    • writeSession — writes token + strategy with correct max-age per strategy type
    • writeWorkspace — merges into stored value to preserve the module field
    • clearSessionCookies — called on logout and 401
  • app/composables/api/usePasskeyApi.ts — WebAuthn profile-settings API

    • Register and list passkeys for authenticated users
    • Delete passkeys by ID

Utility Files ​

  • app/utils/passkey.ts — shared cancellation detection utility
    • isPasskeyCancellation(err) — distinguishes user dismissal (NotAllowedError / AbortError) from real failures
    • Used by both pages/index.vue and useProfilePage

Page Files ​

  • app/pages/index.vue — main login page

    • Password form with email + password validation
    • Google, Microsoft, Passkey social tiles (primary domain only)
    • Domain verification via check-branding API on onMounted
    • Support-platform variant (6-digit PIN instead of password)
    • Referring URL cookie consumed on successful login to restore redirect
  • app/pages/social-login.vue — OAuth / passkey callback landing page

    • Reads ?access_token (Google/Microsoft) or sessionStorage.passkey_access_token (passkey)
    • Calls fetchUser(), resolves workspace, calls nextStep()
    • Shows "We are logging you in…" skeleton during processing
  • app/pages/forgot-password.vue — forgot password page

    • Email field; submits via useDamInstanceSettingsApi.forgotPassword()
    • Redirects back to login on success
  • app/pages/reset-password.vue — password reset confirmation page

    • Accepts the reset token from the URL and sets a new password
  • app/pages/password-setup.vue — initial password setup for invited users

  • app/pages/profile.vue — authenticated user profile page; two tabs (Profile Settings / Notification Settings)

  • app/pages/generate-password.vue — invitation acceptance page; validates an invitation_token from the query string and lets a new user set their initial password

  • app/pages/[workspace_id]/feedback/index.vue — authenticated user feedback submission page; pre-fills name, email, and workspace from session


Page Details ​

Login (pages/index.vue) ​

FieldValue
Route/
Layoutlogin-layout
Middlewareguest-check
Primary composablesuseAuth(), useDamInstanceSettingsApi(), useBrowserOsApi()

On mount, calls the check-branding API to verify the current domain is whitelisted — throws a fatal 404 if the domain is unrecognised (blocks non-whitelisted white-label deployments). Social login buttons (Google, Microsoft, Passkey) are shown only when window.location.origin matches the configured baseUrl; they are hidden on white-label domains. Two form modes: standard email + password and a support-platform variant that uses a 6-digit PIN instead of a password. The passkey flow stores the token in sessionStorage and hands off to /social-login. On successful login, reads the referring_url_data cookie to restore any pre-expiry redirect.


Social Login (pages/social-login.vue) ​

FieldValue
Route/social-login
Layoutlogin-layout
Middlewareguest-check
Primary composablesuseAuth(), useAuthCookies(), useHelpers()

Resolves the access token from ?access_token (Google/Microsoft OAuth redirect) or sessionStorage.passkey_access_token (passkey flow). Calls applySession(), then fetchUser(), sets the workspace cookie, and redirects via nextStep(). Displays a static "We are logging you in…" loading screen with no interactive form. Tracks success and failure paths in Amplitude.


Forgot Password (pages/forgot-password.vue) ​

FieldValue
Route/forgot-password
Layoutlogin-layout
Middlewareguest-check
Primary composableuseDamInstanceSettingsApi() (forgotPassword)

Single email field. On success, shows the API-provided confirmation message and redirects to /. Tracks USER_PASSWORD_RESET_REQUESTED and USER_PASSWORD_RESET_REQUEST_FAILED in Amplitude.


Reset Password (pages/reset-password.vue) ​

FieldValue
Route/reset-password?token=…
Layoutlogin-layout
Middlewareguest-check
Primary composableuseDamInstanceSettingsApi() (getPasswordDetails, resetPassword)

On mount, calls getPasswordDetails with the ?token query param to resolve the user's email and the internal reset_token. If the token is expired or invalid, throws a Nuxt 410 error. Password must be at least 8 characters. Redirects to / on success.


Password Setup (pages/password-setup.vue) ​

FieldValue
Route/password-setup?token=…
Layoutlogin-layout
Middlewareguest-check
Primary composableuseDamInstanceSettingsApi() (generateCommonPassword)

First-time password creation for invited users. Stricter validation than reset-password: requires uppercase, lowercase, a number, and a special character (minimum 8 characters). After submit, shows a 3-second "account setup in progress" interstitial before redirecting to /. Tracks USER_PASSWORD_SET in Amplitude.


Feedback ([workspace_id]/feedback/index.vue) ​

FieldValue
Route/:workspace_id/feedback
Layoutprofile-layout
Middlewareauth-check, check-workspace, can-access, check-workspace-access, check-if-suspended
Primary composablesuseAuth(), useCommonApis() (sendFeedback), useBrand()

User name, email, and workspace name are pre-filled from the session (read-only). Only the comment textarea is editable. On success, replaces the form with a thank-you message (no redirect); the brand name from useBrand() is interpolated into the thank-you text. Tracks FEEDBACK_SUBMITTED with comment_length in Amplitude.


Profile (pages/profile.vue) ​

FieldValue
Route/profile
Layoutprofile-layout
Middlewareauth-check
Key composablesuseAuthCookies, useAuth, useHelpers (inline — no dedicated page composable)

The page uses a ?tab=notification query param to open the Notification Settings tab directly from external links (e.g., from notification emails). The Profile Settings tab allows updating display name, email, and password, and managing registered passkeys. The Notification Settings tab controls per-module email notification preferences.


Generate Password (pages/generate-password.vue) ​

FieldValue
Route/generate-password
Layoutlogin-layout
Middlewarenone
Key composableuseDamInstanceSettingsApi()

Accepts an invitation_token from the URL query string. On load the token is validated server-side; if expired the page renders an "invitation link expired" state with a prompt to request a new one. On successful password submission the composable tracks USER_INVITATION_ACCEPTED in Amplitude and redirects the user into the app.


useAuth ​

File: app/composables/auth/useAuth.ts

Central composable for all authentication state and operations. SSR-safe: uses useState for shared reactive state rather than module-level singletons.

Returned Properties ​

typescript
{
  user: Ref<User | null>                // reactive authenticated user
  isAuthenticated: ComputedRef<boolean> // true if token OR user is present
  isExternalSession: Ref<boolean>       // true for external upload sessions
  login: (credentials, options?) => Promise<User>
  logout: () => Promise<void>
  fetchUser: (forceRefresh?: boolean) => Promise<User | null>
  clearAuthCookies: () => void
  resolveCurrentWorkspace: (userData: User) => Workspace | null
}

Key Methods ​

typescript
// login — calls /login or /support-login, stores token, fetches user
login(credentials: LoginCredentials, options?: { isSupport?: Ref<boolean> | boolean }): Promise<User>

// fetchUser — returns early if no token exists anywhere (cookie or localStorage).
// Otherwise: de-duped, cached 30s, handles external users (/get-external-user endpoint)
fetchUser(forceRefresh = false): Promise<User | null>

// logout — calls /logout, clears cookie, localStorage, and all useState
logout(): Promise<void>

// clearAuthCookies — immediate token wipe without an API call (used by 401 handler)
clearAuthCookies(): void

// resolveCurrentWorkspace — picks workspace from cookie → user.workspace_id → first accessible
resolveCurrentWorkspace(userData: User): Workspace | null

Usage Example ​

vue
<script setup lang="ts">
const { login, isAuthenticated, user, logout } = useAuth()

async function handleLogin(email: string, password: string) {
  const loggedInUser = await login({ email, password })
  // user is now set; navigate to workspace
  await navigateTo({
    name: 'workspace_id-dam',
    params: { workspace_id: loggedInUser.currentWorkspace?.id },
  })
}
</script>

usePasskeyApi ​

File: app/composables/api/usePasskeyApi.ts

Profile-settings side of the WebAuthn ceremony. All endpoints are authenticated — $api attaches the Bearer token automatically.

Methods ​

typescript
{
  getPasskeys: () => Promise<Passkey[]>
  getRegisterOptions: () => Promise<PublicKeyCredentialCreationOptionsJSON>
  registerPasskey: (payload: RegistrationResponseJSON & { alias: string }) => Promise<ApiResponse>
  deletePasskey: (id: number | string) => Promise<ApiResponse>
}

Usage Example ​

vue
<script setup lang="ts">
import { startRegistration } from '@simplewebauthn/browser'

const { getRegisterOptions, registerPasskey, getPasskeys } = usePasskeyApi()

async function addPasskey() {
  const options = await getRegisterOptions()
  const credential = await startRegistration({ optionsJSON: options })
  await registerPasskey({ ...credential, alias: 'My MacBook' })
  const list = await getPasskeys()
}
</script>

Passkey Login Flow ​

The login ceremony is embedded directly in pages/index.vue. It uses @simplewebauthn/browser's startAuthentication() and two dedicated backend routes.

Methods (inline in pages/index.vue) ​

typescript
// Fetch challenge (public endpoint — no auth required)
POST passkeys/login/options → PublicKeyCredentialRequestOptionsJSON

// Verify assertion and return token (same shape as social login)
POST passkeys/login (assertion body) → { access_token: string }

Usage Example ​

vue
<script setup lang="ts">
import { startAuthentication, browserSupportsWebAuthn } from '@simplewebauthn/browser'

const { $api } = useNuxtApp()

async function passkeyLogin() {
  const optRes = await $api.post<PublicKeyCredentialRequestOptionsJSON>('passkeys/login/options')
  const optionsJSON = optRes.data ?? optRes

  let assertion
  try {
    assertion = await startAuthentication({ optionsJSON })
  } catch (err) {
    if (isPasskeyCancellation(err)) return  // user dismissed — stay quiet
    throw err
  }

  const verifyRes = await $api.post<{ access_token?: string }>('passkeys/login', assertion)
  const token = verifyRes.data?.access_token
  sessionStorage.setItem('passkey_access_token', token)
  await navigateTo({ path: '/social-login', query: { provider: 'passkey' } }, { replace: true })
}
</script>

Workflows ​

Password Login ​

pages/index.vue
  → validateEmail() + validatePass()
  → useAuth.login({ email, password })
      → POST /login
      → store token: auth_token cookie (primary) + useState + localStorage (legacy fallback)
      → fetchUser(forceRefresh=true)
      → resolveCurrentWorkspace()
      → damStore.setWorkspaceId()
      → damStore.fetchWorkspaceBranding()
  → check referring_url_data cookie (session-expiry redirect)
  → navigateTo workspace DAM
  → Amplitude: USER_LOGGED_IN { method: 'local' }

Google / Microsoft OAuth ​

pages/index.vue
  → googleSignIn() / microsoftSignIn()
  → window.location.href = config.public.googleAuthUrl / microsoftAuthUrl
      [browser redirects to provider]
      [user consents]
      [provider redirects to backend callback]
      [backend creates/links account, returns JWT]
  → backend redirects to /social-login?access_token=...
pages/social-login.vue
  → read ?access_token
  → set auth_token cookie
  → fetchUser()
  → resolveCurrentWorkspace()
  → nextStep()
  → Amplitude: USER_LOGGED_IN { method: 'google' | 'microsoft' }

Passkey Login ​

pages/index.vue
  → passkeyLogin()
  → POST passkeys/login/options → challenge
  → startAuthentication({ optionsJSON: challenge })
      [browser prompts Face ID / fingerprint / security key]
  → POST passkeys/login (assertion)
  → token → sessionStorage.passkey_access_token
  → navigateTo /social-login?provider=passkey
pages/social-login.vue
  → read sessionStorage.passkey_access_token (consume + clear)
  → set auth_token cookie
  → fetchUser()
  → nextStep()
  → Amplitude: USER_LOGGED_IN { method: 'passkey' }

Passkey Registration (Profile Settings) ​

Account Settings → Security tab
  → "Add Passkey" button
  → usePasskeyApi.getRegisterOptions() → challenge
  → startRegistration({ optionsJSON: challenge })
      [browser prompts authenticator]
  → usePasskeyApi.registerPasskey({ ...credential, alias })
  → refresh passkey list via getPasskeys()
  → Amplitude: passkey registered

API Integration ​

Auth Endpoints ​

EndpointMethodDescription
/loginPOSTPassword login — returns { data: { access_token } }
/support-loginPOSTSupport portal login (PIN-based)
/logoutPOSTServer-side session invalidation
/userGETFetch authenticated user data
/get-external-userGETFetch external user data (is_external sessions)
check-brandingGETDomain verification on login page load
passkeys/login/optionsPOSTWebAuthn authentication challenge
passkeys/loginPOSTWebAuthn assertion verification
passkeys/register/optionsPOSTWebAuthn registration challenge
passkeys/registerPOSTWebAuthn credential registration
passkeysGETList user's registered passkeys
passkeys/:idDELETERevoke a passkey

Login Request / Response ​

json
// POST /login
{
  "email": "[email protected]",
  "password": "secret"
}

// Response
{
  "data": {
    "access_token": "eyJ..."
  }
}

User Response Shape ​

json
{
  "data": {
    "user": {
      "id": 42,
      "email": "[email protected]",
      "workspace_id": 7,
      "is_external": false,
      "accessibleWorkspaces": [
        { "id": 7, "name": "Acme Brand", "url_slug": "acme" }
      ],
      "subscription_features": {}
    }
  }
}

Component Integration ​

Login page (app/pages/index.vue) ​

State is owned directly by the page — there is no dedicated page composable for login. useAuth provides login, isAuthenticated, and user; useHelpers provides nextStep, setCurrentWorkspace, and getUserModulesAndRoles. Form values are a single form ref object, not separate refs per field.

vue
<script setup lang="ts">
import { startAuthentication, browserSupportsWebAuthn } from '@simplewebauthn/browser'
import type { PublicKeyCredentialRequestOptionsJSON, AuthenticationResponseJSON } from '@simplewebauthn/browser'

definePageMeta({
  layout: 'login-layout',
  middleware: ['guest-check'],
})

const config = useRuntimeConfig()
const { $amplitude } = useNuxtApp()
const { login, isAuthenticated, user } = useAuth()
const { nextStep, setCurrentWorkspace, getUserModulesAndRoles } = useHelpers()
const snackbar = useSnackbar()
const currentWorkspaceCookie = useCookie<{ id: number } | null>('currentWorkspace')

const form = ref<{ email: string; password: string }>({ email: '', password: '' })
const errors = ref<{ email: string | null; password: string | null; pin: string | null }>({
  email: null,
  password: null,
  pin: null,
})
const loading = ref(false)
const contentLoading = ref(true)
const showGoogleSignIn = ref(false)
const showMicrosoftSignIn = ref(false)
const showPasskeyLogin = ref(false)
const passkeyLoading = ref(false)

// Social sign-in and passkey are shown only on the primary domain —
// hidden on white-label deployments
const isSupportPlatform = computed(() =>
  config.public.isSupportPlatform === true || String(config.public.isSupportPlatform) === 'true'
)

const disableSubmitBtn = computed(() => {
  const email = form.value.email || ''
  const password = form.value.password || ''
  return (
    loading.value ||
    !!errors.value.email ||
    !!errors.value.password ||
    !!errors.value.pin ||
    !email.trim() ||
    !password.trim()
  )
})

onMounted(async () => {
  // check-branding verifies the domain is registered; throws 404 if not
  await verifyDomain()
  // compare window.location.origin against config.public.baseUrl
  resolveSocialSignIn()
})

async function handleSubmit(): Promise<void> {
  loading.value = true
  try {
    const loggedInUser = await login(
      { email: form.value.email, password: form.value.password },
      { isSupport: isSupportPlatform.value }
    )
    const workspace =
      loggedInUser.accessibleWorkspaces.find((w) => w.id === loggedInUser.workspace_id) ||
      loggedInUser.accessibleWorkspaces[0]
    setCurrentWorkspace(workspace.id)
    $amplitude?.track(AMPLITUDE_EVENTS.USER_LOGGED_IN, { method: 'local' }, { immediate: true })
    snackbar.success('Login successful')
    await nextStep()
  } catch (err) {
    snackbar.error(getApiErrorMessage(err, 'Login failed. Please try again.'))
  } finally {
    loading.value = false
  }
}

function googleSignIn(): void {
  // sessionStorage.login_provider is NOT set for Google — /social-login
  // defaults to 'google' when the key is absent
  window.location.href = config.public.googleAuthUrl as string
}

function microsoftSignIn(): void {
  // Microsoft's callback carries no provider param; sessionStorage survives the redirect
  sessionStorage.setItem('login_provider', 'microsoft')
  window.location.href = config.public.microsoftAuthUrl as string
}

async function passkeyLogin(): Promise<void> {
  passkeyLoading.value = true
  const { $api } = useNuxtApp()
  try {
    const optRes = await $api.post<PublicKeyCredentialRequestOptionsJSON>('passkeys/login/options')
    const optionsJSON = (optRes.data ?? optRes) as PublicKeyCredentialRequestOptionsJSON
    let assertion: AuthenticationResponseJSON
    try {
      assertion = await startAuthentication({ optionsJSON })
    } catch (err) {
      if (isPasskeyCancellation(err)) return
      throw err
    }
    const verifyRes = await $api.post<{ access_token?: string }>('passkeys/login', assertion)
    const token = verifyRes.data?.access_token
    if (!token) { snackbar.error('Passkey sign-in failed. Please try again.'); return }
    // Token is passed via sessionStorage so it never appears in the URL / browser history
    sessionStorage.setItem('passkey_access_token', token)
    await navigateTo({ path: '/social-login', query: { provider: 'passkey' } }, { replace: true })
  } catch (e) {
    snackbar.error(getApiErrorMessage(e, 'Passkey sign-in failed. Please try again.'))
  } finally {
    passkeyLoading.value = false
  }
}
</script>

<template>
  <div>
    <!-- Standard Login form -->
    <div class="signin-screen-body">
      <h3>Please login to your account</h3>
      <v-form @submit.prevent="handleSubmit">
        <v-text-field v-model="form.email" type="email" variant="outlined" density="compact"
          @input="validateEmail" @blur="validateEmail" />
        <div v-if="errors.email" class="form-control-error">{{ errors.email }}</div>

        <v-text-field v-model="form.password" type="password" variant="outlined" density="compact"
          @input="validatePass" @blur="validatePass" />
        <div v-if="errors.password" class="form-control-error">{{ errors.password }}</div>

        <v-btn size="large" type="submit" class="btn-primary w-100" :disabled="disableSubmitBtn">
          <v-progress-circular v-if="loading" indeterminate size="14" width="2" />
          Login
        </v-btn>

        <NuxtLink to="/forgot-password">Forgot Password</NuxtLink>
      </v-form>
    </div>

    <!-- Social / passkey tiles — rendered only on the primary domain -->
    <div v-if="showGoogleSignIn || showMicrosoftSignIn || showPasskeyLogin" class="signin-screen-with">
      <div class="or-divider">or continue with</div>
      <div class="social-tiles">
        <button v-if="showGoogleSignIn" class="social-tile social-tile-google" @click="googleSignIn">
          <AsyncIcon name="googleIcon" />
          <span>Google</span>
        </button>
        <button v-if="showMicrosoftSignIn" class="social-tile social-tile-microsoft" @click="microsoftSignIn">
          <AsyncIcon name="microsoftIcon" />
          <span>Microsoft</span>
        </button>
        <button v-if="showPasskeyLogin" class="social-tile social-tile-passkey"
          :disabled="passkeyLoading" @click="passkeyLogin">
          <AsyncIcon name="passkeyIcon" />
          <span>{{ passkeyLoading ? 'Waiting…' : 'Passkey' }}</span>
        </button>
      </div>
    </div>
  </div>
</template>