-
Notifications
You must be signed in to change notification settings - Fork 155
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add magic link login #213
Open
kieranm
wants to merge
2
commits into
toolbeam:master
Choose a base branch
from
kieranm:magic-link-auth
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,228 @@ | ||
import crypto from "node:crypto" | ||
import type { Context } from "hono" | ||
import { Provider } from "./provider.js" | ||
import { getRelativeUrl } from "../util.js" | ||
import { timingSafeCompare } from "../random.js" | ||
|
||
/** | ||
* Configures a provider that supports Magic Link authentication. This is usually paired with the | ||
* `MagicLinkUI`. | ||
* | ||
* ```ts | ||
* import { MagicLinkUI } from "@openauthjs/openauth/ui/magic-link" | ||
* import { MagicLinkProvider } from "@openauthjs/openauth/provider/magic-link" | ||
* | ||
* export default issuer({ | ||
* providers: { | ||
* magicLink: MagicLinkProvider( | ||
* MagicLinkUI({ | ||
* copy: { | ||
* link_info: "We'll send a link to your email" | ||
* }, | ||
* sendLink: (claims, link) => console.log(claims.email, link) | ||
* }) | ||
* ) | ||
* }, | ||
* // ... | ||
* }) | ||
* ``` | ||
* | ||
* You can customize the provider using. | ||
* | ||
* ```ts {7-9} | ||
* const ui = MagicLinkUI({ | ||
* // ... | ||
* }) | ||
* | ||
* export default issuer({ | ||
* providers: { | ||
* magicLink: MagicLinkProvider( | ||
* { ...ui, expiry: 3600 * 24 } // 1 day expiry time | ||
* ) | ||
* }, | ||
* // ... | ||
* }) | ||
* ``` | ||
* | ||
* Behind the scenes, the `MagicLinkProvider` expects callbacks that implements request handlers | ||
* that generate the UI for the following. | ||
* | ||
* ```ts | ||
* MagicLinkProvider({ | ||
* // ... | ||
* request: (req, state, form, error) => Promise<Response> | ||
* }) | ||
* ``` | ||
* | ||
* This allows you to create your own UI. | ||
* | ||
* @packageDocumentation | ||
*/ | ||
|
||
export interface MagicLinkProviderConfig< | ||
Claims extends Record<string, string> = Record<string, string>, | ||
> { | ||
/** | ||
* The time for which the magic link is valid in seconds | ||
* | ||
* @default 3600 | ||
*/ | ||
expiry?: number | ||
/** | ||
* The request handler to generate the UI for the magic link flow. | ||
* | ||
* Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) | ||
* and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) | ||
* ojects. | ||
* | ||
* Also passes in the current `state` of the flow and any `error` that occurred. | ||
* | ||
* Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object | ||
* in return. | ||
*/ | ||
request: ( | ||
req: Request, | ||
state: MagicLinkProviderState, | ||
form?: FormData, | ||
error?: MagicLinkProviderError, | ||
) => Promise<Response> | ||
/** | ||
* Callback to send the magic link to the user. | ||
* | ||
* @example | ||
* ```ts | ||
* { | ||
* sendLink: async (claims, link) => { | ||
* // Send the magic link through the email or another route based on the claims | ||
* } | ||
* } | ||
* ``` | ||
*/ | ||
sendLink: ( | ||
claims: Claims, | ||
link: string, | ||
) => Promise<void | MagicLinkProviderError> | ||
} | ||
|
||
/** | ||
* The state of the magic link flow. | ||
* | ||
* | State | Description | | ||
* | ----- | ----------- | | ||
* | `start` | The user is asked to enter their email address or phone number to start the flow. | | ||
* | `code` | The user needs to enter the pin code to verify their _claim_. | | ||
*/ | ||
export type MagicLinkProviderState = | ||
| { | ||
type: "start" | ||
} | ||
| { | ||
type: "link" | ||
code: string | ||
state: string | ||
claims: Record<string, string> | ||
} | ||
|
||
/** | ||
* The errors that can happen on the magic link flow. | ||
* | ||
* | Error | Description | | ||
* | ----- | ----------- | | ||
* | `invalid_code` | The code is invalid. | | ||
* | `invalid_claim` | The _claim_, email or phone number, is invalid. | | ||
*/ | ||
export type MagicLinkProviderError = | ||
| { | ||
type: "invalid_code" | ||
} | ||
| { | ||
type: "invalid_claim" | ||
key: string | ||
value: string | ||
} | ||
|
||
export function MagicLinkProvider< | ||
Claims extends Record<string, string> = Record<string, string>, | ||
>(config: MagicLinkProviderConfig<Claims>): Provider<{ claims: Claims }> { | ||
const expiry = config.expiry ?? 3600 | ||
|
||
return { | ||
type: "magic-link", | ||
init(routes, ctx) { | ||
async function transition( | ||
c: Context, | ||
next: MagicLinkProviderState, | ||
fd?: FormData, | ||
err?: MagicLinkProviderError, | ||
) { | ||
await ctx.set<MagicLinkProviderState>(c, "provider", expiry, next) | ||
const resp = ctx.forward( | ||
c, | ||
await config.request(c.req.raw, next, fd, err), | ||
) | ||
return resp | ||
} | ||
|
||
routes.get("/authorize", async (c) => { | ||
const resp = await transition(c, { | ||
type: "start", | ||
}) | ||
return resp | ||
}) | ||
|
||
routes.post("/authorize", async (c) => { | ||
const code = crypto.randomBytes(32).toString("base64url") | ||
const state = crypto.randomUUID() | ||
const fd = await c.req.formData() | ||
const claims = Object.fromEntries(fd) as Claims | ||
|
||
const link = getRelativeUrl(c, `./callback?code=${code}&state=${state}`) | ||
|
||
const err = await config.sendLink(claims, link) | ||
if (err) return transition(c, { type: "start" }, fd, err) | ||
return transition( | ||
c, | ||
{ | ||
type: "link", | ||
claims, | ||
state, | ||
code, | ||
}, | ||
fd, | ||
) | ||
}) | ||
|
||
routes.get("/callback", async (c) => { | ||
const provider = (await ctx.get( | ||
c, | ||
"provider", | ||
)) as MagicLinkProviderState | ||
|
||
if (provider.type !== "link") | ||
return c.redirect(getRelativeUrl(c, "./authorize")) | ||
|
||
const code = c.req.query("code") | ||
const state = c.req.query("state") | ||
|
||
if (!provider || !code || (provider.state && state !== provider.state)) | ||
return c.redirect(getRelativeUrl(c, "./authorize")) | ||
|
||
if (!timingSafeCompare(code, provider.code)) { | ||
return transition(c, provider, undefined, { type: "invalid_code" }) | ||
} | ||
|
||
// Success | ||
await ctx.unset(c, "provider") | ||
return ctx.forward( | ||
c, | ||
await ctx.success(c, { claims: provider.claims as Claims }), | ||
) | ||
}) | ||
}, | ||
} | ||
} | ||
|
||
/** | ||
* @internal | ||
*/ | ||
export type MagicLinkProviderOptions = Parameters<typeof MagicLinkProvider>[0] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
/** | ||
* Configure the UI that's used by the Magic Link provider. | ||
* | ||
* ```ts {1,7-12} | ||
* import { MagicLinkUI } from "@openauthjs/openauth/ui/magic-link" | ||
* import { MagicLinkProvider } from "@openauthjs/openauth/provider/magic-link" | ||
* | ||
* export default issuer({ | ||
* providers: { | ||
* magicLink: MagicLinkProvider( | ||
* MagicLinkUI({ | ||
* copy: { | ||
* link_info: "We'll send a link to your email" | ||
* }, | ||
* sendLink: (claims, link) => console.log(claims.email, link) | ||
* }) | ||
* ) | ||
* }, | ||
* // ... | ||
* }) | ||
* ``` | ||
* | ||
* @packageDocumentation | ||
*/ | ||
/** @jsxImportSource hono/jsx */ | ||
|
||
import { Layout } from "./base.js" | ||
import { FormAlert } from "./form.js" | ||
import { MagicLinkProviderOptions } from "../provider/magic-link.js" | ||
|
||
const DEFAULT_COPY = { | ||
/** | ||
* Copy for the email input. | ||
*/ | ||
email_placeholder: "Email", | ||
/** | ||
* Error message when the email is invalid. | ||
*/ | ||
email_invalid: "Email address is not valid", | ||
/** | ||
* Copy for the continue button. | ||
*/ | ||
button_continue: "Continue", | ||
/** | ||
* Copy informing that the link will be emailed. | ||
*/ | ||
link_info: "We'll send a link to your email.", | ||
/** | ||
* Copy for when the link was sent. | ||
*/ | ||
link_sent: "Link sent to ", | ||
} | ||
|
||
export type MagicLinkUICopy = typeof DEFAULT_COPY | ||
|
||
/** | ||
* Configure the Magic Link UI | ||
*/ | ||
export interface MagicLinkUIOptions { | ||
/** | ||
* Callback to send the magic link to the user. | ||
* | ||
* The `claims` object contains the email of the user. You can send the magic link | ||
* using this. | ||
* | ||
* @example | ||
* ```ts | ||
* async (claims, link) => { | ||
* // Send the link via the claim | ||
* } | ||
* ``` | ||
*/ | ||
sendLink: (claims: Record<string, string>, link: string) => Promise<void> | ||
/** | ||
* Custom copy for the UI. | ||
*/ | ||
copy?: Partial<MagicLinkUICopy> | ||
} | ||
|
||
/** | ||
* Creates a UI for the Magic Link provider flow | ||
* @param props - Configure the UI. | ||
*/ | ||
export function MagicLinkUI( | ||
props: MagicLinkUIOptions, | ||
): MagicLinkProviderOptions { | ||
const copy = { | ||
...DEFAULT_COPY, | ||
...props.copy, | ||
} | ||
|
||
return { | ||
sendLink: props.sendLink, | ||
request: async (_req, state, _form, error): Promise<Response> => { | ||
const jsx = ( | ||
<Layout> | ||
<form data-component="form" method="post"> | ||
{error?.type === "invalid_claim" && ( | ||
<FormAlert message={copy.email_invalid} /> | ||
)} | ||
{state.type === "link" && ( | ||
<FormAlert | ||
message={copy.link_sent + state.claims.email} | ||
color="success" | ||
/> | ||
)} | ||
<input | ||
data-component="input" | ||
autofocus | ||
type="email" | ||
name="email" | ||
inputmode="email" | ||
required | ||
placeholder={copy.email_placeholder} | ||
/> | ||
<button data-component="button">{copy.button_continue}</button> | ||
</form> | ||
<p data-component="form-footer">{copy.link_info}</p> | ||
</Layout> | ||
) | ||
return new Response(jsx.toString(), { | ||
headers: { | ||
"Content-Type": "text/html", | ||
}, | ||
}) | ||
}, | ||
} | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not 100% sure this is the right way to handle expiry