Skip to content

Commit

Permalink
feat: improve OIDC
Browse files Browse the repository at this point in the history
* make scopes configurable
* allow to choose which claim will be used as username / email for users

Fix #827
Fix #826
  • Loading branch information
ptitFicus committed Jun 10, 2024
1 parent 073351d commit be3ef0e
Show file tree
Hide file tree
Showing 17 changed files with 428 additions and 290 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ project/.bloop/
project/metals.sbt
project/project/
izanami-frontend/playwright/.auth/
.oidc-secrets
15 changes: 13 additions & 2 deletions app/fr/maif/izanami/datastores/ConfigurationDatastore.scala
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,19 @@ class ConfigurationDatastore(val env: Env) extends Datastore {
clientSecret <- env.configuration.getOptional[String]("app.openid.client-secret");
authorizeUrl <- env.configuration.getOptional[String]("app.openid.authorize-url");
tokenUrl <- env.configuration.getOptional[String]("app.openid.token-url");
redirectUrl <- env.configuration.getOptional[String]("app.openid.redirect-url")
) yield OIDCConfiguration(clientId=clientId, clientSecret=clientSecret, authorizeUrl=authorizeUrl, tokenUrl=tokenUrl, redirectUrl=redirectUrl)
redirectUrl <- env.configuration.getOptional[String]("app.openid.redirect-url");
usernameField <- env.configuration.getOptional[String]("app.openid.username-field");
emailField <- env.configuration.getOptional[String]("app.openid.email-field");
scopes <- env.configuration.getOptional[String]("app.openid.scopes")
) yield OIDCConfiguration(clientId=clientId,
clientSecret=clientSecret,
authorizeUrl=authorizeUrl,
tokenUrl=tokenUrl,
redirectUrl=redirectUrl,
usernameField = usernameField,
emailField = emailField,
scopes = scopes.split(" ").toSet
)
}

