diff --git a/src/auth/PhutilAuthAdapterOAuthMediaWiki.php b/src/auth/PhutilAuthAdapterOAuthMediaWiki.php
new file mode 100644
--- /dev/null
+++ b/src/auth/PhutilAuthAdapterOAuthMediaWiki.php
@@ -0,0 +1,151 @@
+<?php
+
+/**
+ * Authentication adapter for MediaWiki OAuth1.
+ */
+final class PhutilAuthAdapterOAuthMediaWiki extends PhutilAuthAdapterOAuth1 {
+
+  private $userinfo;
+
+  public function getAccountID() {
+    $this->getHandshakeData();
+    return idx($this->getUserInfo(), 'userid');
+  }
+
+  public function getAccountName() {
+    return idx($this->getUserInfo(), 'username');
+  }
+
+  public function getAccountURI() {
+    $name = $this->getAccountName();
+    if (strlen($name)) {
+      return 'http://en.wikipedia.beta.wmflabs.org/w/index.php?title=User:'.$name;
+    }
+    return null;
+  }
+
+  public function getCallbackURI() {
+    return 'oob';
+  }
+
+  public function getAccountImageURI() {
+    $info = $this->getUserInfo();
+    return idx($info, 'profile_image_url');
+  }
+
+  public function getAccountRealName() {
+    $info = $this->getUserInfo();
+    return idx($info, 'name');
+  }
+
+  public function getAdapterType() {
+    return 'mediawiki';
+  }
+
+  public function getAdapterDomain() {
+    return 'mediawiki.org';
+  }
+
+  protected function getRequestTokenURI() {
+    return 'http://en.wikipedia.beta.wmflabs.org/w/index.php?title=Special:OAuth/initiate';
+  }
+
+  protected function getAuthorizeTokenURI() {
+    return 'http://en.wikipedia.beta.wmflabs.org/w/index.php?title=Special:OAuth/authorize';
+  }
+
+  public function getClientRedirectURI() {
+    $p = parent::getClientRedirectURI();
+    $token = $this->getToken();
+    $token_secret = $this->getTokenSecret();
+
+    // MediaWiki requires the call to /authorize be signed with the temp
+    // secret. This also forces the connected app to prevent CSRF.
+    setcookie('mwoauth', "$token:$token_secret");
+    return $p . "&oauth_consumer_key={$this->getConsumerKey()}";
+  }
+
+  protected function getValidateTokenURI() {
+    return 'http://en.wikipedia.beta.wmflabs.org/w/index.php?title=Special:OAuth/token';
+  }
+
+
+  private function getUserInfo() {
+    if ($this->userinfo === null) {
+      $uri = new PhutilURI('http://en.wikipedia.beta.wmflabs.org/w/index.php?title=Special:OAuth/identify&format=json');
+      $nonce = Filesystem::readRandomCharacters(32); // We gen this so we can check for replay below
+      list($body) = $this->newOAuth1Future($uri)
+        ->setMethod('GET')
+        ->setNonce($nonce)
+        ->resolvex();
+      $this->userinfo = $this->decodeAndVerifyJWT($body, $nonce);
+    }
+    return $this->userinfo;
+  }
+
+  /**
+   * MediaWiki uses a signed JWT to assert the user's identity
+   */
+  private function decodeAndVerifyJWT($jwt, $nonce) {
+    $userinfo = array();
+    $identity = $this->decodeJWT($jwt);
+    $expected_connonical_server = 'http://en.wikipedia.beta.wmflabs.org';
+    $now = time();
+
+    if ($identity->iss !== $expected_connonical_server) {
+      throw new Exception("OAuth JWT iss didn't match expected server name");
+    }
+    if ($identity->aud !== $this->getConsumerKey()) {
+      throw new Exception("OAuth JWT aud didn't match expected consumer key");
+    }
+    if ($identity->iat > $now || $identity->exp < $now) {
+      throw new Exception("OAuth JWT wasn't valid at this time");
+    }
+    if ($identity->nonce !== $nonce) {
+      throw new Exception("OAuth JWT nonce didn't match what we sent. MITM?");
+    }
+    $userinfo['userid'] = $identity->sub;
+    $userinfo['username'] = $identity->username;
+    $userinfo['groups'] = $identity->groups;
+    $userinfo['blocked'] = $identity->blocked;
+    $userinfo['editcount'] = $identity->editcount;
+    return $userinfo;
+  }
+
+  private function decodeJWT($jwt) {
+    list($headb64, $bodyb64, $sigb64) = explode('.', $jwt);
+
+    $header = json_decode($this->urlsafeB64Decode($headb64));
+    $payload = json_decode($this->urlsafeB64Decode($bodyb64));
+    $sig = $this->urlsafeB64Decode($sigb64);
+
+    $expect_sig = hash_hmac(
+        'sha256',
+        "$headb64.$bodyb64",
+        $this->getConsumerSecret()->openEnvelope(),
+        true);
+
+    if ($header->alg !== 'HS256' || !$this->compareHash($sig, $expect_sig)) {
+      throw new Exception('Invalid JWT signature from /identify.');
+    }
+    return $payload;
+  }
+
+  private function urlsafeB64Decode($input) {
+    $remainder = strlen($input) % 4;
+    if ($remainder) {
+      $padlen = 4 - $remainder;
+      $input .= str_repeat('=', $padlen);
+    }
+    return base64_decode(strtr($input, '-_', '+/'));
+  }
+
+  private function compareHash($hash1, $hash2) {
+    $result = strlen($hash1) ^ strlen($hash2);
+    $len = min(strlen($hash1), strlen($hash2)) - 1;
+    for ($i = 0; $i < $len; $i++) {
+        $result |= ord($hash1{$i}) ^ ord($hash2{$i});
+    }
+    return $result == 0;
+  }
+}