← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~leonardr/launchpad/rename-grant-permissions into lp:launchpad

 

Leonard Richardson has proposed merging lp:~leonardr/launchpad/rename-grant-permissions into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)


This branch creates a new path for authorizing OAuth tokens. Now you can authorize your entire desktop with a single token, instead of authorizing individual applications (apport, Ground Control, etc.). Since the entire GNOME desktop forms a single security context, authorizing individual applications within it was aggravating users without providing any security benefit.

In the near future, this path will be the default path for launchpadlib desktop clients. The existing permission levels (READ_PUBLIC, etc.) will only be used when integrating a third-party website into Launchpad, or in desktop environments that have more fine-grained security policies than GNOME.

I took the GRANT_PERMISSIONS permission level, which was never used for anything, and repurposed it into the DESKTOP_INTEGRATION permission level. To get the GRANT_PERMISSIONS permission level, your OAuth consumer key must fit a specific format, giving the type of the desktop (eg. Ubuntu) and the human-readable name of the computer (eg. the hostname). Launchpad uses this information to present the end-user with a special message about integrating their entire desktop into Launchpad. As always, the end-user has the choice to grant or deny access.

There are a few edge cases: DESKTOP_INTEGRATION tokens can't accept any other permission level as a substitute, and you can't specify a callback URL with a DESKTOP_INTEGRATION token, because callback URLs are intended for integrating third-party websites into Launchpad, not desktop apps.

---

I would like a UI review of the new message and buttons presented during DESKTOP_INTEGRATION token signing. To see the changed UI, start up Launchpad locally, then run the following launchpadlib code in a terminal:

from launchpadlib.launchpad import Launchpad
l = Launchpad.login_with("Ubuntu desktop (Bob's Computer)", service_root="dev", allow_access_levels=["DESKTOP_INTEGRATION"])

A browser window will pop up and show you a warning about integrating your Ubuntu desktop with Launchpad.
-- 
https://code.launchpad.net/~leonardr/launchpad/rename-grant-permissions/+merge/36363
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~leonardr/launchpad/rename-grant-permissions into lp:launchpad.
=== modified file 'lib/canonical/launchpad/browser/oauth.py'
--- lib/canonical/launchpad/browser/oauth.py	2010-09-15 20:06:13 +0000
+++ lib/canonical/launchpad/browser/oauth.py	2010-09-22 19:29:46 +0000
@@ -11,7 +11,6 @@
 
 from lazr.restful import HTTPResource
 import simplejson
-from zope.authentication.interfaces import IUnauthenticatedPrincipal
 from zope.component import getUtility
 from zope.formlib.form import (
     Action,
@@ -31,15 +30,9 @@
     )
 from canonical.launchpad.webapp.authentication import (
     check_oauth_signature,
-    extract_oauth_access_token,
     get_oauth_authorization,
-    get_oauth_principal
-    )
-from canonical.launchpad.webapp.interfaces import (
-    AccessLevel,
-    ILaunchBag,
-    OAuthPermission,
-    )
+    )
+from canonical.launchpad.webapp.interfaces import OAuthPermission
 from lp.app.errors import UnexpectedFormData
 from lp.registry.interfaces.distribution import IDistributionSet
 from lp.registry.interfaces.pillar import IPillarNameSet
@@ -96,122 +89,52 @@
 
         token = consumer.newRequestToken()
         if self.request.headers.get('Accept') == HTTPResource.JSON_TYPE:
-            # Don't show the client the GRANT_PERMISSIONS access
+            # Don't show the client the DESKTOP_INTEGRATION access
             # level. If they have a legitimate need to use it, they'll
             # already know about it.
-            permissions = [permission for permission in OAuthPermission.items
-                           if permission != OAuthPermission.GRANT_PERMISSIONS]
+            permissions = [
+                permission for permission in OAuthPermission.items
+                if (permission != OAuthPermission.DESKTOP_INTEGRATION)
+                ]
             return self.getJSONRepresentation(
                 permissions, token, include_secret=True)
         return u'oauth_token=%s&oauth_token_secret=%s' % (
             token.key, token.secret)
 
-
 def token_exists_and_is_not_reviewed(form, action):
     return form.token is not None and not form.token.is_reviewed
 
 
+def token_review_success(form, action, data):
+    """The success callback for a button to approve a token."""
+    form.reviewToken(action.permission)
+
+
 def create_oauth_permission_actions():
-    """Return a list of `Action`s for each possible `OAuthPermission`."""
-    actions = Actions()
-    actions_excluding_grant_permissions = Actions()
-
-    def success(form, action, data):
-        form.reviewToken(action.permission)
-
+    """Return two `Actions` objects containing each possible `OAuthPermission`.
+
+    The first `Actions` object contains every action supported by the
+    OAuthAuthorizeTokenView. The second list contains a good default
+    set of actions, omitting special permissions like DESKTOP_INTEGRATION.
+    """
+    all_actions = Actions()
+    ordinary_actions = Actions()
     for permission in OAuthPermission.items:
         action = Action(
-            permission.title, name=permission.name, success=success,
+            permission.title, name=permission.name,
+            success=token_review_success,
             condition=token_exists_and_is_not_reviewed)
         action.permission = permission
-        actions.append(action)
-        if permission != OAuthPermission.GRANT_PERMISSIONS:
-            actions_excluding_grant_permissions.append(action)
-    return actions, actions_excluding_grant_permissions
-
-
-class CredentialManagerAwareMixin:
-    """A view for which a browser may authenticate with an OAuth token.
-
-    The OAuth token must be signed with a token that has the
-    GRANT_PERMISSIONS access level, and the browser must present
-    itself as the Launchpad Credentials Manager.
-    """
-    # A prefix identifying the Launchpad Credential Manager's
-    # User-Agent string.
-    GRANT_PERMISSIONS_USER_AGENT_PREFIX = "Launchpad Credentials Manager"
-
-    def ensureRequestIsAuthorizedOrSigned(self):
-        """Find the user who initiated the request.
-
-        This property is used by a view that wants to reject access
-        unless the end-user is authenticated with cookie auth, HTTP
-        Basic Auth, *or* a properly authorized OAuth token.
-
-        If the user is logged in with cookie auth or HTTP Basic, then
-        other parts of Launchpad have taken care of the login and we
-        don't have to do anything. But if the user's browser has
-        signed the request with an OAuth token, other parts of
-        Launchpad won't recognize that as an attempt to authorize the
-        request.
-
-        This method does the OAuth part of the work. It checks that
-        the OAuth token is valid, that it's got the correct access
-        level, and that the User-Agent is one that's allowed to sign
-        requests with OAuth tokens.
-
-        :return: The user who Launchpad identifies as the principal.
-         Or, if Launchpad identifies no one as the principal, the user
-         whose valid GRANT_PERMISSIONS OAuth token was used to sign
-         the request.
-
-        :raise Unauthorized: If the request is unauthorized and
-         unsigned, improperly signed, anonymously signed, or signed
-         with a token that does not have the right access level.
-        """
-        user = getUtility(ILaunchBag).user
-        if user is not None:
-            return user
-        # The normal Launchpad code was not able to identify any
-        # user, but we're going to try a little harder before
-        # concluding that no one's logged in. If the incoming
-        # request is signed by an OAuth access token with the
-        # GRANT_PERMISSIONS access level, we will force a
-        # temporary login with the user whose access token this
-        # is.
-        token = extract_oauth_access_token(self.request)
-        if token is None:
-            # The request is not OAuth-signed. The normal Launchpad
-            # code had it right: no one is authenticated.
-            raise Unauthorized("Anonymous access is not allowed.")
-        principal = get_oauth_principal(self.request)
-        if IUnauthenticatedPrincipal.providedBy(principal):
-            # The request is OAuth-signed, but as the anonymous
-            # user.
-            raise Unauthorized("Anonymous access is not allowed.")
-        if token.permission != AccessLevel.GRANT_PERMISSIONS:
-            # The request is OAuth-signed, but the token has
-            # the wrong access level.
-            raise Unauthorized("OAuth token has insufficient access level.")
-
-        # Both the consumer key and the User-Agent must identify the
-        # Launchpad Credentials Manager.
-        must_start_with_prefix = [
-            token.consumer.key, self.request.getHeader("User-Agent")]
-        for string in must_start_with_prefix:
-            if not string.startswith(
-                self.GRANT_PERMISSIONS_USER_AGENT_PREFIX):
-                raise Unauthorized(
-                    "Only the Launchpad Credentials Manager can access this "
-                    "page by signing requests with an OAuth token.")
-        return principal.person
-
-
-class OAuthAuthorizeTokenView(
-    LaunchpadFormView, JSONTokenMixin, CredentialManagerAwareMixin):
+        all_actions.append(action)
+        if permission != OAuthPermission.DESKTOP_INTEGRATION:
+            ordinary_actions.append(action)
+    return all_actions, ordinary_actions
+
+
+class OAuthAuthorizeTokenView(LaunchpadFormView, JSONTokenMixin):
     """Where users authorize consumers to access Launchpad on their behalf."""
 
