Skip to content

Commit 82f3fa4

Browse files
committed
feat: add magic link to core package
1 parent 58d421e commit 82f3fa4

File tree

4 files changed

+185
-0
lines changed

4 files changed

+185
-0
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { authFetch } from "../authFetch.js";
2+
import type { CookiePayload } from "../ensureCookies.js";
3+
import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js";
4+
5+
export interface PollMagicLinkConfirmationInput {
6+
authorization?: string;
7+
}
8+
9+
export interface PollMagicLinkConfirmationOptions {
10+
authServerUrl: string;
11+
cookieDomain?: string;
12+
accessCookieName: string;
13+
refreshCookieName: string;
14+
}
15+
16+
export interface PollMagicLinkConfirmationResult {
17+
status: number;
18+
body?: unknown;
19+
error?: unknown;
20+
setCookies?: {
21+
name: string;
22+
value: CookiePayload;
23+
ttl: number;
24+
domain?: string;
25+
}[];
26+
}
27+
28+
export async function pollMagicLinkConfirmationHandler(
29+
input: PollMagicLinkConfirmationInput,
30+
opts: PollMagicLinkConfirmationOptions,
31+
): Promise<PollMagicLinkConfirmationResult> {
32+
const up = await authFetch(`${opts.authServerUrl}/magic-link/check`, {
33+
method: "GET",
34+
authorization: input.authorization,
35+
});
36+
37+
// 👇 Pending state (important for polling UX)
38+
if (up.status === 204) {
39+
return {
40+
status: 204,
41+
body: { message: "Not verified." },
42+
};
43+
}
44+
45+
const data = await up.json();
46+
47+
if (!up.ok) {
48+
return {
49+
status: up.status,
50+
error: data,
51+
};
52+
}
53+
54+
// 👇 Web mode: auth server already handled cookies
55+
if (!data?.token || !data?.refreshToken || !data?.sub) {
56+
return {
57+
status: up.status,
58+
body: data,
59+
};
60+
}
61+
62+
// 🔐 Verify signed response (same as WebAuthn flow)
63+
const verifiedAccessToken = await verifySignedAuthResponse(
64+
data.token,
65+
opts.authServerUrl,
66+
);
67+
68+
if (!verifiedAccessToken) {
69+
throw new Error("Invalid signed response from Auth Server");
70+
}
71+
72+
if (verifiedAccessToken.sub !== data.sub) {
73+
throw new Error("Signature mismatch with data payload");
74+
}
75+
76+
return {
77+
status: 200,
78+
body: data,
79+
setCookies: [
80+
{
81+
name: opts.accessCookieName,
82+
value: {
83+
sub: data.sub,
84+
roles: data.roles,
85+
email: data.email,
86+
phone: data.phone,
87+
},
88+
ttl: data.ttl,
89+
domain: opts.cookieDomain,
90+
},
91+
{
92+
name: opts.refreshCookieName,
93+
value: {
94+
sub: data.sub,
95+
refreshToken: data.refreshToken,
96+
},
97+
ttl: data.refreshTtl,
98+
domain: opts.cookieDomain,
99+
},
100+
],
101+
};
102+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { authFetch } from "../authFetch.js";
2+
3+
export interface RequestMagicLinkInput {
4+
authorization?: string;
5+
}
6+
7+
export interface RequestMagicLinkOptions {
8+
authServerUrl: string;
9+
}
10+
11+
export interface RequestMagicLinkResult {
12+
status: number;
13+
body?: unknown;
14+
error?: unknown;
15+
}
16+
17+
export async function requestMagicLinkHandler(
18+
input: RequestMagicLinkInput,
19+
opts: RequestMagicLinkOptions,
20+
): Promise<RequestMagicLinkResult> {
21+
const up = await authFetch(`${opts.authServerUrl}/magic-link`, {
22+
method: "GET",
23+
authorization: input.authorization,
24+
});
25+
26+
const data = await up.json();
27+
28+
if (!up.ok) {
29+
return {
30+
status: up.status,
31+
error: data,
32+
};
33+
}
34+
35+
return {
36+
status: up.status,
37+
body: data,
38+
};
39+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { authFetch } from "../authFetch.js";
2+
3+
export interface VerifyMagicLinkInput {
4+
token: string;
5+
}
6+
7+
export interface VerifyMagicLinkOptions {
8+
authServerUrl: string;
9+
}
10+
11+
export interface VerifyMagicLinkResult {
12+
status: number;
13+
body?: unknown;
14+
error?: unknown;
15+
}
16+
17+
export async function verifyMagicLinkHandler(
18+
input: VerifyMagicLinkInput,
19+
opts: VerifyMagicLinkOptions,
20+
): Promise<VerifyMagicLinkResult> {
21+
const up = await authFetch(
22+
`${opts.authServerUrl}/magic-link/verify/${input.token}`,
23+
{
24+
method: "GET",
25+
},
26+
);
27+
28+
const data = await up.json();
29+
30+
if (!up.ok) {
31+
return {
32+
status: up.status,
33+
error: data,
34+
};
35+
}
36+
37+
return {
38+
status: up.status,
39+
body: data,
40+
};
41+
}

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ export * from "./handlers/register.js";
1111
export * from "./handlers/finishRegister.js";
1212
export * from "./handlers/logout.js";
1313
export * from "./handlers/me.js";
14+
export * from "./handlers/verifyMagicLinkHandler.js";
15+
export * from "./handlers/requestMagicLinkHandler.js";
16+
export * from "./handlers/pollMagicLinkConfirmationHandler.js";

0 commit comments

Comments
 (0)