diff options
| -rw-r--r-- | build.gradle.kts | 27 | ||||
| -rw-r--r-- | src/main/java/moe/nea/prickly/config/Config.java | 1 | ||||
| -rw-r--r-- | src/main/java/moe/nea/prickly/model/AuthorizationRequest.java | 10 | ||||
| -rw-r--r-- | src/main/java/moe/nea/prickly/model/package-info.java | 4 | ||||
| -rw-r--r-- | src/main/java/moe/nea/prickly/server/Server.java | 23 | ||||
| -rw-r--r-- | src/main/java/moe/nea/prickly/util/JsonHelper.java | 14 | ||||
| -rw-r--r-- | src/main/java/moe/nea/prickly/util/OAuthUtil.java | 54 | ||||
| -rw-r--r-- | src/main/java/moe/nea/prickly/util/package-info.java | 4 | ||||
| -rw-r--r-- | src/main/jte/authorize.jte | 38 |
9 files changed, 173 insertions, 2 deletions
diff --git a/build.gradle.kts b/build.gradle.kts index b6e8941..25fb4a9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,9 @@ +import com.diffplug.spotless.LineEnding + plugins { id("java") application + id("gg.jte.gradle") version "3.2.1" id("com.diffplug.spotless") version "8.0.0" } @@ -15,9 +18,16 @@ dependencies { compileOnly("org.projectlombok:lombok:1.18.42") annotationProcessor("org.projectlombok:lombok:1.18.42") - implementation("io.javalin:javalin:6.7.0") + val javalinVersion = "6.7.0" + + implementation("io.javalin:javalin:$javalinVersion") + implementation("io.javalin:javalin-rendering:$javalinVersion") + + implementation("gg.jte:jte:3.2.1") + implementation("org.jspecify:jspecify:1.0.0") implementation("com.auth0:java-jwt:4.5.0") + implementation("com.google.guava:guava:33.3.1-jre") runtimeOnly("org.slf4j:slf4j-simple:2.0.17") testImplementation(platform("org.junit:junit-bom:5.10.0")) testImplementation("org.junit.jupiter:junit-jupiter") @@ -32,11 +42,24 @@ tasks.test { useJUnitPlatform() } +jte { + generate() +} + spotless { + val licenseHeader = "(C) \$YEAR Linnea Gräf - Licensed to everyone under the BSD 3 Clause License" + format("jte") { + target("src/main/jte/**/*.jte") + licenseHeader("<%-- $licenseHeader --%>", "@.*") + leadingSpacesToTabs() + trimTrailingWhitespace() + endWithNewline() + lineEndings = LineEnding.UNIX + } java { palantirJavaFormat() formatAnnotations() leadingSpacesToTabs() - licenseHeader("/* (C) \$YEAR Linnea Gräf - Licensed to everyone under the BSD 3 Clause License */") + licenseHeader("/* $licenseHeader */") } } diff --git a/src/main/java/moe/nea/prickly/config/Config.java b/src/main/java/moe/nea/prickly/config/Config.java index 624b1e3..1d8e428 100644 --- a/src/main/java/moe/nea/prickly/config/Config.java +++ b/src/main/java/moe/nea/prickly/config/Config.java @@ -25,6 +25,7 @@ public class Config { public final String SLUG = path.lastPart(); public final String NAME = path.join("NAME").requireString(); public final String HOMEPAGE = path.join("HOMEPAGE").requireString(); + public final String REDIRECT_URI = path.join("REDIRECT_URI").requireString(); } static class ConfigStruct { diff --git a/src/main/java/moe/nea/prickly/model/AuthorizationRequest.java b/src/main/java/moe/nea/prickly/model/AuthorizationRequest.java new file mode 100644 index 0000000..bd5c74f --- /dev/null +++ b/src/main/java/moe/nea/prickly/model/AuthorizationRequest.java @@ -0,0 +1,10 @@ +/* (C) 2025 Linnea Gräf - Licensed to everyone under the BSD 3 Clause License */ +package moe.nea.prickly.model; + +import java.net.URI; +import java.util.List; +import moe.nea.prickly.util.OAuthUtil; +import org.jspecify.annotations.Nullable; + +public record AuthorizationRequest( + OAuthUtil.ResponseType responseType, URI redirectUri, @Nullable String state, List<String> scope) {} diff --git a/src/main/java/moe/nea/prickly/model/package-info.java b/src/main/java/moe/nea/prickly/model/package-info.java new file mode 100644 index 0000000..7c56f0d --- /dev/null +++ b/src/main/java/moe/nea/prickly/model/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package moe.nea.prickly.model; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/moe/nea/prickly/server/Server.java b/src/main/java/moe/nea/prickly/server/Server.java index 2ff7bc9..9bb6df1 100644 --- a/src/main/java/moe/nea/prickly/server/Server.java +++ b/src/main/java/moe/nea/prickly/server/Server.java @@ -1,10 +1,16 @@ /* (C) 2025 Linnea Gräf - Licensed to everyone under the BSD 3 Clause License */ package moe.nea.prickly.server; +import com.google.common.base.Preconditions; import io.javalin.Javalin; import io.javalin.config.JavalinConfig; +import io.javalin.rendering.template.JavalinJte; +import java.util.Map; +import java.util.Objects; import lombok.extern.slf4j.Slf4j; import moe.nea.prickly.config.Config; +import moe.nea.prickly.model.AuthorizationRequest; +import moe.nea.prickly.util.OAuthUtil; @Slf4j public class Server { @@ -29,10 +35,27 @@ public class Server { javalin.get(prefix + "/", ctx -> { ctx.redirect(application.HOMEPAGE); }); + javalin.get(prefix + "/authorize", ctx -> { + var responseType = OAuthUtil.parseResponseType(ctx.queryParam("response_type")); + var redirectUri = OAuthUtil.verifyRedirectUrl(ctx.queryParam("redirect_uri"), application.REDIRECT_URI); + var state = ctx.queryParam("state"); + var clientId = ctx.queryParam("client_id"); + Preconditions.checkArgument( + Objects.equals(clientId, application.SLUG), "client_id does not match application slug"); + var scope = OAuthUtil.parseScopes(ctx.queryParam("scope")); + ctx.render( + "authorize.jte", + Map.of( + "application", + application, + "authorizationRequest", + new AuthorizationRequest(responseType, redirectUri, state, scope))); + }); } protected void configure(JavalinConfig config) { log.info("configuring javalin"); + config.fileRenderer(new JavalinJte()); } public void start() { diff --git a/src/main/java/moe/nea/prickly/util/JsonHelper.java b/src/main/java/moe/nea/prickly/util/JsonHelper.java new file mode 100644 index 0000000..56ab175 --- /dev/null +++ b/src/main/java/moe/nea/prickly/util/JsonHelper.java @@ -0,0 +1,14 @@ +/* (C) 2025 Linnea Gräf - Licensed to everyone under the BSD 3 Clause License */ +package moe.nea.prickly.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.SneakyThrows; + +public class JsonHelper { + public static ObjectMapper mapper = new ObjectMapper(); + + @SneakyThrows + public static String encode(Object object) { + return mapper.writeValueAsString(object); + } +} diff --git a/src/main/java/moe/nea/prickly/util/OAuthUtil.java b/src/main/java/moe/nea/prickly/util/OAuthUtil.java new file mode 100644 index 0000000..ec3b7fd --- /dev/null +++ b/src/main/java/moe/nea/prickly/util/OAuthUtil.java @@ -0,0 +1,54 @@ +/* (C) 2025 Linnea Gräf - Licensed to everyone under the BSD 3 Clause License */ +package moe.nea.prickly.util; + +import com.google.common.base.Preconditions; +import com.google.common.base.Splitter; +import java.net.URI; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +public class OAuthUtil { + private static final Splitter SCOPE_SPLITTER = + Splitter.on(' ').omitEmptyStrings().trimResults(); + + public static List<String> parseScopes(@Nullable String scope) { + if (scope == null) return List.of(); + return SCOPE_SPLITTER.splitToList(scope); + } + + public static URI verifyRedirectUrl(@Nullable String actualRedirectUri, String expectedRedirectUri) { + Objects.requireNonNull(expectedRedirectUri, "expected redirect uri is null"); + if (actualRedirectUri == null) return verifyRedirectUrl(expectedRedirectUri, expectedRedirectUri); + + var expected = URI.create(expectedRedirectUri); + var actual = URI.create(actualRedirectUri).normalize(); + Preconditions.checkArgument(actual.isAbsolute(), "redirect URI must be absolute"); + Preconditions.checkArgument(actual.getFragment() == null, "redirect URI must not have a fragment"); + Preconditions.checkArgument( + Objects.equals(actual.getScheme(), expected.getScheme()), + "scheme differs from registered redirect URI"); + Preconditions.checkArgument( + Objects.equals(actual.getAuthority(), expected.getAuthority()), + "origin differs from registered redirect URI"); + Preconditions.checkArgument(actual.getUserInfo() == null, "redirect URI must not have a user info"); + Preconditions.checkArgument( + expected.getPath() == null || actual.getPath().startsWith(expected.getPath()), + "redirect URI must be a subpath of registered redirect URI"); + return actual; + } + + public static ResponseType parseResponseType(@Nullable String responseType) { + return switch (responseType) { + case "code" -> ResponseType.CODE; + case "token" -> ResponseType.TOKEN; + case null -> throw new IllegalArgumentException("missing response_type"); + default -> throw new IllegalArgumentException("invalid response_type " + responseType); + }; + } + + public enum ResponseType { + TOKEN, + CODE + } +} diff --git a/src/main/java/moe/nea/prickly/util/package-info.java b/src/main/java/moe/nea/prickly/util/package-info.java new file mode 100644 index 0000000..ea2e468 --- /dev/null +++ b/src/main/java/moe/nea/prickly/util/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package moe.nea.prickly.util; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/jte/authorize.jte b/src/main/jte/authorize.jte new file mode 100644 index 0000000..92b6d61 --- /dev/null +++ b/src/main/jte/authorize.jte @@ -0,0 +1,38 @@ +<%-- (C) 2025 Linnea Gräf - Licensed to everyone under the BSD 3 Clause License --%> +@import moe.nea.prickly.util.JsonHelper +@param moe.nea.prickly.config.Config.Application application +@param moe.nea.prickly.model.AuthorizationRequest authorizationRequest +<!doctype html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Authorize ${application.NAME}</title> +</head> +<body> +<h1>Authorize ${application.NAME}?</h1> +<form method="post"> + <%-- TODO: add CSRF param --%> + <input type="hidden" name="authRequest" value="${JsonHelper.encode(authorizationRequest)}"> + <fieldset> + <legend>Login Data</legend> + <p> + <label>Username: <input name="username" required></label> + </p> + </fieldset> + <fieldset> + <legend>Authorize</legend> + <p>You will be redirected + to ${authorizationRequest.redirectUri().toString()}</p> <%-- TODO: change this to actually display the redirect URI --%> + <p>You will provide the scopes + <ul> + @for(var scope : authorizationRequest.scope()) + <li>${scope}</li> + @endfor + </ul> + </p> + <p><input type="submit" value="ACCEPT" name="action"></p> + </fieldset> +</form> +</body> +</html> |