-    actions, actions_excluding_grant_permissions = (
+    actions, actions_excluding_special_permissions = (
         create_oauth_permission_actions())
     label = "Authorize application to access Launchpad on your behalf"
     schema = IOAuthRequestToken
@@ -220,7 +143,7 @@
 
     @property
     def visible_actions(self):
-        """Restrict the actions to the subset the client can make use of.
+        """Restrict the actions to a subset to be presented to the client.
 
         Not all client programs can function with all levels of
         access. For instance, a client that needs to modify the
@@ -240,7 +163,7 @@
 
         allowed_permissions = self.request.form_ng.getAll('allow_permission')
         if len(allowed_permissions) == 0:
-            return self.actions_excluding_grant_permissions
+            return self.actions_excluding_special_permissions
         actions = Actions()
 
         # UNAUTHORIZED is always one of the options. If the client
@@ -249,24 +172,53 @@
         if OAuthPermission.UNAUTHORIZED.name in allowed_permissions:
             allowed_permissions.remove(OAuthPermission.UNAUTHORIZED.name)
 
-        # GRANT_PERMISSIONS cannot be requested as one of several
+        # DESKTOP_INTEGRATION cannot be requested as one of several
         # options--it must be the only option (other than
-        # UNAUTHORIZED). If GRANT_PERMISSIONS is one of several
+        # UNAUTHORIZED). If DESKTOP_INTEGRATION is one of several
         # options, remove it from the list.
-        if (OAuthPermission.GRANT_PERMISSIONS.name in allowed_permissions
+        desktop_permission = OAuthPermission.DESKTOP_INTEGRATION
+        if (desktop_permission.name in allowed_permissions
             and len(allowed_permissions) > 1):
-            allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name)
-
-        # GRANT_PERMISSIONS may only be requested by a specific User-Agent.
-        if (OAuthPermission.GRANT_PERMISSIONS.name in allowed_permissions
-            and not self.request.getHeader("User-Agent").startswith(
-                self.GRANT_PERMISSIONS_USER_AGENT_PREFIX)):
-            allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name)
-
-        for action in self.actions:
-            if (action.permission.name in allowed_permissions
-                or action.permission is OAuthPermission.UNAUTHORIZED):
-                actions.append(action)
+            allowed_permissions.remove(desktop_permission.name)
+
+        if desktop_permission.name in allowed_permissions:
+            if not self.token.consumer.is_integrated_desktop:
+                # Consumers may only ask for desktop integration if
+                # they give a desktop type (eg. "Ubuntu") and a
+                # user-recognizable desktop name (eg. the hostname).
+                raise Unauthorized(
+                    ('Consumer "%s" asked for desktop integration, '
+                     "but didn't say what kind of desktop it is, or name "
+                     "the computer being integrated."
+                     % self.token.consumer.key))
+
+            # We're going for desktop integration. The only two
+            # possibilities are "allow" and "deny". We'll customize
+            # the "allow" message using the hostname provided by the
+            # desktop.
+            label = (
+                'Give all programs running on "%s" access '
+                'to my Launchpad account.')
+            allow_action = [
+                action for action in self.actions
+                if action.name == desktop_permission.name][0]
+            allow_action.label = (
+                    label % self.token.consumer.integrated_desktop_name)
+            actions.append(allow_action)
+
+            # We'll customize the "deny" message as well.
+            deny_action = [
+                action for action in self.actions
+                if action.name == OAuthPermission.UNAUTHORIZED.name][0]
+            deny_action.label = "No, thanks, I don't trust this computer."
+            actions.append(deny_action)
+
+        else:
+            # We're going for web-based integration.
+            for action in self.actions_excluding_special_permissions:
+                if (action.permission.name in allowed_permissions
+                    or action.permission is OAuthPermission.UNAUTHORIZED):
+                    actions.append(action)
 
         if len(list(actions)) == 1:
             # The only visible action is UNAUTHORIZED. That means the
@@ -275,17 +227,41 @@
             # UNAUTHORIZED). Rather than present the end-user with an
             # impossible situation where their only option is to deny
             # access, we'll present the full range of actions (except
-            # for GRANT_PERMISSIONS).
-            return self.actions_excluding_grant_permissions
+            # for special permissions like DESKTOP_INTEGRATION).
+            return self.actions_excluding_special_permissions
         return actions
 
     def initialize(self):
-        self.oauth_authorized_user = self.ensureRequestIsAuthorizedOrSigned()
         self.storeTokenContext()
-
-        key = self.request.form.get('oauth_token')
+        form = get_oauth_authorization(self.request)
+        key = form.get('oauth_token')
         if key:
             self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)
+
+
+        callback = self.request.form.get('oauth_callback')
+        if (self.token is not None
+            and self.token.consumer.is_integrated_desktop):
+            # Nip problems in the bud by appling special rules about
+            # what desktop integrations are allowed to do.
+            if callback is not None:
+                # A desktop integration is not allowed to specify a callback.
+                raise Unauthorized(
+                    "A desktop integration may not specify an "
+                    "OAuth callback URL.")
+            # A desktop integration token can only have one of two
+            # permission levels: "Desktop Integration" and
+            # "Unauthorized". It shouldn't even be able to ask for any
+            # other level.
+            for action in self.visible_actions:
+                if action.permission not in (
+                    OAuthPermission.DESKTOP_INTEGRATION,
+                    OAuthPermission.UNAUTHORIZED):
+                    raise Unauthorized(
+                        ("Desktop integration token requested a permission "
+                         '("%s") not supported for desktop-wide use.')
+                         % action.label)
+
         super(OAuthAuthorizeTokenView, self).initialize()
 
     def render(self):
@@ -314,8 +290,7 @@
         self.token_context = context
 
     def reviewToken(self, permission):
-        self.token.review(self.user or self.oauth_authorized_user,
-                          permission, self.token_context)
+        self.token.review(self.user, permission, self.token_context)
         callback = self.request.form.get('oauth_callback')
         if callback:
             self.next_url = callback
@@ -343,7 +318,7 @@
     return context
 
 
-class OAuthTokenAuthorizedView(LaunchpadView, CredentialManagerAwareMixin):
+class OAuthTokenAuthorizedView(LaunchpadView):
     """Where users who reviewed tokens may get redirected to.
 
     If the consumer didn't include an oauth_callback when sending the user to
@@ -352,7 +327,6 @@
     """
 
     def initialize(self):
-        authorized_user = self.ensureRequestIsAuthorizedOrSigned()
         key = self.request.form.get('oauth_token')
         self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)
         assert self.token.is_reviewed, (

=== modified file 'lib/canonical/launchpad/database/oauth.py'
--- lib/canonical/launchpad/database/oauth.py	2010-09-15 20:55:03 +0000
+++ lib/canonical/launchpad/database/oauth.py	2010-09-22 19:29:46 +0000
@@ -15,6 +15,7 @@
     timedelta,
     )
 
