Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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