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

API based login flow #48

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,54 @@ Full path to the truststore file.
Password for the PKCS12 container.
*example*
`qwerty123`

## Auth flows
The plugin supports login flows based on API or WebView. Clients decide which flow to use via the querystring `bankid_login_flow` in the auth step.
Valid values are `api` and `webview`. Defaults to `webview`.

### API flow

1. Initiate authentication using `<KEYCLOAK_URL>/realms/<REALM>/protocol/openid-connect/auth`.
2. Start polling the `pollingUrl` and wait for the BankID identification to complete.
3. [Launch](https://www.bankid.com/en/utvecklare/guider/teknisk-integrationsguide/programstart) the BankID application using the autostarttoken.
4. When identification is done, the collect endpoint will return status `complete`.
5. Open completion url to finalize the authentication, follow the redirect and grab the code.
6. Exchange the code for a token at `<KEYCLOAK_URL>/realms/<REALM>/protocol/openid-connect/token`

**Auth start:**
(Internally redirected from Keycloak auth endpoint when using API login flow)
```
GET /api/start
{
"pollingUrl": "<POLLING_URL>", // /api/collect
"cancelUrl": "<CANCEL_URL>", // /api/cancel
"autostarttoken": "<AUTOSTART_TOKEN>"
}
```

**Collect:**
```
GET /api/collect
{
"status": "<BANKID_STATUS>", // pending | complete | failed
"hintCode": "<BANKID_HINT_CODE", // Only present for failed orders, defaults to null
"messageShortName": "<BANKID_MESSAGE_SHORT_NAME>", // Only present for failed orders, defaults to null
"completionUrl": "<COMPLETION_URL>" // /api/done
}
```

**Done:**
```
GET /api/done
Redirects back to client application with authorization code
```

**Cancel:**
```
GET /api/cancel
{
"status": "cancelled",
"hintCode": "<BANKID_HINT_CODE>",
"messageShortName": "<BANKID_MESSAGE_SHORT_NAME>"
}
```
192 changes: 158 additions & 34 deletions src/main/java/org/keycloak/broker/bankid/BankidEndpoint.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import java.io.ByteArrayOutputStream;
import java.io.UnsupportedEncodingException;
import java.math.BigInteger;
import java.net.URI;
import java.net.URLEncoder;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
Expand Down Expand Up @@ -35,31 +37,34 @@
import org.keycloak.broker.bankid.model.CompletionData;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityProvider.AuthenticationCallback;
import org.keycloak.broker.provider.util.IdentityBrokerState;
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakUriInfo;
import org.keycloak.sessions.AuthenticationSessionModel;

import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import org.wildfly.security.http.HttpServerRequest;

public class BankidEndpoint {

private BankidIdentityProviderConfig config;
private AuthenticationCallback callback;
private BankidIdentityProvider provider;
private SimpleBankidClient bankidClient;
private final BankidIdentityProviderConfig config;
private final AuthenticationCallback callback;
private final BankidIdentityProvider provider;
private final SimpleBankidClient bankidClient;
private static final Logger logger = Logger.getLogger(BankidEndpoint.class);

// The maximum number of minutes to store bankid session info in the token cache
// Setting this to 5 since BankID will timeout after 3 minutes
private static long MAX_CACHE_LIFESPAN = 5;
private static final long MAX_CACHE_LIFESPAN = 5;

private Cache<Object, Object> actionTokenCache;
private final Cache<Object, Object> actionTokenCache;

private static String qrCodePrefix = "bankid.";
private static final String qrCodePrefix = "bankid.";

@Context
protected KeycloakSession session;
Expand Down Expand Up @@ -94,6 +99,40 @@ public Response start(@QueryParam("state") String state) {
}
}

@GET
@Path("/api/start")
public Response apiStart(@QueryParam("state") String state) {
try {
AuthResponse authResponse = bankidClient.sendAuth(null, session.getContext().getConnection().getRemoteAddr());

UUID bankidRef = UUID.randomUUID();
this.actionTokenCache.put(bankidRef.toString(), authResponse, MAX_CACHE_LIFESPAN, TimeUnit.MINUTES);
URI pollingUri = provider.redirectUriBuilder()
.path("/api/collect")
.queryParam("bankidref", bankidRef)
.queryParam("state", state)
.build();

URI cancelUri = provider.redirectUriBuilder()
.path("/api/cancel")
.queryParam("bankidref", bankidRef)
.queryParam("state", state)
.build();

return Response.ok(String.format("{ \"pollingUrl\": \"%s\", \"cancelUrl\": \"%s\", \"autostarttoken\": \"%s\" }",
pollingUri, cancelUri, authResponse.getAutoStartToken()),
MediaType.APPLICATION_JSON_TYPE).build();

} catch (BankidClientException e) {
BankidHintCodes hintCode = e.getHintCode();
return Response
.status(Status.INTERNAL_SERVER_ERROR).entity(String
.format("{ \"status\": \"%s\", \"hintCode\": \"%s\", \"messageShortName\": \"%s\" }",
"failed", hintCode, hintCode.messageShortName))
.type(MediaType.APPLICATION_JSON_TYPE).build();
}
}

@POST
@Path("/login")
public Response loginPost(@FormParam("nin") String nin, @FormParam("state") String state) {
Expand Down Expand Up @@ -136,36 +175,77 @@ public Response collect(@QueryParam("bankidref") String bankidRef) {
return loginFormsProvider.setError("bankid.error.internal").createErrorPage(Status.INTERNAL_SERVER_ERROR);
}

if (this.actionTokenCache.containsKey(bankidRef)) {
String orderref = ((AuthResponse) this.actionTokenCache.get(bankidRef)).getOrderRef();
try {
CollectResponse responseData = bankidClient.sendCollect(orderref);
if ("failed".equalsIgnoreCase(responseData.getStatus())) {
return Response.status(Status.INTERNAL_SERVER_ERROR)
.entity(String.format("{ \"status\": \"%s\", \"hintCode\": \"%s\" }",
responseData.getStatus(), responseData.getHintCode()))
.type(MediaType.APPLICATION_JSON_TYPE).build();
} else {
if ("complete".equalsIgnoreCase(responseData.getStatus())) {
this.actionTokenCache.put(bankidRef + "-completion", responseData.getCompletionData());
}
return Response.ok(String.format("{ \"status\": \"%s\", \"hintCode\": \"%s\" }",
responseData.getStatus(), responseData.getHintCode()), MediaType.APPLICATION_JSON_TYPE)
.build();
}
} catch (BankidClientException e) {
return Response
.status(Status.INTERNAL_SERVER_ERROR).entity(String
.format("{ \"status\": \"%s\", \"hintCode\": \"%s\" }", "failed", e.getHintCode()))
.type(MediaType.APPLICATION_JSON_TYPE).build();
}
}

String orderref = ((AuthResponse) this.actionTokenCache.get(bankidRef)).getOrderRef();
try {
CollectResponse responseData = bankidClient.sendCollect(orderref);
// Check responseData.getStatus()
if ("failed".equalsIgnoreCase(responseData.getStatus())) {
return Response.status(Status.INTERNAL_SERVER_ERROR)
.entity(String.format("{ \"status\": \"%s\", \"hintCode\": \"%s\" }",
responseData.getStatus(), responseData.getHintCode()))
.type(MediaType.APPLICATION_JSON_TYPE).build();
} else {
if ("complete".equalsIgnoreCase(responseData.getStatus())) {
this.actionTokenCache.put(bankidRef + "-completion", responseData.getCompletionData());
}
return Response.ok(String.format("{ \"status\": \"%s\", \"hintCode\": \"%s\" }",
responseData.getStatus(), responseData.getHintCode()), MediaType.APPLICATION_JSON_TYPE)
@GET
@Path("/api/collect")
public Response apiCollect(@QueryParam("state") String state, @QueryParam("bankidref") String bankidRef) {
if (!this.actionTokenCache.containsKey(bankidRef) ||
!(this.actionTokenCache.get(bankidRef) instanceof AuthResponse)) {
return Response
.status(Status.INTERNAL_SERVER_ERROR).entity(String
.format("{ \"status\": \"%s\", \"hintCode\": \"%s\", \"messageShortName\": null, \"completionUrl\": null }", "500", "bankid.error.internal"))
.type(MediaType.APPLICATION_JSON_TYPE).build();
}

String orderref = ((AuthResponse) this.actionTokenCache.get(bankidRef)).getOrderRef();
try {
CollectResponse responseData = bankidClient.sendCollect(orderref);
if ("failed".equalsIgnoreCase(responseData.getStatus())) {
BankidHintCodes hintCode = BankidHintCodes.valueOf(responseData.getHintCode());
return Response.status(Status.INTERNAL_SERVER_ERROR)
.entity(String.format("{ \"status\": \"%s\", \"hintCode\": \"%s\", \"messageShortName\": \"%s\", \"completionUrl\": null }",
responseData.getStatus(), hintCode, hintCode.messageShortName))
.type(MediaType.APPLICATION_JSON_TYPE).build();
} else {
if ("complete".equalsIgnoreCase(responseData.getStatus())) {
this.actionTokenCache.put(bankidRef + "-completion", responseData.getCompletionData());
URI completionUrl = provider.redirectUriBuilder()
.path("api/done")
.queryParam("bankidref", bankidRef)
.queryParam("state", state)
.build();

return Response.ok(String.format("{ \"status\": \"%s\", \"hintCode\": null, \"messageShortName\": null, \"completionUrl\": \"%s\" }",
responseData.getStatus(), completionUrl), MediaType.APPLICATION_JSON_TYPE)
.build();
} else {
BankidHintCodes hintCode = BankidHintCodes.valueOf(responseData.getHintCode());
return Response.ok(String.format("{ \"status\": \"%s\", \"hintCode\": \"%s\", \"messageShortName\": \"%s\", \"completionUrl\": null }",
responseData.getStatus(), hintCode, hintCode.messageShortName), MediaType.APPLICATION_JSON_TYPE)
.build();
}
} catch (BankidClientException e) {
return Response
.status(Status.INTERNAL_SERVER_ERROR).entity(String
.format("{ \"status\": \"%s\", \"hintCode\": \"%s\" }", "failed", e.getHintCode()))
.type(MediaType.APPLICATION_JSON_TYPE).build();
}
} else {
return Response.ok(String.format("{ \"status\": \"%s\", \"hintCode\": \"%s\" }", "500", "internal"),
MediaType.APPLICATION_JSON_TYPE).build();
}
}
}
} catch (BankidClientException e) {
BankidHintCodes hintCode = e.getHintCode();
return Response
.status(Status.INTERNAL_SERVER_ERROR).entity(String
.format("{ \"status\": \"%s\", \"hintCode\": \"%s\", \"messageShortName\": \"%s\", \"completionUrl\": null }", "failed", hintCode, hintCode.messageShortName))
.type(MediaType.APPLICATION_JSON_TYPE).build();
}
}

@GET
@Path("/done")
Expand All @@ -178,6 +258,26 @@ public Response done(@QueryParam("state") String state, @QueryParam("bankidref")
return loginFormsProvider.setError("bankid.error.internal").createErrorPage(Status.INTERNAL_SERVER_ERROR);
}

return completeAuth(state, bankidRef);
}

