]> www.infradead.org Git - users/dwmw2/openconnect.git/commitdiff
AnyConnect: Add support for external browser SSO
authorDavid Woodhouse <dwmw2@infradead.org>
Thu, 7 Apr 2022 11:30:21 +0000 (12:30 +0100)
committerDavid Woodhouse <dwmw2@infradead.org>
Mon, 11 Apr 2022 13:50:18 +0000 (14:50 +0100)
For external browser SSO we need to listen on a local port to accept the
encoded token from the browser, as it's passed to us via a redirect to
http://localhost:29786/api/sso/<base64>

This implements a simple listening loop, accepting connections and decoding
the blob we get back.

Signed-off-by: David Woodhouse <dwmw2@infradead.org>
Makefile.am
auth.c
hpke.c [new file with mode: 0644]
library.c
openconnect-internal.h
www/changelog.xml

index eea39a3ca1eb703f5256ea74de3e23d578036e06..c4c10ab4d133fe7bde2681395705e93afe547936 100644 (file)
@@ -35,7 +35,7 @@ if OPENCONNECT_WIN32
 openconnect_SOURCES += openconnect.rc
 endif
 library_srcs = ssl.c http.c textbuf.c http-auth.c auth-common.c auth-html.c library.c compat.c lzs.c mainloop.c script.c ntlm.c digest.c mtucalc.c openconnect-internal.h
-lib_srcs_cisco = auth.c cstp.c
+lib_srcs_cisco = auth.c cstp.c hpke.c
 lib_srcs_juniper = oncp.c lzo.c auth-juniper.c
 lib_srcs_pulse = pulse.c
 lib_srcs_globalprotect = gpst.c win32-ipicmp.h auth-globalprotect.c
