Skip to content

Commit

Permalink
feat: use Next.js app router + upgrade to next-auth@5 (beta) (#366)
Browse files Browse the repository at this point in the history
  • Loading branch information
vincentchalamon authored Mar 18, 2024
1 parent 46a2fa8 commit d24b123
Show file tree
Hide file tree
Showing 67 changed files with 1,059 additions and 970 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ jobs:
KEYCLOAK_DOCKER_IMAGE: europe-west1-docker.pkg.dev/${{ secrets.GKE_PROJECT }}/${{ secrets.GKE_PROJECT }}/keycloak:latest
APP_SECRET: ba63418865d58089f7f070e0a437b6d16b1fb970
CADDY_MERCURE_JWT_SECRET: 33b04d361e437e0d7d715600fc24fdefba317154
NEXTAUTH_SECRET: 77e4c3f5a6fb652b6245a5df8a704e04ad90bc7e
AUTH_SECRET: 77e4c3f5a6fb652b6245a5df8a704e04ad90bc7e
POSTGRES_PASSWORD: aae5bf316ef5fe87ad806c6a9240fff68bcfdaf7
KEYCLOAK_POSTGRES_PASSWORD: 26d7f630f1524eb210bbf496443f2038a9316e9e
KEYCLOAK_ADMIN_PASSWORD: 2f31e2fad93941b818449fd8d57fd019b6ce7fa5
Expand Down
2 changes: 1 addition & 1 deletion api/.env
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
TRUSTED_HOSTS=^(localhost|php)$
OIDC_SERVER_URL=https://localhost/oidc/realms/demo
OIDC_SERVER_URL_INTERNAL=http://php/oidc/realms/demo
OIDC_SERVER_URL_INTERNAL=http://keycloak:8080/oidc/realms/demo
OIDC_SWAGGER_CLIENT_ID=api-platform-swagger

###> symfony/framework-bundle ###
Expand Down
2 changes: 0 additions & 2 deletions api/config/packages/api_platform.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@ api_platform:
pkce: true
type: oauth2
flow: authorizationCode
# todo retrieve url from .well-known
tokenUrl: '%env(OIDC_SERVER_URL)%/protocol/openid-connect/token'
# todo retrieve url from .well-known
authorizationUrl: '%env(OIDC_SERVER_URL)%/protocol/openid-connect/auth'
scopes:
openid: (required) Indicates that the application intends to use OIDC to verify the user's identity
Expand Down
2 changes: 1 addition & 1 deletion compose.override.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ services:
keycloak-config-cli:
image: bitnami/keycloak-config-cli:5-debian-11
environment:
KEYCLOAK_URL: http://php/oidc/
KEYCLOAK_URL: http://keycloak:8080/oidc/
KEYCLOAK_USER: ${KEYCLOAK_ADMIN_USER:-admin}
KEYCLOAK_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-!ChangeMe!}
KEYCLOAK_AVAILABILITYCHECK_ENABLED: "true"
Expand Down
2 changes: 1 addition & 1 deletion compose.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ services:
context: ./pwa
target: prod
environment:
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
AUTH_SECRET: ${AUTH_SECRET}

