Account Linking Extension --> Action Problem

I am trying to replace the Account Linking Extension with an action and a client-side react single-page react app but I am not having any luck.

I think this might be related to:

but I can’t figure out how that solution applied to my code.

That solution wants me to do a POST request like so:

POST https://YOUR_AUTH0_DOMAIN/continue?state=${uri_state}

{
    session_token: ${sessionToken},
    state: ${sessionTokenState}
}

but my understanding is that if I do a POST request from a browser react app it will run into CORS issues.

Here’s a summary of my action:


exports.onExecutePostLogin = async (event, api) => {

  // ... a bunch of code to find the users with the same email

  // Enrich payload with additional presentation data similar to legacy extension (picture + connections list)
  const enrichedCandidates = candidateUsers
    .filter((u) => candidateIdentities.find((ci) => ci.user_id === u.user_id))
    .map((u) => ({
      user_id: u.user_id,
      provider: (u.identities && u.identities[0] && u.identities[0].provider) || 'unknown',
      connection: (u.identities && u.identities[0] && u.identities[0].connection) || 'unknown',
      email: u.email,
      picture: u.picture,
      connections: Array.isArray(u.identities) ? u.identities.map((i) => i.connection) : [],
    }))

  const sessionToken = api.redirect.encodeToken({
    payload: {
      current_identity: {
        user_id: event.user.user_id,
        provider: event.connection.strategy,
        connection: event.connection.name,
        email: event.user.email,
        picture: event.user.picture,
        connections: Array.isArray(event.user.identities) ? event.user.identities.map((i) => i.connection) : [],
      },
      candidate_identities: enrichedCandidates,
    },
    secret: event.secrets.SESSION_TOKEN_SHARED_SECRET,
    expiresInSeconds: 300,
  })

  api.redirect.sendUserTo(event.secrets.ACCOUNT_LINKING_SERVICE_URL, {
    query: {
      session_token: sessionToken,
    },
  })
}

exports.onContinuePostLogin = async (event, api) => {
  let validated
  try {
    validated = api.redirect.validateToken({
      secret: event.secrets.SESSION_TOKEN_SHARED_SECRET,
      tokenParameterName: 'session_token',
    })
  } catch (err) {
    console.error('[AccountLinking:onContinuePostLogin] validateToken error.', err)
    return
  }
  // ... Rest of the code it fails before I can ever get here with 

}

onContinuePostLogin is firing but validateToken is failing with:

“State in the token does not match the /continue state”

And no combination of state variables I can find is helping

There seems to be some confusion in the chat about whether or not the Auth0 actions will automatically add “state” to the token or whether I need to do this myself.

here’s my react component:

import React, { useEffect, useState } from 'react'
import { jwtDecode } from 'jwt-decode'
import {
  Container,
  Typography,
  Button,
  Paper,
  Box,
  Alert,
  Stack,
  List,
  ListItemButton,
  ListItemAvatar,
  Avatar,
  Divider,
  alpha,
} from '@mui/material'
import { Topbar } from './Topbar'
import log from 'loglevel'
import { Apple, Cancel, Google, Help, Key } from '@mui/icons-material'

interface IdentityDisplay {
  user_id: string
  provider: string
  connection: string
  email?: string
  picture?: string
  connections?: string[]
}

interface LinkingTokenPayload {
  current_identity: IdentityDisplay
  candidate_identities: IdentityDisplay[]
  [k: string]: unknown
}

