OAuth/For Developers

From MediaWiki.org
Jump to: navigation, search

OAuth Security Provisions

  • MediaWiki users can allow other websites to edit and perform other actions using the MediaWiki api on their behalf.
  • The attached website does not share the user's password, instead they are issued a unique token and secret to make calls on behalf of the user.
  • The access is limited to explicit sets of permissions (“grants”) for the application.[1]
  • Users can revoke their authorization of an attached application at any time.
  • Administrators can reject entire applications at any time.

MediaWiki Specific Provisions

  • Extension:OAuth implements an /identify function to allow the attached application to identify the authorizing user[2]

Signatures and TLS

  • For OAuth 1.0a, all interactions between MediaWiki and the attached application are signed with either a shared secret (using HMAC-SHA1), or RSA signature.
  • If shared secrets are used, the attached application must use TLS when negotiating the shared secret.
  • If RSA is used, TLS is not required (except for the initial registration)

Intended Users

  • Websites that want to take actions on MediaWiki on behalf of their users
  • Bots
  • Websites that want to use MediaWiki as an identity provider for authentication (using the extension's /identity method, which is not standard OAuth)
  • But not...
    • Desktop applications (the Consumer Secret needs to be secret!). Some alternatives are being considered. See past discussions: [1] [2]

Application Approval

  • Wiki administrators will verify that OAuth applications are written by reputable developers, and the developers are intending to use OAuth correctly.
  • Application developers must apply to have their application (Consumer) approved at Special:OAuthConsumerRegistration/propose
  • A user with the oauth-admin right must approve the application (currently users in the oauthadmin group)
  • Your MediaWiki user can authorize your app while waiting for approval, so as a developer, you can start integrating your app immediately, without waiting for approval (you'll just have to get approval before other users can authorize your app)

Browse approved and proposed applications

The Protocol Flow

OAuth-basicSVG.svg

Attached Application Responsibility

  • Establish user's session
  • Special:OAuth/initiate - get a temporary (request) token
  • Redirect the user's browser to Special:Oauth/authorize?oauth_token=<temp token>&oauth_consumer_key=<your app's key>
  • The user will be redirected back the the url you registered
  • Special:OAuth/token – get the authorized (access) token for this user
  • Set an Authorization: header when calling api.php with oauth_version, oauth_nonce, oauth_timestamp, oauth_consumer_key, oauth_token, oauth_signature_method, oauth_signature

Developing

OAuth is now available in your vagrant development environment. Add the oauth role, and your wiki will be able to authorize OAuth apps.

$ vagrant enable-role oauth

Demo Time!

PHP demo cli client with pre-shared secret

PHP demo cli client with RSA keys

Before Starting:

$ openssl genrsa -out appkey.pem 4096
$ openssl rsa -in appkey.pem -pubout > appkey.pub
<?php

if ( PHP_SAPI !== 'cli' ) {
	die( "CLI-only test script\n" );
}

/**
 * A basic client for overall testing
 */

function wfDebugLog( $method, $msg) {
	//echo "[$method] $msg\n";
}


require 'OAuth.php';
require 'MWOAuthSignatureMethod.php';

$consumerKey = '';
#$consumerSecret = ''; // We don't need this, since we're using RSA, except to validate the /identify call

$privateKey = file_get_contents( 'appkey.pem' );

$baseurl = 'http://<wiki>/wiki/Special:OAuth';
$endpoint_req = $baseurl . '/initiate?format=json&oauth_callback=oob'; // format=json makes php a little easier
$endpoint_acc = $baseurl . '/token?format=json';
$endpoint_id = $baseurl . '/identify';

$c = new OAuthConsumer( $consumerKey, $privateKey );

// Make sure we sign title and format
$parsed = parse_url( $endpoint_req );
$extraSignedParams = array();
parse_str($parsed['query'], $extraSignedParams);
$extraSignedParams['title'] = 'Special:OAuth/initiate';

$init_req = OAuthRequest::from_consumer_and_token(
	$c,                // OAuthConsumer for your app
	NULL,              // User token, NULL for calls to initiate
	"GET",             // http method
	$endpoint_req,     // endpoint url (this is signed)
	$extraSignedParams // extra parameters we want to sign (must include title)
);

$rsa_method = new MWOAuthSignatureMethod_RSA_SHA1( new OAuthDataStore(), $privateKey );
$init_req->sign_request(
	$rsa_method, // OAuthSignatureMethod
	$c,          // OAuthConsumer for your app
	NULL         // User token, NULL for calls to initiate
);

echo "Getting request token with: $init_req\n";

$ch = curl_init();
curl_setopt( $ch, CURLOPT_URL, (string) $init_req ); // Pass OAuth in GET params
curl_setopt( $ch, CURLOPT_HTTPGET, true );
curl_setopt( $ch, CURLOPT_HEADER, false );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
$data = curl_exec( $ch );

if( !$data ) {
	'Curl error: ' . curl_error( $ch );
}

echo "Returned: $data\n\n";


$requestToken = json_decode( $data );
print "Visit $baseurl/authorize?oauth_token={$requestToken->key}&oauth_consumer_key=$consumerKey\n";

// ACCESS TOKEN
print "Enter the verification code:\n";
$fh = fopen( "php://stdin", "r" );
$line = fgets( $fh );

$rc = new OAuthToken( $requestToken->key, $requestToken->secret );
$parsed = parse_url( $endpoint_acc );
parse_str($parsed['query'], $params);
$params['oauth_verifier'] = trim($line);
$params['title'] = 'Special:OAuth/token';

$acc_req = OAuthRequest::from_consumer_and_token(
	$c,
	$rc,
	"GET",
	$endpoint_acc,
	$params
);
$acc_req->sign_request($rsa_method, $c, $rc);

echo "Calling: $acc_req\n";

unset( $ch );
$ch = curl_init();
curl_setopt( $ch, CURLOPT_URL, $endpoint_acc );
curl_setopt( $ch, CURLOPT_HEADER, 0 );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
curl_setopt( $ch, CURLOPT_HTTPHEADER, array( $acc_req->to_header() ) ); // Set the Authorization Header
$data = curl_exec( $ch );
if( !$data ) {
	'Curl error: ' . curl_error( $ch );
}

echo "Returned: $data\n\n";
$acc = json_decode( $data );
$accessToken = new OAuthToken( $acc->key, $acc->secret );

/**
 * Insecurely call the api for infomation about the user. A MITM can
 * forge a response from the server, so don't rely on this for identity!
 */
$apiurl = 'http://<wiki>/w/api.php';
$apiParams = array(
	'action' => 'query',
	'meta' => 'userinfo',
	'uiprop' => 'rights',
	'format' => 'json',
);

$api_req = OAuthRequest::from_consumer_and_token(
	$c,           // Consumer
	$accessToken, // User Access Token
	"GET",        // HTTP Method
	$apiurl,      // Endpoint url
	$apiParams    // Extra signed parameters
);
$api_req->sign_request( $rsa_method, $c, $accessToken );

$ch = curl_init();
curl_setopt( $ch, CURLOPT_URL, $apiurl . "?" . http_build_query( $apiParams ) );
curl_setopt( $ch, CURLOPT_HEADER, 0 );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
curl_setopt( $ch, CURLOPT_HTTPHEADER, array( $api_req->to_header() ) ); // Authorization header required for api
$data = curl_exec( $ch );
if( !$data ) {
	'Curl error: ' . curl_error( $ch );
}
echo "Returned: $data\n\n";


/**
 * Securely get the identity of the user
 */
$consumerSecret = '';

$extraSignedParams = array(
	'title' => 'Special:OAuth/identify'
);

$req = OAuthRequest::from_consumer_and_token(
	$c,
	$accessToken,
	"GET",
	$endpoint_id,
	$extraSignedParams
);
$req->sign_request( $rsa_method, $c, $accessToken );

echo "Calling:  '$endpoint_id'\nHeader: {$req->to_header()}\n\n";

$ch = curl_init();
curl_setopt( $ch, CURLOPT_URL, $endpoint_id );
curl_setopt( $ch, CURLOPT_HEADER, 0 );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
curl_setopt( $ch, CURLOPT_HTTPHEADER, array( $req->to_header() ) );
$data = curl_exec( $ch );
if( !$data ) {
	'Curl error: ' . curl_error( $ch );
}

$identity = JWT::decode( $data, $consumerSecret );

// Validate the JWT
if ( !validateJWT( $identity, $consumerKey, $req->get_parameter( 'oauth_nonce' ) ) ) {
	print "The JWT did not validate";
} else {
	print "We got a valid JWT, describing the user as:\n";
	print " * Username: {$identity->username}\n";
	print " * User's current groups: " . implode( ',', $identity->groups ) . "\n";
	print " * User's current rights: " . implode( ',', $identity->rights ) . "\n";
}


/**
 * Validate a JWT, to ensure this isn't a reply, spoof, etc.
 * @param $identity the decoded JWT
 * @param $consumerKey your App's Key
 * @param $nonce the nonce sent with your request, which should be returned
 */
function validateJWT( $identity, $consumerKey, $nonce ) {

	$expectedConnonicalServer = 'http://<wiki>';

	// Verify the issuer is who we expect (server sends $wgCanonicalServer)
	if ( $identity->iss !== $expectedConnonicalServer ) {
		print "Invalid Issuer";
		return false;
	}

	// Verify we are the intended audience
	if ( $identity->aud !== $consumerKey ) {
		print "Invalid Audience";
		return false;
	}

	// Verify we are within the time limits of the token. Issued at (iat) should be
	// in the past, Expiration (exp) should be in the future.
	$now = time();
	if ( $identity->iat > $now || $identity->exp < $now ) {
		print "Invalid Time";
		return false;
	}

	// Verify we haven't seen this nonce before, which would indicate a replay attack
	if ( $identity->nonce !== $nonce ) {
		print "Invalid Nonce";
		return false;
	}

	return true;
}

Golang demo cli client with HMAC

Before you begin:

$ go get github.com/mrjones/oauth
package main

import (
	"fmt"
	"os"
	"github.com/mrjones/oauth"
	"io/ioutil"
	"strconv"
)

func main() {

	var consumerKey string = ""
	var consumerSecret string = ""

	if len(consumerKey) == 0 || len(consumerSecret) == 0 {
		os.Exit(1)
	}

	c := oauth.NewConsumer(
		consumerKey,
		consumerSecret,
		oauth.ServiceProvider{
			RequestTokenUrl:   "http://<wiki>/wiki/index.php/Special:OAuth/initiate",
			AuthorizeTokenUrl: "http://<wiki>/wiki/index.php/Special:OAuth/authorize",
			AccessTokenUrl:    "http://<wiki>/wiki/index.php/Special:OAuth/token",
		})

	c.Debug(true)

	c.AdditionalParams = map[string]string{
		"title":   "Special:OAuth/initiate",
	}

	c.AdditionalAuthorizationUrlParams = map[string]string{
		"oauth_consumer_key": consumerKey,
	}

	requestToken, url, err := c.GetRequestTokenAndUrl("oob")
	if err != nil {
		fmt.Println( err )
	}

	fmt.Println( "Got token " + requestToken.Token )

	fmt.Println("(1) Go to: " + url)
	fmt.Println("(2) Grant access, you should get back a verification code.")
	fmt.Println("(3) Enter that verification code here: ")

	verificationCode := ""
	fmt.Scanln(&verificationCode)

	c.AdditionalParams = map[string]string{
		"title":   "Special:OAuth/token",
	}

	accessToken, err := c.AuthorizeToken(requestToken, verificationCode)
	if err != nil {
		fmt.Println(err)
	}

	fmt.Println( "Got access token " + accessToken.Token )

	c.AdditionalParams = map[string]string{}

	response, err := c.Get(
		"http://<wiki>/wiki/api.php",
		map[string]string{
			"action": "query",
			"meta": "userinfo",
			"uiprop": "rights",
			"format": "json",
		},
		accessToken)

	if err != nil {
		fmt.Println(err)
	}

	fmt.Println( "\tResponse Status: '" + response.Status + "'\n" )
	fmt.Println( "\tResponse Code: " + strconv.Itoa(response.StatusCode) + "\n" )
	bytes, _ := ioutil.ReadAll(response.Body)
	fmt.Println( "\tResponse Body: " + string(bytes) + "\n" )

}

Python

Libraries:

from mwoauth import ConsumerToken, Handshaker
import requests
from requests_oauthlib import OAuth1
from six.moves import input  # For compatibility between python 2 and 3

# Consruct a "consumer" from the key/secret provided by MediaWiki
import config  # You'll need to provide this

consumer_token = ConsumerToken(config.consumer_key, config.consumer_secret)

# Construct handshaker with wiki URI and consumer
handshaker = Handshaker("https://en.wikipedia.org/w/index.php",
                        consumer_token)

# Step 1: Initialize -- ask MediaWiki for a temporary key/secret for user
redirect, request_token = handshaker.initiate()

# Step 2: Authorize -- send user to MediaWiki to confirm authorization
print("Point your browser to: %s" % redirect)  #
response_qs = input("Response query string: ")

# Step 3: Complete -- obtain authorized key/secret for "resource owner"
access_token = handshaker.complete(request_token, response_qs)

# Construct an auth object with the consumer and access tokens
auth1 = OAuth1(consumer_token.key,
               client_secret=consumer_token.secret,
               resource_owner_key=access_token.key,
               resource_owner_secret=access_token.secret)

# Now, accessing the API on behalf of a user
print("Reading top 10 watchlist items")
response = requests.get(
    "https://en.wikipedia.org/w/api.php",
    params={
        'action': "query",
        'list': "watchlist",
        'wllimit': 10,
        'wlprop': "title|comment",
        'format': "json"
    },
    auth=auth1
)
for item in response.json()['query']['watchlist']:
    print("{title}\t{comment}".format(**item))

Ruby: OmniAuth strategy

For Ruby, you can use this MediaWiki strategy for OmniAuth (available under the MIT License): https://github.com/timwaters/omniauth-mediawiki also available as a gem: https://rubygems.org/gems/omniauth-mediawiki

Notes

  1. "Separation of Concerns", wikitech-l, June 2013
  2. https://gerrit.wikimedia.org/r/#/c/93859/ , Add OAuth identify method, November 2013