• Publicity: Public Only All

authorize-procs.tcl

Support for oauth authorization API

This file defines the following Objects and Classes: ::xo::oauth::GitHub[i], ::xo::Authorize[i], ::xo::oauth::GitHub[i], ::xo::Authorize[i]

Location:
packages/xooauth/tcl/authorize-procs.tcl
Author:
Gustaf Neumann

Procedures in this file

Detailed information

Class ::xo::Authorize (public)

 ::nx::Class ::xo::Authorize[i]

Base class to support OAuth authorization API

Testcases:
No testcase defined.

Class ::xo::oauth::GitHub (public)

 ::nx::Class ::xo::oauth::GitHub[i]

Tailored OAuth handler for GitHub

Testcases:
No testcase defined.

xo::Authorize method login_url (public)

 <instance of xo::Authorize[i]> login_url [ -return_url return_url ] \
    [ -login login ]

Returns the URL for log-in

Switches:
-return_url (optional)
-login (optional)

Testcases:
No testcase defined.

xo::Authorize method logout (public)

 <instance of xo::Authorize[i]> logout

Perform logout operation from oauth in the background (i.e. without a redirect) when the logout_url is nonempty.

Testcases:
No testcase defined.

xo::Authorize method name (public)

 <instance of xo::Authorize[i]> name
Returns:
instance name

Testcases:
No testcase defined.

xo::Authorize method perform_login (public)

 <instance of xo::Authorize[i]> perform_login [ -token token ] \
    [ -state state ]