+import re
 import pytz
 from sqlobject import (
     BoolCol,
@@ -60,14 +61,14 @@
 
 # How many hours should a request token be valid for?
 REQUEST_TOKEN_VALIDITY = 12
-# The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that
-# a timestamp "MUST be equal or greater than the timestamp used in
-# previous requests," but this is likely to cause problems if the
-# client does request pipelining, so we use a time window (relative to
-# the timestamp of the existing OAuthNonce) to check if the timestamp
-# can is acceptable. As suggested by Robert, we use a window which is
-# at least twice the size of our hard time out. This is a safe bet
-# since no requests should take more than one hard time out.
+# The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that a
+# timestamp "MUST be equal or greater than the timestamp used in previous
+# requests," but this is likely to cause problems if the client does request
+# pipelining, so we use a time window (relative to the timestamp of the
+# existing OAuthNonce) to check if the timestamp can is acceptable. As
+# suggested by Robert, we use a window which is at least twice the size of our
+# hard time out. This is a safe bet since no requests should take more than
+# one hard time out.
 TIMESTAMP_ACCEPTANCE_WINDOW = 60 # seconds
 # If the timestamp is far in the future because of a client's clock skew,
 # it will effectively invalidate the authentication tokens when the clock is
@@ -77,7 +78,6 @@
 # amount.
 TIMESTAMP_SKEW_WINDOW = 60*60 # seconds, +/-
 
-
 class OAuthBase(SQLBase):
     """Base class for all OAuth database classes."""
 
@@ -104,6 +104,50 @@
     key = StringCol(notNull=True)
     secret = StringCol(notNull=False, default='')
 
+    # This regular expression singles out a consumer key that represents
+    # any and all apps running on a specific computer. For instance:
+    #
+    # Ubuntu desktop (hostname1)
+    #  - An Ubuntu desktop called "hostname1"
+    # Windows desktop (Computer Name)
+    #  - A Windows desktop called "Computer Name"
+    # Mac OS desktop (hostname2)
+    #  - A Macintosh desktop called "hostname2"
+    # Android desktop (Bob's Phone)
+    #  - An Android phone called "Bob's Phone"
+    integrated_desktop_re = re.compile("^(.*) desktop \(([^)]*)\)$")
+
+    def _integrated_desktop_match_group(self, position):
+        """Return information about a desktop integration token.
+
+        A convenience method that runs the desktop integration regular
+        expression against the consumer key.
+
+        :param position: The match group to return if the regular
+        expression matches.
+
+        :return: The value of one of the match groups, or None.
+        """
+        match = self.integrated_desktop_re.match(self.key)
+        if match is None:
+            return None
+        return match.groups()[position]
+
+    @property
+    def is_integrated_desktop(self):
+        """See `IOAuthConsumer`."""
+        return self.integrated_desktop_re.match(self.key) is not None
+
+    @property
+    def integrated_desktop_type(self):
+        """See `IOAuthConsumer`."""
+        return self._integrated_desktop_match_group(0)
+
+    @property
+    def integrated_desktop_name(self):
+        """See `IOAuthConsumer`."""
+        return self._integrated_desktop_match_group(1)
+
     def newRequestToken(self):
         """See `IOAuthConsumer`."""
         key, secret = create_token_key_and_secret(table=OAuthRequestToken)
@@ -325,7 +369,6 @@
     The key will have a length of 20 and we'll make sure it's not yet in the
     given table.  The secret will have a length of 80.
     """
-
     key_length = 20
     key = create_unique_token_for_table(key_length, getattr(table, "key"))
     secret_length = 80

=== modified file 'lib/canonical/launchpad/doc/oauth.txt'
--- lib/canonical/launchpad/doc/oauth.txt	2010-04-16 15:06:55 +0000
+++ lib/canonical/launchpad/doc/oauth.txt	2010-09-22 19:29:46 +0000
@@ -38,6 +38,43 @@
     ...
     AssertionError: ...
 
+Desktop consumers
+=================
+
+In a web context, each application is represented by a unique consumer
+key. But a typical user sitting at a typical desktop, using multiple
+desktop applications that integrate with Launchpad, is represented by
+a single consumer key. The user's session as a whole is a single
+"consumer", and the consumer key is expected to contain structured
+information: the type of desktop (usually the operating system) and a
+string that the end-user would recognize as identifying their
+computer.
+
+    >>> desktop_key = consumer_set.new("Ubuntu desktop (hostname)")
+    >>> desktop_key.is_integrated_desktop
+    True
+    >>> print desktop_key.integrated_desktop_type
+    Ubuntu
+    >>> print desktop_key.integrated_desktop_name
+    hostname
+
+    >>> desktop_key = consumer_set.new("Windows desktop (My Computer)")
+    >>> desktop_key.is_integrated_desktop
+    True
+    >>> print desktop_key.integrated_desktop_type
+    Windows
+    >>> print desktop_key.integrated_desktop_name
+    My Computer
+
+A normal OAuth consumer does not have this information.
+
+    >>> ordinary_key = consumer_set.new("Not a desktop at all.")
+    >>> ordinary_key.is_integrated_desktop
+    False
+    >>> print ordinary_key.integrated_desktop_type
+    None
+    >>> print ordinary_key.integrated_desktop_name
+    None
 
 Request tokens
 ==============

=== modified file 'lib/canonical/launchpad/doc/webapp-authorization.txt'
--- lib/canonical/launchpad/doc/webapp-authorization.txt	2010-08-24 16:44:42 +0000
+++ lib/canonical/launchpad/doc/webapp-authorization.txt	2010-09-22 19:29:46 +0000
@@ -79,24 +79,16 @@
     >>> check_permission('launchpad.View', bug_1)
     False
 
-Now consider a principal authorized to create OAuth tokens. Whenever
-it's not creating OAuth tokens, it has a level of permission
-equivalent to READ_PUBLIC.
+A token used for desktop integration has a level of permission
+equivalent to WRITE_PUBLIC.
 
-    >>> principal.access_level = AccessLevel.GRANT_PERMISSIONS
+    >>> principal.access_level = AccessLevel.DESKTOP_INTEGRATION
     >>> setupInteraction(principal)
     >>> check_permission('launchpad.View', bug_1)
-    False
+    True
 
     >>> check_permission('launchpad.Edit', sample_person)
-    False
-
-This may seem useless from a security standpoint, since once a
-malicious client is authorized to create OAuth tokens, it can escalate
-its privileges at any time by creating a new token for itself. The
-security benefit is more subtle: by discouraging feature creep in
-clients that have this super-access level, we reduce the risk that a
-bug in a _trusted_ client will enable privilege escalation attacks.
+    True
 
 Users logged in through the web application have full access, which
 means they can read/change any object they have access to.

=== modified file 'lib/canonical/launchpad/interfaces/oauth.py'
--- lib/canonical/launchpad/interfaces/oauth.py	2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/interfaces/oauth.py	2010-09-22 19:29:46 +0000
@@ -64,6 +64,22 @@
         description=_('The secret which, if not empty, should be used by the '
                       'consumer to sign its requests.'))
 
+    is_integrated_desktop = Attribute(
+        """This attribute is true if the consumer corresponds to a
+        user account on a personal computer.""")
+
+    integrated_desktop_name = Attribute(
+        """If the consumer corresponds to a user account on a personal
+        computer, this is the self-reported name of that computer. If
+        the consumer is a specific web or desktop application, this is
+        None.""")
+
+    integrated_desktop_type = Attribute(
+        """If the consumer corresponds to a user account on a personal
+        computer, this is the self-reported type of that computer
+        (usually the operating system). If the consumer is a specific
+        web or desktop application, this is None.""")
+
     def newRequestToken():
         """Return a new `IOAuthRequestToken` with a random key and secret.
 

=== modified file 'lib/canonical/launchpad/pagetests/oauth/authorize-token.txt'
--- lib/canonical/launchpad/pagetests/oauth/authorize-token.txt	2010-09-16 21:34:31 +0000
+++ lib/canonical/launchpad/pagetests/oauth/authorize-token.txt	2010-09-22 19:29:46 +0000
@@ -1,6 +1,4 @@
-***************************
-Authorizing a request token
-***************************
+= Authorizing a request token =
 
 Once the consumer gets a request token, it must send the user to
 Launchpad's +authorize-token page in order for the user to authenticate
@@ -21,10 +19,9 @@
 The oauth_token parameter, on the other hand, is required in the
 Launchpad implementation.
 
-Access to the page
-==================
-
-The +authorize-token page is restricted to authenticated users.
+The +authorize-token page is restricted to logged in users, so users will
+first be asked to log in. (We won't show the actual login process because
+it involves OpenID, which would complicate this test quite a bit.)
 
     >>> from urllib import urlencode
     >>> params = dict(
@@ -33,18 +30,7 @@
     >>> browser.open(url)
     Traceback (most recent call last):
     ...
-    Unauthorized: Anonymous access is not allowed.
-
-However, the details of the authentication are different than from any
-other part of Launchpad. Unlike with other pages, a user can authorize
-an OAuth token by signing their outgoing requests with an _existing_
-OAuth token. This makes it possible for a desktop client to retrieve
-this page without knowing the end-user's username and password, or
-making them navigate the arbitrarily complex OpenID login procedure.
-
-But, let's deal with that a little later. First let's show how the
-process works through HTTP Basic Auth (the testing equivalent of a
-regular username-and-password login).
+    Unauthorized:...
 
     >>> browser = setupBrowser(auth='Basic no-priv@xxxxxxxxxxxxx:test')
     >>> browser.open(url)
@@ -58,12 +44,8 @@
     ...
     See all applications authorized to access Launchpad on your behalf.
 
-
-Using the page
-==============
-
 This page contains one submit button for each item of OAuthPermission,
-except for 'Grant Permissions', which must be specifically requested.
+except for 'Desktop Integration', which must be specifically requested.
 
     >>> browser.getControl('No Access')
     <SubmitControl...
@@ -76,6 +58,7 @@
     >>> browser.getControl('Change Anything')
     <SubmitControl...
 
+    # XXX FIXME
     >>> browser.getControl('Grant Permissions')
     Traceback (most recent call last):
     ...
@@ -92,48 +75,17 @@
 that isn't enough for the application. The user always has the option
 to deny permission altogether.
 
-    >>> def filter_user_agent(key, value, new_value):
-    ...     """A filter to replace the User-Agent header in a list of headers.
-    ...
-    ...     [XXX bug=638058] This is a hack to work around a bug in
-    ...     zope.testbrowser.
-    ...     """
-    ...
-    ...     if key.lower() == "user-agent":
-    ...         return (key, new_value)
-    ...     return (key, value)
-
-    >>> def print_access_levels(allow_permission, user_agent=None):
-    ...     if user_agent is not None:
-    ...         # [XXX bug=638058] This is a hack to work around a bug in
-    ...         # zope.testbrowser which prevents browser.addHeader
-    ...         # from working with User-Agent.
-    ...         mech_browser = browser.mech_browser
-    ...         # Store the original User-Agent for later.
-    ...         old_user_agent = [
-    ...             value for key, value in mech_browser.addheaders
-    ...             if key.lower() == "user-agent"][0]
-    ...         # Replace the User-Agent with the value passed into this
-    ...         # function.
-    ...         mech_browser.addheaders = [
-    ...             filter_user_agent(key, value, user_agent)
-    ...             for key, value in mech_browser.addheaders]
-    ...
-    ...     # Okay, now we can make the request.
+    >>> def authorize_token_main_content(allow_permission):
     ...     browser.open(
     ...         "http://launchpad.dev/+authorize-token?%s&%s";
     ...         % (urlencode(params), allow_permission))
-    ...     main_content = find_tag_by_id(browser.contents, 'maincontent')
+    ...     return find_tag_by_id(browser.contents, 'maincontent')
+
+    >>> def print_access_levels(allow_permission):
+    ...     main_content = authorize_token_main_content(allow_permission)
     ...     actions = main_content.findAll('input', attrs={'type': 'submit'})
     ...     for action in actions:
     ...         print action['value']
-    ...
-    ...     if user_agent is not None:
-    ...         # Finally, restore the old User-Agent.
-    ...         mech_browser.addheaders = [
-    ...             filter_user_agent(key, value, old_user_agent)
-    ...             for key, value in mech_browser.addheaders]
-
 
     >>> print_access_levels(
     ...     'allow_permission=WRITE_PUBLIC&allow_permission=WRITE_PRIVATE')
@@ -141,43 +93,9 @@
     Change Non-Private Data
     Change Anything
 
-The only time the 'Grant Permissions' permission shows up in this list
-is if a client identifying itself as the Launchpad Credentials Manager
-specifically requests it, and no other permission. (Also requesting
-UNAUTHORIZED is okay--it will show up anyway.)
-
-    >>> USER_AGENT = "Launchpad Credentials Manager v1.0"
-    >>> print_access_levels(
-    ...     'allow_permission=GRANT_PERMISSIONS', USER_AGENT)
-    No Access
-    Grant Permissions
-
-    >>> print_access_levels(
-    ...     ('allow_permission=GRANT_PERMISSIONS&'
-    ...      'allow_permission=UNAUTHORIZED'),
-    ...     USER_AGENT)
-    No Access
-    Grant Permissions
-
-    >>> print_access_levels(
-    ...     ('allow_permission=WRITE_PUBLIC&'
-    ...      'allow_permission=GRANT_PERMISSIONS'))
-    No Access
-    Change Non-Private Data
-
-If a client asks for GRANT_PERMISSIONS but doesn't claim to be the
-Launchpad Credentials Manager, Launchpad will not show GRANT_PERMISSIONS.
-
-    >>> print_access_levels('allow_permission=GRANT_PERMISSIONS')
-    No Access
-    Read Non-Private Data
-    Change Non-Private Data
-    Read Anything
-    Change Anything
-
 If an application doesn't specify any valid access levels, or only
 specifies the UNAUTHORIZED access level, Launchpad will show all the
-access levels, except for GRANT_PERMISSIONS.
+access levels, except for DESKTOP_INTEGRATION.
 
     >>> print_access_levels('')
     No Access
@@ -193,6 +111,20 @@
     Read Anything
     Change Anything
 
+An application may not request the DESKTOP_INTEGRATION access level
+unless its consumer key matches a certain pattern. (Successful desktop
+integration has its own section, below.)
+
+    >>> allow_permission = "allow_permission=DESKTOP_INTEGRATION"
+    >>> browser.open(
+    ...     "http://launchpad.dev/+authorize-token?%s&%s";
+    ...         % (urlencode(params), allow_permission))
+    Traceback (most recent call last):
+    ...
+    Unauthorized: Consumer "foobar123451432" asked for desktop
+    integration, but didn't say what kind of desktop it is, or name
+    the computer being integrated.
+
 An application may also specify a context, so that the access granted
 by the user is restricted to things related to that context.
 
@@ -331,123 +263,90 @@
     reviewed ... ago.
     See all applications authorized to access Launchpad on your behalf.
 
-Access through OAuth
-====================
-
-Now it's time to show how to go through the same process without
-knowing the end-user's username and password. All you need is an OAuth
-token issued with the GRANT_PERMISSIONS access level, in the name of
-the Launchpad Credentials Manager.
-
-Let's go through the approval process again, without ever sending the
-user's username or password over HTTP. First we'll create a new user,
-and a GRANT_PERMISSIONS access token that they can use to sign
-requests.
-
-    >>> login(ANONYMOUS)
-    >>> user = factory.makePerson(name="test-user", password="never-used")
-    >>> logout()
-
-    >>> from oauth.oauth import OAuthConsumer
-    >>> manager_consumer = OAuthConsumer("Launchpad Credentials Manager", "")
-
-    >>> from lp.testing import oauth_access_token_for
-    >>> login_person(user)
-    >>> grant_permissions_token = oauth_access_token_for(
-    ...     manager_consumer.key, user, "GRANT_PERMISSIONS")
-    >>> logout()
-
-Next, we'll give the new user an OAuth request token that needs to be
-approved using a web browser.
-
-    >>> login_person(user)
-    >>> consumer = getUtility(IOAuthConsumerSet).getByKey('foobar123451432')
-    >>> request_token = consumer.newRequestToken()
-    >>> logout()
-
-    >>> params = dict(oauth_token=request_token.key)
-    >>> url = "http://launchpad.dev/+authorize-token?%s"; % urlencode(params)
-
-Next, we'll create a browser object that knows how to sign requests
-with the new user's existing access token.
-
-    >>> from lp.testing import OAuthSigningBrowser
-    >>> browser = OAuthSigningBrowser(
-    ...     manager_consumer, grant_permissions_token, USER_AGENT)
-    >>> browser.open(url)
-    >>> print browser.title
-    Authorize application to access Launchpad on your behalf
-
-The browser object can approve the request and see the appropriate
-messages, even though we never gave it the user's password.
-
-    >>> browser.getControl('Read Anything').click()
-
-    >>> browser.url
-    'http://launchpad.dev/+token-authorized?...'
-    >>> print extract_text(find_tag_by_id(browser.contents, 'maincontent'))
-    Almost finished ...
-    To finish authorizing the application identified as foobar123451432 to
-    access Launchpad on your behalf you should go back to the application
-    window in which you started the process and inform it that you have done
-    your part of the process.
-
-OAuth error conditions
-----------------------
-
-The OAuth token used to sign the requests must have the
-GRANT_PERMISSIONS access level; no other access level will work.
-
-    >>> login(ANONYMOUS)
-    >>> insufficient_token = oauth_access_token_for(
-    ...     manager_consumer.key, user, "WRITE_PRIVATE")
-    >>> logout()
-
-    >>> browser = OAuthSigningBrowser(
-    ...     manager_consumer, insufficient_token, USER_AGENT)
-    >>> browser.open(url)
-    Traceback (most recent call last):
-    ...
-    Unauthorized: OAuth token has insufficient access level.
-
-The OAuth token must be for the Launchpad Credentials Manager, or it
-cannot be used. (Launchpad shouldn't even _issue_ a GRANT_PERMISSIONS
-token for any other consumer, but even if it somehow does, that token
-can't be used for this.)
-
-    >>> login(ANONYMOUS)
-    >>> wrong_consumer = OAuthConsumer(
-    ...     "Not the Launchpad Credentials Manager", "")
-    >>> wrong_consumer_token = oauth_access_token_for(
-    ...     wrong_consumer.key, user, "GRANT_PERMISSIONS")
-    >>> logout()
-
-    >>> browser = OAuthSigningBrowser(wrong_consumer, wrong_consumer_token)
-    >>> browser.open(url)
-    Traceback (most recent call last):
-    ...
-    Unauthorized: Only the Launchpad Credentials Manager can access
-    this page by signing requests with an OAuth token.
-
-Signing with an anonymous token will also not work.
-
-    >>> from oauth.oauth import OAuthToken
-    >>> anonymous_token = OAuthToken(key="", secret="")
-    >>> browser = OAuthSigningBrowser(manager_consumer, anonymous_token)
-    >>> browser.open(url)
-    Traceback (most recent call last):
-    ...
-    Unauthorized: Anonymous access is not allowed.
-
-Even if it presents the right token, the user agent sending the signed
-request must *also* identify *itself* as the Launchpad Credentials
-Manager.
-
-    >>> browser = OAuthSigningBrowser(
-    ...     manager_consumer, grant_permissions_token,
-    ...     "Not the Launchpad Credentials Manager")
-    >>> browser.open(url)
-    Traceback (most recent call last):
-    ...
-    Unauthorized: Only the Launchpad Credentials Manager can access
-    this page by signing requests with an OAuth token.
+Desktop integration
+===================
+
+The test case given above shows how to integrate a single application
+or website into Launchpad. But it's also possible to integrate an
+entire desktop environment into Launchpad.
+
+The desktop integration option is only available for OAuth consumers
+that say what kind of desktop they are (eg. Ubuntu) and give a name
+that a user can identify with their computer (eg. the hostname). Here,
+we'll create such a token.
+
+    >>> login('salgado@xxxxxxxxxx')
+    >>> desktop_key = "Ubuntu desktop (mycomputer)"
+    >>> consumer = getUtility(IOAuthConsumerSet).new(desktop_key)
+    >>> token = consumer.newRequestToken()
+    >>> logout()
+
+When a desktop tries to integrate with Launchpad, the user gets a
+special warning about giving access to every program running on their
+desktop.
+
+    >>> params = dict(oauth_token=token.key)
+    >>> print extract_text(
+    ...     authorize_token_main_content(
+    ...         'allow_permission=DESKTOP_INTEGRATION'))
+    The Ubuntu computer identified as mycomputer wants access to your
+    Launchpad account. If you allow the integration, all applications
+    running on mycomputer will have read-write access to your
+    Launchpad account, including to your private data.
+    If mycomputer is not the computer you're using right now, or if
+    you don't trust this computer, you should click "No, thanks, I
+    don't trust this computer", or close this window now.
+    Even if you decide to allow the integration, you can
+    change your mind later.
+    See all applications authorized to access Launchpad on your behalf.
+
+The only time the 'Desktop Integration' permission shows up in the
+list of permissions is if the client specifically requests it, and no
+other permission. (Also requesting UNAUTHORIZED is okay--it will show
+up anyway.)
+
+    >>> print_access_levels('allow_permission=DESKTOP_INTEGRATION')
+    Give all programs running on "mycomputer" access to my Launchpad account.
+    No, thanks, I don't trust this computer.
+
+    >>> print_access_levels(
+    ...     'allow_permission=DESKTOP_INTEGRATION&allow_permission=UNAUTHORIZED')
+    Give all programs running on "mycomputer" access to my Launchpad account.
+    No, thanks, I don't trust this computer.
+
+A desktop may not request a level of access other than
+DESKTOP_INTEGRATION, since the whole point is to have a permission
+level that specifically applies across the entire desktop.
+
+    >>> print_access_levels('allow_permission=WRITE_PRIVATE')
+    Traceback (most recent call last):
+    ...
+    Unauthorized: Desktop integration token requested a permission
+    ("Change Anything") not supported for desktop-wide use.
+
+    >>> print_access_levels(
+    ...     'allow_permission=WRITE_PUBLIC&allow_permission=DESKTOP_INTEGRATION')
+    Traceback (most recent call last):
+    ...
+    Unauthorized: Desktop integration token requested a permission
+    ("Change Non-Private Data") not supported for desktop-wide use.
+
+You can't specify a callback URL when authorizing a desktop-wide
+token, since callback URLs should only be used when integrating
+websites into Launchpad.
+
+    >>> params['oauth_callback'] = 'http://launchpad.dev/bzr'
+    >>> print_access_levels('allow_permission=DESKTOP_INTEGRATION')
+    Traceback (most recent call last):
+    ...
+    Unauthorized: A desktop integration may not specify an OAuth
+    callback URL.
+
+This is true even if the desktop token isn't asking for the
+DESKTOP_INTEGRATION permission.
+
+    >>> print_access_levels('allow_permission=WRITE_PRIVATE')
+    Traceback (most recent call last):
+    ...
+    Unauthorized: A desktop integration may not specify an OAuth
+    callback URL.

=== modified file 'lib/canonical/launchpad/templates/oauth-authorize.pt'
--- lib/canonical/launchpad/templates/oauth-authorize.pt	2009-07-17 17:59:07 +0000
+++ lib/canonical/launchpad/templates/oauth-authorize.pt	2010-09-22 19:29:46 +0000
@@ -21,28 +21,57 @@
       <tal:token-not-reviewed condition="not:token/is_reviewed">
         <div metal:use-macro="context/@@launchpad_form/form">
           <div metal:fill-slot="extra_top">
-            <p>The application identified as
-              <strong tal:content="token/consumer/key">consumer</strong>
-              wants to access
-              <tal:has-context condition="view/token_context">
-                things related to
-                <strong tal:content="view/token_context/title">Context</strong>
-                in
-              </tal:has-context>
-              Launchpad on your behalf. What level of access
-              do you want to grant?</p>
-
-            <table>
-              <tr tal:repeat="action view/visible_actions">
-                <td style="text-align: right">
-                  <tal:action replace="structure action/render" />
-                </td>
-                <td>
-                  <span class="lesser"
-                    tal:content="action/permission/description" />
-                </td>
-              </tr>
-            </table>
+
+           <tal:desktop-integration-token condition="token/consumer/is_integrated_desktop">
+             <p>The
+               <tal:desktop replace="structure
+               token/consumer/integrated_desktop_type" />
+             computer identified
+             as <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
+             wants access to your Launchpad account. If you allow the
+             integration, all applications running
+             on <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
+             will have read-write access to your Launchpad account,
+             including to your private data.</p>
+
+             <p>If
+             <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
+             is not the computer you're using right now, or if you
+             don't trust this computer, you should click "No, thanks,
+             I don't trust this computer", or close this window now.</p>
+
+             <p>Even if you decide to allow the integration, you can
+             change your mind later.</p>
+           </tal:desktop-integration-token>
+
+           <tal:web-integration-token condition="not:token/consumer/is_integrated_desktop">
+             <p>The application identified as
+               <strong tal:content="token/consumer/key">consumer</strong>
+               wants to access
+               <tal:has-context condition="view/token_context">
+                 things related to
+                 <strong tal:content="view/token_context/title">Context</strong>
+                 in
+               </tal:has-context>
+               Launchpad on your behalf. What level of access
+               do you want to grant?</p>
+           </tal:web-integration-token>
+
+           <table>
+             <tr tal:repeat="action view/visible_actions">
+               <td style="text-align: right">
+                 <tal:action replace="structure action/render" />
+               </td>
+
+               <tal:web-integration-token
+                  condition="not:token/consumer/is_integrated_desktop">
+                 <td>
+                   <span class="lesser"
+                     tal:content="action/permission/description" />
+                 </td>
+               </tal:web-integration-token>
+             </tr>
+           </table>
           </div>
 
           <div metal:fill-slot="extra_bottom">

=== modified file 'lib/canonical/launchpad/webapp/authentication.py'
--- lib/canonical/launchpad/webapp/authentication.py	2010-09-16 15:40:56 +0000
+++ lib/canonical/launchpad/webapp/authentication.py	2010-09-22 19:29:46 +0000
@@ -5,21 +5,16 @@
 
 __all__ = [
     'check_oauth_signature',
-    'extract_oauth_access_token',
-    'get_oauth_principal',
     'get_oauth_authorization',
     'LaunchpadLoginSource',
     'LaunchpadPrincipal',
-    'OAuthSignedRequest',
     'PlacelessAuthUtility',
     'SSHADigestEncryptor',
     ]
 
 
 import binascii
-from datetime import datetime
 import hashlib
-import pytz
 import random
 from UserDict import UserDict
 
@@ -28,18 +23,13 @@
 from zope.app.security.interfaces import ILoginPassword
 from zope.app.security.principalregistry import UnauthenticatedPrincipal
 from zope.authentication.interfaces import IUnauthenticatedPrincipal
-
 from zope.component import (
     adapts,
     getUtility,
     )
 from zope.event import notify
-from zope.interface import (
-    alsoProvides,
-    implements,
-    )
+from zope.interface import implements
 from zope.preference.interfaces import IPreferenceGroup
-from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
 from zope.session.interfaces import ISession
 
@@ -54,14 +44,6 @@
     ILaunchpadPrincipal,
     IPlacelessAuthUtility,
     IPlacelessLoginSource,
-    OAuthPermission,
-    )
-from canonical.launchpad.interfaces.oauth import (
-    ClockSkew,
-    IOAuthConsumerSet,
-    IOAuthSignedRequest,
-    NonceAlreadyUsed,
-    TimestampOrderingError,
     )
 from lp.registry.interfaces.person import (
     IPerson,
@@ -69,113 +51,6 @@
     )
 
 
-def extract_oauth_access_token(request):
-    """Find the OAuth access token that signed the given request.
-
-    :param request: An incoming request.
-
-    :return: an IOAuthAccessToken, or None if the request is not
-        signed at all.
-
-    :raise Unauthorized: If the token is invalid or the request is an
-        anonymously-signed request that doesn't meet our requirements.
-    """
-    # Fetch OAuth authorization information from the request.
-    form = get_oauth_authorization(request)
-
-    consumer_key = form.get('oauth_consumer_key')
-    consumers = getUtility(IOAuthConsumerSet)
-    consumer = consumers.getByKey(consumer_key)
-    token_key = form.get('oauth_token')
-    anonymous_request = (token_key == '')
-
-    if consumer_key is None:
-        # Either the client's OAuth implementation is broken, or
-        # the user is trying to make an unauthenticated request
-        # using wget or another OAuth-ignorant application.
-        # Try to retrieve a consumer based on the User-Agent
-        # header.
-        anonymous_request = True
-        consumer_key = request.getHeader('User-Agent', '')
-        if consumer_key == '':
-            raise Unauthorized(
-                'Anonymous requests must provide a User-Agent.')
-        consumer = consumers.getByKey(consumer_key)
-
-    if consumer is None:
-        if anonymous_request:
-            # This is the first time anyone has tried to make an
-            # anonymous request using this consumer name (or user
-            # agent). Dynamically create the consumer.
-            #
-            # In the normal website this wouldn't be possible
-            # because GET requests have their transactions rolled
-            # back. But webservice requests always have their
-            # transactions committed so that we can keep track of
-            # the OAuth nonces and prevent replay attacks.
-            if consumer_key == '' or consumer_key is None:
-                raise Unauthorized("No consumer key specified.")
-            consumer = consumers.new(consumer_key, '')
-        else:
-            # An unknown consumer can never make a non-anonymous
-            # request, because access tokens are registered with a
-            # specific, known consumer.
-            raise Unauthorized('Unknown consumer (%s).' % consumer_key)
-    if anonymous_request:
-        # Skip the OAuth verification step and let the user access the
-        # web service as an unauthenticated user.
-        #
-        # XXX leonardr 2009-12-15 bug=496964: Ideally we'd be
-        # auto-creating a token for the anonymous user the first
-        # time, passing it through the OAuth verification step,
-        # and using it on all subsequent anonymous requests.
-        return None
-
-    token = consumer.getAccessToken(token_key)
-    if token is None:
-        raise Unauthorized('Unknown access token (%s).' % token_key)
-    return token
-
-
-def get_oauth_principal(request):
-    """Find the principal to use for this OAuth-signed request.
-
-    :param request: An incoming request.
-    :return: An ILaunchpadPrincipal with the appropriate access level.
-    """
-    token = extract_oauth_access_token(request)
-
-    if token is None:
-        # The consumer is making an anonymous request. If there was a
-        # problem with the access token, extract_oauth_access_token
-        # would have raised Unauthorized.
-        alsoProvides(request, IOAuthSignedRequest)
-        auth_utility = getUtility(IPlacelessAuthUtility)
-        return auth_utility.unauthenticatedPrincipal()
-
-    form = get_oauth_authorization(request)
-    nonce = form.get('oauth_nonce')
-    timestamp = form.get('oauth_timestamp')
-    try:
-        token.checkNonceAndTimestamp(nonce, timestamp)
-    except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
-        raise Unauthorized('Invalid nonce/timestamp: %s' % e)
-    now = datetime.now(pytz.timezone('UTC'))
-    if token.permission == OAuthPermission.UNAUTHORIZED:
-        raise Unauthorized('Unauthorized token (%s).' % token.key)
-    elif token.date_expires is not None and token.date_expires <= now:
-        raise Unauthorized('Expired token (%s).' % token.key)
-    elif not check_oauth_signature(request, token.consumer, token):
-        raise Unauthorized('Invalid signature.')
-    else:
-        # Everything is fine, let's return the principal.
-        pass
-    alsoProvides(request, IOAuthSignedRequest)
-    return getUtility(IPlacelessLoginSource).getPrincipal(
-        token.person.account.id, access_level=token.permission,
-        scope=token.context)
-
-
 class PlacelessAuthUtility:
     """An authentication service which holds no state aside from its
     ZCML configuration, implemented as a utility.
@@ -200,8 +75,9 @@
                     # as the login form is never visited for BasicAuth.
                     # This we treat each request as a separate
                     # login/logout.
-                    notify(
-                        BasicAuthLoggedInEvent(request, login, principal))
+                    notify(BasicAuthLoggedInEvent(
+                        request, login, principal
+                        ))
                     return principal
 
     def _authenticateUsingCookieAuth(self, request):
@@ -314,8 +190,7 @@
         plaintext = str(plaintext)
         if salt is None:
             salt = self.generate_salt()
-        v = binascii.b2a_base64(
-            hashlib.sha1(plaintext + salt).digest() + salt)
+        v = binascii.b2a_base64(hashlib.sha1(plaintext + salt).digest() + salt)
         return v[:-1]
 
     def validate(self, plaintext, encrypted):
@@ -459,7 +334,6 @@
 
 # zope.app.apidoc expects our principals to be adaptable into IAnnotations, so
 # we use these dummy adapters here just to make that code not OOPS.
-
 class TemporaryPrincipalAnnotations(UserDict):
     implements(IAnnotations)
     adapts(ILaunchpadPrincipal, IPreferenceGroup)

=== modified file 'lib/canonical/launchpad/webapp/authorization.py'
--- lib/canonical/launchpad/webapp/authorization.py	2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/webapp/authorization.py	2010-09-22 19:29:46 +0000
@@ -61,7 +61,8 @@
         lp_permission = getUtility(ILaunchpadPermission, permission)
         if lp_permission.access_level == "write":
             required_access_level = [
-                AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE]
+                AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE,
+                AccessLevel.DESKTOP_INTEGRATION]
             if access_level not in required_access_level:
                 return False
         elif lp_permission.access_level == "read":
@@ -80,7 +81,8 @@
         access to private objects, return False.  Return True otherwise.
         """
         private_access_levels = [
-            AccessLevel.READ_PRIVATE, AccessLevel.WRITE_PRIVATE]
+            AccessLevel.READ_PRIVATE, AccessLevel.WRITE_PRIVATE,
+            AccessLevel.DESKTOP_INTEGRATION]
         if access_level in private_access_levels:
             # The user has access to private objects. Return early,
             # before checking whether the object is private, since

=== modified file 'lib/canonical/launchpad/webapp/interfaces.py'
--- lib/canonical/launchpad/webapp/interfaces.py	2010-09-12 11:43:36 +0000
+++ lib/canonical/launchpad/webapp/interfaces.py	2010-09-22 19:29:46 +0000
@@ -527,14 +527,13 @@
         for reading and changing anything, including private data.
         """)
 
-    GRANT_PERMISSIONS = DBItem(60, """
-        Grant Permissions
+    DESKTOP_INTEGRATION = DBItem(60, """
+        Desktop Integration
 
-        The application will be able to grant access to your Launchpad
-        account to any other application. This is a very powerful
-        level of access. You should not grant this level of access to
-        any application except the official Launchpad credential
-        manager.
+        Every application running on your desktop will have read-write
+        access to your Launchpad account, including to your private
+        data. You should not allow this unless you trust the computer
+        you're using right now.
         """)
 
 class AccessLevel(DBEnumeratedType):

=== modified file 'lib/canonical/launchpad/webapp/servers.py'
--- lib/canonical/launchpad/webapp/servers.py	2010-09-21 04:21:16 +0000
+++ lib/canonical/launchpad/webapp/servers.py	2010-09-22 19:29:46 +0000
@@ -8,6 +8,7 @@
 __metaclass__ = type
 
 import cgi
+from datetime import datetime
 import threading
 import xmlrpclib
 
@@ -21,6 +22,7 @@
     WebServiceRequestTraversal,
     )
 from lazr.uri import URI
