-
Notifications
You must be signed in to change notification settings - Fork 188
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add SIWE implementation for app router
- Loading branch information
1 parent
d1b6b06
commit da9407b
Showing
3 changed files
with
250 additions
and
0 deletions.
There are no files selected for viewing
182 changes: 182 additions & 0 deletions
182
packages/connectkit-next-siwe/src/app-router/configureSIWE.ts
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,182 @@ | ||
import { IronSessionOptions } from 'iron-session'; | ||
import { NextRequest, NextResponse } from 'next/server'; | ||
import { generateNonce, SiweErrorType, SiweMessage } from 'siwe'; | ||
import { Address } from 'viem'; | ||
import Session, { type Cookies } from './session'; | ||
|
||
type RouteHandlerOptions = { | ||
afterNonce?: (req: NextRequest, session: NextSIWESession<{}>) => Promise<void>; | ||
afterVerify?: (req: NextRequest, session: NextSIWESession<{}>) => Promise<void>; | ||
afterSession?: (req: NextRequest, session: NextSIWESession<{}>) => Promise<void>; | ||
afterLogout?: (req: NextRequest) => Promise<void>; | ||
}; | ||
type NextServerSIWEConfig = { | ||
session?: Partial<IronSessionOptions>; | ||
options?: RouteHandlerOptions; | ||
}; | ||
|
||
type NextSIWESession<TSessionData extends Object = {}> = Session & TSessionData; | ||
|
||
type NextRouteHandler = (req: NextRequest, { params }: { params: any }) => Promise<Response> | ||
type ConfigureServerSIWEResult<TSessionData extends Object = {}> = { | ||
apiRouteHandler: { | ||
GET: NextRouteHandler; | ||
POST: NextRouteHandler; | ||
}; | ||
getSession: (cookies: Cookies) => Promise<NextSIWESession<TSessionData>>; | ||
}; | ||
|
||
const getSession = async <TSessionData extends Object = {}>( | ||
cookies: Cookies, | ||
sessionConfig: IronSessionOptions | ||
) => { | ||
const session = await Session.fromCookies(cookies, sessionConfig); | ||
return session as NextSIWESession<TSessionData>; | ||
}; | ||
|
||
const logoutRoute = async ( | ||
req: NextRequest, | ||
sessionConfig: IronSessionOptions, | ||
afterCallback?: RouteHandlerOptions['afterLogout'] | ||
): Promise<NextResponse<void>> => { | ||
const session = await getSession(req.cookies, sessionConfig); | ||
const res = new NextResponse<void>(); | ||
await session.destroy(res, sessionConfig); | ||
if (afterCallback) { | ||
await afterCallback(req); | ||
} | ||
return res; | ||
}; | ||
|
||
const nonceRoute = async ( | ||
req: NextRequest, | ||
sessionConfig: IronSessionOptions, | ||
afterCallback?: RouteHandlerOptions['afterNonce'] | ||
): Promise<NextResponse<string>> => { | ||
const session = await getSession(req.cookies, sessionConfig); | ||
let res: NextResponse<string> = new NextResponse<string>(session.nonce); | ||
if (!session.nonce) { | ||
session.nonce = generateNonce(); | ||
res = new NextResponse<string>(session.nonce); | ||
await session.save(res, sessionConfig); | ||
} | ||
if (afterCallback) { | ||
await afterCallback(req, session); | ||
} | ||
return res; | ||
}; | ||
|
||
const sessionRoute = async ( | ||
req: NextRequest, | ||
sessionConfig: IronSessionOptions, | ||
afterCallback?: RouteHandlerOptions['afterSession'] | ||
): Promise<NextResponse<{ address?: string; chainId?: number }>> => { | ||
const session = await getSession(req.cookies, sessionConfig); | ||
if (afterCallback) { | ||
await afterCallback(req, session); | ||
} | ||
const { address, chainId } = session; | ||
return NextResponse.json({ address, chainId }); | ||
}; | ||
|
||
const verifyRoute = async ( | ||
req: NextRequest, | ||
sessionConfig: IronSessionOptions, | ||
afterCallback?: RouteHandlerOptions['afterVerify'] | ||
): Promise<NextResponse<void>> => { | ||
try { | ||
const session = await getSession(req.cookies, sessionConfig); | ||
const { message, signature } = await req.json(); | ||
const siweMessage = new SiweMessage(message); | ||
const { data: fields } = await siweMessage.verify({ signature, nonce: session.nonce }); | ||
if (fields.nonce !== session.nonce) { | ||
return new NextResponse('Invalid nonce.', { status: 422 }); | ||
} | ||
session.address = fields.address as Address; | ||
session.chainId = fields.chainId; | ||
const res = new NextResponse<void>() | ||
await session.save(res, sessionConfig); | ||
if (afterCallback) { | ||
await afterCallback(req, session); | ||
} | ||
return res; | ||
} catch (error) { | ||
switch (error) { | ||
case SiweErrorType.INVALID_NONCE: | ||
case SiweErrorType.INVALID_SIGNATURE: { | ||
return new NextResponse(String(error), { status: 422 }); | ||
} | ||
default: { | ||
return new NextResponse(String(error), { status: 400 }); | ||
} | ||
} | ||
} | ||
}; | ||
|
||
const envVar = (name: string) => { | ||
const value = process.env[name]; | ||
if (!value) { | ||
throw new Error(`Missing environment variable: ${name}`); | ||
} | ||
return value; | ||
}; | ||
|
||
export const configureServerSideSIWE = <TSessionData extends Object = {}>({ | ||
session: { cookieName, password, cookieOptions, ...otherSessionOptions } = {}, | ||
options: { afterNonce, afterVerify, afterSession, afterLogout } = {}, | ||
}: NextServerSIWEConfig): ConfigureServerSIWEResult<TSessionData> => { | ||
const sessionConfig: IronSessionOptions = { | ||
cookieName: cookieName ?? 'connectkit-next-siwe', | ||
password: password ?? envVar('SESSION_SECRET'), | ||
cookieOptions: { | ||
secure: process.env.NODE_ENV === 'production', | ||
...(cookieOptions ?? {}), | ||
}, | ||
...otherSessionOptions, | ||
}; | ||
|
||
function checkRouteParam(params: any): asserts params is { route: string[] } { | ||
if (!(params.route instanceof Array)) { | ||
throw new Error( | ||
'Catch-all query param `route` not found. SIWE API page should be named `[...route].ts` and within your `app/api` directory.' | ||
); | ||
} | ||
} | ||
|
||
const GET: NextRouteHandler = async (req: NextRequest, { params }: { params: any }) => { | ||
checkRouteParam(params); | ||
|
||
const route = params.route.join('/'); | ||
switch (route) { | ||
case 'nonce': | ||
return await nonceRoute(req, sessionConfig, afterNonce); | ||
case 'session': | ||
return await sessionRoute(req, sessionConfig, afterSession); | ||
case 'logout': | ||
return await logoutRoute(req, sessionConfig, afterLogout); | ||
default: | ||
return new Response(null, { status: 404 }); | ||
} | ||
} | ||
|
||
const POST: NextRouteHandler = async (req: NextRequest, { params }: { params: any }) => { | ||
checkRouteParam(params); | ||
|
||
const route = params.route.join('/'); | ||
switch (route) { | ||
case 'verify': | ||
return await verifyRoute(req, sessionConfig, afterVerify); | ||
default: | ||
return new Response(null, { status: 404 }); | ||
} | ||
} | ||
|
||
return { | ||
apiRouteHandler: { | ||
GET, | ||
POST, | ||
}, | ||
getSession: async (cookies: Cookies) => | ||
await getSession<TSessionData>(cookies, sessionConfig), | ||
}; | ||
}; |
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,67 @@ | ||
/** | ||
* iron-session has not been updated for the Next.js 13 App Router yet. This | ||
* class is a shim for the `getIronSession(req, res, options)` function that | ||
* iron-session provides. It uses the lower-level `sealData` and `unsealData` | ||
* APIs and interacts with the request cookies directly. | ||
* | ||
* Adapted from https://github.com/m1guelpf/nextjs13-connectkit-siwe | ||
*/ | ||
|
||
import { sealData, unsealData, type IronSessionOptions } from 'iron-session' | ||
import { RequestCookies } from 'next/dist/compiled/@edge-runtime/cookies' | ||
import { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies' | ||
import { NextResponse } from 'next/server' | ||
import { Address } from 'viem' | ||
|
||
export type Cookies = RequestCookies | ReadonlyRequestCookies; | ||
|
||
export type SerializedSession = { | ||
nonce?: string | ||
chainId?: number | ||
address?: Address | ||
} | ||
|
||
export default class Session { | ||
nonce?: string | ||
chainId?: number | ||
address?: Address | ||
|
||
constructor(session?: SerializedSession) { | ||
this.nonce = session?.nonce | ||
this.chainId = session?.chainId | ||
this.address = session?.address | ||
} | ||
|
||
static async fromCookies( | ||
cookies: RequestCookies | ReadonlyRequestCookies, | ||
config: IronSessionOptions | ||
): Promise<Session> { | ||
const sessionCookie = cookies.get(config.cookieName)?.value; | ||
|
||
if (!sessionCookie) return new Session(); | ||
return new Session(await unsealData<SerializedSession>(sessionCookie, config)); | ||
} | ||
|
||
async destroy(res: NextResponse, config: IronSessionOptions) { | ||
this.nonce = undefined; | ||
this.chainId = undefined; | ||
this.address = undefined; | ||
|
||
return this.save(res, config) | ||
} | ||
|
||
async save(res: NextResponse, config: IronSessionOptions) { | ||
const data = await sealData(this.toJSON(), config); | ||
|
||
// TODO: iron-session manages some default options for these cookies | ||
// Some of these make development much easier, like setting `max-age` based | ||
// on the `ttl`, and defaulting that to 14 days rather than expiring after | ||
// the session ends | ||
// https://github.com/vvo/iron-session/blob/bf7b808707a436a14b9fa4d1f224f9490591a638/src/core.ts#L97 | ||
res.cookies.set(config.cookieName, data, config.cookieOptions); | ||
} | ||
|
||
toJSON(): SerializedSession { | ||
return { nonce: this.nonce, address: this.address, chainId: this.chainId }; | ||
} | ||
} |
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 |
---|---|---|
@@ -1,2 +1,3 @@ | ||
export { configureClientSIWE } from './client' | ||
export { configureServerSideSIWE } from './configureSIWE'; | ||
export { configureServerSideSIWE as configureServerSideSIWEAppRouter } from './app-router/configureSIWE'; |