Skip to content

Commit

Permalink
Adds Keycloak OAuth provider
Browse files Browse the repository at this point in the history
Signed-off-by: Mihovil Ilakovac <mihovil@ilakovac.com>
  • Loading branch information
infomiho committed Mar 14, 2024
1 parent adfa26f commit 6c8e511
Show file tree
Hide file tree
Showing 20 changed files with 251 additions and 10 deletions.
3 changes: 3 additions & 0 deletions waspc/cli/src/Wasp/Cli/Command/Studio.hs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ studio = do
[ "google"
| isJust $ AS.App.Auth.google methods
],
[ "keycloak"
| isJust $ AS.App.Auth.keycloak methods
],
[ "gitHub"
| isJust $ AS.App.Auth.gitHub methods
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ const SocialAuthButtons = styled('div', {
{=# isGoogleAuthEnabled =}
const googleSignInUrl = `${config.apiUrl}{= googleSignInPath =}`
{=/ isGoogleAuthEnabled =}
{=# isKeycloakAuthEnabled =}
const keycloakSignInUrl = `${config.apiUrl}{= keycloakSignInPath =}`
{=/ isKeycloakAuthEnabled =}
{=# isGitHubAuthEnabled =}
const gitHubSignInUrl = `${config.apiUrl}{= gitHubSignInPath =}`
{=/ isGitHubAuthEnabled =}
Expand Down Expand Up @@ -192,6 +195,10 @@ export const LoginSignupForm = ({
<SocialButton href={googleSignInUrl}><SocialIcons.Google/></SocialButton>
{=/ isGoogleAuthEnabled =}

{=# isKeycloakAuthEnabled =}
<SocialButton href={keycloakSignInUrl}><SocialIcons.Keycloak/></SocialButton>
{=/ isKeycloakAuthEnabled =}

{=# isGitHubAuthEnabled =}
<SocialButton href={gitHubSignInUrl}><SocialIcons.GitHub/></SocialButton>
{=/ isGitHubAuthEnabled =}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ export const Google = () => (
</svg>
)

export const Keycloak = () => (
<svg
className={defaultStyles()}
aria-hidden="true"
fill="currentColor"
viewBox="0 0 559 466"
>
<g id="brand" transform="matrix(1,0,0,1,-233.075,-279.1)" fill="#000000" fillRule="nonzero">
<path
d="M786.2,395.5L705.6,395.5C704.1,395.5 702.6,394.7 701.9,393.4L637.2,281.2C636.4,279.9 635,279.1 633.4,279.1L369.4,279.1C367.9,279.1 366.4,279.9 365.7,281.2L298.4,397.6L233.6,509.8C232.9,511.1 232.9,512.7 233.6,514.1L298.4,626.3L365.6,742.8C366.3,744.1 367.8,745 369.3,744.9L633.4,744.9C634.9,744.9 636.4,744.1 637.2,742.8L702,630.6C702.7,629.3 704.2,628.4 705.7,628.5L786.3,628.5C789,628.5 791.1,626.3 791.1,623.7L791.1,400.4C791,397.7 788.8,395.5 786.2,395.5ZM477.5,630.6L457.2,665.6C456.9,666.1 456.4,666.6 455.9,666.9C455.3,667.2 454.7,667.4 454,667.4L413.7,667.4C412.3,667.4 411,666.7 410.4,665.4L350.3,561.1L344.4,550.8L322.8,513.9C322.5,513.4 322.3,512.8 322.4,512.1C322.4,511.5 322.6,510.8 322.9,510.3L344.6,472.7L410.5,358.7C411.2,357.5 412.5,356.7 413.8,356.7L454,356.7C454.7,356.7 455.4,356.9 456.1,357.2C456.6,357.5 457.1,357.9 457.4,358.5L477.7,393.7C478.3,394.9 478.2,396.4 477.5,397.5L412.4,510.3C412.1,510.8 412,511.4 412,511.9C412,512.5 412.2,513 412.4,513.5L477.5,626.2C478.4,627.7 478.3,629.3 477.5,630.6ZM679.6,513.9L658,550.8L652.1,561.1L592,665.4C591.3,666.6 590.1,667.4 588.7,667.4L548.4,667.4C547.7,667.4 547.1,667.2 546.5,666.9C546,666.6 545.5,666.2 545.2,665.6L524.9,630.6C524,629.3 524,627.7 524.8,626.4L589.9,513.7C590.2,513.2 590.3,512.6 590.3,512.1C590.3,511.5 590.1,511 589.9,510.5L524.8,397.7C524.1,396.5 524,395.1 524.6,393.9L544.9,358.7C545.2,358.2 545.7,357.7 546.2,357.4C546.8,357 547.5,356.9 548.3,356.9L588.7,356.9C590.1,356.9 591.4,357.6 592,358.9L657.9,472.9L679.6,510.5C679.9,511.1 680.1,511.7 680.1,512.3C680.1,512.7 679.9,513.3 679.6,513.9Z"
/>
</g>
</svg>
)

// PRIVATE API
export const GitHub = () => (
<svg
Expand Down
1 change: 1 addition & 0 deletions waspc/data/Generator/templates/sdk/wasp/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type PossibleProviderData = {
email: EmailProviderData;
username: UsernameProviderData;
google: OAuthProviderData;
keycloak: OAuthProviderData;
github: OAuthProviderData;
}

Expand Down
3 changes: 3 additions & 0 deletions waspc/data/Generator/templates/sdk/wasp/client/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export * from './username'
{=# isGoogleAuthEnabled =}
export * from './google'
{=/ isGoogleAuthEnabled =}
{=# isKeycloakAuthEnabled =}
export * from './keycloak'
{=/ isKeycloakAuthEnabled =}
{=# isGitHubAuthEnabled =}
export * from './github'
{=/ isGitHubAuthEnabled =}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// PUBLIC API
export { signInUrl as keycloakSignInUrl } from '../../auth/helpers/Keycloak'
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const _waspConfig: ProviderConfig = {
const google = new Google(
env.GOOGLE_CLIENT_ID,
env.GOOGLE_CLIENT_SECRET,
getRedirectUriForCallback(provider.id)
getRedirectUriForCallback(provider.id),
);

const config = mergeDefaultAndUserConfig({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
{{={= =}=}}
import { Router, Request as ExpressRequest } from "express";
import { Keycloak, generateCodeVerifier, generateState } from "arctic";

import { HttpError } from 'wasp/server';
import { handleRejection } from "wasp/server/utils";
import type { ProviderConfig } from "wasp/auth/providers/types";
import { callbackPath, getRedirectUriForCallback } from "../oauth/redirect.js";
import { finishOAuthFlowAndGetRedirectUri } from "../oauth/user.js";
import { getCodeVerifierCookieName, getStateCookieName, getValueFromCookie, setValueInCookie } from "../oauth/cookies.js";
import { ensureEnvVarsForProvider } from "../oauth/env.js";
import { mergeDefaultAndUserConfig } from "../oauth/config.js";

{=# userSignupFields.isDefined =}
{=& userSignupFields.importStatement =}
const _waspUserSignupFields = {= userSignupFields.importIdentifier =}
{=/ userSignupFields.isDefined =}
{=^ userSignupFields.isDefined =}
const _waspUserSignupFields = undefined
{=/ userSignupFields.isDefined =}
{=# configFn.isDefined =}
{=& configFn.importStatement =}
const _waspUserDefinedConfigFn = {= configFn.importIdentifier =}
{=/ configFn.isDefined =}
{=^ configFn.isDefined =}
const _waspUserDefinedConfigFn = undefined
{=/ configFn.isDefined =}

const _waspConfig: ProviderConfig = {
id: "{= providerId =}",
displayName: "{= displayName =}",
createRouter(provider) {
const router = Router();

const env = ensureEnvVarsForProvider(
["KEYCLOAK_REALM_URL", "KEYCLOAK_CLIENT_ID", "KEYCLOAK_CLIENT_SECRET"],
provider
);

const keycloak = new Keycloak(
env.KEYCLOAK_REALM_URL,
env.KEYCLOAK_CLIENT_ID,
env.KEYCLOAK_CLIENT_SECRET,
getRedirectUriForCallback(provider.id),
);

const config = mergeDefaultAndUserConfig({
scopes: {=& requiredScopes =},
}, _waspUserDefinedConfigFn);

router.get('/login', handleRejection(async (_req, res) => {
const state = generateState();
setValueInCookie(getStateCookieName(provider.id), state, res);

const codeVerifier = generateCodeVerifier();
setValueInCookie(
getCodeVerifierCookieName(provider.id),
codeVerifier,
res
);

const url = await keycloak.createAuthorizationURL(state, codeVerifier, config);
return res.status(302)
.setHeader("Location", url.toString())
.end();
}));

router.get(`/${callbackPath}`, handleRejection(async (req, res) => {
try {
const { code, codeVerifier } = getDataFromCallback(req);
const { accessToken } = await keycloak.validateAuthorizationCode(code, codeVerifier);
const { providerProfile, providerUserId } = await getKeycloakProfile(accessToken);
const { redirectUri } = await finishOAuthFlowAndGetRedirectUri(
provider,
providerProfile,
providerUserId,
_waspUserSignupFields,
);

return res
.status(302)
.setHeader("Location", redirectUri)
.end();
} catch (e) {
// TODO: handle different errors
console.error(e);

// TODO: it makes sense to redirect to the client with the OAuth erorr!
throw new HttpError(500, "Something went wrong");
}
}));

function getDataFromCallback(req: ExpressRequest): {
code: string;
codeVerifier: string;
} {
const storedState = getValueFromCookie(
getStateCookieName(provider.id),
req
);
const storedCodeVerifier = getValueFromCookie(
getCodeVerifierCookieName(provider.id),
req
);
const state = req.query.state;
const code = req.query.code;

if (
!storedState ||
!state ||
storedState !== state ||
typeof code !== "string"
) {
throw new Error("Invalid state");
}

if (!storedCodeVerifier) {
throw new Error("Invalid code verifier");
}

return {
code,
codeVerifier: storedCodeVerifier,
}
}

async function getKeycloakProfile(accessToken: string): Promise<{
providerProfile: unknown;
providerUserId: string;
}> {
const userInfoEndpoint = `${env.KEYCLOAK_REALM_URL}/protocol/openid-connect/userinfo`;
const response = await fetch(
userInfoEndpoint,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
const providerProfile = (await response.json()) as {
sub?: string;
};

if (!providerProfile.sub) {
throw new Error("Invalid profile");
}

return { providerProfile, providerUserId: providerProfile.sub };
}

return router;
},
}

export default _waspConfig;
1 change: 1 addition & 0 deletions waspc/data/Generator/templates/server/src/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type PossibleProviderData = {
email: EmailProviderData;
username: UsernameProviderData;
google: OAuthProviderData;
keycloak: OAuthProviderData;
github: OAuthProviderData;
}

Expand Down
1 change: 1 addition & 0 deletions waspc/examples/todoApp/main.wasp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ app todoApp {
configFn: import { config } from "@src/auth/github.js",
userSignupFields: import { userSignupFields } from "@src/auth/github.js"
},
keycloak: {},
email: {
userSignupFields: import { userSignupFields } from "@src/auth/email.js",
fromField: {
Expand Down
5 changes: 5 additions & 0 deletions waspc/examples/todoApp/src/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export function getName(user?: User) {
return `GitHub user ${githubIdentity.providerUserId}`
}

const keycloakIdentity = findUserIdentity(user, 'keycloak')
if (keycloakIdentity) {
return `Keycloak user ${keycloakIdentity.providerUserId}`
}

// If we don't know how to get the name, return null.
return null
}
Expand Down
5 changes: 5 additions & 0 deletions waspc/src/Wasp/AppSpec/App/Auth.hs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module Wasp.AppSpec.App.Auth
isUsernameAndPasswordAuthEnabled,
isExternalAuthEnabled,
isGoogleAuthEnabled,
isKeycloakAuthEnabled,
isGitHubAuthEnabled,
isEmailAuthEnabled,
userSignupFieldsForEmailAuth,
Expand Down Expand Up @@ -40,6 +41,7 @@ data AuthMethods = AuthMethods
{ usernameAndPassword :: Maybe UsernameAndPasswordConfig,
google :: Maybe ExternalAuthConfig,
gitHub :: Maybe ExternalAuthConfig,
keycloak :: Maybe ExternalAuthConfig,
email :: Maybe EmailAuthConfig
}
deriving (Show, Eq, Data)
Expand Down Expand Up @@ -72,6 +74,9 @@ isExternalAuthEnabled auth = any ($ auth) [isGoogleAuthEnabled, isGitHubAuthEnab
isGoogleAuthEnabled :: Auth -> Bool
isGoogleAuthEnabled = isJust . google . methods

isKeycloakAuthEnabled :: Auth -> Bool
isKeycloakAuthEnabled = isJust . keycloak . methods

isGitHubAuthEnabled :: Auth -> Bool
isGitHubAuthEnabled = isJust . gitHub . methods

Expand Down
8 changes: 8 additions & 0 deletions waspc/src/Wasp/Generator/AuthProviders.hs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ googleAuthProvider =
OA._requiredScope = ["profile"]
}

keycloakAuthProvider :: OA.OAuthAuthProvider
keycloakAuthProvider =
OA.OAuthAuthProvider
{ OA._providerId = fromJust $ makeProviderId "keycloak",
OA._displayName = "Keycloak",
OA._requiredScope = []
}

gitHubAuthProvider :: OA.OAuthAuthProvider
gitHubAuthProvider =
OA.OAuthAuthProvider
Expand Down
9 changes: 8 additions & 1 deletion waspc/src/Wasp/Generator/SdkGenerator/Auth/AuthFormsG.hs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ where
import Data.Aeson (object, (.=))
import StrongPath (reldir, relfile, (</>))
import qualified Wasp.AppSpec.App.Auth as AS.Auth
import Wasp.Generator.AuthProviders (gitHubAuthProvider, googleAuthProvider)
import Wasp.Generator.AuthProviders
( gitHubAuthProvider,
googleAuthProvider,
keycloakAuthProvider,
)
import qualified Wasp.Generator.AuthProviders.OAuth as OAuth
import Wasp.Generator.FileDraft (FileDraft)
import Wasp.Generator.Monad (Generator)
Expand Down Expand Up @@ -114,6 +118,9 @@ genLoginSignupForm auth =
-- Google
"isGoogleAuthEnabled" .= AS.Auth.isGoogleAuthEnabled auth,
"googleSignInPath" .= OAuth.serverLoginUrl googleAuthProvider,
-- Keycloak
"isKeycloakAuthEnabled" .= AS.Auth.isKeycloakAuthEnabled auth,
"keycloakSignInPath" .= OAuth.serverLoginUrl keycloakAuthProvider,
-- GitHub
"isGitHubAuthEnabled" .= AS.Auth.isGitHubAuthEnabled auth,
"gitHubSignInPath" .= OAuth.serverLoginUrl gitHubAuthProvider,
Expand Down
10 changes: 8 additions & 2 deletions waspc/src/Wasp/Generator/SdkGenerator/Auth/OAuthAuthG.hs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import Data.Aeson (object, (.=))
import StrongPath (File', Path', Rel', reldir, relfile)
import qualified StrongPath as SP
import qualified Wasp.AppSpec.App.Auth as AS.Auth
import Wasp.Generator.AuthProviders (gitHubAuthProvider, googleAuthProvider)
import Wasp.Generator.AuthProviders
( gitHubAuthProvider,
googleAuthProvider,
keycloakAuthProvider,
)
import Wasp.Generator.AuthProviders.OAuth (OAuthAuthProvider)
import qualified Wasp.Generator.AuthProviders.OAuth as OAuth
import Wasp.Generator.FileDraft (FileDraft)
Expand All @@ -25,11 +29,13 @@ genHelpers auth =
return $
concat
[ [gitHubHelpers | AS.Auth.isGitHubAuthEnabled auth],
[googleHelpers | AS.Auth.isGoogleAuthEnabled auth]
[googleHelpers | AS.Auth.isGoogleAuthEnabled auth],
[keycloakHelpers | AS.Auth.isKeycloakAuthEnabled auth]
]
where
gitHubHelpers = mkHelpersFd gitHubAuthProvider [relfile|GitHub.tsx|]
googleHelpers = mkHelpersFd googleAuthProvider [relfile|Google.tsx|]
keycloakHelpers = mkHelpersFd keycloakAuthProvider [relfile|Keycloak.tsx|]

mkHelpersFd :: OAuthAuthProvider -> Path' Rel' File' -> FileDraft
mkHelpersFd provider helpersFp =
Expand Down
7 changes: 7 additions & 0 deletions waspc/src/Wasp/Generator/SdkGenerator/Client/AuthG.hs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ genNewClientAuth spec =
<++> genAuthEmail auth
<++> genAuthUsername auth
<++> genAuthGoogle auth
<++> genAuthKeycloak auth
<++> genAuthGitHub auth
where
maybeAuth = AS.App.auth $ snd $ getApp spec
Expand Down Expand Up @@ -68,6 +69,12 @@ genAuthGoogle auth =
then sequence [genFileCopy [relfile|client/auth/google.ts|]]
else return []

genAuthKeycloak :: AS.Auth.Auth -> Generator [FileDraft]
genAuthKeycloak auth =
if AS.Auth.isKeycloakAuthEnabled auth
then sequence [genFileCopy [relfile|client/auth/keycloak.ts|]]
else return []

genAuthGitHub :: AS.Auth.Auth -> Generator [FileDraft]
genAuthGitHub auth =
if AS.Auth.isGitHubAuthEnabled auth
Expand Down
Loading

0 comments on commit 6c8e511

Please sign in to comment.