+import pytz
 import transaction
 from transaction.interfaces import ISynchronizer
 from zc.zservertracelog.tracelog import Server as ZServerTracelogServer
@@ -48,7 +50,10 @@
     XMLRPCRequest,
     XMLRPCResponse,
     )
-from zope.security.interfaces import IParticipation
+from zope.security.interfaces import (
+    IParticipation,
+    Unauthorized,
+    )
 from zope.security.proxy import (
     isinstance as zope_isinstance,
     removeSecurityProxy,
@@ -63,9 +68,17 @@
     IPrivateApplication,
     IWebServiceApplication,
     )
+from canonical.launchpad.interfaces.oauth import (
+    ClockSkew,
+    IOAuthConsumerSet,
+    IOAuthSignedRequest,
+    NonceAlreadyUsed,
+    TimestampOrderingError,
+    )
 import canonical.launchpad.layers
 from canonical.launchpad.webapp.authentication import (
-    get_oauth_principal,
+    check_oauth_signature,
+    get_oauth_authorization,
     )
 from canonical.launchpad.webapp.authorization import (
     LAUNCHPAD_SECURITY_POLICY_CACHE_KEY,
@@ -80,6 +93,8 @@
     INotificationRequest,
     INotificationResponse,
     IPlacelessAuthUtility,
+    IPlacelessLoginSource,
+    OAuthPermission,
     )
 from canonical.launchpad.webapp.notifications import (
     NotificationList,
@@ -1197,7 +1212,83 @@
         if request_path.startswith("/%s" % web_service_config.path_override):
             return super(WebServicePublication, self).getPrincipal(request)
 
-        return get_oauth_principal(request)
+        # Fetch OAuth authorization information from the request.
+        form = get_oauth_authorization(request)
+
+        consumer_key = form.get('oauth_consumer_key')
+        consumers = getUtility(IOAuthConsumerSet)
+        consumer = consumers.getByKey(consumer_key)
+        token_key = form.get('oauth_token')
+        anonymous_request = (token_key == '')
+
+        if consumer_key is None:
+            # Either the client's OAuth implementation is broken, or
+            # the user is trying to make an unauthenticated request
+            # using wget or another OAuth-ignorant application.
+            # Try to retrieve a consumer based on the User-Agent
+            # header.
+            anonymous_request = True
+            consumer_key = request.getHeader('User-Agent', '')
+            if consumer_key == '':
+                raise Unauthorized(
+                    'Anonymous requests must provide a User-Agent.')
+            consumer = consumers.getByKey(consumer_key)
+
+        if consumer is None:
+            if anonymous_request:
+                # This is the first time anyone has tried to make an
+                # anonymous request using this consumer name (or user
+                # agent). Dynamically create the consumer.
+                #
+                # In the normal website this wouldn't be possible
+                # because GET requests have their transactions rolled
+                # back. But webservice requests always have their
+                # transactions committed so that we can keep track of
+                # the OAuth nonces and prevent replay attacks.
+                if consumer_key == '' or consumer_key is None:
+                    raise Unauthorized("No consumer key specified.")
+                consumer = consumers.new(consumer_key, '')
+            else:
+                # An unknown consumer can never make a non-anonymous
+                # request, because access tokens are registered with a
+                # specific, known consumer.
+                raise Unauthorized('Unknown consumer (%s).' % consumer_key)
+        if anonymous_request:
+            # Skip the OAuth verification step and let the user access the
+            # web service as an unauthenticated user.
+            #
+            # XXX leonardr 2009-12-15 bug=496964: Ideally we'd be
+            # auto-creating a token for the anonymous user the first
+            # time, passing it through the OAuth verification step,
+            # and using it on all subsequent anonymous requests.
+            alsoProvides(request, IOAuthSignedRequest)
+            auth_utility = getUtility(IPlacelessAuthUtility)
+            return auth_utility.unauthenticatedPrincipal()
+        token = consumer.getAccessToken(token_key)
+        if token is None:
+            raise Unauthorized('Unknown access token (%s).' % token_key)
+        nonce = form.get('oauth_nonce')
+        timestamp = form.get('oauth_timestamp')
+        try:
+            token.checkNonceAndTimestamp(nonce, timestamp)
+        except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
+            raise Unauthorized('Invalid nonce/timestamp: %s' % e)
+        now = datetime.now(pytz.timezone('UTC'))
+        if token.permission == OAuthPermission.UNAUTHORIZED:
+            raise Unauthorized('Unauthorized token (%s).' % token.key)
+        elif token.date_expires is not None and token.date_expires <= now:
+            raise Unauthorized('Expired token (%s).' % token.key)
+        elif not check_oauth_signature(request, consumer, token):
+            raise Unauthorized('Invalid signature.')
+        else:
+            # Everything is fine, let's return the principal.
+            pass
+        alsoProvides(request, IOAuthSignedRequest)
+        principal = getUtility(IPlacelessLoginSource).getPrincipal(
+            token.person.account.id, access_level=token.permission,
+            scope=token.context)
+
+        return principal
 
 
 class LaunchpadWebServiceRequestTraversal(WebServiceRequestTraversal):

=== modified file 'lib/canonical/launchpad/zcml/launchpad.zcml'
--- lib/canonical/launchpad/zcml/launchpad.zcml	2010-09-09 21:09:00 +0000
+++ lib/canonical/launchpad/zcml/launchpad.zcml	2010-09-22 19:29:46 +0000
@@ -266,14 +266,14 @@
       name="+authorize-token"
       class="canonical.launchpad.browser.OAuthAuthorizeTokenView"
       template="../templates/oauth-authorize.pt"
-      permission="zope.Public" />
+      permission="launchpad.AnyPerson" />
 
   <browser:page
       for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"
       name="+token-authorized"
       class="canonical.launchpad.browser.OAuthTokenAuthorizedView"
       template="../templates/token-authorized.pt"
-      permission="zope.Public" />
+      permission="launchpad.AnyPerson" />
 
   <browser:page
       for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"

=== modified file 'lib/lp/testing/__init__.py'
--- lib/lp/testing/__init__.py	2010-09-20 12:56:53 +0000
+++ lib/lp/testing/__init__.py	2010-09-22 19:29:46 +0000
@@ -28,7 +28,6 @@
     'map_branch_contents',
     'normalize_whitespace',
     'oauth_access_token_for',
-    'OAuthSigningBrowser',
     'person_logged_in',
     'record_statements',
     'run_with_login',
@@ -146,7 +145,6 @@
     launchpadlib_credentials_for,
     launchpadlib_for,
     oauth_access_token_for,
-    OAuthSigningBrowser,
     )
 from lp.testing.fixture import ZopeEventHandlerFixture
 from lp.testing.matchers import Provides
