Skip to content

Commit

Permalink
ARC-1234: Added Frontend! (#15)
Browse files Browse the repository at this point in the history
  • Loading branch information
thomasrichner-oviva authored Feb 7, 2024
1 parent af38498 commit ea57a90
Show file tree
Hide file tree
Showing 19 changed files with 676 additions and 96 deletions.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=oviva-ag_ehealthid-relying-party&metric=alert_status&token=ee904c8acea811b217358c63297ebe91fd6aee14)](https://sonarcloud.io/summary/new_code?id=oviva-ag_ehealthid-relying-party)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=oviva-ag_ehealthid-relying-party&metric=coverage&token=ee904c8acea811b217358c63297ebe91fd6aee14)](https://sonarcloud.io/summary/new_code?id=oviva-ag_ehealthid-relying-party)

# TODO
- [ ] Internationalization (ResourceBundles) for templates
- [ ] Basic ExceptionMapper
- [ ] Health & Metrics endpoints
- [ ] Dockerfile & Helm chart

# OpenID Connect Relying Party for GesundheitsID (eHealthID)

## Contents
Expand Down Expand Up @@ -248,4 +254,3 @@ sudo caddy reverse-proxy --from=$DOMAIN --to=:1234
- [AppFlow - Authentication flow to implement](https://wiki.gematik.de/display/IDPKB/App-App+Flow#AppAppFlow-0-FederationMaster)
- [Sektoraler IDP - Examples & Reference Implementation](https://wiki.gematik.de/display/IDPKB/Sektoraler+IDP+-+Referenzimplementierung+und+Beispiele)
- [OpenID Federation Spec](https://openid.net/specs/openid-federation-1_0.html)

7 changes: 7 additions & 0 deletions ehealthid-rp/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
<artifactId>ehealthid</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.github.spullara.mustache.java</groupId>
<artifactId>compiler</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
Expand All @@ -44,6 +48,8 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>

<!-- BEGIN jakarta ws -->
<dependency>
<groupId>jakarta.ws.rs</groupId>
<artifactId>jakarta.ws.rs-api</artifactId>
Expand All @@ -60,6 +66,7 @@
<groupId>com.fasterxml.jackson.jakarta.rs</groupId>
<artifactId>jackson-jakarta-rs-json-provider</artifactId>
</dependency>
<!-- END jakarta ws -->

<!-- BEGIN logging-->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.oviva.ehealthid.relyingparty.svc;

import com.oviva.ehealthid.relyingparty.util.IdGenerator;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import java.util.concurrent.ConcurrentHashMap;
Expand All @@ -11,26 +10,12 @@ public class InMemorySessionRepo implements SessionRepo {
private final ConcurrentMap<String, Session> repo = new ConcurrentHashMap<>();

@Override
public String save(@NonNull Session session) {
if (session.id() != null) {
throw new IllegalStateException(
"session already has an ID=%s, already saved?".formatted(session.id()));
public void save(@NonNull Session session) {
if (session.id() == null) {
throw new IllegalArgumentException("session has no ID");
}

var id = IdGenerator.generateID();
session =
new Session(
id,
session.state(),
session.nonce(),
session.redirectUri(),
session.clientId(),
session.codeVerifier(),
session.trustedSectoralIdpStep());

repo.put(id, session);

return id;
repo.put(session.id(), session);
}

@Nullable
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package com.oviva.ehealthid.relyingparty.svc;

import com.oviva.ehealthid.auth.steps.SelectSectoralIdpStep;
import com.oviva.ehealthid.auth.steps.TrustedSectoralIdpStep;
import edu.umd.cs.findbugs.annotations.NonNull;
import java.net.URI;

public interface SessionRepo {

String save(@NonNull Session session);
void save(@NonNull Session session);

Session load(@NonNull String sessionId);

Expand All @@ -17,5 +18,6 @@ record Session(
URI redirectUri,
String clientId,
String codeVerifier,
SelectSectoralIdpStep selectSectoralIdpStep,
TrustedSectoralIdpStep trustedSectoralIdpStep) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
import com.oviva.ehealthid.relyingparty.svc.SessionRepo;
import com.oviva.ehealthid.relyingparty.svc.SessionRepo.Session;
import com.oviva.ehealthid.relyingparty.svc.TokenIssuer;
import com.oviva.ehealthid.relyingparty.util.IdGenerator;
import com.oviva.ehealthid.relyingparty.ws.OpenIdErrorResponses.ErrorCode;
import com.oviva.ehealthid.relyingparty.ws.ui.Pages;
import com.oviva.ehealthid.relyingparty.ws.ui.TemplateRenderer;
import edu.umd.cs.findbugs.annotations.Nullable;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.CookieParam;
import jakarta.ws.rs.FormParam;
Expand Down Expand Up @@ -42,6 +46,8 @@ public class AuthEndpoint {

private final AuthenticationFlow authenticationFlow;

private final Pages pages = new Pages(new TemplateRenderer());

public AuthEndpoint(
URI baseUri,
RelyingPartyConfig relyingPartyConfig,
Expand Down Expand Up @@ -88,24 +94,19 @@ public Response auth(
try {
parsedRedirect = new URI(redirectUri);
} catch (URISyntaxException e) {
// TODO nice form
return Response.status(Status.BAD_REQUEST)
.entity("bad 'redirect_uri': %s".formatted(parsedRedirect))
.build();
return badRequest(
"Bad redirect_uri='%s'. Passed link is not valid.".formatted(parsedRedirect));
}

if (!"https".equals(parsedRedirect.getScheme())) {
// TODO nice form
return Response.status(Status.BAD_REQUEST)
.entity("not https 'redirect_uri': %s".formatted(parsedRedirect))
.build();
return badRequest(
"Insecure redirect_uri='%s'. Misconfigured server, please use 'https'."
.formatted(parsedRedirect));
}

if (!relyingPartyConfig.validRedirectUris().contains(parsedRedirect)) {
// TODO nice form
return Response.status(Status.BAD_REQUEST)
.entity("untrusted 'redirect_uri': %s".formatted(parsedRedirect))
.build();
return badRequest(
"Untrusted redirect_uri=%s. Misconfigured server.".formatted(parsedRedirect));
}

if (!"openid".equals(scope)) {
Expand Down Expand Up @@ -143,23 +144,18 @@ public Response auth(
state, nonce, relyingPartyCallback, codeChallenge, scopes));

// ==== 2) get the list of available IDPs
var idps = step1.fetchIdpOptions();

// ==== 3) select and IDP

// for now we hardcode the reference IDP from Gematik
var sektoralerIdpIss = "https://gsi.dev.gematik.solutions";

var step2 = step1.redirectToSectoralIdp(sektoralerIdpIss);

var federatedLogin = step2.idpRedirectUri();
var identityProviders = step1.fetchIdpOptions();
var form = pages.selectIdpForm(identityProviders);

// store session
var session = new Session(null, state, nonce, parsedRedirect, clientId, verifier, step2);
var sessionId = sessionRepo.save(session);
var sessionId = IdGenerator.generateID();
var session =
new Session(sessionId, state, nonce, parsedRedirect, clientId, verifier, step1, null);
sessionRepo.save(session);

// TODO: trigger actual flow
return Response.seeOther(federatedLogin).cookie(createSessionCookie(sessionId)).build();
return Response.ok(form, MediaType.TEXT_HTML_TYPE)
.cookie(createSessionCookie(sessionId))
.build();
}

private NewCookie createSessionCookie(String sessionId) {
Expand All @@ -173,27 +169,51 @@ private NewCookie createSessionCookie(String sessionId) {
.build();
}

@POST
@Path("/select-idp")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response postSelectIdp(
@CookieParam("session_id") String sessionId,
@FormParam("identityProvider") String identityProvider) {

if (identityProvider == null || identityProvider.isBlank()) {
return badRequest("No identity provider selected. Please go back.");
}

var session = findSession(sessionId);
if (session == null) {
return badRequest("Oops, no session unknown or expired. Please start again.");
}

var step2 = session.selectSectoralIdpStep().redirectToSectoralIdp(identityProvider);

var federatedLogin = step2.idpRedirectUri();

var newSession =
new SessionRepo.Session(
session.id(),
session.state(),
session.nonce(),
session.redirectUri(),
session.clientId(),
session.codeVerifier(),
session.selectSectoralIdpStep(),
step2);

sessionRepo.save(newSession);

return Response.seeOther(federatedLogin).build();
}

@GET
@Path("/callback")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response callback(
@CookieParam("session_id") String sessionId, @QueryParam("code") String code) {

if (sessionId == null || sessionId.isBlank()) {
// TODO: nice UI
return Response.status(Status.BAD_REQUEST)
.entity("Session missing!")
.type(MediaType.TEXT_PLAIN_TYPE)
.build();
}

var session = sessionRepo.load(sessionId);
var session = findSession(sessionId);
if (session == null) {
// TODO: nice UI
return Response.status(Status.BAD_REQUEST)
.entity("Session not found!")
.type(MediaType.TEXT_PLAIN_TYPE)
.build();
return badRequest("Oops, no session unknown or expired. Please start again.");
}

var idToken =
Expand Down Expand Up @@ -244,6 +264,23 @@ public Response token(
.build();
}

@Nullable
private Session findSession(@Nullable String id) {

if (id == null || id.isBlank()) {
return null;
}

return sessionRepo.load(id);
}

private Response badRequest(String message) {
return Response.status(Status.BAD_REQUEST)
.entity(pages.error(message))
.type(MediaType.TEXT_HTML_TYPE)
.build();
}

public record TokenResponse(
@JsonProperty("access_token") String accessToken,
@JsonProperty("token_type") String tokenType,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.oviva.ehealthid.relyingparty.ws.ui;

import com.oviva.ehealthid.fedclient.IdpEntry;
import java.util.List;
import java.util.Map;

public class Pages {

private final TemplateRenderer renderer;

public Pages(TemplateRenderer renderer) {
this.renderer = renderer;
}

public String selectIdpForm(List<IdpEntry> identityProviders) {
return renderer.render(
"select-idp.html.mustache", Map.of("identityProviders", identityProviders));
}

public String error(String message) {
return renderer.render("error.html.mustache", Map.of("message", message));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.oviva.ehealthid.relyingparty.ws.ui;

import com.github.mustachejava.DefaultMustacheFactory;
import com.github.mustachejava.MustacheFactory;
import java.io.StringWriter;

public class TemplateRenderer {

private MustacheFactory mf = new DefaultMustacheFactory("www");

public TemplateRenderer() {}

public TemplateRenderer(MustacheFactory mf) {
this.mf = mf;
}

public String render(String name, Object scope) {

var template = mf.compile(name);
var w = new StringWriter();
template.execute(w, scope);
return w.toString();
}
}
48 changes: 48 additions & 0 deletions ehealthid-rp/src/main/resources/www/common.css.mustache
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
html body {
position: relative;
width: 100%;
height: 100%;
font-family: Inter, sans-serif;
font-style: normal;
line-height: 150%; /* 36px */
letter-spacing: 0.15px;
color: #000E30;
background-color: #F7ECE7;
margin: 0;
padding: 0;
}

* {
box-sizing: border-box;
}

.container {
max-width: 400px;
margin: 1em auto 2em;
padding: 20px;
border-radius: 12px;
background: #FFF;
}

h1 {
font-family: "DM Serif Text", serif;
font-size: 24px;
}

a {
color: #00645F;
}

@media (max-width: 500px) {
.container {
margin: 0 auto;
border: none;
}

html body {
background: #FFF;
}
}
11 changes: 11 additions & 0 deletions ehealthid-rp/src/main/resources/www/error.html.mustache
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{{<layout.html.mustache}}
{{$body}}
<h1>Log in with GesundheitsID</h1>
<p>
{{#message}}{{message}}{{/message}}
{{^message}}
Oops, something unexpected happened. Please go back and try again.
{{/message}}
</p>
{{/body}}
{{/layout.html.mustache}}
Loading

0 comments on commit ea57a90

Please sign in to comment.