Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/magic-link-attempt-redemption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'seamless-auth-api': minor
---

Magic link sign-in issues one session per confirmed link, to the attempt that requested it, and a link opened on another device needs the person who opened it to confirm (GHSA-9jg2-jjqq-3346).

- Each link records the `jti` of the ephemeral token that requested it, and `GET /magic-link/check` only looks up links for the polling attempt. Another ephemeral token for the same address cannot collect it.
- The first check after the link is confirmed issues the session and marks the link redeemed. Later checks for the same attempt answer `200 { "message": "Success" }` without tokens, so a second tab in the same browser still sees the sign-in complete and reads the session it already has.
- `GET /magic-link/verify/{token}` consumes the link only when it is opened on the requesting device: proven by the requesting attempt's ephemeral bearer token when the adapter forwards it, otherwise by the IP address and user agent a trusted adapter forwards with its service token. A fingerprint no adapter vouched for does not count, since behind an adapter that does not forward client context every request carries the adapter's own address. Opened anywhere else it answers `409 confirmation_required` with the requesting platform and time, and leaves the link unused, so a mail scanner or a click on a link someone else requested no longer signs that someone in.
- New `POST /magic-link/verify/{token}/confirm` consumes the link after the person who opened it approves.
- New auth event `magic_link_confirmation_required`.
- A request or poll with an ephemeral token that has no `jti` is refused with `400`.
- Migration: `magic_link_tokens` gains `attempt_id`, `requested_device_class` and `redeemed_at`. Links requested before the upgrade can no longer be used; they expire within fifteen minutes.

Breaking for clients: a link opened on a different device now needs a confirmation screen. SDK versions without one show the 409 as an error.