@@ -224,7 +222,7 @@
 
 class StormStatementRecorder:
     """A storm tracer to count queries.
-
+    
     This exposes the count and queries as lp.testing._webservice.QueryCollector
     does permitting its use with the HasQueryCount matcher.
 
@@ -683,7 +681,6 @@
     def assertTextMatchesExpressionIgnoreWhitespace(self,
                                                     regular_expression_txt,
                                                     text):
-
         def normalise_whitespace(text):
             return ' '.join(text.split())
         pattern = re.compile(
@@ -860,7 +857,6 @@
         callable, and events are the events emitted by the callable.
     """
     events = []
-
     def on_notify(event):
         events.append(event)
     old_subscribers = zope.event.subscribers[:]

=== modified file 'lib/lp/testing/_webservice.py'
--- lib/lp/testing/_webservice.py	2010-09-16 15:40:56 +0000
+++ lib/lp/testing/_webservice.py	2010-09-22 19:29:46 +0000
@@ -9,104 +9,34 @@
     'launchpadlib_credentials_for',
     'launchpadlib_for',
     'oauth_access_token_for',
-    'OAuthSigningBrowser',
     ]
 
 
 import shutil
 import tempfile
+
+from launchpadlib.credentials import (
+    AccessToken,
+    Credentials,
+    )
+from launchpadlib.launchpad import Launchpad
 import transaction
