KEYCLOAK-2106 HTTP 500 for unparsable refresh tokens
This commit is contained in:
parent
5ea880cfff
commit
c83e3bd2d1
20 changed files with 201 additions and 149 deletions
|
@ -5,6 +5,7 @@ import org.keycloak.broker.provider.BrokeredIdentityContext;
|
||||||
import org.keycloak.constants.AdapterConstants;
|
import org.keycloak.constants.AdapterConstants;
|
||||||
import org.keycloak.events.EventBuilder;
|
import org.keycloak.events.EventBuilder;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserSessionModel;
|
import org.keycloak.models.UserSessionModel;
|
||||||
import org.keycloak.representations.AccessTokenResponse;
|
import org.keycloak.representations.AccessTokenResponse;
|
||||||
|
@ -51,7 +52,13 @@ public class KeycloakOIDCIdentityProvider extends OIDCIdentityProvider {
|
||||||
@POST
|
@POST
|
||||||
@Path(AdapterConstants.K_LOGOUT)
|
@Path(AdapterConstants.K_LOGOUT)
|
||||||
public Response backchannelLogout(String input) {
|
public Response backchannelLogout(String input) {
|
||||||
JWSInput token = new JWSInput(input);
|
JWSInput token = null;
|
||||||
|
try {
|
||||||
|
token = new JWSInput(input);
|
||||||
|
} catch (JWSInputException e) {
|
||||||
|
logger.warn("Failed to verify logout request");
|
||||||
|
return Response.status(400).build();
|
||||||
|
}
|
||||||
PublicKey key = getExternalIdpKey();
|
PublicKey key = getExternalIdpKey();
|
||||||
if (key != null) {
|
if (key != null) {
|
||||||
if (!verify(token, key)) {
|
if (!verify(token, key)) {
|
||||||
|
|
|
@ -29,6 +29,7 @@ import org.keycloak.events.Errors;
|
||||||
import org.keycloak.events.EventBuilder;
|
import org.keycloak.events.EventBuilder;
|
||||||
import org.keycloak.events.EventType;
|
import org.keycloak.events.EventType;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.jose.jws.crypto.RSAProvider;
|
import org.keycloak.jose.jws.crypto.RSAProvider;
|
||||||
import org.keycloak.models.ClientSessionModel;
|
import org.keycloak.models.ClientSessionModel;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
|
@ -282,40 +283,41 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
|
||||||
throw new IdentityBrokerException("No token from server.");
|
throw new IdentityBrokerException("No token from server.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JsonWebToken token;
|
||||||
try {
|
try {
|
||||||
JWSInput jws = new JWSInput(encodedToken);
|
JWSInput jws = new JWSInput(encodedToken);
|
||||||
if (!verify(jws, key)) {
|
if (!verify(jws, key)) {
|
||||||
throw new IdentityBrokerException("token signature validation failed");
|
throw new IdentityBrokerException("token signature validation failed");
|
||||||
}
|
}
|
||||||
JsonWebToken token = jws.readJsonContent(JsonWebToken.class);
|
token = jws.readJsonContent(JsonWebToken.class);
|
||||||
|
} catch (JWSInputException e) {
|
||||||
String iss = token.getIssuer();
|
throw new IdentityBrokerException("Invalid token", e);
|
||||||
|
|
||||||
if (!token.hasAudience(getConfig().getClientId())) {
|
|
||||||
throw new IdentityBrokerException("Wrong audience from token.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token.isActive()) {
|
|
||||||
throw new IdentityBrokerException("Token is no longer valid");
|
|
||||||
}
|
|
||||||
|
|
||||||
String trustedIssuers = getConfig().getIssuer();
|
|
||||||
|
|
||||||
if (trustedIssuers != null) {
|
|
||||||
String[] issuers = trustedIssuers.split(",");
|
|
||||||
|
|
||||||
for (String trustedIssuer : issuers) {
|
|
||||||
if (iss != null && iss.equals(trustedIssuer.trim())) {
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new IdentityBrokerException("Wrong issuer from token. Got: " + iss + " expected: " + getConfig().getIssuer());
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IdentityBrokerException("Could not decode token.", e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String iss = token.getIssuer();
|
||||||
|
|
||||||
|
if (!token.hasAudience(getConfig().getClientId())) {
|
||||||
|
throw new IdentityBrokerException("Wrong audience from token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token.isActive()) {
|
||||||
|
throw new IdentityBrokerException("Token is no longer valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
String trustedIssuers = getConfig().getIssuer();
|
||||||
|
|
||||||
|
if (trustedIssuers != null) {
|
||||||
|
String[] issuers = trustedIssuers.split(",");
|
||||||
|
|
||||||
|
for (String trustedIssuer : issuers) {
|
||||||
|
if (iss != null && iss.equals(trustedIssuer.trim())) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IdentityBrokerException("Wrong issuer from token. Got: " + iss + " expected: " + getConfig().getIssuer());
|
||||||
|
}
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -2,6 +2,7 @@ package org.keycloak;
|
||||||
|
|
||||||
import org.keycloak.common.VerificationException;
|
import org.keycloak.common.VerificationException;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.jose.jws.crypto.RSAProvider;
|
import org.keycloak.jose.jws.crypto.RSAProvider;
|
||||||
import org.keycloak.representations.AccessToken;
|
import org.keycloak.representations.AccessToken;
|
||||||
import org.keycloak.util.TokenUtil;
|
import org.keycloak.util.TokenUtil;
|
||||||
|
@ -22,7 +23,7 @@ public class RSATokenVerifier {
|
||||||
JWSInput input = null;
|
JWSInput input = null;
|
||||||
try {
|
try {
|
||||||
input = new JWSInput(tokenString);
|
input = new JWSInput(tokenString);
|
||||||
} catch (Exception e) {
|
} catch (JWSInputException e) {
|
||||||
throw new VerificationException("Couldn't parse token", e);
|
throw new VerificationException("Couldn't parse token", e);
|
||||||
}
|
}
|
||||||
if (!isPublicKeyValid(input, realmKey)) throw new VerificationException("Invalid token signature.");
|
if (!isPublicKeyValid(input, realmKey)) throw new VerificationException("Invalid token signature.");
|
||||||
|
@ -30,7 +31,7 @@ public class RSATokenVerifier {
|
||||||
AccessToken token;
|
AccessToken token;
|
||||||
try {
|
try {
|
||||||
token = input.readJsonContent(AccessToken.class);
|
token = input.readJsonContent(AccessToken.class);
|
||||||
} catch (IOException e) {
|
} catch (JWSInputException e) {
|
||||||
throw new VerificationException("Couldn't parse token signature", e);
|
throw new VerificationException("Couldn't parse token signature", e);
|
||||||
}
|
}
|
||||||
String user = token.getSubject();
|
String user = token.getSubject();
|
||||||
|
|
|
@ -21,14 +21,14 @@ public class JWSInput {
|
||||||
byte[] signature;
|
byte[] signature;
|
||||||
|
|
||||||
|
|
||||||
public JWSInput(String wire) {
|
public JWSInput(String wire) throws JWSInputException {
|
||||||
this.wireString = wire;
|
|
||||||
String[] parts = wire.split("\\.");
|
|
||||||
if (parts.length < 2 || parts.length > 3) throw new IllegalArgumentException("Parsing error");
|
|
||||||
encodedHeader = parts[0];
|
|
||||||
encodedContent = parts[1];
|
|
||||||
encodedSignatureInput = encodedHeader + '.' + encodedContent;
|
|
||||||
try {
|
try {
|
||||||
|
this.wireString = wire;
|
||||||
|
String[] parts = wire.split("\\.");
|
||||||
|
if (parts.length < 2 || parts.length > 3) throw new IllegalArgumentException("Parsing error");
|
||||||
|
encodedHeader = parts[0];
|
||||||
|
encodedContent = parts[1];
|
||||||
|
encodedSignatureInput = encodedHeader + '.' + encodedContent;
|
||||||
content = Base64Url.decode(encodedContent);
|
content = Base64Url.decode(encodedContent);
|
||||||
if (parts.length > 2) {
|
if (parts.length > 2) {
|
||||||
encodedSignature = parts[2];
|
encodedSignature = parts[2];
|
||||||
|
@ -37,8 +37,8 @@ public class JWSInput {
|
||||||
}
|
}
|
||||||
byte[] headerBytes = Base64Url.decode(encodedHeader);
|
byte[] headerBytes = Base64Url.decode(encodedHeader);
|
||||||
header = JsonSerialization.readValue(headerBytes, JWSHeader.class);
|
header = JsonSerialization.readValue(headerBytes, JWSHeader.class);
|
||||||
} catch (Exception e) {
|
} catch (Throwable t) {
|
||||||
throw new RuntimeException(e);
|
throw new JWSInputException(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,8 +80,12 @@ public class JWSInput {
|
||||||
return header.getAlgorithm().getProvider().verify(this, key);
|
return header.getAlgorithm().getProvider().verify(this, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public <T> T readJsonContent(Class<T> type) throws IOException {
|
public <T> T readJsonContent(Class<T> type) throws JWSInputException {
|
||||||
return JsonSerialization.readValue(content, type);
|
try {
|
||||||
|
return JsonSerialization.readValue(content, type);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new JWSInputException(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String readContentAsString() {
|
public String readContentAsString() {
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
package org.keycloak.jose.jws;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||||
|
*/
|
||||||
|
public class JWSInputException extends Exception {
|
||||||
|
|
||||||
|
public JWSInputException(String s) {
|
||||||
|
super(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JWSInputException() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public JWSInputException(Throwable throwable) {
|
||||||
|
super(throwable);
|
||||||
|
}
|
||||||
|
}
|
|
@ -64,7 +64,7 @@ public class RSAProvider implements SignatureProvider {
|
||||||
verifier.update(input.getEncodedSignatureInput().getBytes("UTF-8"));
|
verifier.update(input.getEncodedSignatureInput().getBytes("UTF-8"));
|
||||||
return verifier.verify(input.getSignature());
|
return verifier.verify(input.getSignature());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException(e);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import java.io.IOException;
|
||||||
|
|
||||||
import org.keycloak.OAuth2Constants;
|
import org.keycloak.OAuth2Constants;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.representations.RefreshToken;
|
import org.keycloak.representations.RefreshToken;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -41,11 +42,15 @@ public class TokenUtil {
|
||||||
* @param decodedToken
|
* @param decodedToken
|
||||||
* @return
|
* @return
|
||||||
*/
|
*/
|
||||||
public static RefreshToken getRefreshToken(byte[] decodedToken) throws IOException {
|
public static RefreshToken getRefreshToken(byte[] decodedToken) throws JWSInputException {
|
||||||
return JsonSerialization.readValue(decodedToken, RefreshToken.class);
|
try {
|
||||||
|
return JsonSerialization.readValue(decodedToken, RefreshToken.class);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new JWSInputException(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static RefreshToken getRefreshToken(String refreshToken) throws IOException {
|
public static RefreshToken getRefreshToken(String refreshToken) throws JWSInputException {
|
||||||
byte[] encodedContent = new JWSInput(refreshToken).getContent();
|
byte[] encodedContent = new JWSInput(refreshToken).getContent();
|
||||||
return getRefreshToken(encodedContent);
|
return getRefreshToken(encodedContent);
|
||||||
}
|
}
|
||||||
|
@ -56,13 +61,9 @@ public class TokenUtil {
|
||||||
* @param refreshToken
|
* @param refreshToken
|
||||||
* @return
|
* @return
|
||||||
*/
|
*/
|
||||||
public static boolean isOfflineToken(String refreshToken) {
|
public static boolean isOfflineToken(String refreshToken) throws JWSInputException {
|
||||||
try {
|
RefreshToken token = getRefreshToken(refreshToken);
|
||||||
RefreshToken token = getRefreshToken(refreshToken);
|
return token.getType().equals(TOKEN_TYPE_OFFLINE);
|
||||||
return token.getType().equals(TOKEN_TYPE_OFFLINE);
|
|
||||||
} catch (IOException ioe) {
|
|
||||||
throw new RuntimeException(ioe);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import org.keycloak.adapters.KeycloakDeployment;
|
||||||
import org.keycloak.adapters.RefreshableKeycloakSecurityContext;
|
import org.keycloak.adapters.RefreshableKeycloakSecurityContext;
|
||||||
import org.keycloak.adapters.ServerRequest;
|
import org.keycloak.adapters.ServerRequest;
|
||||||
import org.keycloak.adapters.spi.LogoutError;
|
import org.keycloak.adapters.spi.LogoutError;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.representations.AccessTokenResponse;
|
import org.keycloak.representations.AccessTokenResponse;
|
||||||
import org.keycloak.representations.RefreshToken;
|
import org.keycloak.representations.RefreshToken;
|
||||||
import org.keycloak.util.JsonSerialization;
|
import org.keycloak.util.JsonSerialization;
|
||||||
|
@ -49,40 +50,44 @@ public class OfflineAccessPortalServlet extends HttpServlet {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
|
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
|
||||||
|
try {
|
||||||
|
if (req.getRequestURI().endsWith("/login")) {
|
||||||
|
storeToken(req);
|
||||||
|
req.getRequestDispatcher("/WEB-INF/pages/loginCallback.jsp").forward(req, resp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.getRequestURI().endsWith("/login")) {
|
String refreshToken = RefreshTokenDAO.loadToken();
|
||||||
storeToken(req);
|
String refreshTokenInfo;
|
||||||
req.getRequestDispatcher("/WEB-INF/pages/loginCallback.jsp").forward(req, resp);
|
boolean savedTokenAvailable;
|
||||||
return;
|
if (refreshToken == null) {
|
||||||
|
refreshTokenInfo = "No token saved in database. Please login first";
|
||||||
|
savedTokenAvailable = false;
|
||||||
|
} else {
|
||||||
|
RefreshToken refreshTokenDecoded = null;
|
||||||
|
refreshTokenDecoded = TokenUtil.getRefreshToken(refreshToken);
|
||||||
|
String exp = (refreshTokenDecoded.getExpiration() == 0) ? "NEVER" : Time.toDate(refreshTokenDecoded.getExpiration()).toString();
|
||||||
|
refreshTokenInfo = String.format("<p>Type: %s</p><p>ID: %s</p><p>Expires: %s</p>", refreshTokenDecoded.getType(), refreshTokenDecoded.getId(), exp);
|
||||||
|
savedTokenAvailable = true;
|
||||||
|
}
|
||||||
|
req.setAttribute("tokenInfo", refreshTokenInfo);
|
||||||
|
req.setAttribute("savedTokenAvailable", savedTokenAvailable);
|
||||||
|
|
||||||
|
String customers;
|
||||||
|
if (req.getRequestURI().endsWith("/loadCustomers")) {
|
||||||
|
customers = loadCustomers(req, refreshToken);
|
||||||
|
} else {
|
||||||
|
customers = "";
|
||||||
|
}
|
||||||
|
req.setAttribute("customers", customers);
|
||||||
|
|
||||||
|
req.getRequestDispatcher("/WEB-INF/pages/view.jsp").forward(req, resp);
|
||||||
|
} catch (JWSInputException e) {
|
||||||
|
throw new ServletException(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
String refreshToken = RefreshTokenDAO.loadToken();
|
|
||||||
String refreshTokenInfo;
|
|
||||||
boolean savedTokenAvailable;
|
|
||||||
if (refreshToken == null) {
|
|
||||||
refreshTokenInfo = "No token saved in database. Please login first";
|
|
||||||
savedTokenAvailable = false;
|
|
||||||
} else {
|
|
||||||
RefreshToken refreshTokenDecoded = TokenUtil.getRefreshToken(refreshToken);
|
|
||||||
String exp = (refreshTokenDecoded.getExpiration() == 0) ? "NEVER" : Time.toDate(refreshTokenDecoded.getExpiration()).toString();
|
|
||||||
refreshTokenInfo = String.format("<p>Type: %s</p><p>ID: %s</p><p>Expires: %s</p>", refreshTokenDecoded.getType(), refreshTokenDecoded.getId(), exp);
|
|
||||||
savedTokenAvailable = true;
|
|
||||||
}
|
|
||||||
req.setAttribute("tokenInfo", refreshTokenInfo);
|
|
||||||
req.setAttribute("savedTokenAvailable", savedTokenAvailable);
|
|
||||||
|
|
||||||
String customers;
|
|
||||||
if (req.getRequestURI().endsWith("/loadCustomers")) {
|
|
||||||
customers = loadCustomers(req, refreshToken);
|
|
||||||
} else {
|
|
||||||
customers = "";
|
|
||||||
}
|
|
||||||
req.setAttribute("customers", customers);
|
|
||||||
|
|
||||||
req.getRequestDispatcher("/WEB-INF/pages/view.jsp").forward(req, resp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void storeToken(HttpServletRequest req) throws IOException {
|
private void storeToken(HttpServletRequest req) throws IOException, JWSInputException {
|
||||||
RefreshableKeycloakSecurityContext ctx = (RefreshableKeycloakSecurityContext) req.getAttribute(KeycloakSecurityContext.class.getName());
|
RefreshableKeycloakSecurityContext ctx = (RefreshableKeycloakSecurityContext) req.getAttribute(KeycloakSecurityContext.class.getName());
|
||||||
String refreshToken = ctx.getRefreshToken();
|
String refreshToken = ctx.getRefreshToken();
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import org.keycloak.adapters.spi.HttpFacade;
|
||||||
import org.keycloak.common.VerificationException;
|
import org.keycloak.common.VerificationException;
|
||||||
import org.keycloak.constants.AdapterConstants;
|
import org.keycloak.constants.AdapterConstants;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.representations.AccessToken;
|
import org.keycloak.representations.AccessToken;
|
||||||
import org.keycloak.representations.IDToken;
|
import org.keycloak.representations.IDToken;
|
||||||
import org.keycloak.common.util.KeycloakUriBuilder;
|
import org.keycloak.common.util.KeycloakUriBuilder;
|
||||||
|
@ -58,10 +59,10 @@ public class CookieTokenStore {
|
||||||
AccessToken accessToken = RSATokenVerifier.verifyToken(accessTokenString, deployment.getRealmKey(), deployment.getRealmInfoUrl(), false, true);
|
AccessToken accessToken = RSATokenVerifier.verifyToken(accessTokenString, deployment.getRealmKey(), deployment.getRealmInfoUrl(), false, true);
|
||||||
IDToken idToken;
|
IDToken idToken;
|
||||||
if (idTokenString != null && idTokenString.length() > 0) {
|
if (idTokenString != null && idTokenString.length() > 0) {
|
||||||
JWSInput input = new JWSInput(idTokenString);
|
|
||||||
try {
|
try {
|
||||||
|
JWSInput input = new JWSInput(idTokenString);
|
||||||
idToken = input.readJsonContent(IDToken.class);
|
idToken = input.readJsonContent(IDToken.class);
|
||||||
} catch (IOException e) {
|
} catch (JWSInputException e) {
|
||||||
throw new VerificationException(e);
|
throw new VerificationException(e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import org.keycloak.common.VerificationException;
|
||||||
import org.keycloak.constants.AdapterConstants;
|
import org.keycloak.constants.AdapterConstants;
|
||||||
import org.keycloak.enums.TokenStore;
|
import org.keycloak.enums.TokenStore;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.representations.AccessToken;
|
import org.keycloak.representations.AccessToken;
|
||||||
import org.keycloak.representations.AccessTokenResponse;
|
import org.keycloak.representations.AccessTokenResponse;
|
||||||
import org.keycloak.representations.IDToken;
|
import org.keycloak.representations.IDToken;
|
||||||
|
@ -313,10 +314,10 @@ public class OAuthRequestAuthenticator {
|
||||||
try {
|
try {
|
||||||
token = RSATokenVerifier.verifyToken(tokenString, deployment.getRealmKey(), deployment.getRealmInfoUrl());
|
token = RSATokenVerifier.verifyToken(tokenString, deployment.getRealmKey(), deployment.getRealmInfoUrl());
|
||||||
if (idTokenString != null) {
|
if (idTokenString != null) {
|
||||||
JWSInput input = new JWSInput(idTokenString);
|
|
||||||
try {
|
try {
|
||||||
|
JWSInput input = new JWSInput(idTokenString);
|
||||||
idToken = input.readJsonContent(IDToken.class);
|
idToken = input.readJsonContent(IDToken.class);
|
||||||
} catch (IOException e) {
|
} catch (JWSInputException e) {
|
||||||
throw new VerificationException();
|
throw new VerificationException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package org.keycloak.adapters;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
import org.keycloak.adapters.spi.HttpFacade;
|
import org.keycloak.adapters.spi.HttpFacade;
|
||||||
import org.keycloak.adapters.spi.UserSessionManagement;
|
import org.keycloak.adapters.spi.UserSessionManagement;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.representations.VersionRepresentation;
|
import org.keycloak.representations.VersionRepresentation;
|
||||||
import org.keycloak.constants.AdapterConstants;
|
import org.keycloak.constants.AdapterConstants;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
@ -178,18 +179,17 @@ public class PreAuthActionsHandler {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
JWSInput input = new JWSInput(token);
|
|
||||||
boolean verified = false;
|
|
||||||
try {
|
try {
|
||||||
verified = RSAProvider.verify(input, deployment.getRealmKey());
|
JWSInput input = new JWSInput(token);
|
||||||
} catch (Exception ignore) {
|
if (RSAProvider.verify(input, deployment.getRealmKey())) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
} catch (JWSInputException ignore) {
|
||||||
}
|
}
|
||||||
if (!verified) {
|
|
||||||
log.warn("admin request failed, unable to verify token");
|
log.warn("admin request failed, unable to verify token");
|
||||||
facade.getResponse().sendError(403, "no token");
|
facade.getResponse().sendError(403, "no token");
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
return input;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import org.keycloak.adapters.KeycloakDeployment;
|
||||||
import org.keycloak.adapters.KeycloakDeploymentBuilder;
|
import org.keycloak.adapters.KeycloakDeploymentBuilder;
|
||||||
import org.keycloak.adapters.ServerRequest;
|
import org.keycloak.adapters.ServerRequest;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.representations.AccessToken;
|
import org.keycloak.representations.AccessToken;
|
||||||
import org.keycloak.representations.AccessTokenResponse;
|
import org.keycloak.representations.AccessTokenResponse;
|
||||||
import org.keycloak.representations.IDToken;
|
import org.keycloak.representations.IDToken;
|
||||||
|
@ -195,10 +196,10 @@ public class KeycloakInstalled {
|
||||||
|
|
||||||
token = RSATokenVerifier.verifyToken(tokenString, deployment.getRealmKey(), deployment.getRealmInfoUrl());
|
token = RSATokenVerifier.verifyToken(tokenString, deployment.getRealmKey(), deployment.getRealmInfoUrl());
|
||||||
if (idTokenString != null) {
|
if (idTokenString != null) {
|
||||||
JWSInput input = new JWSInput(idTokenString);
|
|
||||||
try {
|
try {
|
||||||
|
JWSInput input = new JWSInput(idTokenString);
|
||||||
idToken = input.readJsonContent(IDToken.class);
|
idToken = input.readJsonContent(IDToken.class);
|
||||||
} catch (IOException e) {
|
} catch (JWSInputException e) {
|
||||||
throw new VerificationException();
|
throw new VerificationException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import org.keycloak.adapters.ServerRequest;
|
||||||
import org.keycloak.adapters.spi.AuthenticationError;
|
import org.keycloak.adapters.spi.AuthenticationError;
|
||||||
import org.keycloak.adapters.spi.LogoutError;
|
import org.keycloak.adapters.spi.LogoutError;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.representations.AccessTokenResponse;
|
import org.keycloak.representations.AccessTokenResponse;
|
||||||
import org.keycloak.representations.IDToken;
|
import org.keycloak.representations.IDToken;
|
||||||
import org.keycloak.common.util.KeycloakUriBuilder;
|
import org.keycloak.common.util.KeycloakUriBuilder;
|
||||||
|
@ -153,10 +154,10 @@ public class ServletOAuthClient extends KeycloakDeploymentDelegateOAuthClient {
|
||||||
|
|
||||||
public static IDToken extractIdToken(String idToken) {
|
public static IDToken extractIdToken(String idToken) {
|
||||||
if (idToken == null) return null;
|
if (idToken == null) return null;
|
||||||
JWSInput input = new JWSInput(idToken);
|
|
||||||
try {
|
try {
|
||||||
|
JWSInput input = new JWSInput(idToken);
|
||||||
return input.readJsonContent(IDToken.class);
|
return input.readJsonContent(IDToken.class);
|
||||||
} catch (IOException e) {
|
} catch (JWSInputException e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package org.keycloak.models.utils;
|
package org.keycloak.models.utils;
|
||||||
|
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.jose.jws.crypto.RSAProvider;
|
import org.keycloak.jose.jws.crypto.RSAProvider;
|
||||||
import org.keycloak.models.OTPPolicy;
|
import org.keycloak.models.OTPPolicy;
|
||||||
import org.keycloak.models.PasswordPolicy;
|
import org.keycloak.models.PasswordPolicy;
|
||||||
|
@ -73,11 +74,11 @@ public class CredentialValidation {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean validPasswordToken(RealmModel realm, UserModel user, String encodedPasswordToken) {
|
public static boolean validPasswordToken(RealmModel realm, UserModel user, String encodedPasswordToken) {
|
||||||
JWSInput jws = new JWSInput(encodedPasswordToken);
|
|
||||||
if (!RSAProvider.verify(jws, realm.getPublicKey())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
JWSInput jws = new JWSInput(encodedPasswordToken);
|
||||||
|
if (!RSAProvider.verify(jws, realm.getPublicKey())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
PasswordToken passwordToken = jws.readJsonContent(PasswordToken.class);
|
PasswordToken passwordToken = jws.readJsonContent(PasswordToken.class);
|
||||||
if (!passwordToken.getRealm().equals(realm.getName())) {
|
if (!passwordToken.getRealm().equals(realm.getName())) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -89,7 +90,7 @@ public class CredentialValidation {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (IOException e) {
|
} catch (JWSInputException e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import org.keycloak.events.Errors;
|
||||||
import org.keycloak.events.EventBuilder;
|
import org.keycloak.events.EventBuilder;
|
||||||
import org.keycloak.jose.jws.JWSBuilder;
|
import org.keycloak.jose.jws.JWSBuilder;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.jose.jws.crypto.RSAProvider;
|
import org.keycloak.jose.jws.crypto.RSAProvider;
|
||||||
import org.keycloak.models.ClientModel;
|
import org.keycloak.models.ClientModel;
|
||||||
import org.keycloak.models.ClientSessionModel;
|
import org.keycloak.models.ClientSessionModel;
|
||||||
|
@ -195,44 +196,46 @@ public class TokenManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public RefreshToken verifyRefreshToken(RealmModel realm, String encodedRefreshToken) throws OAuthErrorException {
|
public RefreshToken verifyRefreshToken(RealmModel realm, String encodedRefreshToken) throws OAuthErrorException {
|
||||||
JWSInput jws = new JWSInput(encodedRefreshToken);
|
|
||||||
RefreshToken refreshToken = null;
|
|
||||||
try {
|
try {
|
||||||
|
JWSInput jws = new JWSInput(encodedRefreshToken);
|
||||||
|
RefreshToken refreshToken = null;
|
||||||
if (!RSAProvider.verify(jws, realm.getPublicKey())) {
|
if (!RSAProvider.verify(jws, realm.getPublicKey())) {
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token");
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token");
|
||||||
}
|
}
|
||||||
refreshToken = jws.readJsonContent(RefreshToken.class);
|
refreshToken = jws.readJsonContent(RefreshToken.class);
|
||||||
} catch (Exception e) {
|
|
||||||
|
if (refreshToken.getExpiration() != 0 && refreshToken.isExpired()) {
|
||||||
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh token expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshToken.getIssuedAt() < realm.getNotBefore()) {
|
||||||
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale refresh token");
|
||||||
|
}
|
||||||
|
return refreshToken;
|
||||||
|
} catch (JWSInputException e) {
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token", e);
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token", e);
|
||||||
}
|
}
|
||||||
if (refreshToken.getExpiration() != 0 && refreshToken.isExpired()) {
|
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh token expired");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (refreshToken.getIssuedAt() < realm.getNotBefore()) {
|
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale refresh token");
|
|
||||||
}
|
|
||||||
return refreshToken;
|
|
||||||
}
|
}
|
||||||
public IDToken verifyIDToken(RealmModel realm, String encodedIDToken) throws OAuthErrorException {
|
public IDToken verifyIDToken(RealmModel realm, String encodedIDToken) throws OAuthErrorException {
|
||||||
JWSInput jws = new JWSInput(encodedIDToken);
|
|
||||||
IDToken idToken = null;
|
|
||||||
try {
|
try {
|
||||||
|
JWSInput jws = new JWSInput(encodedIDToken);
|
||||||
|
IDToken idToken;
|
||||||
if (!RSAProvider.verify(jws, realm.getPublicKey())) {
|
if (!RSAProvider.verify(jws, realm.getPublicKey())) {
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid IDToken");
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid IDToken");
|
||||||
}
|
}
|
||||||
idToken = jws.readJsonContent(IDToken.class);
|
idToken = jws.readJsonContent(IDToken.class);
|
||||||
} catch (IOException e) {
|
|
||||||
|
if (idToken.isExpired()) {
|
||||||
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "IDToken expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idToken.getIssuedAt() < realm.getNotBefore()) {
|
||||||
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale IDToken");
|
||||||
|
}
|
||||||
|
return idToken;
|
||||||
|
} catch (JWSInputException e) {
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid IDToken", e);
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid IDToken", e);
|
||||||
}
|
}
|
||||||
if (idToken.isExpired()) {
|
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "IDToken expired");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (idToken.getIssuedAt() < realm.getNotBefore()) {
|
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale IDToken");
|
|
||||||
}
|
|
||||||
return idToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public AccessToken createClientAccessToken(KeycloakSession session, Set<RoleModel> requestedRoles, RealmModel realm, ClientModel client, UserModel user, UserSessionModel userSession, ClientSessionModel clientSession) {
|
public AccessToken createClientAccessToken(KeycloakSession session, Set<RoleModel> requestedRoles, RealmModel realm, ClientModel client, UserModel user, UserSessionModel userSession, ClientSessionModel clientSession) {
|
||||||
|
|
|
@ -3,6 +3,7 @@ package org.keycloak.services.clientregistration;
|
||||||
import org.keycloak.common.util.Time;
|
import org.keycloak.common.util.Time;
|
||||||
import org.keycloak.jose.jws.JWSBuilder;
|
import org.keycloak.jose.jws.JWSBuilder;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.jose.jws.crypto.RSAProvider;
|
import org.keycloak.jose.jws.crypto.RSAProvider;
|
||||||
import org.keycloak.models.ClientInitialAccessModel;
|
import org.keycloak.models.ClientInitialAccessModel;
|
||||||
import org.keycloak.models.ClientModel;
|
import org.keycloak.models.ClientModel;
|
||||||
|
@ -44,7 +45,7 @@ public class ClientRegistrationTokenUtils {
|
||||||
JWSInput input;
|
JWSInput input;
|
||||||
try {
|
try {
|
||||||
input = new JWSInput(token);
|
input = new JWSInput(token);
|
||||||
} catch (Exception e) {
|
} catch (JWSInputException e) {
|
||||||
throw new ForbiddenException(e);
|
throw new ForbiddenException(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,7 +56,7 @@ public class ClientRegistrationTokenUtils {
|
||||||
JsonWebToken jwt;
|
JsonWebToken jwt;
|
||||||
try {
|
try {
|
||||||
jwt = input.readJsonContent(JsonWebToken.class);
|
jwt = input.readJsonContent(JsonWebToken.class);
|
||||||
} catch (IOException e) {
|
} catch (JWSInputException e) {
|
||||||
throw new ForbiddenException(e);
|
throw new ForbiddenException(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import org.jboss.resteasy.spi.ResteasyProviderFactory;
|
||||||
import org.jboss.resteasy.spi.UnauthorizedException;
|
import org.jboss.resteasy.spi.UnauthorizedException;
|
||||||
import org.keycloak.common.ClientConnection;
|
import org.keycloak.common.ClientConnection;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.models.AdminRoles;
|
import org.keycloak.models.AdminRoles;
|
||||||
import org.keycloak.models.ClientModel;
|
import org.keycloak.models.ClientModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
@ -137,11 +138,11 @@ public class AdminRoot {
|
||||||
protected AdminAuth authenticateRealmAdminRequest(HttpHeaders headers) {
|
protected AdminAuth authenticateRealmAdminRequest(HttpHeaders headers) {
|
||||||
String tokenString = authManager.extractAuthorizationHeaderToken(headers);
|
String tokenString = authManager.extractAuthorizationHeaderToken(headers);
|
||||||
if (tokenString == null) throw new UnauthorizedException("Bearer");
|
if (tokenString == null) throw new UnauthorizedException("Bearer");
|
||||||
JWSInput input = new JWSInput(tokenString);
|
|
||||||
AccessToken token;
|
AccessToken token;
|
||||||
try {
|
try {
|
||||||
|
JWSInput input = new JWSInput(tokenString);
|
||||||
token = input.readJsonContent(AccessToken.class);
|
token = input.readJsonContent(AccessToken.class);
|
||||||
} catch (IOException e) {
|
} catch (JWSInputException e) {
|
||||||
throw new UnauthorizedException("Bearer token format error");
|
throw new UnauthorizedException("Bearer token format error");
|
||||||
}
|
}
|
||||||
String realmName = token.getIssuer().substring(token.getIssuer().lastIndexOf('/') + 1);
|
String realmName = token.getIssuer().substring(token.getIssuer().lastIndexOf('/') + 1);
|
||||||
|
|
|
@ -38,6 +38,7 @@ import org.keycloak.events.Details;
|
||||||
import org.keycloak.events.Errors;
|
import org.keycloak.events.Errors;
|
||||||
import org.keycloak.events.Event;
|
import org.keycloak.events.Event;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.models.ClientModel;
|
import org.keycloak.models.ClientModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.ProtocolMapperModel;
|
import org.keycloak.models.ProtocolMapperModel;
|
||||||
|
@ -806,26 +807,15 @@ public class AccessTokenTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private IDToken getIdToken(org.keycloak.representations.AccessTokenResponse tokenResponse) throws VerificationException {
|
private IDToken getIdToken(org.keycloak.representations.AccessTokenResponse tokenResponse) throws JWSInputException {
|
||||||
JWSInput input = new JWSInput(tokenResponse.getIdToken());
|
JWSInput input = new JWSInput(tokenResponse.getIdToken());
|
||||||
IDToken idToken = null;
|
return input.readJsonContent(IDToken.class);
|
||||||
try {
|
|
||||||
idToken = input.readJsonContent(IDToken.class);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new VerificationException();
|
|
||||||
}
|
|
||||||
return idToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private AccessToken getAccessToken(org.keycloak.representations.AccessTokenResponse tokenResponse) throws VerificationException {
|
private AccessToken getAccessToken(org.keycloak.representations.AccessTokenResponse tokenResponse) throws JWSInputException {
|
||||||
JWSInput input = new JWSInput(tokenResponse.getToken());
|
JWSInput input = new JWSInput(tokenResponse.getToken());
|
||||||
AccessToken idToken = null;
|
return input.readJsonContent(AccessToken.class);
|
||||||
try {
|
|
||||||
idToken = input.readJsonContent(AccessToken.class);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new VerificationException();
|
|
||||||
}
|
|
||||||
return idToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected Response executeGrantAccessTokenRequest(WebTarget grantTarget) {
|
protected Response executeGrantAccessTokenRequest(WebTarget grantTarget) {
|
||||||
|
|
|
@ -25,6 +25,7 @@ import org.keycloak.events.Errors;
|
||||||
import org.keycloak.events.Event;
|
import org.keycloak.events.Event;
|
||||||
import org.keycloak.events.EventType;
|
import org.keycloak.events.EventType;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.models.ClientModel;
|
import org.keycloak.models.ClientModel;
|
||||||
import org.keycloak.models.Constants;
|
import org.keycloak.models.Constants;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
|
@ -645,7 +646,12 @@ public class OfflineTokenTest {
|
||||||
StringBuilder response = new StringBuilder("<html><head><title>Offline token servlet</title></head><body><pre>");
|
StringBuilder response = new StringBuilder("<html><head><title>Offline token servlet</title></head><body><pre>");
|
||||||
RefreshableKeycloakSecurityContext ctx = (RefreshableKeycloakSecurityContext) req.getAttribute(KeycloakSecurityContext.class.getName());
|
RefreshableKeycloakSecurityContext ctx = (RefreshableKeycloakSecurityContext) req.getAttribute(KeycloakSecurityContext.class.getName());
|
||||||
String accessTokenPretty = JsonSerialization.writeValueAsPrettyString(ctx.getToken());
|
String accessTokenPretty = JsonSerialization.writeValueAsPrettyString(ctx.getToken());
|
||||||
RefreshToken refreshToken = new JWSInput(ctx.getRefreshToken()).readJsonContent(RefreshToken.class);
|
RefreshToken refreshToken = null;
|
||||||
|
try {
|
||||||
|
refreshToken = new JWSInput(ctx.getRefreshToken()).readJsonContent(RefreshToken.class);
|
||||||
|
} catch (JWSInputException e) {
|
||||||
|
throw new IOException(e);
|
||||||
|
}
|
||||||
String refreshTokenPretty = JsonSerialization.writeValueAsPrettyString(refreshToken);
|
String refreshTokenPretty = JsonSerialization.writeValueAsPrettyString(refreshToken);
|
||||||
|
|
||||||
response = response.append(accessTokenPretty)
|
response = response.append(accessTokenPretty)
|
||||||
|
|
|
@ -115,6 +115,14 @@ public class RefreshTokenTest {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void invalidRefreshToken() throws Exception {
|
||||||
|
AccessTokenResponse response = oauth.doRefreshTokenRequest("invalid", "password");
|
||||||
|
Assert.assertEquals(400, response.getStatusCode());
|
||||||
|
Assert.assertEquals("invalid_grant", response.getError());
|
||||||
|
events.clear();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void refreshTokenRequest() throws Exception {
|
public void refreshTokenRequest() throws Exception {
|
||||||
oauth.doLogin("test-user@localhost", "password");
|
oauth.doLogin("test-user@localhost", "password");
|
||||||
|
|
Loading…
Reference in a new issue