diff --git a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/Client.java b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/Client.java index 502adf3..bf0ec1d 100644 --- a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/Client.java +++ b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/Client.java @@ -3,12 +3,72 @@ package de.srsoftware.oidc.api; import static de.srsoftware.oidc.api.Constants.*; -import java.util.Map; -import java.util.Set; +import java.util.*; -public record Client(String id, String name, String secret, Set redirectUris) { +public final class Client { private static System.Logger LOG = System.getLogger(Client.class.getSimpleName()); - public Map map() { - return Map.of(CLIENT_ID, id, NAME, name, SECRET, secret, REDIRECT_URIS, redirectUris); + private final String id, name, secret; + private String nonce = null; + private final Set redirectUris; + + public Client(String id, String name, String secret, Set redirectUris) { + this.id = id; + this.name = name; + this.secret = secret; + this.redirectUris = redirectUris; + } + + public String id() { + return id; + } + + public Map map() { + return Map.of(CLIENT_ID, id, NAME, name, SECRET, secret, REDIRECT_URIS, redirectUris); + } + + + public String name() { + return name; + } + + public Client nonce(String newVal) { + nonce = newVal; + ; + return this; + } + + public Optional nonce() { + return Optional.ofNullable(nonce); + } + + public String secret() { + return secret; + } + + + public Set redirectUris() { + return redirectUris; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null || obj.getClass() != this.getClass()) return false; + var that = (Client)obj; + return Objects.equals(this.id, that.id) && Objects.equals(this.name, that.name) && Objects.equals(this.secret, that.secret) && Objects.equals(this.redirectUris, that.redirectUris); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, secret, redirectUris); + } + + @Override + public String toString() { + return "Client[" + + "id=" + id + ", " + + "name=" + name + ", " + + "secret=" + secret + ", " + + "redirectUris=" + redirectUris + ']'; } } diff --git a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/Constants.java b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/Constants.java index 8d7259e..b03f00a 100644 --- a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/Constants.java +++ b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/Constants.java @@ -5,6 +5,7 @@ public class Constants { public static final String ACCESS_TOKEN = "access_token"; public static final String APP_NAME = "LightOIDC"; public static final String AUTH_CODE = "authorization_code"; + public static final String AUTHORIZATION = "Authorization"; public static final String BEARER = "Bearer"; public static final String CAUSE = "cause"; public static final String CLIENT_ID = "client_id"; @@ -17,6 +18,7 @@ public class Constants { public static final String GRANT_TYPE = "grant_type"; public static final String ID_TOKEN = "id_token"; public static final String NAME = "name"; + public static final String NONCE = "nonce"; public static final String OPENID = "openid"; public static final String REDIRECT_URI = "redirect_uri"; public static final String REDIRECT_URIS = "redirect_uris"; diff --git a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/PathHandler.java b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/PathHandler.java index 48b216e..7ee0688 100644 --- a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/PathHandler.java +++ b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/PathHandler.java @@ -1,6 +1,7 @@ /* © SRSoftware 2024 */ package de.srsoftware.oidc.api; +import static de.srsoftware.oidc.api.Constants.AUTHORIZATION; import static java.lang.System.Logger.Level.*; import static java.net.HttpURLConnection.*; import static java.nio.charset.StandardCharsets.UTF_8; @@ -89,7 +90,11 @@ public abstract class PathHandler implements HttpHandler { } public static Optional getAuthToken(HttpExchange ex) { - return getHeader(ex, "Authorization"); + return getHeader(ex, AUTHORIZATION); + } + + public static Optional getBearer(HttpExchange ex) { + return getAuthToken(ex).filter(token -> token.startsWith("Bearer ")).map(token -> token.substring(7)); } public static Optional getHeader(HttpExchange ex, String key) { diff --git a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java index df39c17..32e349c 100644 --- a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java +++ b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java @@ -5,12 +5,25 @@ import java.util.List; import java.util.Optional; public interface UserService { - public UserService delete(User user); - public boolean passwordMatches(String password, String hashedPassword); + /** + * create a new access token for a given user + * @param user + * @return + */ + public String accessToken(User user); + public UserService delete(User user); + + /** + * return the user identified by its access token + * @param accessToken + * @return + */ + public Optional forToken(String accessToken); public UserService init(User defaultUser); public List list(); public Optional load(String id); public Optional load(String username, String password); + public boolean passwordMatches(String password, String hashedPassword); public T save(User user); public T updatePassword(User user, String plaintextPassword); } diff --git a/de.srsoftware.oidc.app/src/main/java/de/srsoftware/oidc/app/Application.java b/de.srsoftware.oidc.app/src/main/java/de/srsoftware/oidc/app/Application.java index 94cedb3..fe6d6c4 100644 --- a/de.srsoftware.oidc.app/src/main/java/de/srsoftware/oidc/app/Application.java +++ b/de.srsoftware.oidc.app/src/main/java/de/srsoftware/oidc/app/Application.java @@ -33,7 +33,7 @@ public class Application { public static final String FIRST_USER = "admin"; public static final String FIRST_USER_PASS = "admin"; public static final String FIRST_UUID = UUID.randomUUID().toString(); - public static final String JWKS = "/api/jwks"; + public static final String JWKS = "/api/jwks.json"; public static final String ROOT = "/"; public static final String STATIC_PATH = "/web"; diff --git a/de.srsoftware.oidc.backend/build.gradle b/de.srsoftware.oidc.backend/build.gradle index 7ddaba4..2218230 100644 --- a/de.srsoftware.oidc.backend/build.gradle +++ b/de.srsoftware.oidc.backend/build.gradle @@ -15,6 +15,7 @@ dependencies { implementation project(':de.srsoftware.cookies') implementation project(':de.srsoftware.oidc.api') implementation project(':de.srsoftware.logging') + implementation project(':de.srsoftware.utils') implementation 'org.json:json:20240303' implementation 'org.bitbucket.b_c:jose4j:0.9.6' } diff --git a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/ClientController.java b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/ClientController.java index 69536d2..cfa93ea 100644 --- a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/ClientController.java +++ b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/ClientController.java @@ -40,9 +40,10 @@ public class ClientController extends Controller { var optClient = clients.getClient(clientId); if (optClient.isEmpty()) return badRequest(ex, Map.of(CAUSE, "unknown client", CLIENT_ID, clientId)); var client = optClient.get(); - if (!client.redirectUris().contains(redirect)) return badRequest(ex, Map.of(CAUSE, "unknown redirect uri", REDIRECT_URI, redirect)); + client.nonce(json.has(NONCE) ? json.getString(NONCE) : null); + if (!authorizations.isAuthorized(client, session.user())) { if (json.has(DAYS)) { var days = json.getInt(DAYS); diff --git a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/KeyStoreController.java b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/KeyStoreController.java index b0f0ccb..3fe5623 100644 --- a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/KeyStoreController.java +++ b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/KeyStoreController.java @@ -2,9 +2,14 @@ package de.srsoftware.oidc.backend; import com.sun.net.httpserver.HttpExchange; +import de.srsoftware.oidc.api.KeyManager; import de.srsoftware.oidc.api.KeyStorage; import de.srsoftware.oidc.api.PathHandler; import java.io.IOException; +import org.jose4j.jwk.JsonWebKey; +import org.jose4j.jwk.PublicJsonWebKey; +import org.json.JSONArray; +import org.json.JSONObject; public class KeyStoreController extends PathHandler { private final KeyStorage keyStore; @@ -15,6 +20,26 @@ public class KeyStoreController extends PathHandler { @Override public boolean doGet(String path, HttpExchange ex) throws IOException { - return super.doGet(path, ex); + switch (path) { + case "/": + return jwksJson(ex); + } + return notFound(ex); + } + + private boolean jwksJson(HttpExchange ex) throws IOException { + JSONArray arr = new JSONArray(); + for (var keyId : keyStore.listKeys()) try { + PublicJsonWebKey key = keyStore.load(keyId); + String keyJson = key.toJson(JsonWebKey.OutputControlLevel.PUBLIC_ONLY); + arr.put(new JSONObject(keyJson)); + } catch (IOException e) { + throw new RuntimeException(e); + } catch (KeyManager.KeyCreationException e) { + throw new RuntimeException(e); + } + JSONObject result = new JSONObject(); + result.put("keys", arr); + return sendContent(ex, result); } } diff --git a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/TokenController.java b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/TokenController.java index 0e58b48..b504472 100644 --- a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/TokenController.java +++ b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/TokenController.java @@ -2,6 +2,7 @@ package de.srsoftware.oidc.backend; import static de.srsoftware.oidc.api.Constants.*; +import static de.srsoftware.utils.Optionals.optional; import static java.lang.System.Logger.Level.*; import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; @@ -15,7 +16,6 @@ import java.util.stream.Collectors; import org.jose4j.jwk.PublicJsonWebKey; import org.jose4j.jws.JsonWebSignature; import org.jose4j.jwt.JwtClaims; -import org.jose4j.jwt.MalformedClaimException; import org.jose4j.lang.JoseException; import org.json.JSONObject; @@ -70,13 +70,27 @@ public class TokenController extends PathHandler { var uri = URLDecoder.decode(map.get(REDIRECT_URI), StandardCharsets.UTF_8); if (!client.redirectUris().contains(uri)) sendContent(ex, HTTP_BAD_REQUEST, Map.of(ERROR, "unknown redirect uri", REDIRECT_URI, uri)); - var secretFromClient = URLDecoder.decode(map.get(CLIENT_SECRET)); - if (secretFromClient != null && !client.secret().equals(secretFromClient)) return sendContent(ex, HTTP_BAD_REQUEST, Map.of(ERROR, "client secret mismatch")); - + if (client.secret() != null) { + String clientSecret = optional(ex.getRequestHeaders().get(AUTHORIZATION)) + .map(list -> list.get(0)) + .filter(s -> s.startsWith("Basic ")) + .map(s -> s.substring(6)) + .map(s -> Base64.getDecoder().decode(s)) + .map(bytes -> new String(bytes, StandardCharsets.UTF_8)) + .filter(s -> s.startsWith("%s:".formatted(client.id()))) + .map(s -> s.substring(client.id().length() + 1).trim()) + .map(s -> { + System.err.println(s); + return s; + }) + .orElseGet(() -> map.get(CLIENT_SECRET)); + if (clientSecret == null) return sendContent(ex, HTTP_BAD_REQUEST, Map.of(ERROR, "client secret missing")); + if (!client.secret().equals(clientSecret)) return sendContent(ex, HTTP_BAD_REQUEST, Map.of(ERROR, "client secret mismatch")); + } String jwToken = createJWT(client, user.get()); ex.getResponseHeaders().add("Cache-Control", "no-store"); JSONObject response = new JSONObject(); - response.put(ACCESS_TOKEN, UUID.randomUUID().toString()); // TODO: wofür genau wird der verwendet, was gilt es hier zu beachten + response.put(ACCESS_TOKEN, users.accessToken(user.get())); response.put(TOKEN_TYPE, BEARER); response.put(EXPIRES_IN, 3600); response.put(ID_TOKEN, jwToken); @@ -87,7 +101,7 @@ public class TokenController extends PathHandler { private String createJWT(Client client, User user) { try { PublicJsonWebKey key = keyManager.getKey(); - + key.setUse("sig"); JwtClaims claims = getJwtClaims(user, client); // A JWT is a JWS and/or a JWE with JSON claims as the payload. @@ -99,6 +113,7 @@ public class TokenController extends PathHandler { jws.setKey(key.getPrivateKey()); jws.setKeyIdHeaderValue(key.getKeyId()); jws.setAlgorithmHeaderValue(key.getAlgorithm()); + return jws.getCompactSerialization(); } catch (JoseException | KeyManager.KeyCreationException | IOException e) { throw new RuntimeException(e); @@ -115,17 +130,7 @@ public class TokenController extends PathHandler { claims.setIssuer("https://lightoidc.srsoftware.de"); // who creates the token and signs it claims.setGeneratedJwtId(); // a unique identifier for the token claims.setSubject(user.uuid()); // the subject/principal is whom the token is about - - // die nachfolgenden Claims sind nur Spielerei, ich habe versucht, das System mit Umbrella zum Laufen zu bekommen - claims.setClaim("scope", "openid"); - claims.setStringListClaim("amr", "pwd"); - claims.setClaim("at_hash", Base64.getEncoder().encodeToString("Test".getBytes(StandardCharsets.UTF_8))); - claims.setClaim("azp", client.id()); - claims.setClaim("email_verified", true); - try { - claims.setClaim("rat", claims.getIssuedAt().getValue()); - } catch (MalformedClaimException e) { - } + client.nonce().ifPresent(nonce -> claims.setClaim(NONCE, nonce)); return claims; } } diff --git a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java index fc3c2da..e181f64 100644 --- a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java +++ b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java @@ -8,7 +8,9 @@ import com.sun.net.httpserver.HttpExchange; import de.srsoftware.cookies.SessionToken; import de.srsoftware.oidc.api.*; import java.io.IOException; +import java.util.Map; import java.util.Optional; +import org.json.JSONObject; public class UserController extends Controller { private final UserService users; @@ -20,6 +22,10 @@ public class UserController extends Controller { @Override public boolean doGet(String path, HttpExchange ex) throws IOException { + switch (path) { + case "/info": + return userInfo(ex); + } var optSession = getSession(ex); if (optSession.isEmpty()) return sendEmptyResponse(HTTP_UNAUTHORIZED, ex); @@ -33,6 +39,14 @@ public class UserController extends Controller { return notFound(ex); } + private boolean userInfo(HttpExchange ex) throws IOException { + var optUser = getBearer(ex).flatMap(users::forToken); + if (optUser.isEmpty()) return sendEmptyResponse(HTTP_UNAUTHORIZED, ex); + var user = optUser.get(); + var map = Map.of("sub",user.uuid(),"email",user.email()); + return sendContent(ex, new JSONObject(map)); + } + @Override public boolean doPost(String path, HttpExchange ex) throws IOException { diff --git a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/WellKnownController.java b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/WellKnownController.java index 8174b6c..1291481 100644 --- a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/WellKnownController.java +++ b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/WellKnownController.java @@ -19,6 +19,6 @@ public class WellKnownController extends PathHandler { private boolean openidConfig(HttpExchange ex) throws IOException { var host = hostname(ex); - return sendContent(ex, Map.of("token_endpoint", host + "/api/token", "authorization_endpoint", host + "/web/authorization.html", "userinfo_endpoint", host + "/api/userinfo", "jwks_uri", host + "/api/jwks", "issuer", "https://lightoidc.srsoftware.de")); + return sendContent(ex, Map.of("token_endpoint", host + "/api/token", "authorization_endpoint", host + "/web/authorization.html", "userinfo_endpoint", host + "/api/user/info", "jwks_uri", host + "/api/jwks.json", "issuer", "https://lightoidc.srsoftware.de")); } } diff --git a/de.srsoftware.oidc.datastore.file/build.gradle b/de.srsoftware.oidc.datastore.file/build.gradle index 7b3942d..bfa3e4c 100644 --- a/de.srsoftware.oidc.datastore.file/build.gradle +++ b/de.srsoftware.oidc.datastore.file/build.gradle @@ -13,6 +13,7 @@ dependencies { testImplementation platform('org.junit:junit-bom:5.10.0') testImplementation 'org.junit.jupiter:junit-jupiter' implementation project(':de.srsoftware.oidc.api') + implementation project(':de.srsoftware.utils') implementation 'org.json:json:20240303' implementation 'org.bitbucket.b_c:jose4j:0.9.6' diff --git a/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/FileStore.java b/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/FileStore.java index 0cbab2f..f5a5902 100644 --- a/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/FileStore.java +++ b/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/FileStore.java @@ -1,6 +1,8 @@ /* © SRSoftware 2024 */ package de.srsoftware.oidc.datastore.file; /* © SRSoftware 2024 */ import static de.srsoftware.oidc.api.User.*; +import static de.srsoftware.utils.Optionals.optional; +import static de.srsoftware.utils.Strings.uuid; import de.srsoftware.oidc.api.*; import java.io.File; @@ -12,6 +14,8 @@ import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; + +import de.srsoftware.utils.Optionals; import org.json.JSONObject; public class FileStore implements AuthorizationService, ClientService, SessionService, UserService { @@ -30,6 +34,8 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe private final JSONObject json; private final PasswordHasher passwordHasher; private Duration sessionDuration = Duration.of(10, ChronoUnit.MINUTES); + private Map clients = new HashMap<>(); + private Map accessTokens = new HashMap<>(); public FileStore(File storage, PasswordHasher passwordHasher) throws IOException { this.storageFile = storage.toPath(); @@ -52,14 +58,26 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe } } + /*** User Service Methods ***/ + @Override + public String accessToken(User user) { + var token = uuid(); + accessTokens.put(token, Objects.requireNonNull(user)); + return token; + } + @Override public UserService delete(User user) { return null; } + @Override + public Optional forToken(String accessToken) { + return optional(accessTokens.get(accessToken)); + } @Override public FileStore init(User defaultUser) { @@ -203,8 +221,14 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe @Override public Optional getClient(String clientId) { - var clients = json.getJSONObject(CLIENTS); - if (clients.has(clientId)) return Optional.of(toClient(clientId, clients.getJSONObject(clientId))); + var client = clients.get(clientId); + if (client != null) return Optional.of(client); + var clientsJson = json.getJSONObject(CLIENTS); + if (clientsJson.has(clientId)) { + client = toClient(clientId, clientsJson.getJSONObject(clientId)); + clients.put(clientId, client); + return Optional.of(client); + } return Optional.empty(); } diff --git a/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/PlaintextKeyStore.java b/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/PlaintextKeyStore.java index 86772b4..e941cae 100644 --- a/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/PlaintextKeyStore.java +++ b/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/PlaintextKeyStore.java @@ -2,12 +2,14 @@ package de.srsoftware.oidc.datastore.file; import static java.lang.System.Logger.Level.ERROR; +import static org.jose4j.jwk.JsonWebKey.OutputControlLevel.INCLUDE_PRIVATE; import de.srsoftware.oidc.api.KeyManager; import de.srsoftware.oidc.api.KeyStorage; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashMap; import java.util.List; import org.jose4j.jwk.PublicJsonWebKey; import org.jose4j.lang.JoseException; @@ -15,7 +17,8 @@ import org.jose4j.lang.JoseException; public class PlaintextKeyStore implements KeyStorage { public static System.Logger LOG = System.getLogger(PlaintextKeyStore.class.getSimpleName()); - private final Path dir; + private final Path dir; + private HashMap loaded = new HashMap<>(); public PlaintextKeyStore(Path storageDir) { this.dir = storageDir; @@ -38,9 +41,13 @@ public class PlaintextKeyStore implements KeyStorage { @Override public PublicJsonWebKey load(String keyId) throws IOException, KeyManager.KeyCreationException { + var key = loaded.get(keyId); + if (key != null) return key; var json = Files.readString(filename(keyId)); try { - return PublicJsonWebKey.Factory.newPublicJwk(json); + key = PublicJsonWebKey.Factory.newPublicJwk(json); + loaded.put(keyId, key); + return key; } catch (JoseException e) { throw new KeyManager.KeyCreationException(e); } @@ -48,7 +55,7 @@ public class PlaintextKeyStore implements KeyStorage { @Override public KeyStorage store(PublicJsonWebKey jsonWebKey) throws IOException { - Files.writeString(filename(jsonWebKey.getKeyId()), jsonWebKey.toJson()); + Files.writeString(filename(jsonWebKey.getKeyId()), jsonWebKey.toJson(INCLUDE_PRIVATE)); return this; } diff --git a/de.srsoftware.utils/src/main/java/de/srsoftware/utils/Strings.java b/de.srsoftware.utils/src/main/java/de/srsoftware/utils/Strings.java new file mode 100644 index 0000000..83fa8ac --- /dev/null +++ b/de.srsoftware.utils/src/main/java/de/srsoftware/utils/Strings.java @@ -0,0 +1,10 @@ +/* © SRSoftware 2024 */ +package de.srsoftware.utils; + +import java.util.UUID; + +public class Strings { + public static String uuid() { + return UUID.randomUUID().toString(); + } +}