feat: add keycloak login (#139)
Co-authored-by: Zdeněk Janeček <zdenek.janecek@firma.seznam.cz>
This commit is contained in:
parent
7887b42404
commit
3433371930
11 changed files with 1283 additions and 950 deletions
|
@ -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
|
||||
|
|
63
package.json
63
package.json
|
@ -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"
|
||||
|
|
1868
pnpm-lock.yaml
1868
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -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 ? (
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
38
src/lib/server/auth-props.ts
Normal file
38
src/lib/server/auth-props.ts
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue