Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/feature/refactor-nextjs' into fe…
Browse files Browse the repository at this point in the history
…ature/refactor-nextjs
  • Loading branch information
majkshkurti committed Jul 21, 2023
2 parents c13fb9d + 6b8d1b7 commit 1e24bb0
Show file tree
Hide file tree
Showing 11 changed files with 2,298 additions and 2,831 deletions.
171 changes: 95 additions & 76 deletions app/client-v2/apps/goat/app/api/auth/[...nextauth]/options.ts
Original file line number Diff line number Diff line change
@@ -1,107 +1,126 @@
import type { NextAuthOptions } from "next-auth";
import type { KeycloakTokenSet, NextAuthOptions } from "next-auth";
import type { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak";

/**
* Takes a token, and returns a new token with updated
* `accessToken` and `accessTokenExpires`. If an error occurs,
* returns the old token and an error property
*/
/**
* @param {JWT} token
*/
const refreshAccessToken = async (token: JWT) => {
const keycloak = KeycloakProvider({
id: "keycloak",
clientId: process.env.KEYCLOAK_CLIENT_ID as string,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET as string,
issuer: process.env.KEYCLOAK_ISSUER,
authorization: { params: { scope: "openid email profile offline_access" } },
});

// this performs the final handshake for the keycloak provider
async function doFinalSignoutHandshake(token: JWT) {
if (token.provider == keycloak.id) {
try {
const issuerUrl = keycloak.options?.issuer;
const logOutUrl = new URL(`${issuerUrl}/protocol/openid-connect/logout`);
logOutUrl.searchParams.set("id_token_hint", token.id_token);
const { status, statusText } = await fetch(logOutUrl);
console.log("Completed post-logout handshake", status, statusText);
} catch (e: any) {
console.error("Unable to perform post-logout handshake", e?.code || e);
}
}
}

async function refreshAccessToken(token: JWT): Promise<JWT> {
try {
if (Date.now() > token.refreshTokenExpired) throw Error;
const details = {
client_id: process.env.KEYCLOAK_CLIENT_ID,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
grant_type: ["refresh_token"],
refresh_token: token.refreshToken,
};
const formBody: string[] = [];
Object.entries(details).forEach(([key, value]: [string, any]) => {
const encodedKey = encodeURIComponent(key);
const encodedValue = encodeURIComponent(value);
formBody.push(encodedKey + "=" + encodedValue);
});
const formData = formBody.join("&");
const url = `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`;
const response = await fetch(url, {
const response = await fetch(`${keycloak.options?.issuer}/protocol/openid-connect/token`, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: keycloak.options?.clientId as string,
client_secret: keycloak.options?.clientSecret as string,
grant_type: "refresh_token",
refresh_token: token.refresh_token as string,
}),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
},
body: formData,
});
const refreshedTokens = await response.json();
if (!response.ok) throw refreshedTokens;
return {

const tokensRaw = await response.json();
const tokens: KeycloakTokenSet = tokensRaw;
if (!response.ok) throw tokens;

const expiresAt = Math.floor(Date.now() / 1000 + tokens.expires_in);
console.log(
`Token was refreshed. New token expires in ${tokens.expires_in} sec at ${expiresAt}, refresh token expires in ${tokens.refresh_expires_in} sec`
);
const newToken: JWT = {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpired: Date.now() + (refreshedTokens.expires_in - 15) * 1000,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
refreshTokenExpired: Date.now() + (refreshedTokens.refresh_expires_in - 15) * 1000,
access_token: tokens.access_token,
expires_at: expiresAt,
refresh_token: tokens.refresh_token ?? token.refresh_token,
id_token: tokens.id_token ?? token.id_token,
provider: keycloak.id,
};
return newToken;
} catch (error) {
console.error("Error refreshing access token: ", error);
return {
...token,
error: "RefreshAccessTokenError",
};
}
};

const keycloak = KeycloakProvider({
id: "keycloak",
clientId: process.env.KEYCLOAK_CLIENT_ID as string,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET as string,
issuer: process.env.KEYCLOAK_ISSUER,
});
}

export const options: NextAuthOptions = {
secret: process.env.NEXTAUTH_SECRET,
providers: [keycloak],
pages: {
signIn: "/auth/login",
signOut: "/auth/logout",
error: "/auth/error",
},
theme: {
colorScheme: "light",
},
callbacks: {
async signIn({ user, account }) {
if (account && user) {
return true;
} else {
// TODO : Add unauthorized page
return "/unauthorized";
}
},
async redirect({ url, baseUrl }) {
return url.startsWith(baseUrl) ? url : baseUrl;
},
async session({ session, token }) {
if (token) {
session.user = token.user;
session.error = token.error;
session.accessToken = token.accessToken;
}
console.log(`Executing session() with token ${token.expires_at}`);
session.error = token.error;
console.log(session);
return session;
},
async jwt({ token, user, account }) {
// Initial sign in
async jwt({ token, account, user }) {
console.log(token);
console.log("Executing jwt()");
if (account && user) {
// Add access_token, refresh_token and expirations to the token right after signin
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.accessTokenExpired = Date.now() + (account.expires_at - 15) * 1000;
token.refreshTokenExpired = Date.now() + (account.refresh_expires_in - 15) * 1000;
token.user = user;
return token;
// The account and user will be available on first sign in with this provider
if (!account.access_token) throw Error("Auth Provider missing access token");
if (!account.refresh_token) throw Error("Auth Provider missing refresh token");
if (!account.id_token) throw Error("Auth Provider missing ID token");
// Save the access token and refresh token in the JWT on the initial login
const newToken: JWT = {
...token,
access_token: account.access_token,
refresh_token: account.refresh_token,
id_token: account.id_token,
expires_at: Math.floor(account.expires_at ?? 0),
provider: account.provider,
};
return newToken;
}

// Return previous token if the access token has not expired yet
if (Date.now() < token.accessTokenExpired) return token;

// Access token has expired, try to update it
return refreshAccessToken(token);
if (Date.now() < token.expires_at * 1000) {
// If the access token has not expired yet, return it
console.log("token is valid");
return token;
}
// If the access token has expired, try to refresh it
console.log(`\n>>> Old token expired: ${token.expires_at}`);
const newToken = await refreshAccessToken(token);
console.log(`New token acquired: ${newToken.expires_at}`);
return newToken;
},
},
events: {
signOut: async ({ token }) => doFinalSignoutHandshake(token),
},
jwt: {
maxAge: 1 * 60, // 1 minute, same as in Keycloak
},
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days : 2592000, same as in Keycloak
},
};
8 changes: 4 additions & 4 deletions app/client-v2/apps/goat/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ export default withAuth(
return NextResponse.redirect(url);
}

