Skip to content

Commit

Permalink
feat(jans-auth-server): updated first party native authn implementati…
Browse files Browse the repository at this point in the history
…on ( in backwards compatibility way) #10380 (#10442)

* feat(jans-auth-server): update first party native authn implementation ( in backwards compatibility way) #10380

#10380
Signed-off-by: YuriyZ <[email protected]>

* feat(jans-auth-server): updated redirect uri validation for First-Party Apps

#10380
Signed-off-by: YuriyZ <[email protected]>

* feat(jans-auth-server): do not validate redirect_uri in First-Party Apps case

#10380
Signed-off-by: YuriyZ <[email protected]>

* feat(jans-auth-server): set authorization_challenge flag when First-Party Apps is invoked

Signed-off-by: YuriyZ <[email protected]>

* feat(jans-auth-server): added dpop support for First-Party Apps

Signed-off-by: YuriyZ <[email protected]>

* missed file

Signed-off-by: YuriyZ <[email protected]>

* fixed bug with not passed authorization_challenge flag inside grant #10380

Signed-off-by: YuriyZ <[email protected]>

* missed file

Signed-off-by: YuriyZ <[email protected]>

* added dpop to sample Authorization Challenge custom script #10380

Signed-off-by: YuriyZ <[email protected]>

* doc(jans-auth-server): updated documentation for latest First-Party Apps update

Signed-off-by: YuriyZ <[email protected]>

---------

Signed-off-by: YuriyZ <[email protected]>
  • Loading branch information
yuriyz authored Dec 23, 2024
1 parent 421b8d0 commit 70cf7d3
Show file tree
Hide file tree
Showing 18 changed files with 153 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ tags:
Authorization Challenge Endpoint allows first-party native client obtain authorization code which later can be exchanged on access token.
This can provide an entirely browserless OAuth 2.0 experience suited for native applications.

