001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions Copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2015 ForgeRock AS.
015 */
016
017package org.forgerock.openig.openam;
018
019import static java.lang.String.format;
020import static org.forgerock.http.protocol.Response.newResponsePromise;
021import static org.forgerock.json.JsonValue.field;
022import static org.forgerock.json.JsonValue.object;
023import static org.forgerock.openig.el.Bindings.bindings;
024import static org.forgerock.openig.http.Responses.newInternalServerError;
025import static org.forgerock.openig.util.JsonValues.asExpression;
026import static org.forgerock.openig.util.JsonValues.evaluateJsonStaticExpression;
027import static org.forgerock.openig.util.StringUtil.trailingSlash;
028import static org.forgerock.util.Reject.checkNotNull;
029
030import java.io.IOException;
031import java.net.URI;
032import java.net.URISyntaxException;
033import java.util.Map;
034
035import org.forgerock.http.Filter;
036import org.forgerock.http.Handler;
037import org.forgerock.http.handler.Handlers;
038import org.forgerock.http.protocol.Request;
039import org.forgerock.http.protocol.Response;
040import org.forgerock.http.protocol.Status;
041import org.forgerock.openig.el.Expression;
042import org.forgerock.openig.heap.GenericHeapObject;
043import org.forgerock.openig.heap.GenericHeaplet;
044import org.forgerock.openig.heap.HeapException;
045import org.forgerock.services.context.Context;
046import org.forgerock.util.AsyncFunction;
047import org.forgerock.util.promise.NeverThrowsException;
048import org.forgerock.util.promise.Promise;
049
050/**
051 * A {@link TokenTransformationFilter} is responsible to transform a token issued by OpenAM
052 * into a token of another type.
053 *
054 * <p>Currently only the OpenID Connect id_token to SAML 2.0 Token (Assertions) is supported, {@literal BEARER} mode.
055 *
056 * <pre>
057 *     {@code {
058 *         "type": "TokenTransformationFilter",
059 *         "config": {
060 *             "openamUri": "https://openam.example.com/openam/",
061 *             "realm": "/my-realm",
062 *             "username": "${attributes.username}",
063 *             "password": "${attributes.password}",
064 *             "idToken": "${attributes.id_token}",
065 *             "target": "${attributes.saml_assertions}",
066 *             "instance": "oidc-to-saml",
067 *             "amHandler": "#Handler"
068 *         }
069 *     }
070 *     }
071 * </pre>
072 *
073 * <p>The {@literal openamUri} attribute is the OpenAM base URI against which authentication
074 * and STS requests will be issued.
075 *
076 * <p>The {@literal realm} attribute is the OpenAM realm that contains both the subject
077 * (described through {@literal username} and {@literal password} attributes) and the STS
078 * instance (described with {@literal instance}).
079 *
080 * <p>The {@literal idToken} attribute is an {@link Expression} specifying where to get the JWT id_token.
081 * Note that the referenced value has to be a {@code String} (the JWT encoded value).
082 *
083 * <p>The {@literal target} attribute is an {@link Expression} specifying where to place the
084 * result of the transformation. Note that the pointed location will contains a {@code String}.
085 *
086 * <p>The {@literal instance} attribute is the name of an STS instance: a pre-configured transformation available
087 * under a specific REST endpoint.
088 *
089 * <p>The {@literal amHandler} attribute is a reference to a {@link Handler} heap object. That handler will be used
090 * for all REST calls to OpenAM (as opposed to the {@code next} Handler of the filter method that is dedicated to
091 * continue the execution flow through the chain).
092 *
093 * <p>If errors are happening during the token transformation, the error response is returned as-is to the caller,
094 * and informative messages are being logged for the administrator.
095 */
096public class TokenTransformationFilter extends GenericHeapObject implements Filter {
097
098    private final Handler handler;
099    private final URI endpoint;
100    private final Expression<String> idToken;
101    private final Expression<String> target;
102
103    /**
104     * Constructs a new TokenTransformationFilter transforming the OpenID Connect id_token from {@code idToken}
105     * into a SAML 2.0 Assertions structure (into {@code target}).
106     *
107     * @param handler pipeline used to send the STS transformation request
108     * @param endpoint Fully qualified URI of the STS instance (including the {@literal _action=translate} query string)
109     * @param idToken Expression for reading OpenID Connect id_token (expects a {@code String})
110     * @param target Expression for writing SAML 2.0 token (expects a {@code String})
111     */
112    public TokenTransformationFilter(final Handler handler,
113                                     final URI endpoint,
114                                     final Expression<String> idToken,
115                                     final Expression<String> target) {
116        this.handler = checkNotNull(handler);
117        this.endpoint = checkNotNull(endpoint);
118        this.idToken = checkNotNull(idToken);
119        this.target = checkNotNull(target);
120    }
121
122    @Override
123    public Promise<Response, NeverThrowsException> filter(final Context context,
124                                                          final Request request,
125                                                          final Handler next) {
126
127        final String resolvedIdToken = idToken.eval(bindings(context, request));
128        if (resolvedIdToken == null) {
129            logger.error(format("OpenID Connect id_token expression (%s) has evaluated to null", idToken));
130            return newResponsePromise(newInternalServerError());
131        }
132
133        return handler.handle(context, transformationRequest(resolvedIdToken))
134                      .thenAsync(processIssuedToken(context, request, next));
135    }
136
137    private AsyncFunction<Response, Response, NeverThrowsException> processIssuedToken(final Context context,
138                                                                                       final Request request,
139                                                                                       final Handler next) {
140        return new AsyncFunction<Response, Response, NeverThrowsException>() {
141            @Override
142            public Promise<Response, NeverThrowsException> apply(final Response response) {
143                try {
144                    Map<String, Object> json = parseJsonObject(response);
145                    if (response.getStatus() != Status.OK) {
146                        logger.error(format("Server side error (%s, %s) while transforming id_token:%s",
147                                            response.getStatus(),
148                                            json.get("reason"),
149                                            json.get("message")));
150                        return newResponsePromise(new Response(Status.BAD_GATEWAY));
151                    }
152
153                    String token = (String) json.get("issued_token");
154                    if (token == null) {
155                        // Unlikely to happen, since this is an OK response
156                        logger.error("STS issued_token is null");
157                        return newResponsePromise(newInternalServerError());
158                    }
159                    target.set(bindings(context, request, response), token);
160
161                    // Forward the initial request
162                    return next.handle(context, request);
163                } catch (IOException e) {
164                    logger.error(format("Can't get JSON back from %s", endpoint));
165                    logger.error(e);
166                    return newResponsePromise(newInternalServerError(e));
167                }
168            }
169
170            @SuppressWarnings("unchecked")
171            private Map<String, Object> parseJsonObject(final Response response) throws IOException {
172                return (Map<String, Object>) response.getEntity().getJson();
173            }
174        };
175    }
176
177    private Request transformationRequest(final String resolvedIdToken) {
178        return new Request().setUri(endpoint)
179                            .setMethod("POST")
180                            .setEntity(transformation(resolvedIdToken));
181    }
182
183    private static Object transformation(String idToken) {
184        return object(field("input_token_state", object(field("token_type", "OPENIDCONNECT"),
185                                                        field("oidc_id_token", idToken))),
186                      field("output_token_state", object(field("token_type", "SAML2"),
187                                                         field("subject_confirmation", "BEARER"))));
188    }
189
190    /** Creates and initializes a token transformation filter in a heap environment. */
191    public static class Heaplet extends GenericHeaplet {
192
193        @Override
194        public Object create() throws HeapException {
195            Handler amHandler = heap.resolve(config.get("amHandler").required(),
196                                             Handler.class);
197            URI baseUri = getOpenamBaseUri();
198            String realm = config.get("realm").defaultTo("/").asString();
199            String ssoTokenHeader = config.get("ssoTokenHeader").asString();
200            Expression<String> username = asExpression(config.get("username").required(), String.class);
201            Expression<String> password = asExpression(config.get("password").required(), String.class);
202            SsoTokenFilter ssoTokenFilter = new SsoTokenFilter(amHandler,
203                                                               baseUri,
204                                                               realm,
205                                                               ssoTokenHeader,
206                                                               username,
207                                                               password);
208
209            Expression<String> idToken = asExpression(config.get("idToken").required(), String.class);
210            Expression<String> target = asExpression(config.get("target").required(), String.class);
211
212            String instance = evaluateJsonStaticExpression(config.get("instance").required()).asString();
213
214            return new TokenTransformationFilter(Handlers.chainOf(amHandler, ssoTokenFilter),
215                                                 transformationEndpoint(baseUri, realm, instance),
216                                                 idToken,
217                                                 target);
218        }
219
220        private URI getOpenamBaseUri() throws HeapException {
221            String baseUri = config.get("openamUri").required().asString();
222            try {
223                return new URI(trailingSlash(baseUri));
224            } catch (URISyntaxException e) {
225                throw new HeapException(format("Cannot append trailing '/' on %s", baseUri), e);
226            }
227        }
228
229        private static URI transformationEndpoint(final URI baseUri, final String realm, final String instance)
230                throws HeapException {
231            try {
232                StringBuilder sb = new StringBuilder("rest-sts");
233                if (!realm.startsWith("/")) {
234                    sb.append("/");
235                }
236                sb.append(realm);
237                if (!realm.endsWith("/") && !instance.startsWith("/")) {
238                    sb.append("/");
239                }
240                sb.append(instance);
241                sb.append("?_action=translate");
242
243                return baseUri.resolve(new URI(sb.toString()));
244            } catch (URISyntaxException e) {
245                throw new HeapException("Can't build STS endpoint URI", e);
246            }
247        }
248    }
249}