Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ARC-1234: Frontend bits! #15

Merged
merged 1 commit into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading