This module provides an AuthBundle
to authenticate
users based on JSON Web Tokens and an OpaBundle
to
authorize the request with help of the Open Policy Agent
.
To use the bundle, a dependency to this module has to be added:
compile 'org.sdase.commons:sda-commons-server-auth:<current-version>'
The authentication creates a JwtPrincipal
per
request. This can be accessed from the SecurityContext
:
@PermitAll
@Path("/secure")
public class SecureEndPoint {
@Context
private SecurityContext securityContext;
@GET
public Response getSomethingSecure() {
JwtPrincipal jwtPrincipal = (JwtPrincipal) securityContext.getUserPrincipal();
// ...
}
}
To activate the authentication in an application, the bundle has to be added to the application:
public class MyApplication extends Application<MyConfiguration> {
public static void main(final String[] args) {
new MyApplication().run(args);
}
@Override
public void initialize(Bootstrap<MyConfiguration> bootstrap) {
// ...
bootstrap.addBundle(AuthBundle.builder().withAuthConfigProvider(MyConfiguration::getAuth).withAnnotatedAuthorization().build());
// ...
}
@Override
public void run(MyConfiguration configuration, Environment environment) {
// ...
}
}
The Bundle can be configured to perform basic authorization based on whether a token is presented or not on endpoints
that are annotated with @PermitAll
with the setting .withAnnotatedAuthorization()
.
If the authorization should be handled by e.g. the OpaBundle
, the option .withExternalAuthorization()
still validates
tokens sent in the Authorization
header but also accepts requests without header to be processed and eventually
rejected in a later stage.
The configuration relies on the config.yaml
of the application and the custom property where the
AuthConfig
is mapped. Usually this should be
auth
.
public class MyConfig extends Configuration {
private AuthConfig auth;
// getters and setters
}
The config allows to set the leeway in seconds for validation of
exp
and
nbf
.
Multiple sources for public keys for verification of the signature can be configured. Each source may refer to a
- certificate in PEM format
- an authentication provider root URL or
- a URL providing a
JSON Web Key Set
The authentication can be disabled for use in test and development environments. Be careful to NEVER disable authentication in production.
For the authentication provider root URL and a jwks source a required issuer can be configured by the attribute requiredIssuer
.
If the attribute requiredIssuer
is set, the issuer of the token must match to the provided required issuer.
A warning will be logged if the host name of the source url does not match the host name of the required issuer.
Example config:
auth:
# Disable all authentication, should be NEVER true in production
disableAuth: false
# The accepted leeway in seconds:
leeway: 2
# Definition of key sources providing public keys to verify signed tokens.
keys:
# A public key derived from a local PEM certificate.
# The pemKeyId must match the 'kid' the signing authority sets in the token.
# It may be null if the 'kid' in the token is not set by the signing authority.
# The pemKeyId is only considered for type: PEM
- type: PEM
location: file:///secure/example.pem
pemKeyId: example
# A public key derived from a PEM certificate that is provided from a http server
# The pemKeyId must match the 'kid' the signing authority sets in the token.
# It may be null if the 'kid' in the token is not set by the signing authority.
# The pemSignAlg must match the signing algorithm used in the PEM. Defaults to RS256.
# The pemKeyId and pemSignAlg is only considered for type: PEM
- type: PEM
location: http://example.com/keys/example.pem
pemKeyId: example.com
pemSignAlg: RS256
# Public keys will be loaded from the OpenID provider using discovery.
- type: OPEN_ID_DISCOVERY
location: https://keycloak.example.com/auth/realms/my-realm
requiredIssuer: https://keycloak.example.com/auth/realms/my-realm
- # Public keys will be loaded directly from the JWKS url of the OpenID provider.
type: JWKS
location: https://keycloak.example.com/auth/realms/my-realm/protocol/openid-connect/certs
requiredIssuer: https://keycloak.example.com/auth/realms/my-realm
# Comma separated string of OPEN_ID_DISCOVERY key sources with required issuer. Can be used to
# shorten the configuration when the discovery base URL matches the iss claim, the IDP sets.
# The value used for configuration here must exactly match the iss claim.
# keys and issuers can be used at the same time. Both are added to the accepted key sources.
issuers: "https://keycloak.example.com/auth/realms/my-realm, https://keycloak.example.com/auth/realms/my-other-realm"
The config may be filled from environment variables if the
ConfigurationSubstitutionBundle
is used:
auth:
disableAuth: ${DISABLE_AUTH:-false}
leeway: ${AUTH_LEEWAY:-0}
keys: ${AUTH_KEYS:-[]}
issuers: "${AUTH_ISSUERS:-}"
In this case, the AUTH_KEYS
variable should contain a JSON array of
KeyLocation
objects:
[
{
"type": "OPEN_ID_DISCOVERY",
"location": "https://keycloak.example.com/auth/realms/my-realm"
},
{
"type": "OPEN_ID_DISCOVERY",
"location": "https://keycloak.example.com/auth/realms/my-other-realm"
}
]
Public keys provided via a JSON Web Key Set (JWKS) are stored in a local key-cache. The cache updates every 5 minutes. It will also update when a JWT with unknown kid must be validated. No changes apply when the JWKS is unreachable.
To avoid acceptance of tokens signed by revoked keys, all keys not available in the JWKS are removed on update.
The client that calls the OpenID Discovery endpoint or the JWKS url, is configurable with the standard Dropwizard configuration.
Tip: There is no need to make all configuration properties available as environment variables. Seldomly used properties can always be configured using System Properties.
auth:
keyLoaderClient:
timeout: 500ms
proxy:
host: 192.168.52.11
port: 8080
scheme : http
This configuration can be used to configure a proxy server if needed. Use this if all clients should use an individual proxy configuration.
In addition, the client consumes the standard proxy system properties.
Please note that a specific proxy configuration in the HttpClientConfiguration
disables the proxy system properties for the client using that configuration.
This can be helpful when all clients in an Application should use the same proxy configuration (this includes all clients that are created by the sda-commons-client-jersey
bundle).
Details about the authorization with Open Policy Agent are documented within the authorization concept (see Confluence). In short, Open Policy Agent acts as policy decision point and is started as sidecar to the actual service.
The OPA Bundle acts as a client to the Open Policy Agent and is hooked in as request filter handling requests after they have
been validated by the JwtAuthenticator
and before they reach the service implementation.
The OPA Bundle requires the AuthBundle
in place, so that the JWT can be verified against public keys before it is handed over to OPA.
In case the request does not contain a JWT token at all, the JWT verification in the AuthBundle
will be skipped without error and further
checks should be part of the OPA policy.
If it is still required to reject requests that do not contain a JWT token the AuthBundle
needs to be configured to perform basic authorization. This is achieved
by setting .withAnnotatedAuthorization()
on the AuhtBundle
and annotate endpoints with @PermitAll
.
The OPA bundle requests the policy decision providing the following inputs
- HTTP path as Array
- HTTP method as String
- validated JWT (if available)
- all request headers (can be disabled in the
OpaBundle
builder)
Remark to HTTP request headers:
The bundle normalizes header names to lower case to simplify handling in OPA since HTTP specification defines header names as case-insensitive.
Multivalued headers are not normalized with respect to the representation as list or single string with separator char.
They are forwarded as parsed by the framework.
Security note: Please be aware while a service might only consider one value of a specific header, the OPA is able to authorize on an array of those. Consider this in your policy when you want to make sure you authorize on the same value that a service might use to evaluate the output.
These inputs can be accessed inside a policy .rego
-file in this way:
# each policy lies in a package that is referenced in the configuration of the OpaBundle
package example
# decode the JWT as new variable 'token'
token = {"payload": payload} {
not input.jwt == null
io.jwt.decode(input.jwt, [_, payload, _])
}
# deny by default
default allow = false
allow {
# allow if path match '/contracts/:anyid'
input.path = ["contracts", _]
# allow if request method 'GET' is used
input.httpMethod == "GET"
# allow if 'claim' exists in the JWT payload
token.payload.claim
# allow if a request header 'HttpRequestHeaderName' has a certain value
input.headers["httprequestheadername"][_] == "certain-value"
}
# set some example constraints
constraint1 := true # always true
constraint2 := [ "v2.1", "v2.2" ] # always an array of "v2.1" and "v2.2"
constraint3[token.payload.sub]. # always a set that contains the 'sub' claim from the token
# or is empty if no token is present
The response consists of two parts: The overall allow
decision, and optional rules that represent constraints to limit data access
within the service. These constraints are fully service dependent and MUST be applied when querying the database or
filtering received data.
The following listing presents a sample OPA result with a positive allow decision and two constraints, the first with boolean value and second with a list of string values.
{
"result": {
"allow": true,
"constraint1": true,
"constraint2": [ "v2.1", "v2.2" ],
"constraint3": ["my-sub"]
}
}
The following listing shows a corresponding model class to the example above:
public class ConstraintModel {
private boolean constraint1;
private List<String> constraint2;
// could also be a Set<String>
private List<String> constraint3;
}
The bundle creates a OpaJwtPrincipal
for each request. You can retrieve the constraint model's data from the principal by invoking OpaJwtPrincipal#getConstraintsAsEntity
.
Data from an JwtPrincipal
is
copied to the new principal if existing.
Beside the JWT, the constraints are included in this principal. The OpaJwtPrincipal
includes a
method to parse the constraints JSON string to a Java object.
The OpaJwtPrincipal
can be
injected as field using @Context
in
request scoped beans like endpoint implementations or accessed from the SecurityContext
.
@Path("/secure")
public class SecureEndPoint {
@Context
private OpaJwtPrincipal opaJwtPrincipal;
@GET
public Response getSomethingSecure() {
// ...
}
}
To activate the OPA integration in an application, the bundle has to be added to the application. The Kubernetes file of the service must also be adjusted to start OPA as sidecar.
If you use the SdaPlatformBundle, there is a more convenient way to enable OPA support.
public class MyApplication extends Application<MyConfiguration> {
public static void main(final String[] args) {
new MyApplication().run(args);
}
@Override
public void initialize(Bootstrap<MyConfiguration> bootstrap) {
// ...
bootstrap.addBundle(OpaBundle.builder().withOpaConfigProvider(OpaBundeTestAppConfiguration::getOpaConfig).build());
// ...
}
@Override
public void run(MyConfiguration configuration, Environment environment) {
// ...
}
}
The configuration relies on the config.yaml
of the application and the custom property where the
OpaConfig
is mapped. Usually this should be
opa
.
public class MyConfig extends Configuration {
private OpaConfig opa;
// getters and setters
}
The config includes the connection data to the OPA sidecar and the path to the used policy endpoint.
Example config:
opa:
# Disable authorization. An empty prinicpal is created with an empty set of constraints
disableOpa: false
# Url to the OPA sidecar
baseUrl: http://localhost:8181
# Package name of the policy file that should be evaluated for authorization decision
# The package name is used to resolve the full path
policyPackage: http.authz
# By default, openapi(.json|.yaml) are excluded from authorization.
# To include these endpoints, overwrite the default value of this property to false
excludeOpenApi: true
# Advanced configuration of the HTTP client that is used to call the Open Policy Agent
opaClient:
# timeout for OPA requests, default 500ms
timeout: 500ms
The config may be filled from environment variables if the
ConfigurationSubstitutionBundle
is used:
opa:
disableOpa: ${DISABLE_OPA:-false}
baseUrl: ${OPA_URL:-http://localhost:8181}
policyPackage: ${OPA_POLICY_PACKAGE}
The client that calls the Open Policy Agent is configurable with the standard Dropwizard configuration.
Tip: There is no need to make all configuration properties available as environment variables. Seldomly used properties can always be configured using System Properties.
opa:
opaClient:
timeout: 500ms
This configuration can be used to configure a proxy server if needed.
Please note that this client does not consume the standard proxy system properties but needs to be configured manually! The OPA should be deployed as near to the service as possible, so we don't expect the need for universal proxy settings.
sda-commons-server-auth-testing
provides support for testing
applications with authentication.
The Bundle offers the option to register extensions that send custom data to the Open Policy Agent to be accessed during policy execution.
Such extensions implement the OpaInputExtension
interface and are registered in a dedicated namespace.
The extension is called in the OpaAuthFilter
and is able to access the current RequestContext
to for example extract additional data from the request.
Overriding existing input properties (path
, jwt
, httpMethod
) is not possible.
This extension option should only be used if the normal authorization via constraints is not powerful enough. In general, a custom extension should not be necessary for most use cases.
Remark on accessing the request body in an input extension:
The extension gets the ContainerRequestContext
as input to access information about the request.
Please be aware to not access the request entity since this might break your service.
A test that shows the erroneous behavior can be found in OpaBundleBodyInputExtensionTest.java
Security note: When creating new extensions, be aware that you might access properties of a request that are not yet validated in the method interface. This is especially important if your service expects single values that might be accessible as array value to the OPA. While the service (e.g. in case of query parameters) only considers the first value, make sure to not authorize on other values in the OPA.
The following listing shows an example extension that adds a fixed boolean entry:
public class ExampleOpaInputExtension implements OpaInputExtension<Boolean> {
@Override
public Boolean createAdditionalInputContent(ContainerRequestContext requestContext) {
return true;
}
}
Register the extension during the OpaBundle
creation:
OpaBundle.builder()
.withOpaConfigProvider(YourConfiguration::getOpa)
.withInputExtension("myExtension", new ExampleOpaInputExtension())
.build();
Access the additional input inside a policy .rego
-file in this way:
exampleExtensionWorks {
# check if path match '/contracts'
input.path = ["contracts"]
# check if the custom input has a certain value
input.myExtension == true
}