A coordinated dance to identify the editor

Adding login logic - rationale In a previous article, I described how to take a web page source file, edit it, then send the edited source back to Github. In that post, the authorisation token id-token was one I generated for myself as a Github user. It would also be possible to assign to the hallowed glade (see previous article) an authorisation for a pseudo user or bot specifically set up to take user generated editing. However, when the edit is merged into the source, the author of the commit would be the bot, and not the author of the edit. Suppose, though, we want a 'credits' page listing authors and the number of commits they have made. We could run a command in the repo such as: git log --pretty="%an %n" | sort | uniq -c | sort -n -r and filter the results into a table. But authors who have entered our hallowed glade will not be listed. Another concern is spamming. If we require a community user to have a Github identity, then Github will handle authorisation. If there is spamming by a forest troll, it is in Github's best interest to hold that troll to account. This post explains (mostly to myself) the steps that are needed to insert a login layer. When I finally got the whole thing working, it was like a weird dance between three different entities. Looking at the documentation for the first time, all the steps seemed odd and unrelated, but once they were all put into motion, there is a logic to the whole thing. Overview We already have a hallowed glade, which is the suggestion_box server. The server runs in a docker container, and has a Cro server to handle input from a websocket, a Cro client to interact with Github to create a new branch, store the edited file, and raise a Pull Request. Github allows the owner of a repo to register a 'Github app' and assign it permissions. So, the token granted during the authorisation process is a combination of the permissions of the app and those of a user. It is always scary for a user to have to authorise someone else to do something on their behalf, so here is what Github says about the authorisation token: A token has the same capabilities to access resources and perform actions on those resources that the owner of the token has, and is further limited by any scopes or permissions granted to the token. A token cannot grant additional access capabilities to a user. Github authorisation documentation Coordination Here is diagram of the interaction between the browser, the server (in our case suggestion_box) and Github from a great article by Tony on OAuth2: Suppose we add in the request that if an editor wants to make edits on several documents (which means the page is refreshed), then it is inefficient for Github to generate an id-token for each page. We can keep an object in local storage with the name of the editor and the time the first submission is accepted. Note that Github issues its web tokens for a fixed period of time, by default 8 hours. A pre-requisite is that the Github app, which is our suggestion-box is already registered with Github. Steps The brief laid out above suggests the following series of steps: When a submission is made, the editor field in the submission form must be consistent with Github rules: Github has a user name policy of only alphanumeric characters + -, with a minimum of three characters and a maximum of 39. Letters are case insensitive and other characters are replaced by -. The browser checks to see if the editor has made a successful submission within the last 8 hours, and that 10 minutes is still left. if there is no or insufficient time, the local storage is deleted. If the editor has not been verified, the editor is sent to the Github login page, together with a state string that contains information for the suggestion_box to be able to issue a successful submission message back to the browser. Whether or not the editor has been verified, and in parallel with the Github verification, the suggestion information is sent to the suggestion_box as outlined in my previous article. Inside the suggestion_box server: The editor is verified to see whether an id-token with enough time (10 minutes) is available. If so, the suggestion is queued, and a successful message is sent back together with the remaining time on the token. If there is not enough time left, a failure message is sent back from the server to the browser. If there is no id-token and ten minutes have passed after the suggestion was received with no message from Github, a failure message is sent back to the browser. When a message is received from Github, it is compared with waiting suggestions. If there is not a match between the state field of a suggestion, the Github message is ignored. If the state matches a waiting suggestion, the suggestion-box server starts the process to get a id-token. If successful, the suggestion is queued and a message is sent back to the brows

Apr 25, 2025 - 16:16
 0
A coordinated dance to identify the editor

Adding login logic - rationale

In a previous article, I described how to take a web page source file, edit it, then send the edited source back to Github.

In that post, the authorisation token id-token was one I generated for myself as a Github user. It would also be possible to assign to the hallowed glade (see previous article) an authorisation for a pseudo user or bot specifically set up to take user generated editing.

However, when the edit is merged into the source, the author of the commit would be the bot, and not the author of the edit.

Suppose, though, we want a 'credits' page listing authors and the number of commits they have made. We could run a command in the repo such as:

git log --pretty="%an %n" | sort | uniq -c | sort -n -r

and filter the results into a table. But authors who have entered our hallowed glade will not be listed.

Another concern is spamming. If we require a community user to have a Github identity, then Github will handle authorisation. If there is spamming by a forest troll, it is in Github's best interest to hold that troll to account.

This post explains (mostly to myself) the steps that are needed to insert a login layer.

When I finally got the whole thing working, it was like a weird dance between three different entities. Looking at the documentation for the first time, all the steps seemed odd and unrelated, but once they were all put into motion, there is a logic to the whole thing.

Overview

We already have a hallowed glade, which is the suggestion_box server. The server runs in a docker container, and has a Cro server to handle input from a websocket, a Cro client to interact with Github to create a new branch, store the edited file, and raise a Pull Request.

Github allows the owner of a repo to register a 'Github app' and assign it permissions. So, the token granted during the authorisation process is a combination of the permissions of the app and those of a user.

It is always scary for a user to have to authorise someone else to do something on their behalf, so here is what Github says about the authorisation token:

A token has the same capabilities to access resources and perform actions on those resources that the owner of the token has, and is further limited by any scopes or permissions granted to the token. A token cannot grant additional access capabilities to a user. Github authorisation documentation

Coordination

Here is diagram of the interaction between the browser, the server (in our case suggestion_box) and Github from a great article by Tony on OAuth2:

Browser/Server/Github

Suppose we add in the request that if an editor wants to make edits on several documents (which means the page is refreshed), then it is inefficient for Github to generate an id-token for each page.

We can keep an object in local storage with the name of the editor and the time the first submission is accepted.

Note that Github issues its web tokens for a fixed period of time, by default 8 hours.

A pre-requisite is that the Github app, which is our suggestion-box is already registered with Github.

Steps

The brief laid out above suggests the following series of steps:

  1. When a submission is made, the editor field in the submission form must be consistent with Github rules:
    • Github has a user name policy of only alphanumeric characters + -, with a minimum of three characters and a maximum of 39.
    • Letters are case insensitive and other characters are replaced by -.
  2. The browser checks to see if the editor has made a successful submission within the last 8 hours, and that 10 minutes is still left.
    • if there is no or insufficient time, the local storage is deleted.
  3. If the editor has not been verified, the editor is sent to the Github login page, together with a state string that contains information for the suggestion_box to be able to issue a successful submission message back to the browser.
  4. Whether or not the editor has been verified, and in parallel with the Github verification, the suggestion information is sent to the suggestion_box as outlined in my previous article.

Inside the suggestion_box server:

  1. The editor is verified to see whether an id-token with enough time (10 minutes) is available. If so, the suggestion is queued, and a successful message is sent back together with the remaining time on the token.
  2. If there is not enough time left, a failure message is sent back from the server to the browser.
  3. If there is no id-token and ten minutes have passed after the suggestion was received with no message from Github, a failure message is sent back to the browser.
  4. When a message is received from Github, it is compared with waiting suggestions. If there is not a match between the state field of a suggestion, the Github message is ignored.
  5. If the state matches a waiting suggestion, the suggestion-box server starts the process to get a id-token. If successful, the suggestion is queued and a message is sent back to the browser containing the success and the expiration time.
    • if unsuccessful, a failure message is sent back to the browser

In this scheme,

  • the browser never sees the editor's id-token.
  • the actual name provided by the editor in the suggestion form does not have to be the Github name of the editor because the Github authorisation is independent of the editor's name, but future submissions must use the same editor name to use the id-token, which is time-limited in any case.
  • an editor may edit several files in one session.

The Cro setup

In the previous post, the Cro app had a route for the websocket and a Client section to handle putting suggestions into Github.

We need to modify the Client section to use the editor's id-token, but essentially it remains the same.

We also need to add a route which is used by Github to send login information to the server. In addition, there needs to be a way for the webserver route and the authorisation route to interact.

These modifications imply a shared resource to match editor-name, id-token, id-token expiration date. Since Cro assumes concurrency, the storage has to be thread-safe and ensure that only one thread at a time can access it.

Although the Cro documentation uses OO::Monitor for this purpose, I prefer the simpler Method::Protected module. The is protected trait ensures that only one thread at a time can access the shared resource. For example,

use Method::Protected;
class Editor-Store {
    #| has key = editor, with two attributes :token and :time (a Date::Time)
    has %!storage;
    #| if False, makes sure editor key is deleted
    method is-editor-active( $editor --> Bool) is protected {
        return False unless %!storage{ $editor }:exists;
        return True if %!storage{ $editor } > (now.DateTime + Duration.new(10 * 60));
        sink %!storage{$editor}:delete;
        return False
    }
    #| if there is no editor, an emptry string is returned
    method get-token( $editor --> Str) is protected {
        if self.is-editor-active( $editor ) { %!storage{$editor} }
        else { '' }
    }
    #| expiration date
    method expiration( $editor ) is protected { %!storage{$editor} }
    #| returns the remaining time in seconds as an integer
    method time-remaining( $editor --> Int ) is protected {
        if self.is-editor-active( $editor ) { ( %!storage{ $editor } - now.DateTime).Int }
        else { 0 }
    }
    #| adds editor
    method add-editor( Str $editor, DateTime $expiration, Str $token ) is protected {
        %!storage{$editor} = :$expiration, :$token;
    }
}

