OAuth/Owner-only consumers

From mediawiki.org

Owner-only consumers are a method to use OAuth for authentication and permission control while avoiding most of the complexity of the OAuth protocol (which is in the grant authorization process). It's meant for bots and similar tools which always authenticate with the same user account. To use it, the target wiki must have version 1.27 or higher of the Extension:OAuth installed.

OAuth 2

Using an owner-only app in OAuth 2 is very simple. Just register the app via Special:OAuthConsumerRegistration/propose (make sure to set the protocol version to 2.0) with the option "owner-only" checked. (In case of a wikifarm, the special page is only available on the central wiki of the farm. In case of Wikimedia, it's at meta:Special:OAuthConsumerRegistration/propose.) Then, record the access token that's shown when submitting the form, and sign your API requests by adding the HTTP header Authorization: Bearer <access token>.

OAuth 1

To work as an owner-only consumer, the application must take four strings as configuration settings: the consumer key, the consumer secret, the access token and the access secret. The user can obtain those via Special:OAuthConsumerRegistration/propose. The option "owner-only" has to be checked. (In case of a wikifarm, the special page is only available on the central wiki of the farm. In case of Wikimedia, it's at meta:Special:OAuthConsumerRegistration/propose.)

The application can then authenticate API requests by adding an Authorization header which is computed from those parameters as defined in the OAuth 1.0a standard; libraries exist in many languages to help with this.

Some libraries call this the two-legged OAuth 1.0 protocol. The OAuth Bible more correctly calls it one-legged.

Some sources call the consumer key a "client ID", the consumer secret a "client secret", the access token just a "token", and the access secret a "token secret".

The code snippets below assume the application uses a shared secret (HMAC-SHA1) for signing (i.e., the RSA field was left empty at registration).

PHP

Using Oauthclient-php :

use MediaWiki\OAuthClient\Consumer;
use MediaWiki\OAuthClient\Token;
use MediaWiki\OAuthClient\Request;
use MediaWiki\OAuthClient\SignatureMethod\HmacSha1;

$consumer = new Consumer( $consumerKey, $consumerSecret );
$accessToken = new Token( $accessToken, $accessSecret );
$request = Request::fromConsumerAndToken( $consumer, $accessToken, 'GET', 'https://en.wikipedia.org/w/api.php', $apiParams );
$request->signRequest( new HmacSha1(), $consumer, $accessToken );
$authorizationHeader = $request->toHeader();
// Then use this header on a request, for example:
$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_URL => 'https://en.wikipedia.org/w/api.php',
    CURLOPT_USERAGENT => 'My_bot',  
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($apiParams),
    CURLOPT_HTTPHEADER => [$authenticationHeader],
]);
$result = curl_exec($ch);

Using the PECL package:

$oauth = new OAuth( $consumerKey, $consumerSecret );
$oauth->setToken( $accessToken, $accessSecret );
// Generate a header, perhaps to be sent via a CURL object:
$authorizationHeader = 'Authorization: ' . $oauth->getRequestHeader( 'GET', 'https://en.wikipedia.org/w/api.php', $apiParams );
// Or simply fetch a page directly:
$oauth->fetch('https://en.wikipedia.org/w/api.php', $apiParams);

Python

Using requests_oauthlib:

import requests
from requests_oauthlib import OAuth1

auth = OAuth1(consumer_key, consumer_secret, access_token, access_secret)
requests.post(url='https://en.wikipedia.org/w/api.php', data=data, auth=auth)

Perl

Using Net::OAuth:

use Net::OAuth;

my $request = Net::OAuth->request( 'protected resource' )->new(
    request_method => 'POST',
    request_url => 'https://en.wikipedia.org/w/api.php',
    consumer_key => $consumer_key,
    consumer_secret => $consumer_secret,
    token => $access_token,
    token_secret => $access_secret,
    signature_method => 'HMAC-SHA1',
    timestamp => time(),
    nonce => $nonce,

    # Only if using GET or POST as application/x-www-form-urlencoded,
    # omit extra_params if POSTing as multipart/form-data.
    extra_params => \%params, 
);
$request->sign();
my $authorizationHeader = $request->to_authorization_header();

To generate the nonce, you could just do something like int( rand( 2**32 ) ), but using a random number generator such as Bytes::Random::Secure would be more secure:

use Bytes::Random::Secure ();

# This object may be kept and reused for the lifetime of the program
my $rand = Bytes::Random::Secure->new( NonBlocking => 1 );

# This generates the nonce, and replaces some characters with safer ones.
my $nonce = $rand->bytes_base64( 15 );
$nonce =~ tr!+/\n!-_!;

Awk / shell

Using GNU Awk and openssl. Function library in Wikiget.

#
# MWOAuthGenerateHeader() - MediaWiki Generate OAuth Header
#
#   Credit: translation of PHP script https://www.mediawiki.org/wiki/OAuth/Owner-only_consumers#Algorithm
#          
function MWOAuthGenerateHeader(consumerKey, consumerSecret, accessKey, accessSecret, url, method, data,  

                               nonce,headerParams,dataArr,allParams,allParamsJoined,k,i,j,url2,
                               signatureBaseParts,signatureBaseString,hmac,header,save_sorted) {

  # sort associative arrays by index string ascending (lexicographic order)
    PROCINFO["sorted_in"] = "@ind_str_asc"

    nonce = strip(splitx(sys2varPipe(systime() randomnumber(1000000), "openssl md5"), "= ", 2))

    asplit(headerParams, "oauth_consumer_key=" consumerKey " oauth_token=" accessKey " oauth_signature_method=HMAC-SHA1 oauth_timestamp=" systime() " oauth_nonce=" nonce " oauth_version=1.0") 
    asplit(dataArr, data, "=", "&")
    concatarray(headerParams,dataArr,allParams)
    for (k in allParams) 
        allParamsJoined[i++] = k "=" allParams[k]

    url2 = urlElement(url, "scheme") "://" tolower(urlElement(url, "netloc")) urlElement(url, "path")
    asplit(signatureBaseParts, "0=" toupper(method) " 1=" url " 2=" join(allParamsJoined, 0, length(allParamsJoined) - 1, "&"))
    signatureBaseString = urlencodeawk(signatureBaseParts[0], "rawphp") "&" urlencodeawk(signatureBaseParts[1], "rawphp") "&" urlencodeawk(signatureBaseParts[2], "rawphp")
    hmac = sys2varPipe(signatureBaseString, "openssl sha1 -hmac " shquote(urlencodeawk(consumerSecret, "rawphp") "&" urlencodeawk(accessSecret, "rawphp")) " -binary")
    headerParams["oauth_signature"] = strip(sys2varPipe(hmac, "openssl base64") )

    for (k in headerParams) 
        header[j++] = urlencodeawk(k, "rawphp") "=" urlencodeawk(headerParams[k], "rawphp")

    return sprintf("%s", "Authorization: OAuth " join(header, 0, length(header) - 1, ", "))
}

Java

Using ScribeJava:

import java.io.*;
import java.net.*;
import java.nio.charset.*;
import java.util.*;

import com.github.scribejava.apis.MediaWikiApi;
import com.github.scribejava.core.builder.*;
import com.github.scribejava.core.model.*;
import com.github.scribejava.core.oauth.*;

public class MediaWikiOAuth1 {

    private URL apiUrl = new URL("https://commons.wikimedia.org/w/api.php");

    private String consumerToken = "";
    private String consumerSecret = "";
    private String accessToken = "";
    private String accessSecret = "";
    private String userAgent = ""; // https://meta.wikimedia.org/wiki/User-Agent_policy

    private OAuth1AccessToken oAuthAccessToken;
    private OAuth10aService oAuthService;
    private String csrfToken;

    public MediaWikiOAuth1() throws IOException {
        oAuthService = new ServiceBuilder(consumerToken).apiSecret(consumerSecret).build(MediaWikiApi.instance());
        oAuthAccessToken = new OAuth1AccessToken(accessToken, accessSecret);

        // Check authentication
        System.out.println(queryUserInfo());
        // Fetch CSRF token, mandatory for upload using the Mediawiki API
        System.out.println(queryTokens());
        csrfToken = ""; // should be extracted from above JSON response
        // Upload a file using the CSRF token
        System.out.println(upload("wikicode", "filename.jpg", new URL("http://server/filename.jpg")));
    }