def readConfiguration(): Future[Either[IzanamiError, IzanamiConfiguration]] = {
Expand Down
5 changes: 4 additions & 1 deletion app/fr/maif/izanami/models/IzanamiConfiguration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ case class OIDCConfiguration(
clientSecret: String,
authorizeUrl: String,
tokenUrl: String,
redirectUrl: String
redirectUrl: String,
usernameField: String,
emailField: String,
scopes: Set[String]
)

case class IzanamiConfiguration(
Expand Down
52 changes: 32 additions & 20 deletions app/fr/maif/izanami/web/LoginController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import fr.maif.izanami.models.User.userRightsWrites
import fr.maif.izanami.models.{OIDC, OIDCConfiguration, Rights, User}
import fr.maif.izanami.utils.syntax.implicits.BetterSyntax
import pdi.jwt.{JwtJson, JwtOptions}
import play.api.libs.json.Json
import play.api.libs.json.{JsObject, Json}
import play.api.libs.ws.WSAuthScheme
import play.api.mvc.Cookie.SameSite
import play.api.mvc._
Expand All @@ -22,46 +22,58 @@ class LoginController(
implicit val ec: ExecutionContext = env.executionContext;

def openIdConnect = Action {
env.datastores.configuration.readOIDCConfiguration() match {
case None => MissingOIDCConfigurationError().toHttpResponse
case Some(OIDCConfiguration(clientId, _, authorizeUrl, _, redirectUrl)) => Redirect(s"${authorizeUrl}?scope=openid%20profile%20email%20name&client_id=${clientId}&response_type=code&redirect_uri=${redirectUrl}")
env.datastores.configuration.readOIDCConfiguration() match {
case None => MissingOIDCConfigurationError().toHttpResponse
case Some(OIDCConfiguration(clientId, _, authorizeUrl, _, redirectUrl, _, _, scopes)) => {
val hasOpenIdInScope = scopes.exists(s => s.equalsIgnoreCase("openid"))
val actualScope = (if (!hasOpenIdInScope) scopes + "openid" else scopes).mkString("%20")
Redirect(
s"${authorizeUrl}?scope=$actualScope&client_id=${clientId}&response_type=code&redirect_uri=${redirectUrl}"
)
}
}
}

def openIdCodeReturn = Action.async { implicit request =>
// TODO handle refresh_token
{
for (
code <- request.body.asJson.flatMap(json => (json \ "code").get.asOpt[String]);
OIDCConfiguration(clientId, clientSecret, _, tokenUrl, redirectUrl) <- env.datastores.configuration.readOIDCConfiguration()
code <- request.body.asJson.flatMap(json => (json \ "code").get.asOpt[String]);
OIDCConfiguration(clientId, clientSecret, _, tokenUrl, redirectUrl, usernameField, emailField, _) <-
env.datastores.configuration.readOIDCConfiguration()
)
yield env.Ws
.url(tokenUrl)
.withAuth(clientId, clientSecret, WSAuthScheme.BASIC)
.withHttpHeaders(("content-type", "application/x-www-form-urlencoded"))
.post(Map("grant_type" -> "authorization_code", "code" -> code, "redirect_uri" -> redirectUrl))
//.post(s"grant_type=authorization_code&code=${code}&redirect_uri=${redirectUrl}")
.flatMap(r => {
val maybeToken = (r.json \ "id_token").get.asOpt[String]
maybeToken.fold(Future(InternalServerError(Json.obj("message" -> "Failed to retrieve token"))))(token => {
val maybeClaims = JwtJson.decode(token, JwtOptions(signature = false))
maybeClaims.toOption
.flatMap(claims => claims.subject)
.map(userId =>
env.datastores.users
.findUser(userId)
.flatMap(maybeUser =>
maybeUser.fold(
// TODO handle mail
env.datastores.users.createUser(User(userId, userType = OIDC).withRights(Rights.EMPTY))
)(user => Future(Right(user.withRights(Rights.EMPTY)))).map(either => either.map(_ => userId))
)
)
.flatMap(claims => Json.parse(claims.content).asOpt[JsObject])
.flatMap(json => {
for (
username <- (json \ usernameField).asOpt[String];
email <- (json \ emailField).asOpt[String]
)
yield env.datastores.users
.findUser(username)
.flatMap(maybeUser =>
maybeUser
.fold(
env.datastores.users
.createUser(User(username, email = email, userType = OIDC).withRights(Rights.EMPTY))
)(user => Future(Right(user.withRights(Rights.EMPTY))))
.map(either => either.map(_ => username))
)
})
.getOrElse(Future(Left(InternalServerError(Json.obj("message" -> "Failed to read token claims")))))
.flatMap {
// TODO refactor this whole method
case Right(username) => env.datastores.users.createSession(username).map(id => Right(id))
case Left(err) => Future(Left(err))
case Left(err) => Future(Left(err))
}
.map(maybeId => {
maybeId
Expand Down Expand Up @@ -133,7 +145,7 @@ class LoginController(
)
)
}
case _ => Future(Unauthorized(Json.obj("message" -> "Missing credentials")))
case _ => Future(Unauthorized(Json.obj("message" -> "Missing credentials")))
}
}
}
6 changes: 6 additions & 0 deletions conf/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ app {
token-url = ${?IZANAMI_OPENID_TOKEN_URL}
redirect-url = ${?app.exposition.url}"/login"
redirect-url = ${?IZANAMI_OPENID_REDIRECT_URL}
scopes = "openid email profile"
scopes = ${?IZANAMI_OPENID_SCOPES}
email-field = "email"
email-field = ${?IZANAMI_OPENID_EMAIL_FIELD}
username-field = "name"
username-field = ${?IZANAMI_OPENID_USERNAME_FIELD}
}
wasmo {
url = ${?IZANAMI_WASMO_URL}
Expand Down
3 changes: 3 additions & 0 deletions conf/dev.conf
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ app {
authorize-url = "http://localhost:9001/auth"
token-url = "http://localhost:9001/token"
redirect-url = "http://localhost:3000/login"
scopes = "email profile"
username-field = name
email-field = email
}
admin {
password = "ADMIN_DEFAULT_PASSWORD"
Expand Down
18 changes: 11 additions & 7 deletions izanami-frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,8 +416,7 @@ function RedirectToFirstTenant(): JSX.Element {
}

function Layout() {
const { user, setUser, logout, expositionUrl, setExpositionUrl } =
useContext(IzanamiContext);
const { user, setUser, logout, expositionUrl } = useContext(IzanamiContext);
const loading = !user?.username || !expositionUrl;
useEffect(() => {
if (!user?.username) {
Expand All @@ -426,11 +425,6 @@ function Layout() {
.then((user) => setUser(user))
.catch(console.error);
}
if (!expositionUrl) {
fetch("/api/admin/exposition")
.then((response) => response.json())
.then(({ url }) => setExpositionUrl(url));
}
}, [user?.username]);

if (loading) {
Expand Down Expand Up @@ -654,12 +648,22 @@ export class App extends Component {
}
}

fetchExpositionUrlIfNeeded(): void {
if (!this.state.expositionUrl) {
fetch("/api/admin/exposition")
.then((response) => response.json())
.then(({ url }) => this.setState({ expositionUrl: url }));
}
}

componentDidMount(): void {
this.fetchIntegrationsIfNeeded();
this.fetchExpositionUrlIfNeeded();
}

componentDidUpdate(): void {
this.fetchIntegrationsIfNeeded();
this.fetchExpositionUrlIfNeeded;
setupLightMode();
}

Expand Down
12 changes: 8 additions & 4 deletions izanami-frontend/src/pages/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Logo from "../../izanami.png";
import { Configuration, TUser } from "../utils/types";
import { useMutation } from "react-query";
import { updateConfiguration } from "../utils/queries";
import { Loader } from "../components/Loader";

export function Login(props: any) {
const code = new URLSearchParams(props.location.search).get("code");
Expand Down Expand Up @@ -42,7 +43,11 @@ function TokenWaitScreen({ code }: { code: string }) {
if (error) {
return <div>{error}</div>;
} else if (fetching) {
return <div>Fetching...</div>;
return (
<div>
<Loader message="Waiting redirection..." />
</div>
);
} else {
return <div>Fetched !!!</div>;
}
Expand All @@ -51,7 +56,7 @@ function TokenWaitScreen({ code }: { code: string }) {
function LoginForm(props: { req?: string }) {
const navigate = useNavigate();
const req = props.req;
const { setUser, integrations } = useContext(IzanamiContext);
const { setUser, integrations, expositionUrl } = useContext(IzanamiContext);
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);

Expand Down Expand Up @@ -164,8 +169,7 @@ function LoginForm(props: { req?: string }) {
<button
className="btn btn-secondary"
onClick={() => {
window.location.href =
"http://localhost:9000/api/admin/openid-connect";
window.location.href = `${expositionUrl}/api/admin/openid-connect`;
}}
>
OpenId connect
Expand Down
5 changes: 3 additions & 2 deletions izanami-frontend/src/pages/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Loader } from "../components/Loader";
export function Profile() {
const ctx = React.useContext(IzanamiContext);
const user = ctx.user!;
const isOIDC = user.userType === "OIDC";
const [informationEdition, setInformationEdition] = useState(false);
const [passwordEdition, setPasswordEdition] = useState(false);
const passwordUpdateMutation = useMutation(
Expand Down Expand Up @@ -84,7 +85,7 @@ export function Profile() {
</div>
</>
)}
{!informationEdition && (
{!isOIDC && !informationEdition && (
<button
type="button"
className="btn btn-primary my-2 btn-sm"
Expand All @@ -109,7 +110,7 @@ export function Profile() {
/>
)}

{!passwordEdition && (
{!isOIDC && !passwordEdition && (
<button
type="button"
className="btn btn-primary my-2 btn-sm"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import lunr from "/home/runner/work/izanami/izanami/manual/node_modules/lunr/lunr.js";
require("/home/runner/work/izanami/izanami/manual/node_modules/lunr-languages/lunr.stemmer.support.js")(lunr);
import lunr from "/Users/77199M/workspace/oss/izanami-v2/manual/node_modules/lunr/lunr.js";
require("/Users/77199M/workspace/oss/izanami-v2/manual/node_modules/lunr-languages/lunr.stemmer.support.js")(lunr);
require("@easyops-cn/docusaurus-search-local/dist/client/shared/lunrLanguageZh").lunrLanguageZh(lunr);
require("/home/runner/work/izanami/izanami/manual/node_modules/lunr-languages/lunr.multi.js")(lunr);
require("/Users/77199M/workspace/oss/izanami-v2/manual/node_modules/lunr-languages/lunr.multi.js")(lunr);
export const language = ["en","zh"];
export const removeDefaultStopWordFilter = false;
export const removeDefaultStemmer = false;
export { default as Mark } from "/home/runner/work/izanami/izanami/manual/node_modules/mark.js/dist/mark.js"
export const searchIndexUrl = "search-index{dir}.json?_=e82b425e";
export { default as Mark } from "/Users/77199M/workspace/oss/izanami-v2/manual/node_modules/mark.js/dist/mark.js"
export const searchIndexUrl = "search-index{dir}.json?_=e3105752";
export const searchResultLimits = 8;
export const searchResultContextMaxLength = 50;
export const explicitSearchResultPath = true;
Expand Down
8 changes: 4 additions & 4 deletions manual/.docusaurus/client-modules.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export default [
require("/home/runner/work/izanami/izanami/manual/node_modules/infima/dist/css/default/default.css"),
require("/home/runner/work/izanami/izanami/manual/node_modules/@docusaurus/theme-classic/lib/prism-include-languages"),
require("/home/runner/work/izanami/izanami/manual/node_modules/@docusaurus/theme-classic/lib/nprogress"),
require("/home/runner/work/izanami/izanami/manual/src/css/custom.css"),
require("/Users/77199M/workspace/oss/izanami-v2/manual/node_modules/infima/dist/css/default/default.css"),
require("/Users/77199M/workspace/oss/izanami-v2/manual/node_modules/@docusaurus/theme-classic/lib/prism-include-languages"),
require("/Users/77199M/workspace/oss/izanami-v2/manual/node_modules/@docusaurus/theme-classic/lib/nprogress"),
require("/Users/77199M/workspace/oss/izanami-v2/manual/src/css/custom.css"),
];
Loading

0 comments on commit be3ef0e

Please sign in to comment.