if (!request.nextauth.token?.user?.org_id) {
const url = new URL("/auth/organization", request.url);
return NextResponse.redirect(url);
}
// if (!request.nextauth.token?.user?.org_id) {
// const url = new URL("/auth/organization", request.url);
// return NextResponse.redirect(url);
// }
},
{
callbacks: {
Expand Down
2 changes: 1 addition & 1 deletion app/client-v2/apps/goat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"geojson": "^0.5.0",
"mapbox-gl": "^2.15.0",
"maplibre-gl": "^3.1.0",
"next": "v13.4.10-canary.8",
"next": "v13.4.10",
"next-auth": "^4.22.1",
"next-i18next": "^13.2.2",
"react": "^18.2.0",
Expand Down
95 changes: 17 additions & 78 deletions app/client-v2/apps/goat/types/next-auth.d.ts
Original file line number Diff line number Diff line change
@@ -1,93 +1,32 @@
import type { User } from "next-auth";
import type { DefaultUser, DefaultSession } from "next-auth";
import "next-auth/jwt";

declare module "next-auth" {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `Provider` React Context
*/
interface Session {
user: {
sub: string;
email_verified: boolean;
name: string;
preferred_username: string;
given_name: string;
family_name: string;
email: string;
id: string;
org_id?: string;
org_name?: string;
telephone?: string;
};
error: string;
accessToken: string;
interface User extends DefaultUser {
org_id?: string;
}
/**
* The shape of the user object returned in the OAuth providers' `profile` callback,
* or the second parameter of the `session` callback, when using a database.
*/
interface User {
sub: string;
email_verified: boolean;
name: string;
telephone: string;
preferred_username: string;
org_name: string;
org_id: string;
given_name: string;
family_name: string;
email: string;
id: string;
interface Session extends DefaultSession {
user?: User;
error?: "RefreshAccessTokenError";
}
/**
* Usually contains information about the provider being used
* and also extends `TokenSet`, which is different tokens returned by OAuth Providers.
*/
interface Account {
provider: string;
type: string;
id: string;
interface KeycloakTokenSet {
access_token: string;
accessTokenExpires?: any;
refresh_token: string;
id_token: string;
expires_at: number;
expires_in: number;
refresh_expires_in: number;
refresh_token: string;
token_type: string;
id_token: string;
"not-before-policy": number;
session_state: string;
scope: string;
}
/** The OAuth profile returned from your provider */
interface Profile {
sub: string;
email_verified: boolean;
name: string;
telephone: string;
preferred_username: string;
org_name: string;
org_id: string;
given_name: string;
family_name: string;
email: string;
}
}

declare module "next-auth/jwt" {
/** Returned by the `jwt` callback and `getToken`, when using JWT sessions */
interface JWT {
name: string;
email: string;
sub: string;
name: string;
email: string;
sub: string;
accessToken: string;
refreshToken: string;
accessTokenExpired: number;
refreshTokenExpired: number;
user: User;
error: string;
provider: string;
access_token: string;
refresh_token: string;
id_token: string;
expires_at: number;
user_roles?: string[];
org_id?: string;
error?: "RefreshAccessTokenError";
}
}
2 changes: 1 addition & 1 deletion app/client-v2/apps/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"@mui/lab": "5.0.0-alpha.132",
"@p4b/keycloak-theme": "workspace:*",
"@p4b/ui": "workspace:*",
"next": "v13.4.10-canary.8",
"next": "v13.4.10",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
Expand Down
9 changes: 6 additions & 3 deletions app/client-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@
"@deploysentinel/playwright": "^0.3.4",
"@playwright/test": "^1.33.0",
"commitizen": "^4.3.0",
"dotenv-cli": "7.2.1",
"eslint": "^8.40.0",
"husky": "^8.0.3",
"install": "^0.13.0",
"prettier": "^2.8.8",
"syncpack": "^9.8.6",
"turbo": "latest",
"dotenv-cli": "7.2.1"
"turbo": "latest"
},
"packageManager": "pnpm@7.15.0",
"name": "p4b-client-monorepo"
"name": "p4b-client-monorepo",
"dependencies": {
"react-map-gl": "^7.1.2"
}
}
Loading

0 comments on commit 1e24bb0

Please sign in to comment.