Get the provided claims from the identity provider and perform an OpenACS login, when the user exists. In case the user does not exist, create it optionally (when "create_not_registered_users" is activated. When the user is created, and dotlrn is installed, the new user might be added optionally as a dotlrn user with the role as specified in "create_with_dotlrn_role".

Switches:
-token (optional)
-state (optional)

Testcases:
No testcase defined.

xo::oauth::GitHub method get_user_data (public)

 <instance of xo::oauth::GitHub[i]> get_user_data [ -token token ] \
    [ -required_fields required_fields ]

Get user data based on the temporary code (passed via "-token") provided by GitHub. First, convert the temporary code into an access_token, and use this to get the user data.

Switches:
-token (optional)
-required_fields (optional, defaults to " {email email} {name name} ")

Testcases:
No testcase defined.

xo::oauth::GitHub method logout_url (public)

 <instance of xo::oauth::GitHub[i]> logout_url \
    [ -return_url return_url ]

Returns the URL for logging out. E.g., GitHub has no logout, so provide simply a redirect URL (maybe, we should logout from the application?)

Switches:
-return_url (optional)

Testcases:
No testcase defined.
[ hide source ] | [ make this the default ]

Content File Source

::xo::library doc {
    Support for oauth authorization API

    @author Gustaf Neumann
}

::xo::library require rest-procs

#
# Namespace of oauth handlers
#
namespace eval ::xo::oauth {}

namespace eval ::xo {

    ###########################################################
    #
    # xo::Authorize class
    #
    ###########################################################

    nx::Class create ::xo::Authorize -superclasses ::xo::REST {
        #
        # Base class to support OAuth authorization API
        #

        :property {pretty_name}
        :property {base_url}
        :property {responder_url}

        :property {after_successful_login_url /pvt/}
        :property {login_failure_url /}

        :property {create_not_registered_users:switch false}
        :property {create_with_dotlrn_role ""}

        :property {scope}
        :property {debug:switch false}

        :method qualified {partial_url} {
            return [util_current_location]$partial_url
        }

        :method encoded_state {{-return_url ""}} {
            set state [::xo::oauth::nonce]
            append state . [ns_base64urlencode $return_url]
            return $state
        }

        :method decoded_state {state} {
            lassign [split $state .] nonce encoded_url
            return [list \
                        nonce $nonce \
                        return_url [ns_base64urldecode $encoded_url]]
        }


        :public method login_url {
            {-return_url ""}
            {-login}
        } {
            #
            # Returns the URL for log-in
            #
            set base ${:base_url}/authorize
            set client_id ${:client_id}
            set scope ${:scope}
            set state [:encoded_state -return_url $return_url]
            set redirect_uri [:qualified ${:responder_url}]

            return [export_vars -no_empty -base $base {
                client_id redirect_uri state scope login
            }]
        }

        :method redeem_code {code} {
            set client_id ${:client_id}
            set client_secret ${:client_secret}
            set redirect_uri [:qualified ${:responder_url}]
            set url [export_vars -no_empty -base ${:base_url}/access_token {
                client_id client_secret code redirect_uri
            }]
            set data [ns_http run $url]
            if {[dict get $data status] ne 200} {
                dict set data error oacs-cant_redeem_code
                dict set data error_description $data
            } else {
                set form_data [ns_set array [ns_parsequery [dict get $data body]]]
                ns_log notice "[self] redeem_code formdata has keys: [lsort [dict keys $form_data]]"
                if {![dict exists $form_data access_token]} {
                    if {[dict exists $form_data error]} {
                        dict set data error [dict get $form_data error]
                        dict set data error_description [dict get $form_data error_description]
                    } else {
                        dict set data error oacs-no_access_token
                        dict set data error_description $form_data
                    }
                } else {
                    dict set data access_token [dict get $form_data access_token]
                }
            }
            ns_log notice "[self] redeem_code returns $data"
            return $data
        }

        :public method logout {} {
            #
            # Perform logout operation from oauth in the background
            # (i.e. without a redirect) when the logout_url is
            # nonempty.
            #
            set url [:logout_url]
            if {$url ne ""} {
                ns_http run $url
            }
        }

        :public method name {} {
            #
            # @return instance name
            #
            return [expr {[info exists :pretty_name]
                          ? ${:pretty_name}
                          : [namespace tail [self]]}]
        }


        :method lookup_user_id {-email} -returns integer {
            set user_id [party::get_by_email -email [string tolower $email]]
            if {$user_id eq ""} {
                #
                # Here one could do some more checks or alternative lookups
                #
            }
            return [expr {$user_id eq "" ? 0 : $user_id}]
        }

        :method required_fields {} {
            return [expr {${:create_not_registered_users}
                          ? "email given_name family_name"
                          : "email"}]
        }

        :method get_required_fields {
            {-claims:required}
            {-mapped_fields:required}
        } {
            #
            # Check, if required fields are provided in the claims and
            # perform the name mapping between what was provided from
            # the identity provided and what we need in OpenACS.
            #
            set result ""

            set fields {}
            foreach pair $mapped_fields {
                lassign $pair field target
                if {[dict exists $claims $field]} {
                    dict set fields $target [dict get $claims $field]
                }
            }
            dict set result fields $fields
            foreach field [:required_fields] {
                if {![dict exists $fields $field]
                    || [dict get $fields $field] in {"" "null"}
                } {
                    set not_enough_data $field
                    break
                }
            }

            if {[info exists not_enough_data]} {
                ns_log warning "[self] get_user_data: not enough data:" \
                    $not_enough_data "is missing" \
                    "($field -> $target, claims: $claims)"
                dict set result error oacs-not_enough_data
            }
            return $result
        }

        :method record_oauth_registration {user_id} {
            #
            # Record the fact that this user_id was created via an
            # OAuth identity provider.
            #
            set auth_obj [self]
            db_dml _ {
                INSERT INTO xooauth_authorized_users (user_id, auth_obj)
                VALUES (:user_id, :auth_obj)
            }
        }

        :method register_new_user {
            {-first_names}
            {-last_name}
            {-email}
        } -returns integer {
            #
            # Register the user and return the user_id. In case, the
            # registration of the new user fails, raise an exception.
            #
            # not tested
            #
            db_transaction {
                set user_info(first_names) $first_names
                set user_info(last_name) $last_name
                if {![util_email_unique_p $email]} {
                    error "Email is not unique: $email"
                }
                set user_info(email) $email
                array set creation_info [auth::create_local_account \
                                             -authority_id [auth::authority::local] \
                                             -username $email \
                                             -array user_info]
                if {$creation_info(creation_status) ne "ok"} {
                    set errorMsg ""
                    error [append errorMsg "Error when creating user: " \
                               $creation_info(creation_status) " " \
                               $creation_info(element_messages)]
                }

                set user_id $creation_info(user_id)
                :record_oauth_registration $user_id

                if {[apm_package_installed_p dotlrn] && ${:create_with_dotlrn_role} ne ""} {
                    #
                    # We have DotLRN installed, and we want to create
                    # for this register object the new users in the
                    # provided role. Note that one can define
                    # different instances of this class behaving
                    # differently.
                    #
                    dotlrn::user_add \
                        -type ${:create_with_dotlrn_role} \
                        -can_browse=1 \
                        -id $email \
                        -user_id $user_id

                    ::permission::grant \
                        -party_id $user_id \
                        -object_id [dotlrn::get_package_id] \
                        -privilege read_private_data
                }
            } on_error {
                ns_log error "OAuth Login (Error during user creation): $errmsg ($email)"
                error "OAuth user creation failed: $errmsg"
            }
            return $user_id
        }

        :public method perform_login {-token {-state ""}} {
            #
            # Get the provided claims from the identity provider and
            # perform an OpenACS login, when the user exists.
            #
            # In case the user does not exist, create it optionally (when
            # "create_not_registered_users" is activated.
            #
            # When the user is created, and dotlrn is installed, the
            # new user might be added optionally as a dotlrn user with
            # the role as specified in "create_with_dotlrn_role".
            #
            set data [:get_user_data -token $token]
            if {[dict exists $data error]} {
                #
                # There was already an error in the steps leading to
                # this.
                #
                ns_log warning "[self] OAuth login failed:" \
                    [dict get $data error] "\n$data"

            } elseif {![dict exists $data email]} {
                #
                # No error and no email in result... actually, this
                # should not happen.
                #
                dict set data error oacs-no_email_in_result
                ns_log warning "OAuth login failed strangely: " \
                    [dict get $data error] "\n$data"

            } else {
                dict set data decoded_state [:decoded_state $state]
                set user_id [:lookup_user_id -email [dict get $data email]]
                if {!${:debug}
                    && $user_id == 0
                    && ${:create_not_registered_users}
                } {
                    try {
                        :register_new_user \
                            -first_names [dict get $data given_name] \
                            -last_name [dict get $data family_name] \
                            -email [dict get $data email]
                    } on ok {result} {
                        set user_id $result
                    } on error {errorMsg} {
                        dict set data error oacs-register_failed
                        dict set data error_description $errorMsg
                    }
                }
                dict set data user_id $user_id
                if {$user_id != 0} {
                    #
                    # The lookup of the user_id was successful. We can
                    # login as this user.... but only, when no "debug"
                    # is activated.
                    #
                    if {!${:debug}} {
                        ad_user_login -external_registry [self$user_id
                    }
                } else {
                    #
                    # For the time being, just report data back to the
                    # calling script.
                    #
                    dict set data error "oacs-no_such_user"
                }
            }
            return $data
        }
    }

    ####################################################################
    #
    # ::xo::oauth::GitHub class
    #
    ####################################################################

    nx::Class create ::xo::oauth::GitHub -superclasses ::xo::Authorize {
        #
        # Tailored OAuth handler for GitHub
        #

        :property {pretty_name "GitHub"}
        :property {base_url https://github.com/login/oauth}
        :property {responder_url /oauth/github-login-handler}
        :property {scope {read:user user:email}}

        :method get_api_data {access_token} {
            set data [ns_http run \
                          -headers [ns_set create query Authorization "Bearer $access_token"] \
                          https://api.github.com/user]
            if {[dict get $data status] ne 200} {
                dict set result error oacs-cant_get_api_data
                dict set result error_description $data
            } else {
                set json_body [dict get $data body]
                dict set result claims [:json_to_dict $json_body]
            }
            ns_log notice "[self] get_api_data $access_token returns $result"
            return $result
        }

        :public method get_user_data {
            -token
            {-required_fields {
                {email email}
                {name name}
            }}
        } {
            #
            # Get user data based on the temporary code (passed via
            # "-token") provided by GitHub.  First, convert the
            # temporary code into an access_token, and use this to get
            # the user data.
            #
            set data [:redeem_code $token]
            ns_log notice "[self] redeem_code returned keys: [lsort [dict keys $data]]"
            set result $data

            if {[dict exists $data access_token]} {
                #
                # When we received the access_token, we have no error
                # so far.
                #
                set access_token [dict get $data access_token]
                #ns_log notice "[self] redeemed form data: $access_token"
                set result [:get_api_data $access_token]
                ns_log notice "[self] get_user_data: get_api_data contains error:" \
                    [dict exists $result error]

                if {![dict exists $result error]} {
                    set data [:get_required_fields \
                                  -claims [dict get $result claims] \
                                  -mapped_fields {
                                      {email email}
                                      {name name}
                                  }]
                    if {[dict exists $data error]} {
                        set result [dict merge $data $result]
                    } else {
                        set result [dict merge $result [dict get $data fields]]
                        #
                        # We have still to split up "name" into its
                        # components, since GitHub does not provide
                        # the exactly needed fields. Actually, we need
                        # this just for creating a new user_id, so it
                        # might not be always needed.
                        #
                        set first_names [join [lrange [dict get $result name] 0 end-1] " "]
                        set last_name [lindex [dict get $result name] end]
                        dict set result first_names $first_names
                        dict set result last_name $last_name
                    }
                }
            }
            ns_log notice "[self] get_user_data returns $result"
            return $result
        }

        :public method logout_url { {-return_url ""} } {
            #
            # Returns the URL for logging out. E.g., GitHub has no
            # logout, so provide simply a redirect URL (maybe, we
            # should logout from the application?)
            #
            return $return_url
        }

    }

    #
    # In general, it might be possible, that a user is identified over
    # multiple OAuth identity providers, so the unique constraint
    # might be too strong. For now, we add only users to this table,
    # which were created from this authority - such that the unique
    # constraint holds.
    #
    ::xo::db::require table xooauth_authorized_users [subst {
        user_id   {integer references users(user_id) on delete cascade}
        auth_obj  {character varying(255)}
    }]
    ::xo::db::require index -table xooauth_authorized_users -col user_id -unique true
}
::xo::library source_dependent
#
# Local variables:
#    mode: tcl
#    tcl-indent-level: 2
#    indent-tabs-mode: nil
# End