@GET
@Path("/api/done")
public Response apiDone(@QueryParam("state") String state, @QueryParam("bankidref") String bankidRef) {

if (!this.actionTokenCache.containsKey(bankidRef + "-completion") ||
!(this.actionTokenCache.get(bankidRef + "-completion") instanceof CompletionData)) {
logger.error("Action token cache does not have a CompletionData object.");
return Response
.status(Status.INTERNAL_SERVER_ERROR).entity(String
.format("{ \"status\": \"%s\", \"hintCode\": \"%s\" }", "500", "bankid.error.internal"))
.type(MediaType.APPLICATION_JSON_TYPE).build();
}

return completeAuth(state, bankidRef);
}

private Response completeAuth(String state, String bankidRef) {
CompletionData completionData = (CompletionData) this.actionTokenCache.get(bankidRef + "-completion");
BankidUser user = completionData.getUser();
// Make sure to remove the authresponse attribute from the session
Expand Down Expand Up @@ -243,6 +343,30 @@ public Response cancel(@QueryParam("bankidref") String bankidRef) {
.createErrorPage(Status.INTERNAL_SERVER_ERROR);
}

@GET
@Path("/api/cancel")
public Response apiCancel(@QueryParam("bankidref") String bankidRef) {

if (!this.actionTokenCache.containsKey(bankidRef) ||
!(this.actionTokenCache.get(bankidRef) instanceof AuthResponse)) {
return Response
.status(Status.INTERNAL_SERVER_ERROR).entity(String
.format("{ \"status\": \"%s\", \"hintCode\": \"%s\", \"messageShortName\": null, }", "500", "bankid.error.internal"))
.type(MediaType.APPLICATION_JSON_TYPE).build();
}

AuthResponse authResponse = (AuthResponse) this.actionTokenCache.get(bankidRef);
if (authResponse != null) {
String orderRef = authResponse.getOrderRef();
bankidClient.sendCancel(orderRef);
}

return Response
.ok(String.format("{ \"status\": \"%s\", \"hintCode\": \"%s\", \"messageShortName\": \"%s\" }", "cancelled",
BankidHintCodes.cancelled, BankidHintCodes.cancelled.messageShortName))
.type(MediaType.APPLICATION_JSON_TYPE).build();
}

@GET
@Path("/error")
public Response error(@QueryParam("code") String hintCode) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
package org.keycloak.broker.bankid;

import java.net.URI;
import java.net.URISyntaxException;

import javax.ws.rs.core.Response;

import org.apache.http.client.HttpClient;
import org.keycloak.broker.bankid.model.BankidLoginFlow;
import org.keycloak.broker.provider.AbstractIdentityProvider;
import org.keycloak.broker.provider.AuthenticationRequest;
import org.keycloak.connections.httpclient.HttpClientBuilder;
Expand All @@ -15,8 +11,15 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;

import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import java.net.URI;
import java.net.URISyntaxException;

public class BankidIdentityProvider extends AbstractIdentityProvider<BankidIdentityProviderConfig> {

private static final String BANKID_LOGIN_FLOW_PARAMETER_NAME = "client_request_param_bankid_login_flow";

public BankidIdentityProvider(KeycloakSession session, BankidIdentityProviderConfig config) {
super(session, config);
}
Expand All @@ -29,8 +32,9 @@ public Object callback(RealmModel realm, AuthenticationCallback callback, EventB
@Override
public Response performLogin(AuthenticationRequest request) {
try {
String path = (getLoginFlow(request) == BankidLoginFlow.api) ? "/api/start" : "/start";
return Response.status(302)
.location(new URI(request.getRedirectUri() + "/start?state=" + request.getState().getEncoded()))
.location(new URI(request.getRedirectUri() + path + "?state=" + request.getState().getEncoded()))
.build();
} catch (URISyntaxException e) {
throw new IllegalArgumentException();
Expand Down Expand Up @@ -60,6 +64,15 @@ public ProxyMappings generateProxyMapping(){
return ProxyMappings.withFixedProxyMapping(httpsProxy, noProxy);
}

public UriBuilder redirectUriBuilder() {
return session.getContext().getUri().getBaseUriBuilder()
.path("realms")
.path(session.getContext().getRealm().getId())
.path("broker")
.path(getConfig().getAlias())
.path("endpoint");
}

public HttpClient buildBankidHttpClient() {

try {
Expand All @@ -72,4 +85,8 @@ public HttpClient buildBankidHttpClient() {
}
}

private BankidLoginFlow getLoginFlow(AuthenticationRequest request) {
String bankidLoginFlowQueryString = request.getAuthenticationSession().getClientNote(BANKID_LOGIN_FLOW_PARAMETER_NAME);
return BankidLoginFlow.valueOfOrDefault(bankidLoginFlowQueryString, BankidLoginFlow.webview);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.keycloak.broker.bankid.model;

public enum BankidLoginFlow {
api,
webview;

public static BankidLoginFlow valueOfOrDefault(String s, BankidLoginFlow defaultValue) {
try {
return BankidLoginFlow.valueOf(s);
} catch (Exception e) {
return defaultValue;
}
}
}