export const Auth0Linker: React.FC = () => {
  const [payload, setPayload] = useState<LinkingTokenPayload | null>(null)
  const [error, setError] = useState<string>('')
  const [submitting, setSubmitting] = useState<boolean>(false)
  const [rawSessionToken, setRawSessionToken] = useState<string>('')
  // We'll derive the correct Auth0 domain from the token issuer (iss) claim to avoid mismatches.
  const [auth0Domain, setAuth0Domain] = useState<string>('')
  const [auth0State, setAuth0State] = useState<string>('')

  useEffect(() => {
    const search = window.location.search
    const params = new URLSearchParams(search)
    const token = params.get('session_token')
    const redirectStateParam = params.get('state') || undefined // Auth0-added redirect state (not matching token)
    log.debug('[Auth0Linker] initial params', Object.fromEntries(params.entries()))
    if (!token) {
      setError('Missing session_token in URL.')
      return
    }
    try {
      const decoded = jwtDecode<LinkingTokenPayload & { iss?: string; state?: string }>(token)
      if (!decoded || !decoded.current_identity || !Array.isArray(decoded.candidate_identities)) {
        setError('Malformed token payload.')
        return
      }
      setPayload(decoded)
      setRawSessionToken(token)

      // Prefer state claim from token; this must be echoed back
      const effectiveState = decoded.state || ''
      setAuth0State(effectiveState)
      log.debug('[Auth0Linker] State selection.', {
        token_state: decoded.state,
        redirect_state_param: redirectStateParam,
        effective_state: effectiveState,
      })

      if (decoded.iss) {
        try {
          const u = new URL(decoded.iss.startsWith('http') ? decoded.iss : `https://${decoded.iss}`)
          setAuth0Domain(u.host)
          log.debug('[Auth0Linker] Issuer-derived domain set.', { issuer: decoded.iss, domain: u.host })
        } catch {
          log.warn('[Auth0Linker] Unable to parse issuer for domain.', { iss: decoded.iss })
        }
      }
    } catch (e) {
      log.error('[Auth0Linker] decode failure', e)
      setError('Invalid / undecodable session_token.')
    }
  }, [])

  const candidateIdentities = payload?.candidate_identities ?? []

  const canInteract = !!payload && !!auth0Domain && !!rawSessionToken && !submitting

  function navigateContinue(domain: string, params: Record<string, string | undefined>) {
    const qs = new URLSearchParams()
    Object.entries(params).forEach(([k, v]) => {
      if (v !== undefined) qs.set(k, v)
    })
    const url = `https://${domain}/continue?${qs.toString()}`
    window.location.replace(url)
  }

  function continueWithoutLinking() {
    if (!canInteract) return
    setSubmitting(true)
    navigateContinue(auth0Domain!, {
      session_token: rawSessionToken,
      declined: 'true',
    })
  }

  function linkIdentity(userId: string) {
    if (!canInteract) return
    const selected = candidateIdentities.find((c) => c.user_id === userId)
    if (!selected) return
    setSubmitting(true)
    navigateContinue(auth0Domain!, {
      session_token: rawSessionToken,
      link_user_id: userId,
    })
  }

  if (error)
    return (
      <Box>
        <Topbar />
        <Container maxWidth="sm" sx={{ mt: 4 }}>
          <Alert severity="error" sx={{ mb: 2 }}>
            {error}
          </Alert>
          <Button variant="outlined" onClick={() => window.location.reload()}>
            Retry
          </Button>
        </Container>
      </Box>
    )

  if (!payload)
    return (
      <Box>
        <Topbar />
        <Container maxWidth="sm" sx={{ mt: 6 }}>
          <Typography variant="body1">Loading token payload…</Typography>
        </Container>
      </Box>
    )

  if (!auth0Domain)
    return (
      <Box>
        <Topbar />
        <Container maxWidth="sm" sx={{ mt: 6 }}>
          <Alert severity="error">Missing Auth0 domain environment variable (VITE_AUTH0_DOMAIN).</Alert>
        </Container>
      </Box>
    )
  log.info('Auth0 domain + state context.', { candidateIdentities, payload, domain: auth0Domain, state: auth0State })

  return (
    <Box>
      <Topbar />
      <Container maxWidth="sm">
        <Paper sx={{ p: 3, mt: 5 }} elevation={3}>
          <Typography variant="h5" gutterBottom>
            Link Accounts
          </Typography>
          <Typography variant="body2" sx={{ mb: 2 }} color="text.secondary">
            We found other logins that use this same email address. Linking them lets you sign in to Riverscapes with
            any of those providers while keeping a single profile. We recommend that you link these accounts.
          </Typography>
          <Typography variant="subtitle2" sx={{ mb: 1 }}>
            Current Account:
          </Typography>
          <List>
            <ProviderListItem identity={payload.current_identity} />
          </List>
          <Typography variant="subtitle2" sx={{ mb: 1 }}>
            Select an account to link:
          </Typography>
          <List>
            {candidateIdentities.map((ci) => (
              <ProviderListItem key={ci.user_id} identity={ci} onClick={() => linkIdentity(ci.user_id)} />
            ))}
          </List>
          <Divider sx={{ my: 2 }} />
          <Typography variant="caption" sx={{ mb: 2 }} color="text.secondary">
            If you choose to continue without linking, these accounts will be treated as separate user profiles in our
            systems.
          </Typography>
          <Stack direction="row" spacing={2} sx={{ mt: 3 }}>
            <Box flexGrow={1} />
            <Button
              type="button"
              variant="text"
              startIcon={<Cancel />}
              color="error"
              disabled={!canInteract}
              onClick={continueWithoutLinking}
            >
              Continue without linking
            </Button>
          </Stack>
        </Paper>
      </Container>
    </Box>
  )
}

