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
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
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_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)
# Defaults to true
CREDENTIAL_AUTH=true

View file

@ -14,68 +14,69 @@
},
"dependencies": {
"@next-auth/prisma-adapter": "^1.0.5",
"@next/eslint-plugin-next": "13.2.5-canary.19",
"@prisma/client": "^4.10.1",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.3",
"@radix-ui/react-popover": "^1.0.4",
"@radix-ui/react-tabs": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.4",
"@vercel/og": "^0.0.27",
"@next/eslint-plugin-next": "^13.2.4",
"@prisma/client": "^4.11.0",
"@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-popover": "^1.0.5",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.5",
"@vercel/og": "^0.4.0",
"client-only": "^0.0.1",
"client-zip": "2.3.0",
"cmdk": "^0.1.22",
"jest": "^29.4.3",
"cmdk": "^0.2.0",
"jest": "^29.5.0",
"lodash.debounce": "^4.0.8",
"next": "13.2.5-canary.19",
"next-auth": "^4.19.2",
"next": "^13.2.4",
"next-auth": "^4.20.1",
"next-themes": "^0.2.1",
"react": "18.2.0",
"react-cookie": "^4.1.1",
"react-datepicker": "4.8.0",
"react-cookie": "^4.1.1",
"react-datepicker": "4.10.0",
"react-dom": "18.2.0",
"react-dropzone": "14.2.3",
"react-error-boundary": "^3.1.4",
"react-feather": "^2.0.10",
"react-hot-toast": "2.4.0",
"server-only": "^0.0.1",
"swr": "^2.0.4",
"swr": "^2.1.0",
"textarea-markdown-editor": "1.0.4",
"ts-jest": "^29.0.5",
"uuid": "^9.0.0",
"zlib": "^1.0.5"
},
"devDependencies": {
"@next/bundle-analyzer": "13.1.7-canary.26",
"@total-typescript/ts-reset": "^0.3.7",
"@next/bundle-analyzer": "13.2.4",
"@total-typescript/ts-reset": "^0.4.2",
"@types/bcrypt": "^5.0.0",
"@types/git-http-backend": "^1.0.1",
"@types/jest": "^29.4.0",
"@types/jest": "^29.4.1",
"@types/lodash.debounce": "^4.0.7",
"@types/node": "18.11.18",
"@types/react": "18.0.27",
"@types/react-datepicker": "4.8.0",
"@types/react-dom": "18.0.10",
"@types/node": "18.15.3",
"@types/react": "18.0.28",
"@types/react-datepicker": "4.10.0",
"@types/react-dom": "18.0.11",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.53.0",
"@typescript-eslint/parser": "^5.53.0",
"@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "^5.55.0",
"@wcj/markdown-to-html": "^2.2.1",
"clsx": "^1.2.1",
"cross-env": "7.0.3",
"csstype": "^3.1.1",
"dotenv": "^16.0.3",
"eslint": "8.33.0",
"eslint-config-next": "13.1.7-canary.26",
"jest-mock-extended": "^3.0.2",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"jest-mock-extended": "^3.0.3",
"next-unused": "0.0.6",
"postcss": "^8.4.21",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-hover-media-feature": "^1.0.2",
"postcss-nested": "^6.0.1",
"postcss-preset-env": "^8.0.1",
"prettier": "2.8.3",
"prisma": "^4.10.1",
"typescript": "4.9.4",
"typescript-plugin-css-modules": "4.1.1"
"prettier": "2.8.4",
"prisma": "^4.11.0",
"typescript": "4.9.5",
"typescript-plugin-css-modules": "4.2.3"
},
"optionalDependencies": {
"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 Input from "@components/input"
import Button from "@components/button"
import { GitHub } from "react-feather"
import { Key } from "react-feather"
import { useToasts } from "@components/toasts"
import { useRouter } from "next/navigation"
import Note from "@components/note"
import { ErrorQueryParamsHandler } from "./query-handler"
import { AuthProviders } from "@lib/server/auth-props"
function Auth({
page,
credentialAuth,
requiresServerPassword,
isGithubEnabled
authProviders
}: {
page: "signup" | "signin"
credentialAuth?: boolean
requiresServerPassword?: boolean
isGithubEnabled?: boolean
authProviders?: AuthProviders
}) {
const [serverPassword, setServerPassword] = useState("")
const { setToast } = useToasts()
@ -102,53 +105,62 @@ function Auth({
</>
) : null}
<Input
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 ? (
{credentialAuth ? (
<>
<hr style={{ width: "100%" }} />
<Button
type="submit"
<Input
type="text"
id="username"
value={username}
onChange={handleChangeUsername}
placeholder="Username"
required={true}
minLength={3}
width="100%"
style={{
color: "var(--fg)"
}}
iconLeft={<GitHub />}
onClick={(e) => {
e.preventDefault()
signIn("github", {
callbackUrl: "/",
registration_password: serverPassword
})
}}
>
Sign {signText.toLowerCase()} with GitHub
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>
</>
) : 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 className={styles.formContentSpace}>
{signingIn ? (

View file

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

View file

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

View file

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

View file

@ -7,6 +7,10 @@ type Config = {
url: string
github_client_id: string
github_client_secret: string
keycloak_client_id: string
keycloak_client_secret: string
keycloak_issuer: string
keycloak_name: string
nextauth_secret: string
credential_auth: boolean
}
@ -83,8 +87,12 @@ export const config = (env: Environment): Config => {
`https://${throwIfUndefined("VERCEL_URL")}`,
github_client_id: env.GITHUB_CLIENT_ID ?? "",
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"),
credential_auth: stringToBoolean("CREDENTIAL_AUTH") ?? true
credential_auth: stringToBoolean(env.CREDENTIAL_AUTH) ?? true
}
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 { NextAuthOptions } from "next-auth"
import { NextAuthOptions, User } from "next-auth"
import GitHubProvider from "next-auth/providers/github"
import KeycloakProvider from "next-auth/providers/keycloak"
import CredentialsProvider from "next-auth/providers/credentials"
import { prisma } from "@lib/server/prisma"
import config from "@lib/config"
import * as crypto from "crypto"
import {
isCredentialEnabled,
isGithubEnabled,
isKeycloakEnabled
} from "./auth-props"
const credentialsOptions = () => {
const options: Record<string, unknown> = {
@ -33,7 +39,7 @@ const credentialsOptions = () => {
const providers = () => {
const providers = []
if (config.github_client_id && config.github_client_secret) {
if (isGithubEnabled()) {
providers.push(
GitHubProvider({
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(
CredentialsProvider({
name: "Drift",
@ -207,6 +247,58 @@ export const authOptions: NextAuthOptions = {
username: dbUser.username,
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

View file

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