The Websocket route of the Cro app needed refactoring completely. Instead of getting all the information for an edit from the browser, typically, the edit suggestion comes first but the editor and their id-token comes later. So the Webocket needs to create a promise and also to put a timer on it.

My first-draft solution is (the code needs to be cleaned up somewhat):

get -> 'suggestion_box' {
            web-socket :json, -> $incoming {
                supply whenever $incoming -> $message {
                    my $json = await $message.body;
                    # first filter out the handshake signal for opening a websocket
                    if $json {
                        say strftime(DateTime.now, '%v %R') ~ ': connection made'
                            if $debug;
                        emit({ :connection })
                    }
                    else {
                        if $debug {
                            say strftime(DateTime.now, '%v %R') ~ ': got suggestion, now at ' ~ +@suggestions;
                            for $json.kv -> $k, $v {
                                say "KEY $k =>\n$v"
                            }
                            say "edit suggestion finished\n";
                        }
                        my $response = sanitise( $json ); # sanitise returns an error message or 'ok'
                        my $editor := $json;
                        if $response ne 'OK' {
                            emit( {
                                :timestamp( DateTime.now.Str ),
                                :$response,
                                :$editor,
                            })
                        }
                        # handle the socket with an active editor
                        elsif $store.is-editor-active($editor) {
                            say strftime(DateTime.now, '%v %R') ~ ': editor is registered' if $debug;
                            # too little time left for token
                            if $store.time-remaining( $editor ) <= 10 * 60 {
                                say strftime(DateTime.now, '%v %R') ~ ': not enough time' if $debug;
                                emit( {
                                    :timestamp( DateTime.now.Str ),
                                    :response,
                                    :$editor,
                                })
                            }
                            else {
                                say strftime(DateTime.now, '%v %R') ~ ': handling with stored token' if $debug;
                                $json = $store.get-token($editor);
                                @suggestions.push: $json;
                                emit( {
                                    :timestamp( DateTime.now.Str),
                                    :response,
                                    :$editor,
                                    :expiration( strftime($store.expiration($editor), '%v %R'))
                                } )
                            }
                        }
                        # the editor does not have a token, but may have in some period of time
                        else {
                            say strftime(DateTime.now, '%v %R') ~ ': editor without authorisation' if $debug;
                            my $timestamp;
                            my $response = 'NoAuthorisation';
                            my $expiration = '';
                            my $token = '';
                            my Promise $tapped-out .= new;
                            my $tap = $see-new-auths.tap( -> %a {
                                if %a eq $editor {
                                    $timestamp = DateTime.now.Str;
                                    $response = 'OK';
                                    $token = %a;
                                    $expiration = %a;
                                    $tapped-out.keep;
                                }
                            });
                            await Promise.anyof(
                                $tapped-out,
                                my $timer = Promise.in($auth-wait-time).then: {
                                    $response = 'NoAuthorisation';
                                }
                            ).then( { $tap.close } );
                            if $response eq 'OK' {
                                $json = $token;
                                @suggestions.push: $json;
                                say strftime(DateTime.now, '%v %R') ~ ": authorising suggestion {$json.raku}, now at " ~ +@suggestions if $debug;
                            }
                            emit( %( :$timestamp, :$response, :$expiration, :$editor) )
                        }
                    }
                }
            }

The $tapped-out Promise is created so that it can be kept with .keep inside the code that listens for an authorisation event.

Then a separate Promise composed of the $tapped-out Promise and a timer Promise is created so that whichever comes first triggers the next step. If the timer exits before an authorisation, then the edit suggestion is discarded, otherwise it is combined with the id-token and queued.

In the code section above, the webserver is a supply and when the emit sub is called, the hash argument is mapped by Cro into a JSON object and returned to the browser that has connected to the Cro app. So it can be picked by the Javascript's websocket's onmessage function, and the data used by the browser program.

Authorisation event

When a Github app is registered, a route is supplied for the authorisation data. Consequently, the server section of the Cro app has to be set up to service this route. I chose the route /raku-auth (and in my code comes before the websocket, but since we are dealing with concurrent processes, the order of websocket and raku-auth is irrelevant):

get -> 'raku-auth', :%params {
            CATCH {
                default {
                    content 'text/html', '

Raku documentation

Authorisation error.

Please report'; say 'error is: ', .message; for .backtrace.reverse { next if .file.starts-with('SETTING::'); next unless .subname; say " in block { .subname } at { .file } line { .line }"; last if .file.starts-with('NQP::') } } } my %decoded = from-json( base64-decode( %params).decode ); say strftime(DateTime.now, '%v %R') ~ ': got from Github params: ', %params , ' state decoded: ', %decoded if $debug; my $editor = %decoded; my $resp = await Cro::HTTP::Client.post( "https://github.com/login/oauth/access_token", query => %( :$client_id, :$client_secret, :code( %params ), ), ); # Github returns an object with keys access_token, expires_in (& others not needed) my $body = await $resp.body; my %data = $body.decode.split('&').map(|*.split("=",2)); # first store the data for future suggestions my $token = %data; my $expiration = now.DateTime + Duration.new( %data); $store.add-editor($editor, $expiration, $token ); # next put the data in a stream for suggestions that have already arrived $auth-stream.emit( %( :$editor, :$expiration, :$token ) ); content 'text/html', '

Raku documentation

Editing has been authorised.

Thank you'; } get -> 'suggestion_box' {

The CATCH phaser is only ever triggered if there is an error processing the route. If it is triggered, then the string after content is sent back to Github, which displays it in the tab containing the authorisation button. The HTML could be improved.

At the end of the code, the content sub similarly sends back an HTML string to indicated authorisation is successful.

When a route has a query appended to it, the Cro route function get captures the data into a named hash, which I have called %params. Github recommends that a state variable is sent when sending the user to Github for authorisation. I have chosen to send the name submitted by the editor with Base64 encoding. Since the editor name in the form and the user's Github id could be different, this adds some complexity to improve security.

I have to say that the Cro syntax is easy to work with. When the data is transmitted as a JSON, there is the named :body field, and with the data is transmitted as a query, there is the named :query field. Otherwise the get sub has a consistent syntax. Compare this to a cURL command.

The parameters sent to the route include a code field, which is then returned (again as a query) to Github. When it receives the code, it returns the id-token for the editor. This two-fold handshake makes it difficult to impersonate someone else.

In addition, Github returns the number of seconds the id-token is valid for. So this needs to be combined into a DateTime both for the suggestion-box server and the browser.

Finally, the editor name, id-token and expiration date are both stored in a thread-safe Hash, and placed in an event stream into which the websocket has tapped.

Raku's concurent structures make it easy to set up the event loop into which the raku-auth route injects information and the websocket listens for information. At the start of the code we have

my $auth-stream = Supplier.new;
my $see-new-auths = $auth-stream.Supply;

As can be seen from the code fragments, the raku-auth route has the line $auth-stream.emit( %(...) ) and the websocket code has the line my $tap = $see-new-auths.tap( -> %a { ... }).

The first stanza supplies an item, in this case as hash, while the second listens for all items supplied. The second then determines which item to react to.

Keeping secrets

One of the pieces of data required by a Github app is a 'secret' for the app. There are also several items to be provided to the Cro app.

Since this Cro app is intended for a docker container, the configuration data is conveyed in Environment variables. So at the start of the app, several secret variables are extracted as (just an example here):

my $client_id = %*ENV;

and these data can be temporarily saved in an environment file env-file. Then when the docker image is invoked the data can be supplied:

sudo docker run -d --rm --env-file env-file my-docker-image

Treading on toes

As can be seen from the diagram above, getting authorisation is delicate dance, and it took me days to stop the browser, Github and the suggestion-box treading on each others' toes.

First, the interaction between Github and the suggestion-box server to exchange a code for an id-token are all conducted using the query format. That is a url ending ?data-item=stuff&item-two=nonsense, which is in turn Base64 encoded. All the Github API calls use JSON data with authorisation headers.

In hindsight, we can recognise that the OAuth protocol is an industry standard, while the Github API protocols are not, so can be different. But it took me a while to work out what the strange data was being received by the server. I did not come across this behaviour as being explicitly documented.

Second, Github requires that the suggestion-box server is registered by the owner of the repo as a Github app and specific permissions need to be allocated to it. There are dozens of permissions in over a dozen categories and the correct ones have to be given to the Github app. I thought - mistakenly - that the permissions for Pull requests was what I needed.

Actually this was the last bug I had to overcome and it took a couple of days to figure out that the error was not in the server code, but that I had not allocated enough permissions.

To summarise, the suggestion-box server uses four separate Github API calls and the permissions are different:

  • api.github.com/repos/{$repo-name}/git/ref/heads/main to get the commit sha for the repo-name - this is public data and the permission is mandatory for an App, so it does not need to be specifically added
  • api.github.com/repos/{$repo-name}/git/refs (with data) to create a reference - this requires the Contents permission with read/write
  • api.github.com/repos/{$repo-name}/contents/{$file-path} to supply the edited - this requires the Contents permission
  • api.github.com/repos/{$repo-name}/pulls to create a pull request of the new branch - this requires the Pull Request permission

Final thoughts

Some 'simple' requests have complex solutions. Although Cro and Raku's concurrent structures take a while to understand, they are easy to apply.