-
Notifications
You must be signed in to change notification settings - Fork 47
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'v2' into feature/add-preview-content
# Conflicts: # app/client-v2/apps/goat/middleware.ts # app/client-v2/pnpm-lock.yaml
- Loading branch information
Showing
6 changed files
with
4,458 additions
and
5,300 deletions.
There are no files selected for viewing
171 changes: 95 additions & 76 deletions
171
app/client-v2/apps/goat/app/api/auth/[...nextauth]/options.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 |
---|---|---|
@@ -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 | ||
}, | ||
}; |
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
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
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,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"; | ||
} | ||
} |
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
Oops, something went wrong.