Upgrade the server adapter together with the API. Current adapters (`@seamless-auth/express`, `@seamless-auth/fastify`, `@seamless-auth/nextjs`, and the Go, Python and Rust adapters) forward the browser's pre-auth token on verify, which proves a link opened in the requesting browser. On an older adapter that proof is missing: Express still forwards the browser's address, so same-browser links keep working there, but `@seamless-auth/nextjs` forwards no address unless `resolveClientIp` is configured, so a link opened in the requesting browser is answered with the confirmation step.
1 change: 1 addition & 0 deletions adapter-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
{ "method": "POST", "path": "/magic-link", "credential": "preAuth", "delivery": true },
{ "method": "GET", "path": "/magic-link/check", "credential": "preAuth", "issues": "session" },
{ "method": "GET", "path": "/magic-link/verify/{token}", "credential": "none" },
{ "method": "POST", "path": "/magic-link/verify/{token}/confirm", "credential": "none" },
{
"method": "POST",
"path": "/oauth/{providerId}/callback",
Expand Down
242 changes: 216 additions & 26 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3454,6 +3454,7 @@
"logout_failed",
"logout_success",
"logout_suspicious",
"magic_link_confirmation_required",
"magic_link_poll_completed_successfully",
"magic_link_failed",
"magic_link_requested",
Expand Down Expand Up @@ -3532,6 +3533,7 @@
"logout_failed",
"logout_success",
"logout_suspicious",
"magic_link_confirmation_required",
"magic_link_poll_completed_successfully",
"magic_link_failed",
"magic_link_requested",
Expand Down Expand Up @@ -7360,33 +7362,31 @@
"description": "HTTP 200",
"content": {
"application/json": {
"example": {
"message": "string",
"token": "string",
"refreshToken": "string",
"sub": "string",
"roles": [null],
"email": "string",
"phone": "string",
"ttl": 0,
"refreshTtl": 0,
"nextStep": null
},
"example": null,
"schema": {
"type": "object",
"properties": {
"message": { "type": "string" },
"token": { "type": "string" },
"refreshToken": { "type": "string" },
"sub": { "type": "string" },
"roles": { "type": "array", "items": { "type": "string" } },
"email": { "type": "string" },
"phone": { "type": "string", "nullable": true },
"ttl": { "type": "number" },
"refreshTtl": { "type": "number" },
"nextStep": { "type": "string", "enum": ["enroll_passkey"] }
},
"required": ["message"]
"anyOf": [
{
"type": "object",
"properties": {
"message": { "type": "string" },
"token": { "type": "string" },
"refreshToken": { "type": "string" },
"sub": { "type": "string" },
"roles": { "type": "array", "items": { "type": "string" } },
"email": { "type": "string" },
"phone": { "type": "string", "nullable": true },
"ttl": { "type": "number" },
"refreshTtl": { "type": "number" },
"nextStep": { "type": "string", "enum": ["enroll_passkey"] }
},
"required": ["message"]
},
{
"type": "object",
"properties": { "message": { "type": "string", "enum": ["Success"] } },
"required": ["message"]
}
]
}
}
}
Expand Down Expand Up @@ -7506,6 +7506,196 @@
"/magic-link/verify/{token}": {
"get": {
"summary": "Verify magic link token",
"description": "Consumes the link when it is opened on the device that requested it, proven by the requesting attempt's ephemeral bearer token when the adapter forwards it, otherwise by the IP address and user agent a trusted adapter forwards. Opened anywhere else, answers 409 without consuming it; confirm with POST /magic-link/verify/{token}/confirm.",
"tags": ["MagicLinks"],
"parameters": [
{ "schema": { "type": "string" }, "required": true, "name": "token", "in": "path" }
],
"responses": {
"200": {
"description": "HTTP 200",
"content": {
"application/json": {
"example": { "message": "string", "token": "string", "delivery": null },
"schema": {
"type": "object",
"properties": {
"message": { "type": "string" },
"token": { "type": "string" },
"delivery": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["otp_email"] },
"to": { "type": "string" },
"token": { "type": "string" }
},
"required": ["kind", "to", "token"]
},
{
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["otp_sms"] },
"to": { "type": "string" },
"token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }
},
"required": ["kind", "to", "token"]
},
{
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["magic_link_email"] },
"to": { "type": "string" },
"token": { "type": "string" },
"magicLinkUrl": { "type": "string" }
},
"required": ["kind", "to", "magicLinkUrl"]
},
{
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["enrollment_invite_email"] },
"to": { "type": "string" },
"signInUrl": { "type": "string" }
},
"required": ["kind", "to", "signInUrl"]
}
]
}
},
"required": ["message"]
}
}
}
},
"400": {
"description": "HTTP 400",
"content": {
"application/json": {
"example": {
"error": "string",
"message": "string",
"details": { "issues": [null] }
},
"schema": {
"type": "object",
"properties": {
"error": { "type": "string" },
"message": { "type": "string" },
"details": {
"type": "object",
"properties": {
"issues": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {
"type": "array",
"items": { "anyOf": [{ "type": "string" }, { "type": "number" }] }
},
"code": { "type": "string" },
"message": { "type": "string" }
},
"required": ["path", "code", "message"]
}
}
},
"required": ["issues"]
}
},
"required": ["error"]
}
}
}
},
"403": {
"description": "HTTP 403",
"content": {
"application/json": {
"example": { "message": "string", "error": "string" },
"schema": {
"type": "object",
"properties": { "message": { "type": "string" }, "error": { "type": "string" } },
"required": ["error"]
}
}
}
},
"409": {
"description": "HTTP 409",
"content": {
"application/json": {
"example": {
"error": null,
"message": "string",
"requestedFrom": { "deviceClass": null, "requestedAt": "string" }
},
"schema": {
"type": "object",
"properties": {
"error": { "type": "string", "enum": ["confirmation_required"] },
"message": { "type": "string" },
"requestedFrom": {
"type": "object",
"properties": {
"deviceClass": {
"type": "string",
"enum": [
"ios",
"android",
"macos",
"windows",
"linux",
"chromeos",
"bot",
"unknown"
]
},
"requestedAt": { "type": "string" }
},
"required": ["deviceClass", "requestedAt"]
}
},
"required": ["error", "message", "requestedFrom"]
}
}
}
},
"429": {
"description": "HTTP 429",
"content": {
"application/json": {
"example": { "message": "string", "error": "string" },
"schema": {
"type": "object",
"properties": { "message": { "type": "string" }, "error": { "type": "string" } },
"required": ["error"]
}
}
}
},
"500": {
"description": "HTTP 500",
"content": {
"application/json": {
"example": { "message": "string", "error": "string" },
"schema": {
"type": "object",
"properties": { "message": { "type": "string" }, "error": { "type": "string" } },
"required": ["error"]
}
}
}
}
}
}
},
"/magic-link/verify/{token}/confirm": {
"post": {
"summary": "Confirm a magic link opened on another device",
"description": "Consumes the link after the person who opened it has approved signing in the device that requested it.",
"tags": ["MagicLinks"],
"parameters": [
{ "schema": { "type": "string" }, "required": true, "name": "token", "in": "path" }
Expand Down
Loading
Loading