diff --git a/reviewboard/htdocs/media/rb/js/oauth-client.js b/reviewboard/htdocs/media/rb/js/oauth-client.js
new file mode 100644
index 0000000000000000000000000000000000000000..d066c692c51a2a50229c8290506ea89f045714ea
--- /dev/null
+++ b/reviewboard/htdocs/media/rb/js/oauth-client.js
@@ -0,0 +1,29 @@
+function access_token_error(jqXHR, textStatus, errorThrown) {
+    alert('Status: ' + textStatus + '. Request unsuccessful. (' + errorThrown + ')');
+    for (attr in jqXHR)
+    {
+        alert(attr + ': ' + jqXHR[attr]);
+    }
+}
+
+function got_access_token(data, textStatus, jqXHR) {
+    $('#access_token').val(data.access_token);
+    $('#refresh_token').val(data.refresh_token);
+}
+
+function get_access_token() {
+    // alert('id: ' + $('#client_id').val() + ' secret: ' + $('#client_secret').val() + ' code: ' + $('#code').val());
+    $.ajax({
+        url: 'http://127.0.0.1:8080/oauth/token/',
+        type: 'POST',
+        data: {
+            'grant_type': 'authorization_code',
+            'redirect_uri': 'http://striemer.ca/oauth-client.html',
+            'client_id': $('#client_id').val(),
+            'client_secret': $('#client_secret').val(),
+            'code': $('#code').val()
+        },
+        success: got_access_token,
+        error: access_token_error
+    });
+}
\ No newline at end of file
diff --git a/reviewboard/oauth/admin.py b/reviewboard/oauth/admin.py
new file mode 100644
index 0000000000000000000000000000000000000000..2c68cdf23dc1680b3a3c10fdc64c160fb2755e59
--- /dev/null
+++ b/reviewboard/oauth/admin.py
@@ -0,0 +1,25 @@
+from django.contrib import admin
+
+from reviewboard.oauth.models import ConsumerApplication, AuthorizationCode, Token
+
+def is_active(obj):
+    return obj.is_active()
+
+
+class ConsumerApplicationAdmin(admin.ModelAdmin):
+    list_display = ('name', 'author', 'authorized', 'public', 'num_requests',
+                    'registration_date', 'last_request_date')
+    search_fields = ['name', 'author']
+    ordering = ['name']
+
+
+class AuthorizationCodeAdmin(admin.ModelAdmin):
+    list_display = ('consumer', 'user', 'creation_date', 'authorized', is_active)
+
+
+class TokenAdmin(admin.ModelAdmin):
+    list_display = ('consumer', 'user', 'token_type', 'creation_date', 'authorized', is_active)
+
+admin.site.register(ConsumerApplication, ConsumerApplicationAdmin)
+admin.site.register(AuthorizationCode, AuthorizationCodeAdmin)
+admin.site.register(Token, TokenAdmin)
\ No newline at end of file
diff --git a/reviewboard/oauth/decorators.py b/reviewboard/oauth/decorators.py
new file mode 100644
index 0000000000000000000000000000000000000000..f1041c7a0170251924e1b60a4cd761bd80f6f412
--- /dev/null
+++ b/reviewboard/oauth/decorators.py
@@ -0,0 +1,48 @@
+from django.http import HttpResponseForbidden
+from django.shortcuts import redirect
+from oauth.models import ConsumerApplication, AuthorizationCode, Token
+
+def process_oauth_request(fn):
+    """Process and OAuth request and return the consumer or an error."""
+    def inner(request, *args, **kwargs):
+        oauth = {}
+        oauth['client_id'] = request.REQUEST.get('client_id', None)
+        oauth['client_secret'] = request.REQUEST.get('client_secret', None)
+        oauth['redirect_uri'] = request.REQUEST.get('redirect_uri', None)
+        oauth['state'] = request.REQUEST.get('state', None)
+        oauth['code'] = request.REQUEST.get('code', None)
+        oauth['grant_type'] = request.REQUEST.get('grant_type', None)
+        try:
+            consumer_args = {'key': oauth['client_id'], 'user': request.user}
+            if oauth['client_secret'] is not None:
+                consumer_args.update({'secret': oauth['client_secret']})
+            oauth['consumer'] = ConsumerApplication.objects.get(**consumer_args)
+            if oauth['code'] is not None:
+                oauth['authorization_code'] = AuthorizationCode.objects.get(
+                    consumer=oauth['consumer'], user=request.user,
+                    code=oauth['code'], authorized=True)
+        except ConsumerApplication.DoesNotExist:
+            raise RuntimeError, '%r' % oauth
+            return redirect('oauth.views.invalid_request')
+        if oauth['redirect_uri'] != oauth['consumer'].redirect_uri:
+            raise RuntimeError, 'redirect_uri'
+            return redirect('oauth.views.invalid_request')
+        kwargs.update(oauth)
+        return fn(request, *args, **kwargs)
+    return inner
+
+def oauth_login_required(fn):
+    """Require OAuth login for access to fn."""
+    def inner(request, *args, **kwargs):
+        # We don't really care what method is being used
+        token = request.REQUEST.get('token', None)
+        if token is not None:
+            try:
+                access_token = Token.objects.get(token=token,
+                                                 token_type='access')
+            except Token.DoesNotExist:
+                access_token = None
+            if access_token is not None:
+                return fn(request, *args, **kwargs)
+        return HttpResponseForbidden('OAuth authentication is required.')
+    return inner
diff --git a/reviewboard/oauth/models.py b/reviewboard/oauth/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..8a78f034683ef954b1da31062c52ec279e6192ae
--- /dev/null
+++ b/reviewboard/oauth/models.py
@@ -0,0 +1,107 @@
+from random import choice
+from datetime import datetime, timedelta
+
+from django.db import models
+from django.contrib.auth.models import User
+
+KEY_LENGTH = 20
+SECRET_LENGTH = 40
+KEY_OPTION_CHARACTERS = list('abcdefghijklmnopqrstuvwxyz'
+                             'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
+                             '0123456789')
+AUTHORIZATION_HOURS = 1
+ACCESS_DAYS = 30
+REFRESH_DAYS = 60
+
+def generate_random_string(size):
+    """Generates a key for the given model."""
+    return ''.join([choice(KEY_OPTION_CHARACTERS) for c in range(size)])
+
+def generate_key():
+    return generate_random_string(KEY_LENGTH)
+
+def generate_secret():
+    return generate_random_string(SECRET_LENGTH)
+
+
+# TODO: ENCRYPT THE SECRET TOKENS!
+class ConsumerApplication(models.Model):
+    """
+    An OAuth consumer application.
+
+    Stores the data that is used by the consumer application to authenticate
+    with ReviewBoard along with some information about the application so
+    users have some feedback when authorizing access.
+    """
+    key = models.CharField(max_length=80, unique=True, default=generate_key)
+    secret = models.CharField(max_length=80, default=generate_secret)
+    name = models.CharField(max_length=255)
+    description = models.TextField(blank=True, null=True)
+    author = models.CharField(max_length=255)
+    url = models.URLField(blank=True, null=True,
+        help_text='A URL to the application')
+    user = models.ForeignKey(User)
+    redirect_uri = models.URLField()
+    authorized = models.BooleanField()
+    public = models.BooleanField()
+    num_requests = models.IntegerField(default=0)
+    registration_date = models.DateTimeField(default=datetime.now)
+    last_request_date = models.DateTimeField(blank=True, null=True)
+
+    def get_authorization_code(self):
+        """Create an AuthorizationCode for this consumer."""
+        authorization_code = AuthorizationCode(consumer=self, user=self.user)
+        authorization_code.full_clean()
+        authorization_code.save()
+        return authorization_code
+
+    def get_access_and_request_token(self):
+        """
+        Create an access token and a refresh token for this consumer
+        and authorization_code.
+        """
+        access = Token(consumer=self, user=self.user, token_type='access')
+        refresh = Token(consumer=self, user=self.user, token_type='refresh')
+        access.full_clean()
+        refresh.full_clean()
+        access.save()
+        refresh.save()
+        return access, refresh
+
+    def __unicode__(self):
+        """Return a unicode version of self."""
+        return '%s by %s' % (self.name, self.author)
+
+class AuthorizationCode(models.Model):
+    """
+    An OAuth authorization code, may be revoked for an access token later.
+    """
+    consumer = models.ForeignKey(ConsumerApplication)
+    user = models.ForeignKey(User)
+    code = models.CharField(max_length=80, unique=True, default=generate_key)
+    creation_date = models.DateTimeField(default=datetime.now)
+    authorized = models.BooleanField(default=True)
+
+    def is_active(self):
+        """Check if an authorization code is still valid."""
+        return (self.authorized and self.creation_date +
+                timedelta(hours=AUTHORIZATION_HOURS) > datetime.now())
+
+
+class Token(models.Model):
+    """
+    An OAuth access token.
+
+    Stores an OAuth access token for a given consumer application and user.
+    """
+    consumer = models.ForeignKey(ConsumerApplication)
+    user = models.ForeignKey(User)
+    token = models.CharField(max_length=80, unique=True, default=generate_key)
+    token_type = models.CharField(max_length=30)
+    creation_date = models.DateTimeField(default=datetime.now)
+    authorized = models.BooleanField(default=True)
+
+    def is_active(self):
+        """Check if an authorization code is still valid."""
+        return (self.authorized and self.creation_date +
+                timedelta(days=ACCESS_DAYS) > datetime.now())
diff --git a/reviewboard/oauth/oauth-client.html b/reviewboard/oauth/oauth-client.html
new file mode 100644
index 0000000000000000000000000000000000000000..85aa78bd5b80c888642d52d09ca4c2f63a650d93
--- /dev/null
+++ b/reviewboard/oauth/oauth-client.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <title>OAuth 2.0 Client</title>
+        <style>
+        </style>
+        <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.5.2/jquery.min.js"></script>
+        <script type="text/javascript" src="http://127.0.0.1:8080/media/rb/js/oauth-client.js"></script>
+        <script type="text/javascript">
+            $.support.cors = true;
+
+            function getParameterByName( name )
+            {
+                name = name.replace(/[\[]/,"\\\[").replace(/[\]]/,"\\\]");
+                var regexS = "[\\?&]"+name+"=([^&#]*)";
+                var regex = new RegExp( regexS );
+                var results = regex.exec( window.location.href );
+                if( results == null )
+                    return "";
+                else
+                    return decodeURIComponent(results[1].replace(/\+/g, " "));
+            }
+
+            $(function() {
+                $('#code').val(getParameterByName('code'));
+                $('#access_token').val(getParameterByName('access_token'));
+                $('#refresh_token').val(getParameterByName('refresh_token'));
+            });
+        </script>
+    </head>
+    <body>
+        <h1>OAuth 2.0 Client</h1>
+        <div id="authorize">
+            <h2>Request Authorization</h2>
+            <p><a href="http://127.0.0.1:8080/oauth/authorize/?response_type=code&amp;client_id=test&amp;redirect_uri=http://striemer.ca/oauth-client.html">Authorize with ReviewBoard</a></p>
+        </div>
+        <div id="request-access">
+            <h2>Request Access Code</h2>
+            <p>Request access and refresh tokens</p>
+            <form action="http://127.0.0.1:8080/oauth/token/" method="post">
+                <input type="hidden" name="grant_type" value="authorization_code" />
+                <input type="hidden" name="redirect_uri" value="http://striemer.ca/oauth-client.html" />
+                <label for="client_id">Client id:</label> <input type="text" id="client_id" name="client_id" value="test" /><br />
+                <label for="client_secret">Client secret:</label> <input type="text" id="client_secret" name="client_secret" value="terces" /><br />
+                <label for="code">Code:</label> <input type="text" id="code" name="code" value="code" /><br />
+                <input type="submit" value="Get access token" />
+            </form>
+        </div>
+        <div class="protected">
+            <h2>Access the Protected resource</h2>
+            <form action="http://127.0.0.1:8080/dashboard/">
+                <label for="access_token">Access token:</label> <input type="text" id="access_token" name="token" value="" /><br />
+                <label for="refresh_token">Refresh token:</label> <input type="text" id="refresh_token" name="refresh_token" value="" /><br />
+                <input type="submit" value="Get protected resource" />
+            </form>
+        </div>
+    </body>
+</html>
+
diff --git a/reviewboard/oauth/oauth.rest b/reviewboard/oauth/oauth.rest
new file mode 100644
index 0000000000000000000000000000000000000000..ddba58ec63aec7d5cc441142027263e4206eecaa
--- /dev/null
+++ b/reviewboard/oauth/oauth.rest
@@ -0,0 +1,38 @@
+Authorization Request::
+
+    The user is sent to RB:
+    
+        GET /oauth/authorize?response_type=code&client_id=aAINia28&
+            redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb&
+            state=2378 HTTP/1.1
+        Host: reviewboard.com
+    
+    The parameters are:
+    
+        response_type [REQUIRED]
+            Must be equal to 'code'
+        client_id [REQUIRED]
+            The client key in the ConsumerApplication table
+        redirect_uri [REQUIRED]
+            May be omitted if already set on the ConsumerApplication model.
+        scope [OPTIONAL]
+            The scope of access to RB (I won't be using this).
+        state [OPTIONAL]
+            Used by the application requesting authorization, must be returned
+            when sending the user to `redirect_uri`.
+
+Authorization Response::
+
+    If the user authorized the application, redirect the user to the provided
+    redirect URI with the authorization code and the application's state.
+    
+        HTTP/1.1 302 Found
+        Location: https://client.example.com/cb?code=i1WsRn1uB1&state=2378
+    
+    The parameters are:
+    
+        code [REQUIRED]
+            The authorization code created for this authorization.
+        state [REQUIRED]
+            The state data sent to RB by the requesting application. Do not
+            modify the data at all, just return it as is.
\ No newline at end of file
diff --git a/reviewboard/oauth/oauth.txt b/reviewboard/oauth/oauth.txt
new file mode 100644
index 0000000000000000000000000000000000000000..c9fd7e86ae146861ff9c35b1069afc8f7ba80fe3
--- /dev/null
+++ b/reviewboard/oauth/oauth.txt
@@ -0,0 +1,11 @@
+1) Consumer Application (CA) registers with RB
+2) A user tries to use the CA and the CA requires authentication
+3) CA starts the authorization process
+    a) The CA requests an 'access_token' (and 'refresh_token') from RB
+    b) RB responds with an 'access_token' and 'refresh_token'
+    c) The CA sends the user to RB along with the 'access_token' to authenticate
+    d) The user grants the CA access to RB
+    e) RB redirects the user to the CA's 'callback_uri'
+    f) The CA uses its 'access_token' to receive a 'request_token'
+3) The 'request_token' is used to access the RB API
+4) The 'request_token' expires and the 'refresh_token' is used to receive a new 'request_token' (and 'refresh_token')
diff --git a/reviewboard/oauth/tests.py b/reviewboard/oauth/tests.py
new file mode 100644
index 0000000000000000000000000000000000000000..2247054b354559ab535df60bb5dc65c2aa5be686
--- /dev/null
+++ b/reviewboard/oauth/tests.py
@@ -0,0 +1,23 @@
+"""
+This file demonstrates two different styles of tests (one doctest and one
+unittest). These will both pass when you run "manage.py test".
+
+Replace these with more appropriate tests for your application.
+"""
+
+from django.test import TestCase
+
+class SimpleTest(TestCase):
+    def test_basic_addition(self):
+        """
+        Tests that 1 + 1 always equals 2.
+        """
+        self.failUnlessEqual(1 + 1, 2)
+
+__test__ = {"doctest": """
+Another way to test that 1 + 1 is equal to 2.
+
+>>> 1 + 1 == 2
+True
+"""}
+
diff --git a/reviewboard/oauth/urls.py b/reviewboard/oauth/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..ed29660d5f5b96f684e00972bf02989aced36858
--- /dev/null
+++ b/reviewboard/oauth/urls.py
@@ -0,0 +1,8 @@
+from django.conf.urls.defaults import patterns
+
+urlpatterns = patterns('',
+    (r'^authorize/$', 'oauth.views.authorize'),
+    (r'^invalid_request/$', 'oauth.views.invalid_request'),
+    (r'^token/$', 'oauth.views.token'),
+    (r'^protected/$', 'oauth.views.protected'),
+)
\ No newline at end of file
diff --git a/reviewboard/oauth/views.py b/reviewboard/oauth/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..f520b4c19ceff830465b271cf5ab8cbe1ff9ae50
--- /dev/null
+++ b/reviewboard/oauth/views.py
@@ -0,0 +1,75 @@
+from django.http import HttpResponseRedirect, HttpResponse, \
+                        HttpResponseForbidden, QueryDict
+from django.shortcuts import render_to_response, redirect
+from django.template.context import RequestContext
+from django.utils import simplejson
+
+from oauth.models import ConsumerApplication, AuthorizationCode, Token
+from oauth.decorators import process_oauth_request
+
+@process_oauth_request
+def authorize(request, client_id=None, redirect_uri=None,
+              state=None, consumer=None, *args, **kwargs):
+    """Grant an OAuth authorization request."""
+    if request.method == 'POST':
+        # A decision was made
+        authorize = request.POST.get('authorize', None)
+        # I figure this beats throwing 500 errors
+        if consumer is None:
+            return redirect('oauth.views.invalid_request')
+        if authorize != 'Authorize':
+            return redirect('%s?%s' % (consumer.redirect_uri, 'error='))
+        # Create the authorization code
+        authorization_code = consumer.get_authorization_code()
+        q = QueryDict('', mutable=True)
+        q['code'] = authorization_code.code
+        if state is not None:
+            q['state'] = state
+        return HttpResponseRedirect('%s?%s' % (redirect_uri, q.urlencode()))
+    else:
+        return render_to_response('oauth/authorize.html',
+                RequestContext(request, {'consumer': consumer,
+                    'redirect_uri': redirect_uri, 'state': state}))
+
+@process_oauth_request
+def token(request, client_id=None, client_secret=None, grant_type=None,
+          redirect_uri=None, state=None, consumer=None,
+          authorization_code=None, *args, **kwargs):
+    """Grant an access token for an authorization code."""
+    if request.method == 'POST' and not request.is_secure():
+        # I figure this beats throwing 500 errors
+        if grant_type != 'authorization_code' or client_secret is None or \
+           authorization_code is None or consumer is None:
+            return redirect('oauth.views.invalid_request')
+        if authorization_code.is_active():
+            access_token, refresh_token = \
+                    consumer.get_access_and_request_token()
+            response = QueryDict('', mutable=True)
+            response['access_token'] = access_token.token
+            # response['expires_in'] = 3600
+            response['refresh_token'] = refresh_token.token
+            return HttpResponse(response.urlencode())
+        else:
+            return redirect('%s?error=unauthorized_client&state=%s' %
+                                                        (redirect_uri, state))
+    else:
+        return redirect('oauth.views.invalid_request')
+
+def invalid_request(request):
+    """Notify the user that there was an invalid OAuth request."""
+    return render_to_response('oauth/invalid_request.html',
+            RequestContext(request))
+
+def protected(request):
+    """A 'protected' resource."""
+    token = request.REQUEST.get('token', None)
+    if token is None:
+        return HttpResponseForbidden('You are not authorized to access this resource (no token provided)')
+    try:
+        access_token = Token.objects.get(token=token, token_type='access')
+    except Token.DoesNotExist:
+        return HttpResponseForbidden('You are not authorized to access this resource (token does not exist)')
+    if access_token.is_active():
+        return HttpResponse('The protected resource is: "(ReviewBoard + OAuth-2.0) / 2.0 = 2:47AM"')
+    else:
+        return HttpResponseForbidden('You are not authrized to access this resource (token is no longer active)')
diff --git a/reviewboard/reviews/views.py b/reviewboard/reviews/views.py
index de098fd0b25cbe1bbf43201fdcbed0eb283f7d10..22c244b7ec1d270227ebc6b1a8eb95eaab610455 100644
--- a/reviewboard/reviews/views.py
+++ b/reviewboard/reviews/views.py
@@ -1,3 +1,5 @@
+from oauth.decorators import oauth_login_required
+
 import logging
 import time
 from datetime import datetime
