feat: add keycloak login (#139)

Co-authored-by: Zdeněk Janeček <zdenek.janecek@firma.seznam.cz>
This commit is contained in:
Zdeněk Janeček 2023-03-28 09:37:27 +02:00 committed by GitHub
parent 7887b42404
commit 3433371930
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1283 additions and 950 deletions

View file

@ -20,6 +20,12 @@ REGISTRATION_PASSWORD=
GITHUB_CLIENT_ID= GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET= GITHUB_CLIENT_SECRET=
# Optional: if you want Keycloak oauth. Currently incompatible with the registration password
KEYCLOAK_ID=
KEYCLOAK_SECRET=
KEYCLOAK_ISSUER= # keycloak path including realm
KEYCLOAK_NAME=
# Optional: if you want to support credential auth (username/password, supports registration password) # Optional: if you want to support credential auth (username/password, supports registration password)
# Defaults to true # Defaults to true
CREDENTIAL_AUTH=true CREDENTIAL_AUTH=true

View file

@ -14,68 +14,69 @@
}, },
"dependencies": { "dependencies": {
"@next-auth/prisma-adapter": "^1.0.5", "@next-auth/prisma-adapter": "^1.0.5",
"@next/eslint-plugin-next": "13.2.5-canary.19", "@next/eslint-plugin-next": "^13.2.4",
"@prisma/client": "^4.10.1", "@prisma/client": "^4.11.0",
"@radix-ui/react-dialog": "^1.0.2", "@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.3", "@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-popover": "^1.0.4", "@radix-ui/react-popover": "^1.0.5",
"@radix-ui/react-tabs": "^1.0.2", "@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.5",
"@vercel/og": "^0.0.27", "@vercel/og": "^0.4.0",
"client-only": "^0.0.1", "client-only": "^0.0.1",
"client-zip": "2.3.0", "client-zip": "2.3.0",
"cmdk": "^0.1.22", "cmdk": "^0.2.0",
"jest": "^29.4.3", "jest": "^29.5.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"next": "13.2.5-canary.19", "next": "^13.2.4",
"next-auth": "^4.19.2", "next-auth": "^4.20.1",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"react": "18.2.0", "react": "18.2.0",
"react-cookie": "^4.1.1", "react-cookie": "^4.1.1",
"react-datepicker": "4.8.0", "react-datepicker": "4.10.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-dropzone": "14.2.3", "react-dropzone": "14.2.3",
"react-error-boundary": "^3.1.4", "react-error-boundary": "^3.1.4",
"react-feather": "^2.0.10", "react-feather": "^2.0.10",
"react-hot-toast": "2.4.0", "react-hot-toast": "2.4.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"swr": "^2.0.4", "swr": "^2.1.0",
"textarea-markdown-editor": "1.0.4", "textarea-markdown-editor": "1.0.4",
"ts-jest": "^29.0.5", "ts-jest": "^29.0.5",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"zlib": "^1.0.5" "zlib": "^1.0.5"
}, },
"devDependencies": { "devDependencies": {
"@next/bundle-analyzer": "13.1.7-canary.26", "@next/bundle-analyzer": "13.2.4",
"@total-typescript/ts-reset": "^0.3.7", "@total-typescript/ts-reset": "^0.4.2",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/git-http-backend": "^1.0.1", "@types/git-http-backend": "^1.0.1",
"@types/jest": "^29.4.0", "@types/jest": "^29.4.1",
"@types/lodash.debounce": "^4.0.7", "@types/lodash.debounce": "^4.0.7",
"@types/node": "18.11.18", "@types/node": "18.15.3",
"@types/react": "18.0.27", "@types/react": "18.0.28",
"@types/react-datepicker": "4.8.0", "@types/react-datepicker": "4.10.0",
"@types/react-dom": "18.0.10", "@types/react-dom": "18.0.11",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.53.0", "@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "^5.53.0", "@typescript-eslint/parser": "^5.55.0",
"@wcj/markdown-to-html": "^2.2.1", "@wcj/markdown-to-html": "^2.2.1",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"csstype": "^3.1.1", "csstype": "^3.1.1",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"eslint": "8.33.0", "eslint": "8.36.0",
"eslint-config-next": "13.1.7-canary.26", "eslint-config-next": "13.2.4",
"jest-mock-extended": "^3.0.2", "jest-mock-extended": "^3.0.3",
"next-unused": "0.0.6", "next-unused": "0.0.6",
"postcss": "^8.4.21",
"postcss-flexbugs-fixes": "^5.0.2", "postcss-flexbugs-fixes": "^5.0.2",
"postcss-hover-media-feature": "^1.0.2", "postcss-hover-media-feature": "^1.0.2",
"postcss-nested": "^6.0.1", "postcss-nested": "^6.0.1",
"postcss-preset-env": "^8.0.1", "postcss-preset-env": "^8.0.1",
"prettier": "2.8.3", "prettier": "2.8.4",
"prisma": "^4.10.1", "prisma": "^4.11.0",
"typescript": "4.9.4", "typescript": "4.9.5",
"typescript-plugin-css-modules": "4.1.1" "typescript-plugin-css-modules": "4.2.3"
}, },
"optionalDependencies": { "optionalDependencies": {
"sharp": "^0.31.3" "sharp": "^0.31.3"

File diff suppressed because it is too large Load diff

View file

@ -6,20 +6,23 @@ import Link from "../../../components/link"
import { signIn } from "next-auth/react" import { signIn } from "next-auth/react"
import Input from "@components/input" import Input from "@components/input"
import Button from "@components/button" import Button from "@components/button"
import { GitHub } from "react-feather" import { Key } from "react-feather"
import { useToasts } from "@components/toasts" import { useToasts } from "@components/toasts"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Note from "@components/note" import Note from "@components/note"
import { ErrorQueryParamsHandler } from "./query-handler" import { ErrorQueryParamsHandler } from "./query-handler"
import { AuthProviders } from "@lib/server/auth-props"
function Auth({ function Auth({
page, page,
credentialAuth,
requiresServerPassword, requiresServerPassword,
isGithubEnabled authProviders
}: { }: {
page: "signup" | "signin" page: "signup" | "signin"
credentialAuth?: boolean
requiresServerPassword?: boolean requiresServerPassword?: boolean
isGithubEnabled?: boolean authProviders?: AuthProviders
}) { }) {
const [serverPassword, setServerPassword] = useState("") const [serverPassword, setServerPassword] = useState("")
const { setToast } = useToasts() const { setToast } = useToasts()
@ -102,53 +105,62 @@ function Auth({
</> </>
) : null} ) : null}
<Input {credentialAuth ? (
type="text"
id="username"
value={username}
onChange={handleChangeUsername}
placeholder="Username"
required={true}
minLength={3}
width="100%"
aria-label="Username"
/>
<Input
type="password"
id="password"
value={password}
onChange={handleChangePassword}
placeholder="Password"
required={true}
minLength={6}
width="100%"
aria-label="Password"
/>
<Button width={"100%"} type="submit" loading={submitting}>
Sign {signText}
</Button>
{isGithubEnabled ? (
<> <>
<hr style={{ width: "100%" }} /> <Input
<Button type="text"
type="submit" id="username"
value={username}
onChange={handleChangeUsername}
placeholder="Username"
required={true}
minLength={3}
width="100%" width="100%"
style={{ aria-label="Username"
color: "var(--fg)" />
}} <Input
iconLeft={<GitHub />} type="password"
onClick={(e) => { id="password"
e.preventDefault() value={password}
signIn("github", { onChange={handleChangePassword}
callbackUrl: "/", placeholder="Password"
registration_password: serverPassword required={true}
}) minLength={6}
}} width="100%"
> aria-label="Password"
Sign {signText.toLowerCase()} with GitHub />
<Button width={"100%"} type="submit" loading={submitting}>
Sign {signText}
</Button> </Button>
</> </>
) : null} ) : null}
{authProviders?.length ? (
<>
{authProviders?.map((provider) => {
return provider.enabled ? (
<Button
type="submit"
width="100%"
key={provider.id + "-button"}
style={{
color: "var(--fg)"
}}
iconLeft={<Key />}
onClick={(e) => {
e.preventDefault()
signIn(provider.id, {
callbackUrl: "/",
registration_password: serverPassword
})
}}
>
Sign {signText.toLowerCase()} with {provider.public_name}
</Button>
) : null
})}
</>
) : null}
</div> </div>
<div className={styles.formContentSpace}> <div className={styles.formContentSpace}>
{signingIn ? ( {signingIn ? (

View file

@ -1,15 +1,16 @@
import { getMetadata } from "src/app/lib/metadata" import { getMetadata } from "src/app/lib/metadata"
import config from "@lib/config"
import Auth from "../components"
function isGithubEnabled() { import Auth from "../components"
return config.github_client_id.length && config.github_client_secret.length import { getAuthProviders, isCredentialEnabled } from "@lib/server/auth-props"
? true
: false
}
export default function SignInPage() { export default function SignInPage() {
return <Auth page="signin" isGithubEnabled={isGithubEnabled()} /> return (
<Auth
page="signin"
credentialAuth={isCredentialEnabled()}
authProviders={getAuthProviders()}
/>
)
} }
export const metadata = getMetadata({ export const metadata = getMetadata({

View file

@ -1,25 +1,20 @@
import Auth from "../components" import Auth from "../components"
import { getRequiresPasscode } from "src/pages/api/auth/requires-passcode" import { getRequiresPasscode } from "src/pages/api/auth/requires-passcode"
import config from "@lib/config"
import { getMetadata } from "src/app/lib/metadata" import { getMetadata } from "src/app/lib/metadata"
import { getAuthProviders, isCredentialEnabled } from "@lib/server/auth-props"
async function getPasscode() { async function getPasscode() {
return await getRequiresPasscode() return await getRequiresPasscode()
} }
function isGithubEnabled() {
return config.github_client_id.length && config.github_client_secret.length
? true
: false
}
export default async function SignUpPage() { export default async function SignUpPage() {
const requiresPasscode = await getPasscode() const requiresPasscode = await getPasscode()
return ( return (
<Auth <Auth
page="signup" page="signup"
requiresServerPassword={requiresPasscode} requiresServerPassword={requiresPasscode}
isGithubEnabled={isGithubEnabled()} credentialAuth={isCredentialEnabled()}
authProviders={getAuthProviders()}
/> />
) )
} }

View file

@ -45,14 +45,15 @@ export function HeaderButtons({
isAuthenticated: boolean isAuthenticated: boolean
theme: string theme: string
}) { }) {
const { isAdmin } = useSessionSWR() const { isAdmin, userId } = useSessionSWR()
return ( return (
<> <>
{getButtons({ {getButtons({
isAuthenticated, isAuthenticated,
theme, theme,
isAdmin isAdmin,
userId
})} })}
</> </>
) )
@ -108,12 +109,14 @@ export function getButtons({
isAuthenticated, isAuthenticated,
theme, theme,
// mutate: mutateSession, // mutate: mutateSession,
isAdmin isAdmin,
userId
}: { }: {
isAuthenticated: boolean isAuthenticated: boolean
theme: string theme: string
// mutate: KeyedMutator<Session> // mutate: KeyedMutator<Session>
isAdmin?: boolean isAdmin?: boolean,
userId?: string
}) { }) {
return [ return [
<NavButton <NavButton
@ -151,7 +154,11 @@ export function getButtons({
icon={<UserX />} icon={<UserX />}
value="signout" value="signout"
onClick={() => { onClick={() => {
signOut() signOut({
callbackUrl: `/signedout${
userId ? "?userId=" + userId : ""
}`
})
}} }}
width={SIGN_IN_WIDTH} width={SIGN_IN_WIDTH}
/> />

View file

@ -7,6 +7,10 @@ type Config = {
url: string url: string
github_client_id: string github_client_id: string
github_client_secret: string github_client_secret: string
keycloak_client_id: string
keycloak_client_secret: string
keycloak_issuer: string
keycloak_name: string
nextauth_secret: string nextauth_secret: string
credential_auth: boolean credential_auth: boolean
} }
@ -83,8 +87,12 @@ export const config = (env: Environment): Config => {
`https://${throwIfUndefined("VERCEL_URL")}`, `https://${throwIfUndefined("VERCEL_URL")}`,
github_client_id: env.GITHUB_CLIENT_ID ?? "", github_client_id: env.GITHUB_CLIENT_ID ?? "",
github_client_secret: env.GITHUB_CLIENT_SECRET ?? "", github_client_secret: env.GITHUB_CLIENT_SECRET ?? "",
keycloak_client_id: env.KEYCLOAK_ID ?? "",
keycloak_client_secret: env.KEYCLOAK_SECRET ?? "",
keycloak_issuer: env.KEYCLOAK_ISSUER ?? "",
keycloak_name: env.KEYCLOAK_NAME ?? "",
nextauth_secret: throwIfUndefined("NEXTAUTH_SECRET"), nextauth_secret: throwIfUndefined("NEXTAUTH_SECRET"),
credential_auth: stringToBoolean("CREDENTIAL_AUTH") ?? true credential_auth: stringToBoolean(env.CREDENTIAL_AUTH) ?? true
} }
return config return config
} }

View file

@ -0,0 +1,38 @@
import config from "@lib/config"
export type AuthProviders = {
enabled: boolean
id: "keycloak" | "github"
public_name: string
}[]
export function isGithubEnabled(): boolean {
return !!(config.github_client_id && config.github_client_secret)
}
export function isKeycloakEnabled(): boolean {
return !!(
config.keycloak_client_id &&
config.keycloak_client_secret &&
config.keycloak_issuer
)
}
export function isCredentialEnabled(): boolean {
return config.credential_auth
}
export function getAuthProviders(): AuthProviders {
return [
{
enabled: isGithubEnabled(),
id: "github",
public_name: "Github"
},
{
enabled: isKeycloakEnabled(),
id: "keycloak",
public_name: config.keycloak_name || "Keycloak"
}
]
}

View file

@ -1,10 +1,16 @@
import { PrismaAdapter } from "@next-auth/prisma-adapter" import { PrismaAdapter } from "@next-auth/prisma-adapter"
import { NextAuthOptions } from "next-auth" import { NextAuthOptions, User } from "next-auth"
import GitHubProvider from "next-auth/providers/github" import GitHubProvider from "next-auth/providers/github"
import KeycloakProvider from "next-auth/providers/keycloak"
import CredentialsProvider from "next-auth/providers/credentials" import CredentialsProvider from "next-auth/providers/credentials"
import { prisma } from "@lib/server/prisma" import { prisma } from "@lib/server/prisma"
import config from "@lib/config" import config from "@lib/config"
import * as crypto from "crypto" import * as crypto from "crypto"
import {
isCredentialEnabled,
isGithubEnabled,
isKeycloakEnabled
} from "./auth-props"
const credentialsOptions = () => { const credentialsOptions = () => {
const options: Record<string, unknown> = { const options: Record<string, unknown> = {
@ -33,7 +39,7 @@ const credentialsOptions = () => {
const providers = () => { const providers = () => {
const providers = [] const providers = []
if (config.github_client_id && config.github_client_secret) { if (isGithubEnabled()) {
providers.push( providers.push(
GitHubProvider({ GitHubProvider({
clientId: config.github_client_id, clientId: config.github_client_id,
@ -42,7 +48,41 @@ const providers = () => {
) )
} }
if (config.credential_auth) { if (isKeycloakEnabled()) {
const keycloak = KeycloakProvider({
clientId: config.keycloak_client_id,
clientSecret: config.keycloak_client_secret,
issuer: config.keycloak_issuer,
token: {
async request(ctx) {
const { client, provider, params, checks } = ctx
const tokens = await client.callback(
provider.callbackUrl,
params,
checks
)
if ("refresh_expires_in" in tokens) {
tokens.refresh_token_expires_in = tokens.refresh_expires_in
delete tokens.refresh_expires_in
}
delete tokens["not-before-policy"]
return { tokens }
}
}
})
const originalKeycloakProfile = keycloak.profile
;(keycloak.profile = async (profile, tokens) => {
const originalProfile = await originalKeycloakProfile(profile, tokens)
const newProfile: User & { displayName?: string | null } = {
...originalProfile,
displayName: originalProfile.name ?? null
}
return newProfile
}),
providers.push(keycloak)
}
if (isCredentialEnabled()) {
providers.push( providers.push(
CredentialsProvider({ CredentialsProvider({
name: "Drift", name: "Drift",
@ -207,6 +247,58 @@ export const authOptions: NextAuthOptions = {
username: dbUser.username, username: dbUser.username,
sessionToken: token.sessionToken sessionToken: token.sessionToken
} }
},
async redirect({ url, baseUrl }) {
if (url.startsWith(baseUrl)) return url
if (url.startsWith("/signedout")) {
const userIdFound = url.match(/userId=([^?&]+)/)
let userId = null
if (userIdFound?.length === 2) {
userId = userIdFound[1]
}
if (!userId) {
return baseUrl
}
const account = await prisma.account.findFirst({
where: {
AND: [
{
userId
}
]
}
})
let ssoLogoutUrl = null
let idToken = null
let clientId = null
// OpenID Connect Logout
if (account?.provider === "keycloak") {
ssoLogoutUrl = `${config.keycloak_issuer}/protocol/openid-connect/logout`
idToken = account.id_token
clientId = config.keycloak_client_id
}
if (!ssoLogoutUrl) {
return baseUrl
}
let signoutWithRedirectUrl = `${ssoLogoutUrl}?post_logout_redirect_uri=${encodeURIComponent(
baseUrl
)}`
if (idToken) {
signoutWithRedirectUrl += `&id_token_hint=${idToken}`
} else if (clientId) {
signoutWithRedirectUrl += `&client_id=${clientId}`
}
return signoutWithRedirectUrl
}
// Allows relative callback URLs
if (url.startsWith("/")) return new URL(url, baseUrl).toString()
return baseUrl
} }
} }
} as const } as const

View file

@ -24,6 +24,7 @@ export function useSessionSWR(swrOpts: SWRConfiguration = {}) {
isAuthenticated: session?.user?.id ? true : isLoading ? undefined : false, isAuthenticated: session?.user?.id ? true : isLoading ? undefined : false,
/** undefined while loading */ /** undefined while loading */
isAdmin: isAdmin:
session?.user?.id === "admin" ? true : isLoading ? undefined : false session?.user?.id === "admin" ? true : isLoading ? undefined : false,
userId: session?.user?.id
} }
} }