Skip to content

Commit dd2fc4f

Browse files
committed
Fix authentication loop and token issues: refactor frontend init, fix backend token payload parsing, and improve token attachment
1 parent 3216ca6 commit dd2fc4f

9 files changed

Lines changed: 1087 additions & 546 deletions

File tree

docs/sotohp-api-docs.json

Lines changed: 813 additions & 327 deletions
Large diffs are not rendered by default.

frontend-user-interface/assets/js/app.js

Lines changed: 161 additions & 122 deletions
Large diffs are not rendered by default.

frontend-user-interface/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,6 @@ <h3>Synchronization</h3>
175175
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
176176
<script src="https://cdn.jsdelivr.net/npm/keycloak-js@25.0.4/dist/keycloak.min.js"></script>
177177
<!-- App -->
178-
<script type="module" src="assets/js/app.js"></script>
178+
<script type="module" src="assets/js/app.js?v=14"></script>
179179
</body>
180180
</html>

resources/keycloak/sotohp-realm.json

Lines changed: 4 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,12 @@
9898
"enabled": true,
9999
"clientAuthenticatorType": "client-secret",
100100
"redirectUris": [
101-
"http://localhost:3000/*",
102-
"http://localhost:8080/*",
103-
"http://localhost:8180/*",
104-
"http://127.0.0.1:8080/*"
101+
"http://127.0.0.1:8080/*",
102+
"http://192.168.1.222:8080/*"
105103
],
106104
"webOrigins": [
107-
"http://localhost:3000",
108-
"http://localhost:8080",
109-
"http://localhost:8180",
110-
"http://127.0.0.1:8080"
105+
"http://127.0.0.1:8080",
106+
"http://192.168.1.222:8080"
111107
],
112108
"publicClient": true,
113109
"bearerOnly": false,
@@ -205,40 +201,6 @@
205201
],
206202
"realmRoles": ["admin"],
207203
"groups": ["/admins"]
208-
},
209-
{
210-
"username": "reader",
211-
"enabled": true,
212-
"emailVerified": true,
213-
"firstName": "Reader",
214-
"lastName": "User",
215-
"email": "reader@example.com",
216-
"credentials": [
217-
{
218-
"type": "password",
219-
"value": "reader",
220-
"temporary": true
221-
}
222-
],
223-
"realmRoles": ["reader"],
224-
"groups": ["/users"]
225-
},
226-
{
227-
"username": "pending",
228-
"enabled": true,
229-
"emailVerified": true,
230-
"firstName": "Pending",
231-
"lastName": "User",
232-
"email": "pending@example.com",
233-
"credentials": [
234-
{
235-
"type": "password",
236-
"value": "pending",
237-
"temporary": true
238-
}
239-
],
240-
"realmRoles": [],
241-
"groups": ["/pending"]
242204
}
243205
],
244206
"scopeMappings": [

user-interfaces/api/src/main/resources/reference.conf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ sotohp {
2424
enabled = ${?PHOTOS_AUTH_ENABLED}
2525
issuer = "http://localhost:8081/realms/sotohp"
2626
issuer = ${?PHOTOS_AUTH_ISSUER}
27+
client-id = "sotohp-web"
28+
client-id = ${?PHOTOS_AUTH_CLIENT_ID}
2729
jwks-url = "http://localhost:8081/realms/sotohp/protocol/openid-connect/certs"
2830
jwks-url = ${?PHOTOS_AUTH_JWKS_URL}
2931
audience = ${?PHOTOS_AUTH_AUDIENCE}

user-interfaces/api/src/main/scala/fr/janalyse/sotohp/api/ApiApp.scala

Lines changed: 84 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,12 @@ object ApiApp extends ZIOAppDefault {
6161
val configProviderLayer = Runtime.setConfigProvider(configProvider)
6262

6363
override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = {
64-
//val fmt = LogFormat.level |-| LogFormat.annotations |-| LogFormat.line
64+
// val fmt = LogFormat.level |-| LogFormat.annotations |-| LogFormat.line
6565
// val fmt = LogFormat.annotations |-| LogFormat.line
66-
//val loggingLayer = Runtime.removeDefaultLoggers >>> SLF4J.slf4j(format = fmt)
67-
//val loggingLayer = zio.logging.slf4j.bridge.Slf4jBridge.initialize
66+
// val loggingLayer = Runtime.removeDefaultLoggers >>> SLF4J.slf4j(format = fmt)
67+
// val loggingLayer = zio.logging.slf4j.bridge.Slf4jBridge.initialize
6868
val loggingLayer = Runtime.removeDefaultLoggers >>> consoleLogger()
6969

70-
7170
loggingLayer
7271
++ configProviderLayer
7372
++ Runtime.enableAutoBlockingExecutor // TODO identify where some blocking operation markers have been forgotten
@@ -95,10 +94,22 @@ object ApiApp extends ZIOAppDefault {
9594
// Secure endpoint bases with optional bearer authentication
9695
// When auth is disabled, endpoints work without token; when enabled, token is validated
9796

98-
def securityError: EndpointOutput[ApiIssue] = jsonBody[ApiSecurityError].map(e => e: ApiIssue) {
99-
case e: ApiSecurityError => e
100-
case _ => ApiSecurityError("Unknown security error")
101-
}
97+
def securityError: EndpointOutput[ApiIssue] =
98+
oneOf[ApiIssue](
99+
oneOfVariant(StatusCode.Unauthorized, jsonBody[ApiSecurityError].map(e => e: ApiIssue) {
100+
case e: ApiSecurityError => e
101+
case _ => ApiSecurityError("Unknown security error")
102+
}.description("Authentication failed")),
103+
oneOfVariant(StatusCode.Forbidden, jsonBody[ApiSecurityError].map(e => e: ApiIssue) {
104+
case e: ApiSecurityError => e
105+
case _ => ApiSecurityError("Unknown security error")
106+
}.description("Insufficient permissions")),
107+
// Fallback for any other ApiSecurityError to 401
108+
oneOfVariant(StatusCode.Unauthorized, jsonBody[ApiSecurityError].map(e => e: ApiIssue) {
109+
case e: ApiSecurityError => e
110+
case _ => ApiSecurityError("Unknown security error")
111+
})
112+
)
102113

103114
val secureSystemEndpoint = systemEndpoint.securityIn(SecureEndpoints.bearerAuth).errorOut(securityError).zServerSecurityLogic(securityLogic)
104115
val secureAdminEndpoint = adminEndpoint.securityIn(SecureEndpoints.bearerAuth).errorOut(securityError).zServerSecurityLogic(securityLogic)
@@ -591,26 +602,26 @@ object ApiApp extends ZIOAppDefault {
591602
// We need to read the config to set the cache header, but endpoints are defined as vals.
592603
// We can wrap the header logic or rely on the fact that ApiConfig.config is a ZIO effect.
593604
// Ideally, we'd restructure to build endpoints after config, but for minimal intrusion:
594-
// We'll define a placeholder or read it unsafely/default if necessary?
605+
// We'll define a placeholder or read it unsafely/default if necessary?
595606
// No, let's use a serverLogic wrapper or just a fixed value if dynamic is too hard in this structure.
596-
// Actually, 'header' in tapir output is static unless mapped.
607+
// Actually, 'header' in tapir output is static unless mapped.
597608
// Let's use a Task/ZIO to build the header output? Tapir doesn't support ZIO in definition easily.
598609
// Best approach: Use a var or lazy val initialized early, OR just assume a default since we can't change the val structure easily without refactoring 'ApiApp' into a class/layer.
599-
610+
600611
// WAIT: ApiApp is an object extending ZIOAppDefault. config is available via ApiConfig.config effect.
601612
// But 'mediaContentGetOriginalEndpoint' is a val. It is evaluated at initialization time.
602613
// We cannot use the effectful config here.
603614
// HOWEVER: We can use `out(header[String]("Cache-Control"))` and provide the value in the serverLogic.
604-
615+
605616
// Let's modify the endpoints to include the header in the output definition, and then provide the value in the logic.
606-
617+
607618
// Helper to add cache control
608619
def addCacheHeader[R, E, Err](logic: ZIO[R, E, (String, ZStream[Any, Err, Byte])]): ZIO[R, E, (String, String, ZStream[Any, Err, Byte])] = {
609620
for {
610-
config <- ApiConfig.config.orDie
611-
tuple <- logic
621+
config <- ApiConfig.config.orDie
622+
tuple <- logic
612623
(contentType, stream) = tuple
613-
cacheControl = s"private, max-age=${config.cacheMaxAgeSeconds}"
624+
cacheControl = s"private, max-age=${config.cacheMaxAgeSeconds}"
614625
} yield (contentType, cacheControl, stream)
615626
}
616627

@@ -906,14 +917,13 @@ object ApiApp extends ZIOAppDefault {
906917
.errorOutVariantPrepend(statusForApiInternalError)
907918
.errorOutVariantPrepend(statusForApiResourceNotFound)
908919
.errorOutVariantPrepend(statusForApiInvalidRequestError)
909-
.serverLogic[ApiEnv] { user =>
910-
rawFaceId =>
911-
for {
912-
faceId <- extractFaceId(rawFaceId)
913-
_ <- MediaService
914-
.faceDelete(faceId)
915-
.mapError(err => ApiInternalError("Couldn't delete face"))
916-
} yield ()
920+
.serverLogic[ApiEnv] { user => rawFaceId =>
921+
for {
922+
faceId <- extractFaceId(rawFaceId)
923+
_ <- MediaService
924+
.faceDelete(faceId)
925+
.mapError(err => ApiInternalError("Couldn't delete face"))
926+
} yield ()
917927
}
918928

919929
def faceGetImageBytesLogic(faceId: FaceId) = {
@@ -1017,11 +1027,10 @@ object ApiApp extends ZIOAppDefault {
10171027
.in("synchronize")
10181028
.in(query[Option[Int]]("addedThoseLastDays").description("for faster synchronize operations provide how much days back to look for new medias"))
10191029
.errorOutVariantPrepend(statusForApiInternalError)
1020-
.serverLogic[ApiEnv] { user =>
1021-
addedThoseLastDays =>
1022-
MediaService
1023-
.synchronizeStart(addedThoseLastDays)
1024-
.mapError(err => ApiInternalError("Couldn't synchronize"))
1030+
.serverLogic[ApiEnv] { user => addedThoseLastDays =>
1031+
MediaService
1032+
.synchronizeStart(addedThoseLastDays)
1033+
.mapError(err => ApiInternalError("Couldn't synchronize"))
10251034
}
10261035

10271036
val adminSynchronizeStatusEndpoint =
@@ -1032,12 +1041,11 @@ object ApiApp extends ZIOAppDefault {
10321041
.in("synchronize")
10331042
.out(jsonBody[ApiSynchronizeStatus])
10341043
.errorOutVariantPrepend(statusForApiInternalError)
1035-
.serverLogic[ApiEnv] { user =>
1036-
_ =>
1037-
MediaService
1038-
.synchronizeStatus()
1039-
.map(_.transformInto[ApiSynchronizeStatus])
1040-
.mapError(err => ApiInternalError("Couldn't get synchronize status"))
1044+
.serverLogic[ApiEnv] { user => _ =>
1045+
MediaService
1046+
.synchronizeStatus()
1047+
.map(_.transformInto[ApiSynchronizeStatus])
1048+
.mapError(err => ApiInternalError("Couldn't get synchronize status"))
10411049
}
10421050

10431051
// -------------------------------------------------------------------------------------------------------------------
@@ -1491,6 +1499,33 @@ object ApiApp extends ZIOAppDefault {
14911499
.errorOutVariantPrepend(statusForApiInternalError)
14921500
.serverLogic[ApiEnv](user => _ => serviceInfoLogic)
14931501

1502+
val serviceClientConfigEndpoint =
1503+
systemEndpoint.get
1504+
.in("config")
1505+
.out(jsonBody[ApiClientConfig])
1506+
.zServerLogic[ApiEnv] { _ =>
1507+
for {
1508+
config <- ApiConfig.config.orDie // .mapError(err => ApiInternalError("Couldn't get client configuration"))
1509+
auth = config.auth
1510+
issuer = auth.issuer
1511+
parts = issuer.split("/realms/")
1512+
} yield {
1513+
val (url, realm) = if (parts.length >= 2) {
1514+
(parts(0), parts(1))
1515+
} else {
1516+
("http://127.0.0.1:8081", "sotohp")
1517+
}
1518+
ApiClientConfig(
1519+
auth = ApiClientAuth(
1520+
enabled = auth.enabled,
1521+
url = url,
1522+
realm = realm,
1523+
clientId = auth.clientId
1524+
)
1525+
)
1526+
}
1527+
}
1528+
14941529
// -------------------------------------------------------------------------------------------------------------------
14951530
val apiRoutes = List(
14961531
// -------------------------
@@ -1545,7 +1580,8 @@ object ApiApp extends ZIOAppDefault {
15451580
adminSynchronizeStatusEndpoint,
15461581
// -------------------------
15471582
serviceStatusEndpoint,
1548-
serviceInfoEndpoint
1583+
serviceInfoEndpoint,
1584+
serviceClientConfigEndpoint
15491585
)
15501586

15511587
def apiDocRoutes =
@@ -1651,23 +1687,25 @@ object ApiApp extends ZIOAppDefault {
16511687
}
16521688

16531689
val securityServiceLayer: ZLayer[Any, Nothing, SecurityService] =
1654-
ZLayer.fromZIO {
1655-
ApiConfig.config.map(_.auth).orDie
1656-
}.flatMap { authConfigEnv =>
1657-
val authConfig = authConfigEnv.get
1658-
if (authConfig.enabled) {
1659-
ZLayer.succeed(authConfig) >>> SecurityService.live
1660-
} else {
1661-
SecurityService.disabled
1690+
ZLayer
1691+
.fromZIO {
1692+
ApiConfig.config.map(_.auth).orDie
1693+
}
1694+
.flatMap { authConfigEnv =>
1695+
val authConfig = authConfigEnv.get
1696+
if (authConfig.enabled) {
1697+
ZLayer.succeed(authConfig) >>> SecurityService.live
1698+
} else {
1699+
SecurityService.disabled
1700+
}
16621701
}
1663-
}
16641702

16651703
override def run = {
16661704
getArgs.flatMap { args =>
16671705
args.toList match {
16681706
case "--just-generate-openapi-specs" :: fileName :: Nil =>
16691707
generateOpenApiSpec(fileName)
1670-
case _ =>
1708+
case _ =>
16711709
for {
16721710
config <- ApiConfig.config
16731711
_ <- ZIO.logInfo(s"Authentication enabled: ${config.auth.enabled}")

user-interfaces/api/src/main/scala/fr/janalyse/sotohp/api/ApiConfig.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import zio.config.magnolia.*
77

88
case class AuthConfig(
99
enabled: Boolean = false,
10-
issuer: String = "http://localhost:8081/realms/sotohp",
11-
jwksUrl: String = "http://localhost:8081/realms/sotohp/protocol/openid-connect/certs",
10+
issuer: String = "http://127.0.0.1:8081/realms/sotohp",
11+
clientId: String = "sotohp-web",
12+
jwksUrl: String = "http://127.0.0.1:8081/realms/sotohp/protocol/openid-connect/certs",
1213
audience: Option[String] = None,
1314
jwksRefreshIntervalSeconds: Int = 300
1415
)

user-interfaces/api/src/main/scala/fr/janalyse/sotohp/api/security/SecureEndpoints.scala

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package fr.janalyse.sotohp.api.security
33
import fr.janalyse.sotohp.api.AuthConfig
44
import fr.janalyse.sotohp.api.protocol.{ApiIssue, ApiSecurityError}
55
import sttp.model.StatusCode
6-
import sttp.tapir.{EndpointInput, EndpointOutput, Schema, auth, oneOf, oneOfVariant, query}
6+
import sttp.tapir.{EndpointInput, EndpointOutput, Schema, auth, oneOf, oneOfVariant, query, statusCode}
77
import sttp.tapir.json.zio.jsonBody
88
import zio.*
99
import zio.json.*
@@ -21,11 +21,15 @@ object SecureEndpoints {
2121
case PendingUser(msg) => ApiSecurityError(msg)
2222
}
2323

24-
val securityErrorOutput: EndpointOutput.OneOf[ApiSecurityError, ApiSecurityError] =
25-
oneOf[ApiSecurityError](
26-
oneOfVariant(StatusCode.Unauthorized, jsonBody[ApiSecurityError].description("Authentication failed")),
27-
oneOfVariant(StatusCode.Forbidden, jsonBody[ApiSecurityError].description("Insufficient permissions"))
28-
)
24+
def securityError: EndpointOutput[ApiIssue] =
25+
jsonBody[ApiSecurityError]
26+
.map(e => e: ApiIssue)( {
27+
case e: ApiSecurityError => e
28+
case _ => ApiSecurityError("Unknown security error")
29+
})
30+
.description("Authentication failed")
31+
.example(ApiSecurityError("Bearer token required"))
32+
.and(statusCode(StatusCode.Unauthorized))
2933

3034
val bearerAuth: EndpointInput[String] =
3135
auth.bearer[Option[String]]()

user-interfaces/api/src/main/scala/fr/janalyse/sotohp/api/security/SecurityService.scala

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,15 @@ private class LiveSecurityService(
160160

161161
private def parsePayload(claim: JwtClaim): IO[SecurityError, JwtPayload] =
162162
ZIO.fromEither(claim.content.fromJson[JwtPayload])
163+
.map { partial =>
164+
partial.copy(
165+
iss = partial.iss.orElse(claim.issuer),
166+
sub = partial.sub.orElse(claim.subject),
167+
aud = partial.aud.orElse(claim.audience.flatMap(_.headOption)),
168+
exp = partial.exp.orElse(claim.expiration),
169+
iat = partial.iat.orElse(claim.issuedAt)
170+
)
171+
}
163172
.mapError(e => TokenInvalid(s"Failed to parse token payload: $e"))
164173

165174
private def validateIssuer(payload: JwtPayload): IO[SecurityError, Unit] =

0 commit comments

Comments
 (0)