;
4 | export default GreetAdmin;
5 |
6 | // no need to manually check if admin on each page here
7 | // middleware makes life easy!
8 |
--------------------------------------------------------------------------------
/src/server/trpc.ts:
--------------------------------------------------------------------------------
1 | import { Context } from "./context";
2 | import { initTRPC } from "@trpc/server";
3 |
4 | const t = initTRPC.context().create();
5 |
6 | export const router = t.router;
7 |
8 | export const publicProcedure = t.procedure;
9 |
--------------------------------------------------------------------------------
/pages/api/trpc/[trpc].ts:
--------------------------------------------------------------------------------
1 | import * as trpcNext from "@trpc/server/adapters/next";
2 | import { createContext } from "src/server/context";
3 | import { appRouter } from "src/server/routers/_app";
4 |
5 | export default trpcNext.createNextApiHandler({
6 | router: appRouter,
7 | createContext: createContext,
8 | });
9 |
--------------------------------------------------------------------------------
/.env.production:
--------------------------------------------------------------------------------
1 | # ⚠️ The SECRET_COOKIE_PASSWORD should never be inside your repository directly, it's here only to ease
2 | # the example deployment
3 | # For production you should use https://vercel.com/blog/environment-variables-ui if you're hosted on Vercel or
4 | # any other secret environment variable mean
5 |
6 | SECRET_COOKIE_PASSWORD=2gyZ3GDw3LHZQKDhPmPDL3sjREVRXPr8
--------------------------------------------------------------------------------
/.env.development:
--------------------------------------------------------------------------------
1 | # ⚠️ The SECRET_COOKIE_PASSWORD should never be inside your repository directly, it's here only to ease
2 | # the example deployment
3 | # For local development, you should store it inside a `.env.local` gitignored file
4 | # See https://nextjs.org/docs/basic-features/environment-variables#loading-environment-variables
5 |
6 | SECRET_COOKIE_PASSWORD=2gyZ3GDw3LHZQKDhPmPDL3sjREVRXPr8
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { AppProps } from "next/app";
2 | import { trpc } from "src/utils/trpc";
3 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
4 |
5 | function MyApp({ Component, pageProps }: AppProps) {
6 | return (
7 | <>
8 |
9 |
10 | >
11 | );
12 | }
13 |
14 | export default trpc.withTRPC(MyApp);
15 |
--------------------------------------------------------------------------------
/src/server/context.ts:
--------------------------------------------------------------------------------
1 | import * as trpc from "@trpc/server";
2 | import * as trpcNext from "@trpc/server/adapters/next";
3 | import { getIronSession } from "iron-session";
4 | import { sessionOptions } from "lib/session";
5 |
6 | export async function createContext(opts: trpcNext.CreateNextContextOptions) {
7 | const session = await getIronSession(opts.req, opts.res, sessionOptions);
8 |
9 | return {
10 | session,
11 | };
12 | }
13 |
14 | export type Context = trpc.inferAsyncReturnType;
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
--------------------------------------------------------------------------------
/lib/session.ts:
--------------------------------------------------------------------------------
1 | // this file is a wrapper with defaults to be used in both API routes and `getServerSideProps` functions
2 | import type { IronSessionOptions } from "iron-session";
3 |
4 | export type User = {
5 | isLoggedIn: boolean;
6 | login: string;
7 | avatarUrl: string;
8 | };
9 |
10 | export const sessionOptions: IronSessionOptions = {
11 | password: process.env.SECRET_COOKIE_PASSWORD as string,
12 | cookieName: "iron-session/examples/next.js",
13 | cookieOptions: {
14 | secure: process.env.NODE_ENV === "production",
15 | },
16 | };
17 |
18 | // This is where we specify the typings of req.session.*
19 | declare module "iron-session" {
20 | interface IronSessionData {
21 | user?: User;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "baseUrl": ".",
21 | "incremental": true
22 | },
23 | "include": [
24 | "next-env.d.ts",
25 | "**/*.ts",
26 | "**/*.tsx",
27 | "next.config.js"
28 | ],
29 | "exclude": [
30 | "node_modules"
31 | ]
32 | }
33 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import type { NextRequest } from "next/server";
3 | import { getIronSession } from "iron-session/edge";
4 | import { sessionOptions } from "lib/session";
5 |
6 | export const middleware = async (req: NextRequest) => {
7 | const res = NextResponse.next();
8 | const session = await getIronSession(req, res, sessionOptions);
9 |
10 | // do anything with session here:
11 | const { user } = session;
12 |
13 | // like mutate user:
14 | // user.something = someOtherThing;
15 | // or:
16 | // session.user = someoneElse;
17 |
18 | // uncomment next line to commit changes:
19 | // await session.save();
20 | // or maybe you want to destroy session:
21 | // await session.destroy();
22 |
23 | console.log("from middleware", { user });
24 |
25 | // demo:
26 | if (user?.login !== "vvo") {
27 | return new NextResponse(null, { status: 403 }); // unauthorized to see pages inside admin/
28 | }
29 |
30 | return res;
31 | };
32 |
33 | export const config = {
34 | matcher: "/admin",
35 | };
36 |
--------------------------------------------------------------------------------
/components/Form.tsx:
--------------------------------------------------------------------------------
1 | import { FormEvent } from "react";
2 |
3 | export default function Form({
4 | errorMessage,
5 | onSubmit,
6 | }: {
7 | errorMessage: string;
8 | onSubmit: (e: FormEvent) => void;
9 | }) {
10 | return (
11 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import Header from "components/Header";
3 |
4 | export default function Layout({ children }: { children: React.ReactNode }) {
5 | return (
6 | <>
7 |
8 | With Iron Session
9 |
10 |
32 |
33 |
34 |
35 |
{children}
36 |
37 | >
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Kyungeun Park
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.ko.md:
--------------------------------------------------------------------------------
1 | # trpc-iron-session
2 |
3 | [English](./README.md) | [`한국어(KR)`](./README.ko.md)
4 |
5 | `iron-session` 인증을 기반으로 `tRPC` 를 보호합니다.
6 |
7 | [iron-session examples next.js-typescript][project-structure-based] 프로젝트를 기반으로 구축되었습니다.
8 |
9 | 프로젝트 구조를 최대한 바꾸지 않고 `HTTP API` 부분을 `tRPC` 로 변경했습니다.
10 |
11 | 덕분에 해당 리포에 [iron-session examples next.js-typescript][project-structure-based]를 그대로 넣으면 `diff` 가 되어 변경 사항을 쉽게 확인할 수 있습니다.
12 |
13 | > 예제의 `SSG` 페이지는 `tRPC` 의 `SSR` 옵션 때문에 의미가 없어졌습니다. 🙄
14 |
15 | ## Start
16 |
17 | ```shell
18 | npm i
19 | npm run dev
20 | ```
21 |
22 | ## Core
23 |
24 | ### SSR에 Cookies 전달하기
25 |
26 | `SSR` 을 통해서 로그인한 사용자의 경우 `` 부분에 사용자 정보를 빠르게 표시하고 싶을 수 있습니다.
27 |
28 | 이렇게 하려면 `SSR` 동안 `tRPC` 에게 쿠키를 전달해야 합니다.
29 |
30 | 관련 코드는 [src/utils/trpc.ts](./src/utils/trpc.ts) 를 참고해주세요.
31 |
32 | ### `tRPC` Context에 `iron-session` 값 전달
33 |
34 | `iron-session` 에 익숙하다면 알겠지만 `req.session` 을 사용하여 쿠키를 조작합니다.
35 |
36 | 이를 위해서 `tRPC` 는 `Context` 로 값을 전달해야 합니다.
37 |
38 | 관련 코드는 [src/server/context.ts](./src/server/context.ts) 를 참고해주세요.
39 |
40 | ## Reference
41 |
42 | [해당 프로젝트를 기반으로 스케폴드 진행][project-structure-based]
43 |
44 | [project-structure-based]: https://github.com/vvo/iron-session/blob/main/examples/next.js-typescript/README.md
45 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next.js-typescript",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "next build",
7 | "dev": "next dev",
8 | "start": "next start"
9 | },
10 | "dependencies": {
11 | "@tanstack/react-query": "^4.14.5",
12 | "@trpc/client": "^10.0.0-rc.6",
13 | "@trpc/next": "^10.0.0-rc.6",
14 | "@trpc/react-query": "^10.0.0-rc.6",
15 | "@trpc/server": "^10.0.0-rc.6",
16 | "iron-session": "^6.2.0",
17 | "next": "^12.2.5",
18 | "octokit": "^2.0.7",
19 | "react": "^18.2.0",
20 | "react-dom": "^18.2.0",
21 | "zod": "^3.19.1"
22 | },
23 | "devDependencies": {
24 | "@octokit/types": "^7.0.0",
25 | "@tanstack/react-query-devtools": "^4.14.5",
26 | "@types/react": "^18.0.17",
27 | "eslint-config-next": "latest",
28 | "typescript": "^4.7.4"
29 | },
30 | "renovate": {
31 | "extends": [
32 | "config:js-app",
33 | ":automergePatch",
34 | ":automergeBranch",
35 | ":automergePatch",
36 | ":automergeBranch",
37 | ":automergeLinters",
38 | ":automergeTesters",
39 | ":automergeTypes"
40 | ],
41 | "timezone": "Europe/Paris",
42 | "schedule": [
43 | "before 3am on Monday"
44 | ]
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # trpc-iron-session
2 |
3 | [`English`](./README.md) | [한국어(KR)](./README.ko.md)
4 |
5 | Secure your tRPC based on `iron-session` authentication.
6 |
7 | The project structure is based on [iron-session examples next.js-typescript][project-structure-based]
8 |
9 | Changed the `HTTP API` part to `tRPC` without changing the project structure as much as possible.
10 |
11 | Thanks to this, if you pour [iron-session examples next.js-typescript][project-structure-based] here as it is, it will `diff` so you can easily see the changes.
12 |
13 | > that the `SSG` page in the example is meaningless because of the `SSR` option of `tRPC`. 🙄
14 |
15 | ## Start
16 |
17 | ```shell
18 | npm i
19 | npm run dev
20 | ```
21 |
22 | ## Core
23 |
24 | ### Forwarding SSR Cookies
25 |
26 | If you are logged in through `SSR` , you may want to quickly display user information in the `` .
27 |
28 | To do this, you need to pass cookies to tRPC during SSR.
29 |
30 | Please refer to [src/utils/trpc.ts](./src/utils/trpc.ts) for the related code.
31 |
32 | ### Passing iron-session value to tRPC Context
33 |
34 | If you are familiar with `iron-session`, we will use the `req.session` to manipulate cookies.
35 |
36 | `tRPC` must be passed as a `Context` to achieve this.
37 |
38 | Please refer to [src/server/context.ts](./src/server/context.ts) for the related code.
39 |
40 | ## Reference
41 |
42 | [Projects used for scaffolding][project-structure-based]
43 |
44 | [project-structure-based]: https://github.com/vvo/iron-session/blob/main/examples/next.js-typescript/README.md
45 |
--------------------------------------------------------------------------------
/pages/login.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import Layout from "components/Layout";
3 | import Form from "components/Form";
4 | import { trpc } from "src/utils/trpc";
5 | import { useRouter } from "next/router";
6 | import { withIronSessionSsr } from "iron-session/next";
7 | import { sessionOptions } from "lib/session";
8 |
9 | export default function Login() {
10 | const router = useRouter();
11 |
12 | const login = trpc.session.login.useMutation({
13 | onSuccess() {
14 | router.push("/profile-sg");
15 | },
16 | onError(err) {
17 | setErrorMsg(err.message);
18 | },
19 | });
20 |
21 | const [errorMsg, setErrorMsg] = useState("");
22 |
23 | return (
24 |
25 |
16 | This example creates an authentication system that uses a{" "}
17 | signed and encrypted cookie to store session data.
18 |
19 |
20 |
21 | It uses current best practices as for authentication in the Next.js
22 | ecosystem:
23 |
24 | 1. no `getInitialProps` to ensure every page is static
25 |
26 | 2. `useUser` hook together with `
27 | swr` for data fetching
28 |
29 |
30 |
Features
31 |
32 |
33 |
Logged in status synchronized between browser windows/tabs
34 |
Layout based on logged in status
35 |
All pages are static
36 |
Session data is signed and encrypted in a cookie
37 |
38 |
39 |
Steps to test the functionality:
40 |
41 |
42 |
Click login and enter your GitHub username.
43 |
44 | Click home and click profile again, notice how your session is being
45 | used through a token stored in a cookie.
46 |
47 |
48 | Click logout and try to go to profile again. You'll get
49 | redirected to the `/login` route.
50 |
51 |
52 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/server/routers/session.ts:
--------------------------------------------------------------------------------
1 | import { router, publicProcedure } from "src/server/trpc";
2 | import { z } from "zod";
3 | import { User } from "lib/session";
4 | import { Octokit } from "octokit";
5 | import type { Endpoints } from "@octokit/types";
6 | import { TRPCError } from "@trpc/server";
7 |
8 | const octokit = new Octokit();
9 | export type Events =
10 | Endpoints["GET /users/{username}/events"]["response"]["data"];
11 |
12 | export const sessionRouter = router({
13 | user: publicProcedure.query(async ({ ctx }) => {
14 | if (ctx.session.user) {
15 | return {
16 | ...ctx.session.user,
17 | isLoggedIn: true,
18 | };
19 | } else {
20 | return {
21 | isLoggedIn: false,
22 | login: "",
23 | avatarUrl: "",
24 | };
25 | }
26 | }),
27 | event: publicProcedure.query(async ({ ctx }) => {
28 | const user = ctx.session.user;
29 |
30 | if (!user || user.isLoggedIn === false) {
31 | throw new TRPCError({
32 | code: "UNAUTHORIZED",
33 | });
34 | }
35 |
36 | try {
37 | const { data: events } =
38 | await octokit.rest.activity.listPublicEventsForUser({
39 | username: user.login,
40 | });
41 |
42 | return events;
43 | } catch (error) {
44 | return [];
45 | }
46 | }),
47 | login: publicProcedure
48 | .input(
49 | z.object({
50 | username: z.string(),
51 | })
52 | )
53 | .mutation(async ({ ctx, input }) => {
54 | const { username } = input;
55 |
56 | try {
57 | const {
58 | data: { login, avatar_url },
59 | } = await octokit.rest.users.getByUsername({ username });
60 |
61 | const user = { isLoggedIn: true, login, avatarUrl: avatar_url } as User;
62 | ctx.session.user = user;
63 | await ctx.session.save();
64 | return user;
65 | } catch (error) {
66 | throw new TRPCError({
67 | code: "INTERNAL_SERVER_ERROR",
68 | message: (error as Error).message,
69 | });
70 | }
71 | }),
72 | logout: publicProcedure.mutation(async ({ ctx }) => {
73 | ctx.session.destroy();
74 | return { isLoggedIn: false, login: "", avatarUrl: "" };
75 | }),
76 | });
77 |
--------------------------------------------------------------------------------
/src/utils/trpc.ts:
--------------------------------------------------------------------------------
1 | import { httpBatchLink, loggerLink } from "@trpc/client";
2 | import { createTRPCNext } from "@trpc/next";
3 | import { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
4 | // ℹ️ Type-only import: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export
5 | import type { AppRouter } from "src/server/routers/_app";
6 |
7 | function getBaseUrl() {
8 | // 브라우저는 상대 경로를 사용해야 합니다: 해당 코드가 있어야 Client Side에서 서빙되는 도메인으로 요청이 됩니다
9 | if (typeof window !== "undefined") {
10 | return "";
11 | }
12 |
13 | return `http://localhost:${process.env.PORT ?? 3000}`;
14 | }
15 |
16 | export const trpc = createTRPCNext({
17 | config({ ctx }) {
18 | return {
19 | links: [
20 | // 개발 중인 콘솔에 예쁜 로그를 추가하고 프로덕션에서 오류를 기록합니다.
21 | loggerLink({
22 | enabled: (opts) =>
23 | process.env.NODE_ENV === "development" ||
24 | (opts.direction === "down" && opts.result instanceof Error),
25 | }),
26 | httpBatchLink({
27 | url: `${getBaseUrl()}/api/trpc`,
28 | headers() {
29 | if (ctx?.req) {
30 | /**
31 | * SSR을 제대로 사용하려면 클라이언트의 헤더를 서버로 전달해야 합니다.
32 | * 이는 SSR시 쿠키와 같은 것을 전달할 수 있도록 하기 위한 것입니다.
33 | * 해당 작업을 진행하지 않으면 SSR시 쿠키가 전달되지 않음으로 SSR에서 쿠키 값으로 사용자 정보를 SSR 굽는 등 iron-session 처리를 할 수 없습니다.
34 | * 햇갈리면 안되는 것은 해당 작업은 SSR시 처리를 위함이며 해당 코드가 없다고 해서 Client Side에서 호출되는 tRPC가 iron-session 처리를 못한다는 것이 아닙니다.
35 | * @see [가져온 코드 Origin](https://trpc.io/docs/v10/ssr)
36 | */
37 | const { connection: _connection, ...headers } = ctx.req.headers;
38 | return {
39 | ...headers,
40 | };
41 | }
42 | return {};
43 | },
44 | }),
45 | ],
46 | queryClientConfig: {
47 | defaultOptions: {
48 | queries: {
49 | // react-query 옵션 설정 방법 예제 추가하는 겸 재시도 계속하면 디버깅 힘들어서 설정
50 | retry: 1,
51 | },
52 | },
53 | },
54 | };
55 | },
56 | ssr: true,
57 | });
58 |
59 | export type RouterInput = inferRouterInputs;
60 | export type RouterOutput = inferRouterOutputs;
61 |
--------------------------------------------------------------------------------
/pages/profile-sg.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Layout from "components/Layout";
3 | import { trpc } from "src/utils/trpc";
4 | import { withIronSessionSsr } from "iron-session/next";
5 | import { sessionOptions } from "lib/session";
6 | import { useRouter } from "next/router";
7 |
8 | // Make sure to check https://nextjs.org/docs/basic-features/layouts for more info on how to use layouts
9 | export default function SgProfile() {
10 | const router = useRouter();
11 |
12 | const userQuery = trpc.session.user.useQuery(undefined, {
13 | /**
14 | * NOTE: 꼭 사용자 정보 쿼리가 사용자 정보가 없는 경우 로그인 페이지로 리디렉션 시켜야하는 것은 아닙니다.
15 | * `` 와 같이 모든 페이지에서 호출되긴 하는데 정보가 없으면 없는데로 사용하는 경우가 있기 때문입니다.
16 | */
17 | onSuccess(data) {
18 | if (data.isLoggedIn === false) {
19 | router.push("/login");
20 | }
21 | },
22 | });
23 | const eventQuery = trpc.session.event.useQuery(undefined, {
24 | // 사용자가 로그인한 경우에만 수행합니다.
25 | enabled: userQuery.data?.isLoggedIn,
26 | });
27 |
28 | const user = userQuery.data;
29 | const events = eventQuery.data;
30 |
31 | return (
32 |
33 |