database:
environment:
Expand Down
12 changes: 7 additions & 5 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ services:
MERCURE_PUBLIC_URL: https://${SERVER_NAME:-localhost}/.well-known/mercure
MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
OIDC_SERVER_URL: ${OIDC_SERVER_URL:-https://localhost/oidc/realms/demo}
OIDC_SERVER_URL_INTERNAL: ${OIDC_SERVER_URL_INTERNAL:-http://php/oidc/realms/demo}
OIDC_SERVER_URL_INTERNAL: ${OIDC_SERVER_URL_INTERNAL:-http://keycloak:8080/oidc/realms/demo}
ports:
# HTTP
- target: 80
Expand All @@ -38,11 +38,13 @@ services:
image: ${IMAGES_PREFIX:-}app-pwa
environment:
NEXT_PUBLIC_ENTRYPOINT: http://php
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-!ChangeThisNextAuthSecret!}
NEXTAUTH_URL: ${NEXTAUTH_URL:-https://localhost/api/auth}
NEXTAUTH_URL_INTERNAL: http://127.0.0.1:3000/api/auth
AUTH_SECRET: ${AUTH_SECRET:-!ChangeThisNextAuthSecret!}
AUTH_URL: ${AUTH_URL:-https://localhost/api/auth}
AUTH_URL_INTERNAL: http://127.0.0.1:3000/api/auth
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-api-platform-pwa}
OIDC_SERVER_URL: ${OIDC_SERVER_URL_INTERNAL:-http://php/oidc/realms/demo}
OIDC_SERVER_URL: ${OIDC_SERVER_URL:-https://localhost/oidc/realms/demo}
OIDC_SERVER_URL_INTERNAL: ${OIDC_SERVER_URL_INTERNAL:-http://keycloak:8080/oidc/realms/demo}
NEXT_SHARP_PATH: /srv/app/node_modules/sharp

###> doctrine/doctrine-bundle ###
database:
Expand Down
9 changes: 7 additions & 2 deletions helm/api-platform/templates/pwa-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@ spec:
env:
- name: NEXT_PUBLIC_ENTRYPOINT
value: http://{{ include "api-platform.fullname" . }}
- name: NEXTAUTH_URL
- name: AUTH_URL
valueFrom:
configMapKeyRef:
name: {{ include "api-platform.fullname" . }}
key: next-auth-url
- name: NEXTAUTH_SECRET
- name: AUTH_SECRET
valueFrom:
secretKeyRef:
name: {{ include "api-platform.fullname" . }}
Expand All @@ -51,6 +51,11 @@ spec:
configMapKeyRef:
name: {{ include "api-platform.fullname" . }}
key: oidc-server-url
- name: OIDC_SERVER_URL_INTERNAL
valueFrom:
configMapKeyRef:
name: {{ include "api-platform.fullname" . }}
key: oidc-server-url-internal
- name: OIDC_CLIENT_ID
valueFrom:
configMapKeyRef:
Expand Down
2 changes: 1 addition & 1 deletion pwa/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@


# Versions
FROM node:20-alpine AS node_upstream
FROM node:21-alpine AS node_upstream


# Base stage for dev and build
Expand Down
4 changes: 3 additions & 1 deletion pwa/pages/admin/index.tsx → pwa/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import dynamic from "next/dynamic";

const Admin = dynamic(() => import("@/components/admin/Admin"), {
const Admin = dynamic(() => import("../../components/admin/Admin"), {
ssr: false,
});

Expand Down
1 change: 1 addition & 0 deletions pwa/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GET, POST } from "../../../auth";
73 changes: 51 additions & 22 deletions pwa/pages/api/auth/[...nextauth].tsx → pwa/app/auth.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import NextAuth, { type AuthOptions, type SessionOptions, type DefaultUser, type TokenSet } from "next-auth";
import { type TokenSet } from "@auth/core/types";
import { signOut as logout, type SignOutParams } from "next-auth/react";
import NextAuth, { type Session as DefaultSession, type User as DefaultUser } from "next-auth";
import KeycloakProvider from "next-auth/providers/keycloak";

import { OIDC_CLIENT_ID, OIDC_SERVER_URL } from "@/config/keycloak";
import { OIDC_CLIENT_ID, OIDC_SERVER_URL, OIDC_SERVER_URL_INTERNAL } from "../config/keycloak";

interface Session extends SessionOptions {
export interface User extends DefaultUser {
sub?: string | null
}

export interface Session extends DefaultSession {
error?: "RefreshAccessTokenError"
accessToken: string
idToken: string
error?: "RefreshAccessTokenError"
user?: User
}

interface User extends DefaultUser {
sub?: string | null
}

interface JWT {
accessToken: string
idToken: string
Expand All @@ -30,10 +32,28 @@ interface Account {
refresh_token: string
}

export const authOptions: AuthOptions = {
interface Profile {
sub: string
}

interface SignOutResponse {
url: string
}

export async function signOut<R extends boolean = true>(
session: DefaultSession,
options?: SignOutParams<R>
): Promise<R extends true ? undefined : SignOutResponse> {
return await logout({
// @ts-ignore
callbackUrl: `${OIDC_SERVER_URL}/protocol/openid-connect/logout?id_token_hint=${session.idToken}&post_logout_redirect_uri=${options?.callbackUrl ?? window.location.origin}`,
});
}

export const { handlers: { GET, POST }, auth } = NextAuth({
callbacks: {
// @ts-ignore
async jwt({ token, account }: { token: JWT, account: Account }): Promise<JWT> {
async jwt({ token, account, profile }: { token: JWT, account: Account, profile: Profile }): Promise<JWT> {
if (account) {
// Save the access token and refresh token in the JWT on the initial login
return {
Expand All @@ -42,14 +62,14 @@ export const authOptions: AuthOptions = {
idToken: account.id_token,
expiresAt: Math.floor(Date.now() / 1000 + account.expires_in),
refreshToken: account.refresh_token,
sub: profile.sub,
};
} else if (Date.now() < token.expiresAt * 1000) {
// If the access token has not expired yet, return it
return token;
} else {
// If the access token has expired, try to refresh it
try {
// todo use .well-known
const response = await fetch(`${OIDC_SERVER_URL}/protocol/openid-connect/token`, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
Expand Down Expand Up @@ -106,23 +126,32 @@ export const authOptions: AuthOptions = {
id: 'keycloak',
clientId: OIDC_CLIENT_ID,
issuer: OIDC_SERVER_URL,

// user information will be extracted from the `id_token` claims, instead of making a request to the `userinfo` endpoint
// https://next-auth.js.org/configuration/providers/oauth
// @ts-ignore
idToken: true,

// https://github.com/nextauthjs/next-auth/issues/685#issuecomment-785212676
protection: "pkce",
client: {
token_endpoint_auth_method: "none",
},

// would love to use discovery, but can't because since next-auth:v5 token endpoint is called internally
// also, discovery doesn't seem to work properly: https://github.com/nextauthjs/next-auth/pull/9718
// wellKnown: `${OIDC_SERVER_URL}/.well-known/openid-configuration`,
token: `${OIDC_SERVER_URL_INTERNAL}/protocol/openid-connect/token`,
userinfo: `${OIDC_SERVER_URL}/protocol/openid-connect/token`,
authorization: {
url: `${OIDC_SERVER_URL}/protocol/openid-connect/auth`,
// https://authjs.dev/guides/basics/refresh-token-rotation#jwt-strategy
params: {
access_type: "offline",
scope: "openid profile email",
prompt: "consent",
},
},
// https://github.com/nextauthjs/next-auth/issues/685#issuecomment-785212676
protection: "pkce",
// https://github.com/nextauthjs/next-auth/issues/4707
// @ts-ignore
clientSecret: null,
client: {
token_endpoint_auth_method: "none"
},
}),
],
};

export default NextAuth(authOptions);
});
46 changes: 46 additions & 0 deletions pwa/app/bookmarks/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { type Metadata } from "next";
import { redirect } from "next/navigation";

import { List, type Props as ListProps } from "../../components/bookmark/List";
import { type Bookmark } from "../../types/Bookmark";
import { type PagedCollection } from "../../types/collection";
import { type FetchResponse, fetchApi } from "../../utils/dataAccess";
import { type Session, auth } from "../auth";

interface Query extends URLSearchParams {
page?: number|string|null;
}

export const metadata: Metadata = {
title: 'Bookmarks',
}
async function getServerSideProps({ page = 1 }: Query, session: Session): Promise<ListProps> {
try {
const response: FetchResponse<PagedCollection<Bookmark>> | undefined = await fetchApi(`/bookmarks?page=${Number(page)}`, {
next: { revalidate: 3600 },
}, session);
if (!response?.data) {
throw new Error('Unable to retrieve data from /bookmarks.');
}

return { data: response.data, hubURL: response.hubURL, page: Number(page) };
} catch (error) {
console.error(error);
}

return { data: null, hubURL: null, page: Number(page) };
}

export default async function Page({ searchParams }: { searchParams: Query }) {
// @ts-ignore
const session: Session|null = await auth();
if (!session || session?.error === "RefreshAccessTokenError") {
// todo find a way to redirect directly to keycloak from here
// Can't use next-auth/middleware because of https://github.com/nextauthjs/next-auth/discussions/7488
redirect("/api/auth/signin?callbackUrl=/bookmarks");
}

const props = await getServerSideProps(searchParams, session);

return <List {...props}/>;
}
65 changes: 65 additions & 0 deletions pwa/app/books/[id]/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { type Metadata } from "next";
import { notFound } from "next/navigation";

import { Show, type Props as ShowProps } from "../../../../components/book/Show";
import { Book } from "../../../../types/Book";
import { type FetchResponse, fetchApi } from "../../../../utils/dataAccess";
import { type Session, auth } from "../../../auth";

interface Props {
params: { id: string };
}

export async function generateMetadata({ params }: Props): Promise<Metadata|undefined> {
const id = params.id;
// @ts-ignore
const session: Session|null = await auth();
try {
const response: FetchResponse<Book> | undefined = await fetchApi(`/books/${id}`, {
next: { revalidate: 3600 },
}, session);
if (!response?.data) {
throw new Error(`Unable to retrieve data from /books/${id}.`);
}
const item = response.data;

return {
title: `${item["title"]}${!!item["author"] && ` - ${item["author"]}`}`,
};
} catch (error) {
console.error(error);
}

return undefined;
}

async function getServerSideProps(id: string, session: Session|null): Promise<ShowProps|undefined> {
try {
const response: FetchResponse<Book> | undefined = await fetchApi(`/books/${id}`, {
headers: {
Preload: "/books/*/reviews",
},
next: { revalidate: 3600 },
}, session);
if (!response?.data) {
throw new Error(`Unable to retrieve data from /books/${id}.`);
}

return { data: response.data, hubURL: response.hubURL };
} catch (error) {
console.error(error);
}

return undefined;
}

export default async function Page({ params }: Props) {
// @ts-ignore
const session: Session|null = await auth();
const props = await getServerSideProps(params.id, session);
if (!props) {
notFound();
}

return <Show {...props}/>;
}
Loading

0 comments on commit d24b123

Please sign in to comment.