diff --git a/auth.c b/auth.c
index ad07b2a55e89e879ba806ade81656fc00b99c662..72cf7cb4a139403ff32d6ae3342cbc31fe37bec9 100644 (file)
--- a/auth.c
+++ b/auth.c
@@ -428,6 +428,7 @@ static int parse_auth_node(struct openconnect_info *vpninfo, xmlNode *xml_node,
                xmlnode_get_text(xml_node, "sso-v2-login-final", &vpninfo->sso_login_final);
                xmlnode_get_text(xml_node, "sso-v2-token-cookie-name", &vpninfo->sso_token_cookie);
                xmlnode_get_text(xml_node, "sso-v2-error-cookie-name", &vpninfo->sso_error_cookie);
+               xmlnode_get_text(xml_node, "sso-v2-browser-mode", &vpninfo->sso_browser_mode);
 
                if (xmlnode_is_named(xml_node, "form")) {
 
@@ -809,6 +810,12 @@ static xmlDocPtr xmlpost_new_query(struct openconnect_info *vpninfo, const char
        if (!node)
                goto bad;
 
+#ifdef HAVE_HPKE_SUPPORT
+       node = xmlNewTextChild(capabilities, NULL, XCAST("auth-method"), XCAST("single-sign-on-external-browser"));
+       if (!node)
+               goto bad;
+#endif
+
        *rootp = root;
        return doc;
 
diff --git a/hpke.c b/hpke.c
new file mode 100644 (file)
index 0000000..aa40057
--- /dev/null
+++ b/hpke.c
@@ -0,0 +1,318 @@
+/*
+ * OpenConnect (SSL + DTLS) VPN client
+ *
+ * Copyright © 2022 David Woodhouse <dwmw2@infradead.org>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public License
+ * version 2.1, as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ */
+
+#include <config.h>
+
+#include "openconnect-internal.h"
+
+#ifndef HAVE_HPKE_SUPPORT
+int handle_external_browser(struct openconnect_info *vpninfo)
+{
+       return -EINVAL;
+}
+#else
+
+#include <ctype.h>
+
+#define HPKE_TAG_PUBKEY                1
+#define HPKE_TAG_AEAD_TAG      2
+#define HPKE_TAG_CIPHERTEXT    3
+#define HPKE_TAG_IV            4
+/*
+ * Hard-coded HTTP responses
+ */
+static const char response_404[] =
+       "HTTP/1.1 404 Not Found\r\n"
+       "Connection: close\r\n"
+       "Content-Type: text/html\r\n"
+       "Content-Length: 0\r\n\r\n";
+
+static const char response_302[] =
+       "HTTP/1.1 302 Found\r\n"
+       "Connection: close\r\n"
+       "Content-Type: text/html\r\n"
+       "Content-Length: 0\r\n"
+       "Location: %s\r\n\r\n";
+
+static const char response_200[] =
+       "HTTP/1.1 200 OK\r\n"
+       "Connection: close\r\n"
+       "Content-Type: text/html\r\n\r\n"
+       "<html><title>Success</title><body>Success</body></html>\r\n";
+
+
+/*
+ * If we use an external browser where we can't just snoop for cookies
+ * or completion... how do we get the results back? Cisco's answer:
+ * We run an HTTP server on http://localhost:29786/ and listen for
+ * a GET request to /api/sso/<base64 blob>?return=<finalurl>. It
+ * returns a redirect to that final URL, which is a pretty 'success'
+ * page. And decodes the base64 blob to obtain the SSO token, qv.
+ */
+int handle_external_browser(struct openconnect_info *vpninfo)
+{
+       int ret = 0;
+       struct sockaddr_in6 sin6 = { };
+       sin6.sin6_family = AF_INET6;
+       sin6.sin6_port = htons(29786);
+       sin6.sin6_addr = in6addr_loopback;
+
+       int listen_fd = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
+       if (listen_fd < 0) {
+               char *errstr;
+       sockerr:
+#ifdef _WIN32
+               errstr = openconnect__win32_strerror(WSAGetLastError());
+#else
+               errstr = strerror(errno);
+#endif
+
+               vpn_progress(vpninfo, PRG_ERR,
+                            _("Failed to listen on local port 29786: %s\n"),
+                            errstr);
+#ifdef _WIN32
+               free(errstr);
+#endif
+               if (listen_fd >= 0)
+                       closesocket(listen_fd);
+               return -EIO;
+       }
+
+       int optval = 1;
+       setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
+
+       if (bind(listen_fd, (void *)&sin6, sizeof(sin6)) < 0)
+               goto sockerr;
+
+       if (listen(listen_fd, 1))
+               goto sockerr;
+
+       if (set_sock_nonblock(listen_fd))
+               goto sockerr;
+
+
+       /* Now that we are listening on the socket, we can spawn the browser...
+          or just tell the user to, for now */
+       vpn_progress(vpninfo, PRG_ERR,
+                    _("Point browser to this URL:\n%s\n"),
+                    vpninfo->sso_login);
+
+       char *returl = NULL;
+       struct oc_text_buf *b64_buf = NULL;
+
+       /* There may be other stray connections. Repeat until we have one
+        * that looks like the actual auth attempt from the browser. */
+       while (1) {
+               int accept_fd = cancellable_accept(vpninfo, listen_fd);
+               if (accept_fd < 0) {
+                       ret = accept_fd;
+                       goto out;
+               }
+               vpn_progress(vpninfo, PRG_TRACE,
+                            _("Accepted incoming external-browser connection on port 29786\n"));
+
+               char line[4096];
+               ret = cancellable_gets(vpninfo, accept_fd, line, sizeof(line));
+               if (ret < 15 || strncmp(line, "GET /", 5) ||
+                   strncmp(line + ret - 9, " HTTP/1.", 8)) {
+                       vpn_progress(vpninfo, PRG_TRACE,
+                                    _("Invalid incoming external-browser request\n"));
+                       closesocket(accept_fd);
+                       continue;
+               }
+               if (strncmp(line, "GET /api/sso/", 13)) {
+               give_404:
+                       cancellable_send(vpninfo, accept_fd, response_404, sizeof(response_404) - 1);
+                       closesocket(accept_fd);
+                       continue;
+               }
+
+               /*
+                * OK, now we have a "GET /api/sso/… HTTP/1.x" that looks sane.
+                * Kill the " HTTP/1.x" at the end.
+                * */
+               line[ret - 9] = 0;
+
+               /* Scan for ?return= (and other params that shouldn't be there) */
+               char *b64 = line + 13;
+               char *q = strchr(b64, '?');
+               while (q) {
+                       *q = 0;
+                       q++;
+                       if (!strncmp(q, "return=", 7))
+                               returl = q + 7;
+                       q = strchr(q, '&');
+               }
+
+               /* Attempt to decode the base64 */
+               urldecode_inplace(b64);
+               b64_buf = buf_alloc();
+               if (!b64_buf) {
+                       ret = -ENOMEM;
+                       closesocket(accept_fd);
+                       goto out;
+               }
+
+               b64_buf->data = openconnect_base64_decode(&ret, b64);
+               if (ret < 0) {
+                       /* If the final part of the URL after /api/sso/ is not
+                        * valid base64, give a 404 and wait for a valid req. */
+                       buf_free(b64_buf);
+                       b64_buf = NULL;
+                       goto give_404;
+               }
+               b64_buf->pos = b64_buf->buf_len = ret;
+
+               /* Decode and store the returl (since we'll reuse the line buf) */
+               if (returl) {
+                       urldecode_inplace(returl);
+                       returl = strdup(returl);
+               }
+
+               /* Now consume the rest of the HTTP request lines */
+               while (cancellable_gets(vpninfo, accept_fd, line, sizeof(line)) > 0) {
+                       vpn_progress(vpninfo, PRG_DEBUG,
+                                    "< %s\n", line);
+               }
+
+               /* Finally, send the response to redirect to the success page */
+               if (returl) {
+                       line[sizeof(line) - 1] = 0;
+                       ret = snprintf(line, sizeof(line) - 1, response_302, returl);
+                       ret = cancellable_send(vpninfo, accept_fd, line, ret);
+                       free(returl);
+                       returl = NULL;
+               } else {
+                       ret = cancellable_send(vpninfo, accept_fd, response_200, sizeof(response_200) - 1);
+               }
+               closesocket(accept_fd);
+               if (ret < 0)
+                       goto out_b64;
+
+               break;
+       }
+
+       vpn_progress(vpninfo, PRG_DEBUG, _("Got encrypted SSO token of %d bytes\n"),
+                    b64_buf->pos);
+
+       /* Example encrypted token:
+          < 0000:  00 01 00 01 00 5b 30 59  30 13 06 07 2a 86 48 ce  |.....[0Y0...*.H.|
+          < 0010:  3d 02 01 06 08 2a 86 48  ce 3d 03 01 07 03 42 00  |=....*.H.=....B.|
+          < 0020:  04 fa 42 63 40 b6 f4 a6  02 9a dd 57 f5 8c 74 3e  |..Bc@......W..t>|
+          < 0030:  11 82 18 8d 78 c4 b5 13  d0 c7 c0 d7 f9 79 6c 16  |....x........yl.|
+          < 0040:  e9 bc 30 fa f0 ea 09 8d  17 d1 84 e4 08 55 31 28  |..0..........U1(|
+          < 0050:  a6 62 e4 6d c5 7c be 19  d9 14 41 37 20 6e 4c ce  |.b.m.|....A7 nL.|
+          < 0060:  2c 00 02 00 0c ac 81 ce  79 56 6e 4c 00 cc 9b e3  |,.......yVnL....|
+          < 0070:  d0 00 03 00 17 bb 4d 2e  57 61 c0 90 58 86 86 79  |......M.Wa..X..y|
+          < 0080:  64 05 28 0c c9 f2 c8 c2  2a 2e fb 5c 00 04 00 0c  |d.(.....*..\....|
+          < 0090:  2c 03 5f 13 3c b7 27 7e  36 fe 5a b8              |,._.<.'~6.Z.|
+
+          This contains the server's DH pubkey (type 1) at 0x0008,
+          the AEAD tag (type 2) at 0x0065, the ciphertext (type 3) at 0x0075
+          and the IV (type 4) at 0x0090.
+       */
+
+       /* tagdata[0] is unused because I can't be doing with all that
+        * (HPKE_TAG_IV-1) nonsense. */
+       struct {
+               void *p;
+               int len;
+       } tagdata[HPKE_TAG_IV + 1];
+
+       memset(tagdata, 0, sizeof(tagdata));
+
+       int pos = 0;
+       ret = 0;
+       while (pos < b64_buf->buf_len) {
+               uint16_t tag, len;
+               if (pos + 4 > b64_buf->pos) {
+                       ret = -EINVAL;
+                       break;
+               }
+
+               tag = load_be16(b64_buf->data + pos);
+               len = load_be16(b64_buf->data + pos + 2);
+
+               /* Special case, first word must be 0x0001 before the TLVs start */
+               if (!pos) {
+                       if (tag != 0x0001) {
+                               ret = -EINVAL;
+                               break;
+                       }
+                       pos += 2;
+                       continue;
+               }
+
+               if (tag < HPKE_TAG_PUBKEY || tag > HPKE_TAG_IV ||
+                   tagdata[tag].p || pos + 4 + len > b64_buf->pos) {
+                       ret = -EINVAL;
+                       break;
+               }
+
+               tagdata[tag].p = b64_buf->data + pos + 4;
+               tagdata[tag].len = len;
+               pos += len + 4;
+       }
+
+       if (!tagdata[HPKE_TAG_PUBKEY].p || !tagdata[HPKE_TAG_CIPHERTEXT].p ||
+           !tagdata[HPKE_TAG_AEAD_TAG].p || tagdata[HPKE_TAG_AEAD_TAG].len != 12 ||
+           !tagdata[HPKE_TAG_IV].p || tagdata[HPKE_TAG_IV].len != 12)
+               ret = -EINVAL;
+
+       if (ret) {
+               vpn_progress(vpninfo, PRG_ERR, _("Failed to decode SSO token at %d:\n"),
+                            pos);
+               dump_buf_hex(vpninfo, PRG_ERR, '<', (void *)b64_buf->data, b64_buf->pos);
+               goto out_b64;
+       }
+
+       unsigned char secret[32];
+       ret = ecdh_compute_secp256r1(vpninfo, tagdata[HPKE_TAG_PUBKEY].p,
+                                    tagdata[HPKE_TAG_PUBKEY].len, secret);
+       if (ret)
+               goto out_b64;
+
+       ret = hkdf_sha256_extract_expand(vpninfo, secret, "AC_ECIES", 8);
+       if (ret)
+               goto out_b64;
+
+       unsigned char *token = tagdata[HPKE_TAG_CIPHERTEXT].p;
+       int token_len = tagdata[HPKE_TAG_CIPHERTEXT].len;
+       ret = aes_256_gcm_decrypt(vpninfo, secret, token, token_len,
+                                 tagdata[HPKE_TAG_IV].p, tagdata[HPKE_TAG_AEAD_TAG].p);
+       if (ret)
+               goto out_b64;
+
+       int i;
+       for (i = 0; i < token_len; i++) {
+               if (!isalnum(token[i])) {
+                       vpn_progress(vpninfo, PRG_ERR,
+                                    _("SSO token not alphanumeric\n"));
+                       ret = -EINVAL;
+                       goto out_b64;
+               }
+       }
+
+       vpninfo->sso_cookie_value = strndup((char *)token, token_len);
+       if (!vpninfo->sso_cookie_value)
+               ret = -ENOMEM;
+
+ out_b64:
+       buf_free(b64_buf);
+ out:
+       closesocket(listen_fd);
+       return ret;
+}
+#endif /* HAVE_HPKE_SUPPORT */
index cd5fd9ee50b28e2c60b57d9fdff5b08b77e912db..969c8b8eab23b75dde82437de48260dc1762e43a 100644 (file)
--- a/library.c
+++ b/library.c
@@ -593,6 +593,15 @@ void openconnect_vpninfo_free(struct openconnect_info *vpninfo)
        free_strap_keys(vpninfo);
        free(vpninfo->strap_pubkey);
        free(vpninfo->strap_dh_pubkey);
+
+       free(vpninfo->sso_username);
+       free(vpninfo->sso_cookie_value);
+       free(vpninfo->sso_browser_mode);
+       free(vpninfo->sso_login);
+       free(vpninfo->sso_login_final);
+       free(vpninfo->sso_error_cookie);
+       free(vpninfo->sso_token_cookie);
+
        free(vpninfo->ppp);
        buf_free(vpninfo->ppp_tls_connect_req);
        buf_free(vpninfo->ppp_dtls_connect_req);
@@ -1613,28 +1622,40 @@ retry:
                nuke_opt_values(form->opts);
 
        if (do_sso) {
-               if (vpninfo->open_webview) {
-                       free(vpninfo->sso_cookie_value);
-                       free(vpninfo->sso_username);
-                       vpninfo->sso_cookie_value = NULL;
-                       vpninfo->sso_username = NULL;
+               free(vpninfo->sso_cookie_value);
+               free(vpninfo->sso_username);
+               vpninfo->sso_cookie_value = NULL;
+               vpninfo->sso_username = NULL;
+
+               /* Handle the special Cisco external browser mode */
+               if (vpninfo->sso_browser_mode && !strcmp(vpninfo->sso_browser_mode, "external")) {
+                       ret = handle_external_browser(vpninfo);
+               } else if (vpninfo->open_webview) {
                        ret = vpninfo->open_webview(vpninfo, vpninfo->sso_login, vpninfo->cbdata);
+               } else {
+                       vpn_progress(vpninfo, PRG_ERR,
+                                    _("No SSO handler\n")); /* XX: print more debugging info */
+                       ret = -EINVAL;
+               }
+               if (!ret) {
                        for (opt = form->opts; opt; opt = opt->next) {
                                if (opt->type == OC_FORM_OPT_SSO_TOKEN) {
                                        free(opt->_value);
                                        opt->_value = vpninfo->sso_cookie_value;
+                                       vpninfo->sso_cookie_value = NULL;
                                } else if (opt->type == OC_FORM_OPT_SSO_USER) {
                                        free(opt->_value);
                                        opt->_value = vpninfo->sso_username;
+                                       vpninfo->sso_username = NULL;
                                }
                        }
-                       vpninfo->sso_username = NULL;
-                       vpninfo->sso_cookie_value = NULL;
-               } else {
-                       vpn_progress(vpninfo, PRG_ERR,
-                                    _("No SSO handler\n")); /* XX: print more debugging info */
-                       ret = -EINVAL;
                }
+               free(vpninfo->sso_username);
+               vpninfo->sso_username = NULL;
+               free(vpninfo->sso_cookie_value);
+               vpninfo->sso_cookie_value = NULL;
+               free(vpninfo->sso_browser_mode);
+               vpninfo->sso_browser_mode = NULL;
        }
 
        return ret;
index 449d29403854fb178cd18dac892cf006fe5fe17d..629136cc59806f288fb5f2ea67d44f9e80e949df 100644 (file)
@@ -773,6 +773,7 @@ struct openconnect_info {
        char *sso_token_cookie;
        char *sso_error_cookie;
        char *sso_cookie_value;
+       char *sso_browser_mode;
 
        int verbose;
        void *cbdata;
@@ -1598,6 +1599,9 @@ int process_auth_form(struct openconnect_info *vpninfo, struct oc_auth_form *for
 /* This is private for now since we haven't yet worked out what the API will be */
 void openconnect_set_juniper(struct openconnect_info *vpninfo);
 
+/* hpke.c */
+int handle_external_browser(struct openconnect_info *vpninfo);
+
 /* version.c */
 extern const char openconnect_version_str[];
 
index 5d078f93f596e50ac3ffaae5560a74b939d7d502..7aa1527a6b287bc4d37076d9769a911266314102 100644 (file)
@@ -15,6 +15,7 @@
 <ul>
    <li><b>OpenConnect HEAD</b>
      <ul>
+       <li>Add support for AnyConnect "external browser" SSO mode (<a href="https://gitlab.com/openconnect/openconnect/-/merge_requests/354">!354</a>).</li>
        <li>On Windows, fix crash on tunnel setup. (<a href="https://gitlab.com/openconnect/openconnect/-/issues/370">#370</a>, <a href="https://gitlab.com/openconnect/openconnect/commit/6a2ffbbcd1c4ef0b689cce3d17154f6d4c2e3bc0">6a2ffbb</a>)</li>
        <li>Bugfix RSA SecurID token decryption and PIN entry forms, broken in v8.20. (<a href="https://gitlab.com/openconnect/openconnect/-/issues/388">#388</a>, <a href="https://gitlab.com/openconnect/openconnect/-/merge_requests/344">!344</a>)</li>
      </ul><br/>