using Shotwell; using Shotwell.Plugins; namespace Publishing.Authenticator.Shotwell.Google { private const string OAUTH_CLIENT_ID = "534227538559-hvj2e8bj0vfv2f49r7gvjoq6jibfav67.apps.googleusercontent.com"; private const string REVERSE_CLIENT_ID = "com.googleusercontent.apps.534227538559-hvj2e8bj0vfv2f49r7gvjoq6jibfav67"; private const string OAUTH_CLIENT_SECRET = "pwpzZ7W1TCcD5uIfYCu8sM7x"; private const string OAUTH_CALLBACK_URI = REVERSE_CLIENT_ID + ":/auth-callback"; private class WebAuthenticationPane : Common.WebAuthenticationPane { public static bool cache_dirty = false; private string? auth_code = null; public signal void error(); public override void constructed() { base.constructed(); var ctx = WebKit.WebContext.get_default(); ctx.register_uri_scheme(REVERSE_CLIENT_ID, this.on_shotwell_auth_request_cb); } public override void on_page_load() { var uri = new Soup.URI(get_view().get_uri()); if (uri.scheme == REVERSE_CLIENT_ID && this.auth_code == null) { var form_data = Soup.Form.decode (uri.query); this.auth_code = form_data.lookup("code"); } if (this.auth_code != null) { this.authorized(this.auth_code); } } private void on_shotwell_auth_request_cb(WebKit.URISchemeRequest request) { var uri = new Soup.URI(request.get_uri()); debug("URI: %s", request.get_uri()); var form_data = Soup.Form.decode (uri.query); this.auth_code = form_data.lookup("code"); var response = ""; var mins = new MemoryInputStream.from_data(response.data, null); request.finish(mins, -1, "text/plain"); } public signal void authorized(string auth_code); public WebAuthenticationPane(string auth_sequence_start_url) { Object (login_uri : auth_sequence_start_url); } public static bool is_cache_dirty() { return cache_dirty; } } private class Session : Publishing.RESTSupport.Session { public string access_token = null; public string refresh_token = null; public int64 expires_at = -1; public override bool is_authenticated() { return (access_token != null); } public void deauthenticate() { access_token = null; refresh_token = null; expires_at = -1; } } private class GetAccessTokensTransaction : Publishing.RESTSupport.Transaction { private const string ENDPOINT_URL = "https://accounts.google.com/o/oauth2/token"; public GetAccessTokensTransaction(Session session, string auth_code) { base.with_endpoint_url(session, ENDPOINT_URL); add_argument("code", auth_code); add_argument("client_id", OAUTH_CLIENT_ID); add_argument("client_secret", OAUTH_CLIENT_SECRET); add_argument("redirect_uri", OAUTH_CALLBACK_URI); add_argument("grant_type", "authorization_code"); } } private class RefreshAccessTokenTransaction : Publishing.RESTSupport.Transaction { private const string ENDPOINT_URL = "https://accounts.google.com/o/oauth2/token"; public RefreshAccessTokenTransaction(Session session) { base.with_endpoint_url(session, ENDPOINT_URL); add_argument("client_id", OAUTH_CLIENT_ID); add_argument("client_secret", OAUTH_CLIENT_SECRET); add_argument("refresh_token", session.refresh_token); add_argument("grant_type", "refresh_token"); } } private class UsernameFetchTransaction : Publishing.RESTSupport.Transaction { private const string ENDPOINT_URL = "https://www.googleapis.com/oauth2/v1/userinfo"; public UsernameFetchTransaction(Session session) { base.with_endpoint_url(session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.GET); add_header("Authorization", "Bearer " + session.access_token); } } internal class Google : Spit.Publishing.Authenticator, Object { private string scope = null; private Spit.Publishing.PluginHost host = null; private GLib.HashTable params = null; private WebAuthenticationPane web_auth_pane = null; private Session session = null; private string welcome_message = null; public Google(string scope, string welcome_message, Spit.Publishing.PluginHost host) { this.host = host; this.params = new GLib.HashTable(str_hash, str_equal); this.scope = scope; this.session = new Session(); this.welcome_message = welcome_message; } public void authenticate() { var refresh_token = host.get_config_string("refresh_token", null); if (refresh_token != null && refresh_token != "") { on_refresh_token_available(refresh_token); do_exchange_refresh_token_for_access_token(); return; } // FIXME: Find a way for a proper logout if (WebAuthenticationPane.is_cache_dirty()) { host.set_service_locked(false); host.install_static_message_pane(_("You have already logged in and out of a Google service during this Shotwell session.\n\nTo continue publishing to Google services, quit and restart Shotwell, then try publishing again.")); } else { this.do_show_service_welcome_pane(); } } public bool can_logout() { return true; } public GLib.HashTable get_authentication_parameter() { return this.params; } public void logout() { session.deauthenticate(); host.set_config_string("refresh_token", ""); } public void refresh() { // TODO: Needs to re-auth } private void do_hosted_web_authentication() { debug("ACTION: running OAuth authentication flow in hosted web pane."); string user_authorization_url = "https://accounts.google.com/o/oauth2/auth?" + "response_type=code&" + "client_id=" + OAUTH_CLIENT_ID + "&" + "redirect_uri=" + Soup.URI.encode(OAUTH_CALLBACK_URI, null) + "&" + "scope=" + Soup.URI.encode(this.scope, null) + "+" + Soup.URI.encode("https://www.googleapis.com/auth/userinfo.profile", null) + "&" + "state=connect&" + "access_type=offline&" + "approval_prompt=force"; web_auth_pane = new WebAuthenticationPane(user_authorization_url); web_auth_pane.authorized.connect(on_web_auth_pane_authorized); host.install_dialog_pane(web_auth_pane); } private void on_web_auth_pane_authorized(string auth_code) { web_auth_pane.authorized.disconnect(on_web_auth_pane_authorized); debug("EVENT: user authorized scope %s with auth_code %s", scope, auth_code); do_get_access_tokens(auth_code); } private void do_get_access_tokens(string auth_code) { debug("ACTION: exchanging authorization code for access & refresh tokens"); host.install_login_wait_pane(); GetAccessTokensTransaction tokens_txn = new GetAccessTokensTransaction(session, auth_code); tokens_txn.completed.connect(on_get_access_tokens_complete); tokens_txn.network_error.connect(on_get_access_tokens_error); try { tokens_txn.execute(); } catch (Spit.Publishing.PublishingError err) { host.post_error(err); } } private void on_get_access_tokens_complete(Publishing.RESTSupport.Transaction txn) { txn.completed.disconnect(on_get_access_tokens_complete); txn.network_error.disconnect(on_get_access_tokens_error); debug("EVENT: network transaction to exchange authorization code for access tokens " + "completed successfully."); do_extract_tokens(txn.get_response()); } private void on_get_access_tokens_error(Publishing.RESTSupport.Transaction txn, Spit.Publishing.PublishingError err) { txn.completed.disconnect(on_get_access_tokens_complete); txn.network_error.disconnect(on_get_access_tokens_error); debug("EVENT: network transaction to exchange authorization code for access tokens " + "failed; response = '%s'", txn.get_response()); host.post_error(err); } private void do_extract_tokens(string response_body) { debug("ACTION: extracting OAuth tokens from body of server response"); Json.Parser parser = new Json.Parser(); try { parser.load_from_data(response_body); } catch (Error err) { host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( "Couldn't parse JSON response: " + err.message)); return; } Json.Object response_obj = parser.get_root().get_object(); if ((!response_obj.has_member("access_token")) && (!response_obj.has_member("refresh_token"))) { host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( "neither access_token nor refresh_token not present in server response")); return; } if (response_obj.has_member("expires_in")) { var duration = response_obj.get_int_member("expires_in"); var abs_time = GLib.get_real_time() + duration * 1000L * 1000L; on_expiry_time_avilable(abs_time); } if (response_obj.has_member("refresh_token")) { string refresh_token = response_obj.get_string_member("refresh_token"); if (refresh_token != "") on_refresh_token_available(refresh_token); } if (response_obj.has_member("access_token")) { string access_token = response_obj.get_string_member("access_token"); if (access_token != "") on_access_token_available(access_token); } } private void on_refresh_token_available(string token) { debug("EVENT: an OAuth refresh token has become available; token = '%s'.", token); this.params.insert("RefreshToken", new Variant.string(token)); session.refresh_token = token; } private void on_expiry_time_avilable(int64 abs_time) { debug("EVENT: an OAuth access token expiry time became available; time = %'" + int64.FORMAT + "'.", abs_time); session.expires_at = abs_time; this.params.insert("ExpiryTime", new Variant.int64(abs_time)); } private void on_access_token_available(string token) { debug("EVENT: an OAuth access token has become available; token = '%s'.", token); session.access_token = token; this.params.insert("AccessToken", new Variant.string(token)); do_fetch_username(); } private void do_fetch_username() { debug("ACTION: running network transaction to fetch username."); host.install_login_wait_pane(); host.set_service_locked(true); UsernameFetchTransaction txn = new UsernameFetchTransaction(session); txn.completed.connect(on_fetch_username_transaction_completed); txn.network_error.connect(on_fetch_username_transaction_error); try { txn.execute(); } catch (Error err) { host.post_error(err); } } private void on_fetch_username_transaction_completed(Publishing.RESTSupport.Transaction txn) { txn.completed.disconnect(on_fetch_username_transaction_completed); txn.network_error.disconnect(on_fetch_username_transaction_error); debug("EVENT: username fetch transaction completed successfully."); do_extract_username(txn.get_response()); } private void on_fetch_username_transaction_error(Publishing.RESTSupport.Transaction txn, Spit.Publishing.PublishingError err) { txn.completed.disconnect(on_fetch_username_transaction_completed); txn.network_error.disconnect(on_fetch_username_transaction_error); debug("EVENT: username fetch transaction caused a network error"); host.post_error(err); } private void do_extract_username(string response_body) { debug("ACTION: extracting username from body of server response"); Json.Parser parser = new Json.Parser(); try { parser.load_from_data(response_body); } catch (Error err) { host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( "Couldn't parse JSON response: " + err.message)); return; } Json.Object response_obj = parser.get_root().get_object(); if (response_obj.has_member("name")) { string username = response_obj.get_string_member("name"); if (username != "") this.params.insert("UserName", new Variant.string(username)); } if (response_obj.has_member("access_token")) { string access_token = response_obj.get_string_member("access_token"); if (access_token != "") this.params.insert("AccessToken", new Variant.string(access_token)); } // by the time we get a username, the session should be authenticated, or else something // really tragic has happened assert(session.is_authenticated()); host.set_config_string("refresh_token", session.refresh_token); this.authenticated(); } private void do_exchange_refresh_token_for_access_token() { debug("ACTION: exchanging OAuth refresh token for OAuth access token."); host.install_login_wait_pane(); RefreshAccessTokenTransaction txn = new RefreshAccessTokenTransaction(session); txn.completed.connect(on_refresh_access_token_transaction_completed); txn.network_error.connect(on_refresh_access_token_transaction_error); try { txn.execute(); } catch (Spit.Publishing.PublishingError err) { host.post_error(err); } } private void on_refresh_access_token_transaction_completed(Publishing.RESTSupport.Transaction txn) { txn.completed.disconnect(on_refresh_access_token_transaction_completed); txn.network_error.disconnect(on_refresh_access_token_transaction_error); debug("EVENT: refresh access token transaction completed successfully."); if (session.is_authenticated()) // ignore these events if the session is already auth'd return; do_extract_tokens(txn.get_response()); } private void on_refresh_access_token_transaction_error(Publishing.RESTSupport.Transaction txn, Spit.Publishing.PublishingError err) { txn.completed.disconnect(on_refresh_access_token_transaction_completed); txn.network_error.disconnect(on_refresh_access_token_transaction_error); debug("EVENT: refresh access token transaction caused a network error."); if (session.is_authenticated()) // ignore these events if the session is already auth'd return; if (txn.get_status_code() == Soup.Status.BAD_REQUEST || txn.get_status_code() == Soup.Status.UNAUTHORIZED) { // Refresh token invalid, starting over host.set_config_string("refresh_token", ""); Idle.add (() => { this.authenticate(); return false; }); } host.post_error(err); } private void do_show_service_welcome_pane() { debug("ACTION: showing service welcome pane."); this.host.install_welcome_pane(this.welcome_message, on_service_welcome_login); } private void on_service_welcome_login() { debug("EVENT: user clicked 'Login' in welcome pane."); this.do_hosted_web_authentication(); } } }