@@ -520,7 +522,7 @@ def group_list(request,
     grid = GroupDataGrid(request, local_site=local_site)
     return grid.render_to_response(template_name)
 
-
+@oauth_login_required
 @login_required
 @valid_prefs_required
 def dashboard(request,
diff --git a/reviewboard/settings.py b/reviewboard/settings.py
index e313e4efb074712102238570f164009cadd2c819..ef822324441f87ccc9b56b6314d684e959a21b8e 100644
--- a/reviewboard/settings.py
+++ b/reviewboard/settings.py
@@ -113,6 +113,7 @@ INSTALLED_APPS = (
     'reviewboard.scmtools',
     'reviewboard.site',
     'reviewboard.webapi',
+    'reviewboard.oauth',
     'django_evolution', # Must be last
 )
 
diff --git a/reviewboard/templates/oauth/authorize.html b/reviewboard/templates/oauth/authorize.html
new file mode 100644
index 0000000000000000000000000000000000000000..2d25dc2bc95f0c376c60997d7b0a7e31402dc55c
--- /dev/null
+++ b/reviewboard/templates/oauth/authorize.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+{% load i18n %}
+
+{% block title %}{% trans "Authorization Request" %}{% endblock %}
+
+{% block content %}
+    <p>The application {{ consumer.name }} by {{ consumer.author }} wants to access your information on ReviewBoard. Would you like to authorize this application?</p>
+    {% if consumer.description %}
+        <p>{{ consumer.name }} describes itself as: &quot;{{ consumer.description }}&quot;</p>
+    {% endif %}
+    <form method="post">
+        <input type="hidden" name="client_id" value="{{ consumer.key }}" />
+        <input type="hidden" name="state" value="{{ state }}" />
+        <input type="hidden" name="redirect_uri" value="{{ redirect_uri }}" />
+        <input type="submit" name="authorize" value="Authorize" />
+        <input type="submit" name="authorize" value="Do not authorize" />
+    </form>
+{% endblock %}
diff --git a/reviewboard/templates/oauth/invalid_request.html b/reviewboard/templates/oauth/invalid_request.html
new file mode 100644
index 0000000000000000000000000000000000000000..fa19e2b5b503080e44f473ea12c175a2e42cb6e7
--- /dev/null
+++ b/reviewboard/templates/oauth/invalid_request.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+{% load i18n %}
+
+{% block title %}{% trans "Invalid Authorization Request" %}{% endblock %}
+
+{% block content %}
+    <p>The OAuth authentication request was invalid.</p>
+{% endblock %}
diff --git a/reviewboard/urls.py b/reviewboard/urls.py
index df56432b0b34f8f2509c8248b885fa6bc34c5e29..ec09a2efdcb2864a0d1455e4729f3c79ba9d608b 100644
--- a/reviewboard/urls.py
+++ b/reviewboard/urls.py
@@ -87,6 +87,7 @@ localsite_urlpatterns = patterns('',
 urlpatterns += patterns('',
     (r'^account/', include('reviewboard.accounts.urls')),
     (r'^reports/', include('reviewboard.reports.urls')),
+    (r'^oauth/', include('reviewboard.oauth.urls')),
 
     (r'^s/(?P<local_site_name>[A-Za-z0-9\-_.]+)/',
      include(localsite_urlpatterns)),