-from urllib2 import BaseHandler
-
-from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT
-
 from zope.app.publication.interfaces import IEndRequestEvent
 from zope.app.testing import ztapi
-from zope.testbrowser.testing import Browser
 from zope.component import getUtility
 import zope.testing.cleanup
 
-from launchpadlib.credentials import (
-    AccessToken,
-    Credentials,
-    )
-from launchpadlib.launchpad import Launchpad
-
-from lp.testing._login import (
-    login,
-    logout,
-    )
-
 from canonical.launchpad.interfaces import (
     IOAuthConsumerSet,
     IPersonSet,
-    OAUTH_REALM,
     )
 from canonical.launchpad.webapp.adapter import get_request_statements
 from canonical.launchpad.webapp.interaction import ANONYMOUS
 from canonical.launchpad.webapp.interfaces import OAuthPermission
-
-
-class OAuthSigningHandler(BaseHandler):
-    """A urllib2 handler that signs requests with an OAuth token."""
-
-    def __init__(self, consumer, token):
-        """Constructor
-
-        :param consumer: An OAuth consumer.
-        :param token: An OAuth token.
-        """
-        self.consumer = consumer
-        self.token = token
-
-    def default_open(self, req):
-        """Set the Authorization header for the outgoing request."""
-        signer = OAuthRequest.from_consumer_and_token(
-            self.consumer, self.token)
-        signer.sign_request(
-            OAuthSignatureMethod_PLAINTEXT(), self.consumer, self.token)
-        auth_header = signer.to_header(OAUTH_REALM)['Authorization']
-        req.headers['Authorization'] = auth_header
-
-
-class UserAgentFilteringHandler(BaseHandler):
-    """A urllib2 handler that replaces the User-Agent header.
-
-    [XXX bug=638058] This is a hack to work around a bug in
-    zope.testbrowser.
-    """
-    def __init__(self, user_agent):
-        """Constructor."""
-        self.user_agent = user_agent
-
-    def default_open(self, req):
-        """Set the User-Agent header for the outgoing request."""
-        req.headers['User-Agent'] = self.user_agent
-
-
-class OAuthSigningBrowser(Browser):
-    """A browser that signs each outgoing request with an OAuth token.
-
-    This lets us simulate the behavior of the Launchpad Credentials
-    Manager.
-    """
-    def __init__(self, consumer, token, user_agent=None):
-        """Constructor.
-
-        :param consumer: An OAuth consumer.
-        :param token: An OAuth token.
-        :param user_agent: The User-Agent string to send.
-        """
-        super(OAuthSigningBrowser, self).__init__()
-        self.mech_browser.add_handler(
-            OAuthSigningHandler(consumer, token))
-        if user_agent is not None:
-            self.mech_browser.add_handler(
-                UserAgentFilteringHandler(user_agent))
-
-        # This will give us tracebacks instead of unhelpful error
-        # messages.
-        self.handleErrors = False
+from lp.testing._login import (
+    login,
+    logout,
+    )
 
 
 def oauth_access_token_for(consumer_name, person, permission, context=None):