Skip to content

Authentication Methods ​

Overview ​

Collage Admin supports five 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.
  5. SSO (SAML) — a workspace's identity provider, set up by the owner on the Single Sign-On page. The SSO tile shows on the primary domain, and on a white-label host when check-branding returns sso.enabled: true. The browser goes to the API's auth/sso/<url_slug>/redirect, the IdP signs the user in, and the API redirects to /social-login like the OAuth providers (login_provider = 'sso').

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, starts the CSRF fetch (useCsrf().fetchCsrfToken()) without awaiting it, then calls the check-branding API to verify the current domain is whitelisted; both run in parallel (await Promise.all([csrfRequest, verifyDomain()]), since 38752451) and a CSRF failure is swallowed. verifyDomain() 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. The SSO tile follows the same rule but is also shown when check-branding returns sso.enabled: true. Clicking it goes straight to the IdP when sso.url_slug is known, otherwise opens a work-email step that resolves the workspace with POST auth/sso/resolve (flow in Single Sign-On). The live forms render only once useBrand().brandingReady is true. 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 (lock icon for SSO). Tracks success and failure paths in Amplitude.

Without a token it reads ?reason= (Amplitude failure reason, default unauthorized) and a base64, UTF-8 ?message=. For the sso provider with a decodable message it shows "We could not sign you in", the message and a Back to login button instead of a snackbar, so the user can read why they were blocked. Other providers show the decoded message (or You are not an authorized person to login.) as a snackbar and return to /.


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
  status: Ref<AuthStatus>               // 'idle' | 'loading' | 'authenticated' | 'unauthenticated' | 'unreachable'
  isBackendUnreachable: ComputedRef<boolean> // status === 'unreachable'
  retryAuth: () => Promise<User | null> // reset back-off and fetchUser(true)
  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 — POSTs /logout (or /external-logout) with keepalive + skipCancel, waits at most
// LOGOUT_WAIT_MS (300) on the client, then 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

Auth status and retry back-off ​

status lives in useState('auth-status') and the retry counter in useState('auth-retry') ({ attempts, nextRetryAt }). They exist to stop the /user request storm when the API is down (65cdeb93): before, every middleware re-fetched the user and every failure redirected to login.

EventstatusRetry state
no token anywhereunauthenticatedunchanged
fetch startsloading
user returnedauthenticatedreset; clearSessionTeardown()
empty user dataunauthenticated
request aborted (isAbortError)authenticated if a user is loaded, else idleunchanged
failure with no status, status 0 or >= 500unreachableattempts + 1, next retry after back-off
any other failure (for example 401, 403)unauthenticatedattempts + 1
logout / clearAuthCookiesunauthenticatedreset

Back-off: min(1000 * 2^attempts, 30000) ms with ±20% jitter. While attempts > 0 and the retry time has not passed, fetchUser() (without forceRefresh) returns the current user without a request. For unreachable failures only the first failure shows a snackbar; 401s and origin_url errors never do. auth-reconnect.client.ts calls retryAuth() on the browser online event when the backend was unreachable.

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 ​

Google / Microsoft OAuth ​

Passkey Login ​

Passkey Registration (Profile Settings) ​

Logout ​

Logout favours a fast redirect over waiting for the API. The session is cleared locally whatever the API does, so the request only has to leave the browser.

RuleValueWhy
Request options{ method: 'POST', keepalive: true, skipCancel: true }keepalive lets the browser finish the request after the page navigates away; skipCancel keeps it out of the cancel pool so cancelAllRequests() does not abort it
Client waitLOGOUT_WAIT_MS = 300Promise.race with a 300 ms timer; a slow API no longer delays the redirect
SSRawaits the full requestmiddleware such as check-workspace logs out during SSR, where there is no navigation to race against
API errorignoredtoken and cookies are cleared in finally inside nuxtApp.runWithContext
Redirectimmediate window.location.replace('/')before 38752451 logoutApp waited 5 s (setTimeout(..., 5000))

Introduced in 38752451 and 0907e092 (PR #116, #118).

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; also returns sso: { enabled, url_slug }
auth/sso/resolvePOST{ email } → url_slug, workspace_name, provider, redirect_url for the SSO work-email step
auth/sso/:url_slug/redirectGET (browser navigation)Starts the SAML sign-in; query return_host, return_path
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 () => {
  // CSRF fetch starts first and is not awaited on its own; failures are swallowed
  let csrfRequest: Promise<unknown> = Promise.resolve()
  try {
    const { fetchCsrfToken } = useCsrf()
    csrfRequest = Promise.resolve(fetchCsrfToken()).catch(() => {})
  } catch {}

  // (mobile app redirect via config.public.mobileAppDomain happens here)

  // check-branding verifies the domain is registered; throws 404 if not.
  // Runs in parallel with the CSRF fetch (38752451)
  await Promise.all([csrfRequest, 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>