This endpoint conforms to [OAuth 2.0 for First-Party Native Applications](https://www.ietf.org/archive/id/draft-parecki-oauth-first-party-native-apps-02.html) specifications.
This endpoint conforms to [OAuth 2.0 for First-Party Applications](https://www.ietf.org/archive/id/draft-parecki-oauth-first-party-apps-02.html) specifications.

URL to access authorization challenge endpoint on Janssen Server is listed in the response of Janssen Server's well-known
[configuration endpoint](./configuration.md) given below.
Expand Down Expand Up @@ -152,6 +152,15 @@ String clientId = context.getHttpRequest().getParameter("client_id");
authorizationChallengeSessionObject.getAttributes().getAttributes().put("client_id", clientId);
```

AS automatically validates DPoP if it is set during auth session creation.
Thus it's recommended to set `jkt` of the auth session if DPoP is used.
```java
final String dpop = context.getHttpRequest().getHeader(DpopService.DPOP);
if (StringUtils.isNotBlank(dpop)) {
authorizationChallengeSessionObject.getAttributes().setJkt(getDpopJkt(dpop));
}
```

Full sample script can be found [here](../../../script-catalog/authorization_challenge/AuthorizationChallenge.java)

## Web session
Expand Down
2 changes: 1 addition & 1 deletion docs/janssen-server/auth-server/oauth-features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The [Janssen Authentication Server](https://github.com/JanssenProject/jans/tree/
- OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens [(spec)](https://datatracker.ietf.org/doc/html/rfc8705)
- Assertion Framework for OAuth 2.0 Client Authentication and Authorization Grants [(spec)](https://www.rfc-editor.org/rfc/rfc7521.html)
- JWT Secured Authorization Response Mode for OAuth 2.0 (JARM) [(spec)](https://openid.net/specs/oauth-v2-jarm.html)
- OAuth 2.0 for First-Party Native Applications [(spec draft)](https://www.ietf.org/archive/id/draft-parecki-oauth-first-party-native-apps-02.html)
- OAuth 2.0 for First-Party Applications [(spec draft)](https://www.ietf.org/archive/id/draft-parecki-oauth-first-party-apps-02.html)
- The Use of Attestation in OAuth 2.0 Dynamic Client Registration [(spec draft)](https://www.ietf.org/id/draft-tschofenig-oauth-attested-dclient-reg-00.html)
- OpenID Connect Core Error Code unmet_authentication_requirements [(spec)](https://openid.net/specs/openid-connect-unmet-authentication-requirements-1_0.html)
- Transaction Tokens [(spec)](https://drafts.oauth.net/oauth-transaction-tokens/draft-ietf-oauth-transaction-tokens.html)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import io.jans.as.common.model.common.User;
import io.jans.as.common.model.session.AuthorizationChallengeSession;
import io.jans.as.server.auth.DpopService;
import io.jans.as.server.authorize.ws.rs.AuthorizationChallengeSessionService;
import io.jans.as.server.service.UserService;
import io.jans.as.server.service.external.context.ExternalScriptContext;
Expand Down Expand Up @@ -128,9 +129,15 @@ private AuthorizationChallengeSession prepareAuthorizationChallengeSession(Exter
AuthorizationChallengeSessionService authorizationChallengeSessionService = CdiUtil.bean(AuthorizationChallengeSessionService.class);
boolean newSave = authorizationChallengeSessionObject == null;
if (newSave) {
// authorizationChallengeSessionObject = authorizationChallengeSessionService.newAuthorizationChallengeSession();
authorizationChallengeSessionObject = authorizationChallengeSessionService.newAuthorizationChallengeSession();
}

final String dpop = context.getHttpRequest().getHeader(DpopService.DPOP);
if (StringUtils.isNotBlank(dpop)) {
authorizationChallengeSessionObject.getAttributes().setJkt(getDpopJkt(dpop));
}


String username = context.getHttpRequest().getParameter(USERNAME_PARAMETER);
if (StringUtils.isNotBlank(username)) {
authorizationChallengeSessionObject.getAttributes().getAttributes().put(USERNAME_PARAMETER, username);
Expand Down Expand Up @@ -160,6 +167,19 @@ private AuthorizationChallengeSession prepareAuthorizationChallengeSession(Exter
return authorizationChallengeSessionObject;
}

public String getDpopJkt(String dpop) {
if (StringUtils.isBlank(dpop)) {
return null;
}

try {
return DpopService.getDpopJwkThumbprint(dpop);
} catch (Exception e) {
scriptLogger.error("Failed to get jkt from DPoP: " + dpop,e);
return null;
}
}

private String getParameterFromAuthorizationChallengeSession(ExternalScriptContext context, String parameterName) {
final AuthorizationChallengeSession sessionObject = context.getAuthzRequest().getAuthorizationChallengeSessionObject();
if (sessionObject != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ tags:

## Overview

The Jans-Auth server implements [OAuth 2.0 for First-Party Native Applications](https://www.ietf.org/archive/id/draft-parecki-oauth-first-party-native-apps-02.html).
The Jans-Auth server implements [OAuth 2.0 for First-Party Applications](https://www.ietf.org/archive/id/draft-parecki-oauth-first-party-apps-02.html).
This script is used to control/customize Authorization Challenge Endpoint.

## Behavior
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ public class AuthorizationChallengeSessionAttributes implements Serializable {
@JsonProperty("acr_values")
private String acrValues;

// jkt - JWK SHA-256 Thumbprint confirmation method.
// The value of the jkt member MUST be the base64url encoding (as defined in [RFC7515]) of the JWK SHA-256 Thumbprint
// (according to [RFC7638]) of the DPoP public key (in JWK format) to which the access token is bound.
@JsonProperty("jkt")
private String jkt;

@JsonProperty("attributes")
private Map<String, String> attributes;

Expand All @@ -38,11 +44,21 @@ public void setAcrValues(String acrValues) {
this.acrValues = acrValues;
}

public String getJkt() {
return jkt;
}

public AuthorizationChallengeSessionAttributes setJkt(String jkt) {
this.jkt = jkt;
return this;
}

@Override
public String toString() {
return "DeviceSessionAttributes{" +
"acrValues='" + acrValues + '\'' +
"attributes='" + attributes + '\'' +
"jkt='" + jkt + '\'' +
'}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ private Response.ResponseBuilder error(int status, TokenErrorResponseType type,
return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE).entity(errorResponseFactory.errorAsJson(type, reason));
}

public String getDpopJwkThumbprint(String dpopStr) throws InvalidJwtException, NoSuchAlgorithmException, JWKException, NoSuchProviderException {
public static String getDpopJwkThumbprint(String dpopStr) throws InvalidJwtException, NoSuchAlgorithmException, JWKException, NoSuchProviderException {
final Jwt dpop = Jwt.parseOrThrow(dpopStr);
JSONWebKey jwk = JSONWebKey.fromJSONObject(dpop.getHeader().getJwk());
return jwk.getJwkThumbprint();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.jans.as.server.authorize.ws.rs;

import io.jans.as.model.util.QueryStringDecoder;
import io.jans.as.server.auth.DpopService;
import io.jans.as.server.service.RequestParameterService;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
Expand Down Expand Up @@ -38,6 +39,8 @@ public Response requestAuthorizationPost(
@FormParam("acr_values") String acrValues,
@FormParam("auth_session") String authorizationChallengeSession,
@FormParam("use_auth_session") String useAuthorizationChallengeSession,
@FormParam("device_session") String deviceSession, // old name in draft 00
@FormParam("use_device_session") String useDeviceSession, // old name in draft 00
@FormParam("prompt") String prompt,
@FormParam("state") String state,
@FormParam("nonce") String nonce,
Expand All @@ -63,6 +66,15 @@ public Response requestAuthorizationPost(
authzRequest.setCodeChallenge(codeChallenge);
authzRequest.setCodeChallengeMethod(codeChallengeMethod);
authzRequest.setAuthzDetailsString(authorizationDetails);
authzRequest.setDpop(httpRequest.getHeader(DpopService.DPOP));

// backwards compatibilty: device_session (up to draft 02) vs auth_session (draft 02 and later)
if (authorizationChallengeSession == null && deviceSession != null) {
authzRequest.setAuthorizationChallengeSession(deviceSession);
}
if (useAuthorizationChallengeSession == null && useDeviceSession != null) {
authzRequest.setUseAuthorizationChallengeSession(Boolean.parseBoolean(useDeviceSession));
}

return authorizationChallengeService.requestAuthorization(authzRequest);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ public void prepareAuthzRequest(AuthzRequest authzRequest) {
if (StringUtils.isNotBlank(authzRequest.getAuthorizationChallengeSession())) {
final AuthorizationChallengeSession session = authorizationChallengeSessionService.getAuthorizationChallengeSession(authzRequest.getAuthorizationChallengeSession());

authorizationChallengeValidator.validateDpopJkt(session, authzRequest.getDpop());

authzRequest.setAuthorizationChallengeSessionObject(session);
if (session != null) {
final Map<String, String> attributes = session.getAttributes().getAttributes();
Expand Down Expand Up @@ -188,6 +190,7 @@ public Response authorize(AuthzRequest authzRequest) throws IOException, TokenBi
authorizationGrant.setClaims(authzRequest.getClaims());
authorizationGrant.setSessionDn(sessionUser != null ? sessionUser.getDn() : "no_session_for_authorization_challenge"); // no need for session as at Authorization Endpoint
authorizationGrant.setAcrValues(grantAcr);
authorizationGrant.setAuthorizationChallenge(true);
authorizationGrant.save();

String authorizationCode = authorizationGrant.getAuthorizationCode().getCode();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;

import java.util.*;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.UUID;

/**
* @author Yuriy Z
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
package io.jans.as.server.authorize.ws.rs;

import io.jans.as.common.model.registration.Client;
import io.jans.as.common.model.session.AuthorizationChallengeSession;
import io.jans.as.model.authorize.AuthorizeErrorResponseType;
import io.jans.as.model.common.GrantType;
import io.jans.as.model.configuration.AppConfiguration;
import io.jans.as.model.error.ErrorResponseFactory;
import io.jans.as.model.token.TokenErrorResponseType;
import io.jans.as.server.auth.DpopService;
import io.jans.as.server.model.config.Constants;
import io.jans.as.server.service.ScopeService;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;

import java.util.Arrays;
Expand All @@ -35,6 +39,30 @@ public class AuthorizationChallengeValidator {
@Inject
private ScopeService scopeService;

public void validateDpopJkt(AuthorizationChallengeSession session, String dpop) {
final String jkt = session.getAttributes().getJkt();
if (StringUtils.isBlank(jkt)) {
return;
}

try {
final String dpopJwkThumbprint = DpopService.getDpopJwkThumbprint(dpop);
if (jkt.equals(dpopJwkThumbprint)) {
return;
} else {
log.debug("Unable to match dpopJkt: {} with sessionJkt: {}", dpopJwkThumbprint, jkt);
}
} catch (Exception e) {
String msg = String.format("Failed to validate dpop jtk. jkt: %s, dpop: %s", jkt, dpop);
log.debug(msg, e);
}

throw new WebApplicationException(errorResponseFactory
.newErrorResponse(Response.Status.BAD_REQUEST)
.entity(errorResponseFactory.getErrorAsJson(TokenErrorResponseType.INVALID_DPOP_PROOF, "", "Invalid DPoP."))
.build());
}

public void validateGrantType(Client client, String state) {
if (client == null) {
final String msg = "Unable to find client.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public class AuthzRequest {
private String claims;
private String authReqId;
private String dpopJkt;
private String dpop;
private String authzDetailsString;
private AuthzDetails authzDetails;
private String httpMethod;
Expand Down Expand Up @@ -95,6 +96,15 @@ public void setDpopJkt(String dpopJkt) {
this.dpopJkt = dpopJkt;
}

public String getDpop() {
return dpop;
}

public AuthzRequest setDpop(String dpop) {
this.dpop = dpop;
return this;
}

public AuthorizationChallengeSession getAuthorizationChallengeSessionObject() {
return authorizationChallengeSessionObject;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public abstract class AbstractAuthorizationGrant implements IAuthorizationGrant

private String acrValues;
private String sessionDn;
private boolean isAuthorizationChallenge;

protected final ConcurrentMap<String, TxToken> txTokens = new ConcurrentHashMap<>();
protected final ConcurrentMap<String, AccessToken> accessTokens = new ConcurrentHashMap<>();
Expand Down Expand Up @@ -110,6 +111,15 @@ public void setReferenceId(String referenceId) {
this.referenceId = referenceId;
}

public boolean isAuthorizationChallenge() {
return isAuthorizationChallenge;
}

public AbstractAuthorizationGrant setAuthorizationChallenge(boolean authorizationChallenge) {
isAuthorizationChallenge = authorizationChallenge;
return this;
}

public Integer getStatusListIndex() {
return statusListIndex;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ private void initTokenFromGrant(TokenEntity token) {
}

token.getAttributes().setAuthorizationDetails(getAuthzDetailsAsString());
token.getAttributes().setAuthorizationChallenge(isAuthorizationChallenge());
token.setScope(getScopesAsString());
token.setAuthMode(getAcrValues());
token.setSessionDn(getSessionDn());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ public AuthorizationGrant asGrant(TokenEntity tokenEntity) {
result.setTokenEntity(tokenEntity);
result.setReferenceId(tokenEntity.getReferenceId());
result.setStatusListIndex(tokenEntity.getAttributes().getStatusListIndex());
result.setAuthorizationChallenge(tokenEntity.getAttributes().isAuthorizationChallenge());
if (StringUtils.isNotBlank(grantId)) {
result.setGrantId(grantId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ grantType, code, redirectUri, username, refreshToken, clientId, prepareForLogs(r
scope = ServerUtil.urlDecode(scope); // it may be encoded in uma case

try {
tokenRestWebServiceValidator.validateParams(grantType, code, redirectUri, refreshToken, auditLog);
tokenRestWebServiceValidator.validateParams(grantType, code, refreshToken, auditLog);

GrantType gt = GrantType.fromString(grantType);
log.debug("Grant type: '{}'", gt);
Expand All @@ -212,7 +212,7 @@ grantType, code, redirectUri, username, refreshToken, clientId, prepareForLogs(r
executionContext.setAuthzDetails(authzDetails);

if (gt == GrantType.AUTHORIZATION_CODE) {
return processAuthorizationCode(code, scope, codeVerifier, sessionIdObj, executionContext);
return processAuthorizationCode(code, scope, codeVerifier, sessionIdObj, redirectUri, executionContext);
} else if (gt == GrantType.REFRESH_TOKEN) {
return processRefreshTokenGrant(scope, refreshToken, idTokenPreProcessing, executionContext);
} else if (gt == GrantType.CLIENT_CREDENTIALS) {
Expand Down Expand Up @@ -434,14 +434,19 @@ private TokenEntity lockAndRemoveRefreshToken(String refreshTokenCode) {
return null;
}

private Response processAuthorizationCode(String code, String scope, String codeVerifier, SessionId sessionIdObj, ExecutionContext executionContext) {
private Response processAuthorizationCode(String code, String scope, String codeVerifier, SessionId sessionIdObj, String redirectUri, ExecutionContext executionContext) {
Client client = executionContext.getClient();

log.debug("Attempting to find authorizationCodeGrant by clientId: '{}', code: '{}'", client.getClientId(), code);
final AuthorizationCodeGrant authorizationCodeGrant = authorizationGrantList.getAuthorizationCodeGrant(code);
executionContext.setGrant(authorizationCodeGrant);
log.trace("AuthorizationCodeGrant : '{}'", authorizationCodeGrant);

// validate redirectUri only for Authorization Code Flow. For First-Party App redirect uri is blank. It is perfectly valid case.
if (!authorizationCodeGrant.isAuthorizationChallenge()) {
tokenRestWebServiceValidator.validateRedirectUri(redirectUri, executionContext.getAuditLog());
}

// if authorization code is not found then code was already used or wrong client provided = remove all grants with this auth code
tokenRestWebServiceValidator.validateGrant(authorizationCodeGrant, client, code, executionContext.getAuditLog(), grant -> grantService.removeAllByAuthorizationCode(code));
tokenRestWebServiceValidator.validatePKCE(authorizationCodeGrant, codeVerifier, executionContext.getAuditLog());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public void validatePKCE(AuthorizationCodeGrant grant, String codeVerifier, OAut
}

public void validateParams(String grantType, String code,
String redirectUri, String refreshToken, OAuth2AuditLog auditLog) {
String refreshToken, OAuth2AuditLog auditLog) {
log.debug("Starting to validate request parameters");
if (grantType == null || grantType.isEmpty()) {
final String msg = "Grant Type is not set.";
Expand All @@ -98,11 +98,6 @@ public void validateParams(String grantType, String code,
log.trace(msg);
throw new WebApplicationException(response(error(400, TokenErrorResponseType.INVALID_REQUEST, msg), auditLog));
}
if (StringUtils.isBlank(redirectUri)) {
final String msg = "redirect_uri is not set for AUTHORIZATION_CODE.";
log.trace(msg);
throw new WebApplicationException(response(error(400, TokenErrorResponseType.INVALID_REQUEST, msg), auditLog));
}
return;
}

Expand Down Expand Up @@ -173,6 +168,14 @@ public void validateGrant(AuthorizationGrant grant, Client client, Object identi
validateGrant(grant, client, identifier, auditLog, null);
}

public void validateRedirectUri(String redirectUri, OAuth2AuditLog auditLog) {
if (StringUtils.isBlank(redirectUri)) {
final String msg = "redirect_uri is not set for AUTHORIZATION_CODE.";
log.trace(msg);
throw new WebApplicationException(response(error(400, TokenErrorResponseType.INVALID_REQUEST, msg), auditLog));
}
}


public void validateGrant(AuthorizationGrant grant, Client client, Object identifier, OAuth2AuditLog auditLog, Consumer<AuthorizationGrant> onFailure) {
if (grant == null) {
Expand Down
Loading

0 comments on commit 70cf7d3

Please sign in to comment.