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`