    public String queryTokens() throws IOException {
        return apiHttpGet("?action=query&meta=tokens");
    }

    public String queryUserInfo() throws IOException {
        return apiHttpGet("?action=query&meta=userinfo&uiprop=blockinfo|groups|rights|ratelimits");
    }

    private String apiHttpGet(String path) throws IOException {
        return httpGet(apiUrl.toExternalForm() + path + "&format=json");
    }

    private String apiHttpPost(Map<String, String> params) throws IOException {
        return httpPost(apiUrl.toExternalForm(), params);
    }

    private String httpGet(String url) throws IOException {
        return httpCall(Verb.GET, url, Collections.emptyMap(), Collections.emptyMap());
    }

    private String httpPost(String url, Map<String, String> params) throws IOException {
        return httpCall(Verb.POST, url, Map.of("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"),
                params);
    }

    private String httpCall(Verb verb, String url, Map<String, String> headers, Map<String, String> params)
            throws IOException {
        OAuthRequest request = new OAuthRequest(verb, url);
        request.setCharset(StandardCharsets.UTF_8.name());
        params.forEach(request::addParameter);
        headers.forEach(request::addHeader);
        request.addHeader("User-Agent", userAgent);
        oAuthService.signRequest(oAuthAccessToken, request);
        try {
            return oAuthService.execute(request).getBody();
        } catch (InterruptedException | ExecutionException e) {
            throw new IOException(e);
        }
    }

    public String upload(String wikiCode, String filename, URL url) throws IOException {
        String apiResponse = apiHttpPost(Map.of(
                "action", "upload", 
                "comment", "", 
                "format", "json", 
                "filename", filename, 
                "ignorewarnings", "1", 
                "text", wikiCode, 
                "token", csrfToken, 
                "url", url.toExternalForm()));

        if (!apiResponse.contains("success")) {
            throw new IllegalArgumentException(apiResponse);
        }
        return apiResponse;
    }
}

Algorithm

 Authorization: OAuth oauth_consumer_key="<consumer key>",
                      oauth_token="<access token>",
                      oauth_signature_method="HMAC-SHA1",
                      oauth_signature="<base64(HMAC_SHA1(key="<consumer secret>&<token secret>", text="<signature base string>"))>",
                      oauth_timestamp="<current time as unix timestamp>",
                      oauth_nonce="<a long random string>",
                      oauth_version="1.0"

where <signature base string> is the urlencoded, &-concatenated list of the request method, the request endpoint (ie. the full URL to api.php), and all the parameters of the request (GET, POST, and Authorization header, except oauth_signature itself) in lexicographic order.

For example, computing the header in PHP would look like this (cutting some corners such as nested parameter handling):

function oauthHeader( $consumerKey, $consumerSecret, $accessToken, $accessSecret, $method, $url, $data ) {
    $headerParams = [
        'oauth_consumer_key' => $consumerKey,
        'oauth_token' => $accessToken,
        'oauth_signature_method' => 'HMAC-SHA1',
        'oauth_timestamp' => time(),
        'oauth_nonce' => md5( microtime() . mt_rand() ),
        'oauth_version' => '1.0',
    ];

    $allParams = array_merge( $headerParams, $data );
    uksort( $allParams, 'strcmp' );
    $allParamsJoined = array();
    foreach ( $allParams as $key => $value ) {
        $allParamsJoined[] = rawurlencode( $key ) . '=' . rawurlencode( $value );
    }

    $urlParts = parse_url( $url );
    $url = $urlParts['scheme'] . '://' . strtolower( $urlParts['host'] ) . $urlParts['path'];

    $signatureBaseParts = [ strtoupper( $method ), $url, implode( '&', $allParamsJoined ) ];
    $signatureBaseString = implode( '&', array_map( 'rawurlencode', $signatureBaseParts ) );
    $headerParams['oauth_signature'] = base64_encode( hash_hmac( 'sha1', $signatureBaseString, rawurlencode( $consumerSecret ) . '&' . rawurlencode( $accessSecret ), true ) );

    $header = array();
    foreach ( $headerParams as $key => $value ) {
        $header[] = rawurlencode( $key ) . '=' . rawurlencode( $value );
    }
    return 'Authorization: OAuth ' . implode( ', ', $header );
}

See also