const ProviderListItem: React.FC<{ identity: IdentityDisplay; onClick?: () => void }> = ({ identity, onClick }) => {
  return (
    <ListItemButton
      onClick={onClick}
      disabled={!onClick}
      sx={{
        my: 1,
        borderRadius: 3,
        backgroundColor: (theme) => (onClick ? alpha(theme.palette.success.light, 0.2) : 'transparent'),
        border: (theme) => (onClick ? `1px solid ${theme.palette.success.main}` : 'none'),
      }}
    >
      <ListItemAvatar>
        <Avatar src={identity.picture}>{!identity.picture && getProviderIcon(identity.provider)}</Avatar>
      </ListItemAvatar>
      <Stack direction="column">
        <Typography variant="body1">
          {getProviderName(identity.provider)} ({identity.connection})
        </Typography>
        {identity.email && (
          <Typography variant="caption" color="text.secondary">
            {identity.email}
          </Typography>
        )}
      </Stack>
    </ListItemButton>
  )
}

function getProviderName(provider: string): string {
  let currentAccount = 'Unknown'
  switch (provider) {
    case 'auth0':
      currentAccount = 'Username & Password'
      break
    case 'google-oauth2':
      currentAccount = 'Google'
      break
    case 'apple':
      currentAccount = 'Apple'
      break
    default:
      log.warn('Unhandled current identity provider.', {
        provider: provider,
      })
      currentAccount = provider
  }
  return currentAccount
}
function getProviderIcon(provider: string): React.ReactNode {
  switch (provider) {
    case 'auth0':
      return <Key />
    case 'google-oauth2':
      return <Google />
    case 'apple':
      return <Apple />
    default:
      return <Help />
  }
}
````Preformatted text`
2 Likes

This is a separate point but I’m part of a very small dev team and none of us are security experts. We were depending on the “Auth0 Account Link” extension and now that Rules are being deprecated in favour of Actions there really isn’t anything to fill that void without all of us rolling our own actions and standing up web services to link the accounts.

It would be wonderful if Auth0 could offer us a service or extension to replace or update the “Auth0 Account Link” extension since having amateur Auth0 users like me develop their own separately adds a bunch of vulnerability to the authentication.

2 Likes

Hi @matt35,

Welcome to the Auth0 Community!

The documentation is a good starting point for implementing User Account Linking. You can also find examples of implementation.

https://auth0.com/docs/manage-users/user-accounts/user-account-linking/link-user-accounts

If you have any further questions, please don’t hesitate to reach out.

Have a good one,
Vlad

1 Like

I’m looking at your example github repo here:

It’s making a POST request from inside the client. My understanding is that this will not work when deployed because of CORS issues. Can you confirm this?

I figured it out finally. I decided to give up on the /continue workflow because I just couldn’t get the state parameter to work.

This all now works entirely with client-side POST calls to the /identities API

  await fetch(`https://${config.domain}/api/v2/users/${sub}/identities`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },
    body: JSON.stringify({
      link_with: targetUserIdToken,
    }),
  });

That seems to work even though I had some weird CORS issues that seemed to go away for some reason.