Magic link email IDP
Associa identità email e integra un accesso passwordless monouso vincolato tramite PKCE.
Magic link email IDP
I magic link permettono a un'applicazione terza di autenticare un'identità registrata in Sentinel senza esporre la propria API key al browser. L'applicazione richiede l'invio, riceve il callback email, verifica la prova server-to-server e crea infine la propria sessione.
Tutti gli endpoint richiedono una API key bearer con permesso idp:write e sono isolati sulla company associata alla chiave.
Prima di iniziare
- Abilita Magic link via email in Sentinel → Impostazioni.
- Registra ogni URL di callback HTTPS esatto. Credenziali e frammenti URL non sono ammessi.
- Associa un account email esterno all'utente Sentinel.
La allowlist può rimanere vuota finché la funzionalità è disabilitata. Per abilitarla è obbligatorio almeno un callback.
Associa un account email
idp:writePath, query, and header parameters
externalUserIdRequiredhandlerRequiredRequest Body
{
"external_account": {
"identifier": "alexander@enterprise.com"
}
}external_account.identifierRequiredResponses
Identità email associata. L'attendibilità resta asserted fino alla prima verifica magic link riuscita.
{
"external_account": {
"handler": "email",
"identifier": "alexander@enterprise.com",
"status": "asserted",
"verified_at": null
}
}L'utente Sentinel non esiste nella company.
L'email normalizzata è già associata a un altro utente della company.
Request examples
curl --request PUT 'https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/external-accounts/HANDLER.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"external_account": {
"identifier": "alexander@enterprise.com"
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/external-accounts/HANDLER.json', {
method: 'PUT',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"external_account": {
"identifier": "alexander@enterprise.com"
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/external-accounts/HANDLER.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"external_account": {
"identifier": "alexander@enterprise.com"
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Cambiare l'email riporta il suo stato a asserted; ripetere la stessa associazione conserva un eventuale stato verified. Il profilo utente espone l'elenco external_accounts. Per rimuovere l'associazione:
DELETE /2026-02-01/idp/{externalUserId}/external-accounts/{handler}.json1. Crea la prova PKCE
Sul backend genera un code_verifier crittograficamente casuale di 43–128 caratteri. Conservalo solo lato server e calcola:
code_challenge = BASE64URL(SHA256(code_verifier))È accettato esclusivamente il metodo S256. Associa il verifier al tentativo di login e a un valore state anti-CSRF di breve durata.
2. Richiedi l'invio email
idp:writePath, query, and header parameters
No path, query, or additional header parameters.
Request Body
{
"magic_link": {
"external_id": "ORG_USR_9921_AFG",
"redirect_uri": "https://app.example.com/auth/magic/callback",
"code_challenge": "BASE64URL_SHA256_CHALLENGE",
"code_challenge_method": "S256",
"locale": "it",
"state": "VALORE_CASUALE_ANTI_CSRF"
}
}magic_link.external_idmagic_link.account.handlermagic_link.account.identifiermagic_link.redirect_uriRequiredmagic_link.code_challengeRequiredmagic_link.code_challenge_methodmagic_link.localemagic_link.stateResponses
Richiesta accettata senza rivelare se l'identità esiste.
{
"magic_link": {
"status": "accepted",
"transaction_id": "ID_TRANSAZIONE_CASUALE",
"expires_at": "2026-09-13T21:30:00+00:00"
}
}Funzionalità disabilitata o callback non presente esattamente nella allowlist.
Cooldown o limite richieste configurato superato.
Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/idp/magic-link/request.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"magic_link": {
"external_id": "ORG_USR_9921_AFG",
"redirect_uri": "https://app.example.com/auth/magic/callback",
"code_challenge": "BASE64URL_SHA256_CHALLENGE",
"code_challenge_method": "S256",
"locale": "it",
"state": "VALORE_CASUALE_ANTI_CSRF"
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/magic-link/request.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"magic_link": {
"external_id": "ORG_USR_9921_AFG",
"redirect_uri": "https://app.example.com/auth/magic/callback",
"code_challenge": "BASE64URL_SHA256_CHALLENGE",
"code_challenge_method": "S256",
"locale": "it",
"state": "VALORE_CASUALE_ANTI_CSRF"
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/magic-link/request.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"magic_link": {
"external_id": "ORG_USR_9921_AFG",
"redirect_uri": "https://app.example.com/auth/magic/callback",
"code_challenge": "BASE64URL_SHA256_CHALLENGE",
"code_challenge_method": "S256",
"locale": "it",
"state": "VALORE_CASUALE_ANTI_CSRF"
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Puoi sostituire external_id con account: { handler: "email", identifier: "alexander@enterprise.com" }. La risposta è indistinguibile per identità sconosciute, inattive, disabilitate, prive di associazione o non raggiungibili. accepted conferma la gestione della richiesta, non la consegna email; gli errori di consegna generano eventi Sentinel interni.
Se l'identità è idonea, Donutwork invia un URL con i parametri query dw_magic_transaction, dw_magic_token e lo state originale. Una nuova richiesta per lo stesso utente e la stessa API key invalida la transazione precedente ancora attiva.
3. Gestisci il callback browser
Confronta state con il valore salvato prima di procedere. Invia transazione e token al tuo backend, quindi rimuovi subito i parametri sensibili dalla cronologia del browser. Non registrarli nei log e non inoltrarli a sistemi analytics o risorse di terze parti.
Mantieni le credenziali lato server
Non chiamare mai la Public API dal browser. La API key bearer e il code_verifier devono restare sul backend.
4. Verifica e crea la sessione
idp:writePath, query, and header parameters
No path, query, or additional header parameters.
Request Body
{
"magic_link": {
"transaction_id": "ID_TRANSAZIONE_CASUALE",
"token": "TOKEN_DAL_CALLBACK_EMAIL",
"code_verifier": "VERIFIER_PRIVATO_LATO_SERVER",
"context": {
"ip": "203.0.113.10",
"user_agent": "Mozilla/5.0 ...",
"session_id": "ID_DEL_TUO_TENTATIVO_LOGIN"
}
}
}magic_link.transaction_idRequiredmagic_link.tokenRequiredmagic_link.code_verifierRequiredmagic_link.contextResponses
Prova verificata e consumata.
{
"status": "verified",
"authentication": {
"method": "email_magic_link",
"transaction_id": "ID_TRANSAZIONE_CASUALE",
"authenticated_at": "2026-09-13T21:25:14+00:00"
},
"user": {
"id": "6908...",
"external_id": "ORG_USR_9921_AFG",
"status": "active",
"metadata": {
"plan": "enterprise"
}
},
"sentinel": {
"decision": "allow",
"risk_score": 0.12,
"risk_band": "low",
"recommended_factor": null
}
}Numero massimo di tentativi di verifica superato.
Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/idp/magic-link/verify.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"magic_link": {
"transaction_id": "ID_TRANSAZIONE_CASUALE",
"token": "TOKEN_DAL_CALLBACK_EMAIL",
"code_verifier": "VERIFIER_PRIVATO_LATO_SERVER",
"context": {
"ip": "203.0.113.10",
"user_agent": "Mozilla/5.0 ...",
"session_id": "ID_DEL_TUO_TENTATIVO_LOGIN"
}
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/magic-link/verify.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"magic_link": {
"transaction_id": "ID_TRANSAZIONE_CASUALE",
"token": "TOKEN_DAL_CALLBACK_EMAIL",
"code_verifier": "VERIFIER_PRIVATO_LATO_SERVER",
"context": {
"ip": "203.0.113.10",
"user_agent": "Mozilla/5.0 ...",
"session_id": "ID_DEL_TUO_TENTATIVO_LOGIN"
}
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/magic-link/verify.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"magic_link": {
"transaction_id": "ID_TRANSAZIONE_CASUALE",
"token": "TOKEN_DAL_CALLBACK_EMAIL",
"code_verifier": "VERIFIER_PRIVATO_LATO_SERVER",
"context": {
"ip": "203.0.113.10",
"user_agent": "Mozilla/5.0 ...",
"session_id": "ID_DEL_TUO_TENTATIVO_LOGIN"
}
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
La risposta di verifica è una prova di autenticazione, non una sessione applicativa. Valuta il consiglio sentinel.decision secondo la tua policy, quindi emetti e ruota i tuoi identificativi di sessione.
Controlli configurabili
Le impostazioni Sentinel espongono durata del link, cooldown tra invii, finestra del limite, richieste per finestra, tentativi di verifica e fino a 20 callback esatti. Le transazioni sono monouso, scadono automaticamente, sono vincolate a company e API key richiedente e vengono memorizzate senza il token email in chiaro.