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 const string SCHEMA_KEY_PROFILE_ID = "shotwell-profile-id"; private const string SCHEMA_KEY_ACCOUNTNAME = "accountname"; 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() { if (this.load_error != null) { this.error (); return; } try { var uri = GLib.Uri.parse(get_view().get_uri(), UriFlags.NONE); if (uri.get_scheme() == REVERSE_CLIENT_ID && this.auth_code == null) { var form_data = Soup.Form.decode (uri.get_query()); this.auth_code = form_data.lookup("code"); } } catch (Error err) { debug ("Failed to parse auth code from URI %s: %s", get_view().get_uri(), err.message); } if (this.auth_code != null) { this.authorized(this.auth_code); } } private void on_shotwell_auth_request_cb(WebKit.URISchemeRequest request) { try { var uri = GLib.Uri.parse(request.get_uri(), GLib.UriFlags.NONE); debug("URI: %s", request.get_uri()); var form_data = Soup.Form.decode (uri.get_query()); this.auth_code = form_data.lookup("code"); } catch (Error err) { debug("Failed to parse request URI: %s", err.message); } var response = ""; var mins = new MemoryInputStream.from_data(response.data); 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://oauth2.googleapis.com/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://oauth2.googleapis.com/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 const string PASSWORD_SCHEME = "org.gnome.Shotwell.Google"; private string scope = null; // Prepare for multiple user accounts private string accountname = "default"; 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; private Secret.Schema? schema = 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; this.schema = new Secret.Schema(PASSWORD_SCHEME, Secret.SchemaFlags.NONE, SCHEMA_KEY_PROFILE_ID, Secret.SchemaAttributeType.STRING, SCHEMA_KEY_ACCOUNTNAME, Secret.SchemaAttributeType.STRING, "scope", Secret.SchemaAttributeType.STRING); } public void authenticate() { string? refresh_token = null; try { refresh_token = Secret.password_lookup_sync(this.schema, null, SCHEMA_KEY_PROFILE_ID, host.get_current_profile_id(), SCHEMA_KEY_ACCOUNTNAME, this.accountname, "scope", this.scope); } catch (Error err) { critical("Failed to lookup refresh_token from password store: %s", err.message); } if (refresh_token != null && refresh_token != "") { on_refresh_token_available(refresh_token); do_exchange_refresh_token_for_access_token.begin(); 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(); try { Secret.password_clear_sync(this.schema, null, SCHEMA_KEY_PROFILE_ID, host.get_current_profile_id(), SCHEMA_KEY_ACCOUNTNAME, this.accountname, "scope", this.scope); } catch (Error err) { critical("Failed to remove password for scope %s: %s", this.scope, err.message); } } public void refresh() { // TODO: Needs to re-auth } public void set_accountname(string accountname) { this.accountname = accountname; } 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=" + GLib.Uri.escape_string(OAUTH_CALLBACK_URI, null) + "&" + "scope=" + GLib.Uri.escape_string(this.scope, null) + "+" + GLib.Uri.escape_string("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); web_auth_pane.error.connect(on_web_auth_pane_error); 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.begin(auth_code); } private void on_web_auth_pane_error() { host.post_error(web_auth_pane.load_error); } private async 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); try { yield tokens_txn.execute_async(); debug("EVENT: network transaction to exchange authorization code for access tokens " + "completed successfully."); do_extract_tokens(tokens_txn.get_response()); } catch (Error err) { debug("EVENT: network transaction to exchange authorization code for access tokens " + "failed; response = '%s'", tokens_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.begin(); } private async 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); try { yield txn.execute_async(); debug("EVENT: username fetch transaction completed successfully."); do_extract_username(txn.get_response()); } catch (Error err) { 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()); try { Secret.password_store_sync(this.schema, Secret.COLLECTION_DEFAULT, "Shotwell publishing (Google account scope %s@%s)".printf(this.accountname, this.scope), session.refresh_token, null, SCHEMA_KEY_PROFILE_ID, host.get_current_profile_id(), SCHEMA_KEY_ACCOUNTNAME, this.accountname, "scope", this.scope); } catch (Error err) { critical("Failed to look up password for scope %s: %s", this.scope, err.message); } this.authenticated(); web_auth_pane.clear(); } private async 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); try { yield txn.execute_async(); 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()); } catch (Error err) { 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 try { Secret.password_clear_sync(this.schema, null, SCHEMA_KEY_PROFILE_ID, host.get_current_profile_id(), SCHEMA_KEY_ACCOUNTNAME, this.accountname, "scope", this.scope); } catch (Error err) { critical("Failed to remove password for accountname@scope %s@%s: %s", this.accountname, this.scope, err.message); } Idle.add (() => { this.authenticate(); return false; }); } web_auth_pane.clear(); 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(); } } }