Signing in without a password: OAuth2
A program that reads someone's files on Dropbox, or posts to their team's Slack, needs their permission, but should never see their password. OAuth2 is how almost every web service handles this. Your program opens the service's own sign-in page; the person signs in there and agrees to what your program asks for, its scopes; and the service gives your program an access token, a long random string that stands for that permission and nothing more. The person can take it back at any time from the service's settings.
First you register your program with the service, on its developer pages. It gives you a client id and a client secret, and the addresses of its sign-in page and its token page. You also give it a redirect address, where the sign-in page sends the person when they are done: for a desktop program it is http://127.0.0.1: and a port, because the OAuth2 library listens there itself. Then one command does the whole dance:
constant kAuthURL = "https://www.dropbox.com/oauth2/authorize"
constant kTokenURL = "https://api.dropboxapi.com/oauth2/token"
constant kClientID = "your client id"
constant kClientSecret = "your client secret"
on mouseUp
local tAuth
OAuth2 kAuthURL, kTokenURL, kClientID, kClientSecret, empty, 54303
if the result is not empty then
answer error "Not signed in:" && the result
exit mouseUp
end if
put it into tAuth
set the cAccessToken of this stack to tAuth["access_token"]
set the cRefreshToken of this stack to tAuth["refresh_token"]
answer "Signed in."
end mouseUp
OAuth2 shows the sign-in page in a window of its own and waits. When the person is done, it is an array of what the token page sent: access_token, and often refresh_token and expires_in, the seconds the access token lasts. If they close the window or say no, the result says so. The port, 54303 here, must match the redirect address you registered; one from 49152 to 65535 is unlikely to be in use.
Using the token
With each request to the service, send the token in an Authorization header, as Bearer and the token. A service answers a request without a good token with status 401. Here a small server of our own (A web server of your own) plays the part of the service:
on serve pSocket, pRequest
if pRequest["headers"]["Authorization"] is "Bearer sl.abc123" then
httpdResponse pSocket, 200, "Your files: notes.txt, photo.jpg"
else
httpdResponse pSocket, 401, "Sign in first"
end if
end serve
httpdStart "serve", 0
put "http://127.0.0.1:" & it & "/files" into tFiles
get URL tFiles
put the result
set the httpHeaders to "Authorization: Bearer sl.abc123"
put URL tFiles
set the httpHeaders to empty
httpdStop
error 401 Unauthorized Your files: notes.txt, photo.jpg
the httpHeaders goes with every request after it is set, so set it back to empty when you are done, or the token goes to every server you ask for anything.
When the token runs out
An access token usually lasts hours, not months. When the service starts answering 401, the refresh token gets a new one without asking the person again: OAuth2Refresh takes the token page, your id and secret, the refresh token and the port, and puts a new array in it:
constant kTokenURL = "https://api.dropboxapi.com/oauth2/token"
constant kClientID = "your client id"
constant kClientSecret = "your client secret"
function fetchWithToken pURL
local tPage, tAuth
set the httpHeaders to "Authorization: Bearer" && the cAccessToken of this stack
put URL pURL into tPage
if the result contains "401" then
OAuth2Refresh kTokenURL, kClientID, kClientSecret, the cRefreshToken of this stack, 54303
if the result is empty then
put it into tAuth
set the cAccessToken of this stack to tAuth["access_token"]
set the httpHeaders to "Authorization: Bearer" && tAuth["access_token"]
put URL pURL into tPage
end if
end if
set the httpHeaders to empty
return tPage
end fetchWithToken
If the refresh fails too, the person has taken the permission back, or the refresh token has run out as well, and only signing in again with OAuth2 will do.
Dropbox
The Dropbox library wraps Dropbox's whole API, a command for each thing it can do: dropboxListFolder, dropboxUpload, dropboxDownload, dropboxCreateFolder, dropboxMove, dropboxDelete, sharing links and more. Each takes the access token first. With no callback it waits for the answer, which is JSON in it, encoded as UTF-8:
function dropboxNames pToken, pFolder
local tList, tEntry, tNames
dropboxListFolder pToken, pFolder, false, false
if the result is not empty then
return "error:" && the result
end if
put JSONImport(textDecode(it, "UTF-8")) into tList
repeat for each element tEntry in tList["entries"]
put tEntry["name"] & return after tNames
end repeat
return line 1 to -1 of tNames
end dropboxNames
command saveNote pToken, pText
dropboxUpload pToken, "/Notes/today.txt", "overwrite", false, false, textEncode(pText, "UTF-8")
end saveNote
Dropbox paths start with a slash, and the top of the person's Dropbox is the empty path. Register your program in the Dropbox App Console to get its id and secret; its pages also say what each call sends back.
Messages from a service: checking the signature
Many services can also call you: when something happens, they send a webhook, a request to a server of yours. Anyone could send such a request, so the service signs each one with an HMAC of its content and a secret you both know (Hashing and security), in a header. Work out the signature yourself and compare: only a request from the service matches:
constant kSecret = "whsec_our_shared_secret"
function signatureOf pBody
local tHex
get binaryDecode("H*", messageAuthenticationCode(pBody, kSecret, "HMAC-SHA-256"), tHex)
return tHex
end signatureOf
on serve pSocket, pRequest
if pRequest["headers"]["X-Signature"] is signatureOf(pRequest["content"]) then
httpdResponse pSocket, 200, "accepted:" && pRequest["content"]
else
httpdResponse pSocket, 403, "refused"
end if
end serve
httpdStart "serve", 0
put "http://127.0.0.1:" & it & "/hook" into tHook
put "order 1042 paid" into tBody
set the httpHeaders to "Content-Type: text/plain" & return & "X-Signature:" && signatureOf(tBody)
post tBody to URL tHook
put it
post "order 1042 refunded" to URL tHook
put the result
set the httpHeaders to empty
httpdStop
accepted: order 1042 paid error 403 Forbidden
The second request carries a real signature, copied from the first, but for a different message, so it is refused. The Content-Type matters too: post sends form data unless told otherwise, and the server library takes form data apart into parameters and leaves content empty, so the signature would be worked out over nothing. Each service says how it signs (which hash, which header, whether a time is mixed in to stop an old message being sent again), so follow its documentation exactly.
Try it yourself
A token array from OAuth2 has expires_in, the seconds it lasts from when it was given. Write a command keepToken that stores the array with the moment it arrived, and a function tokenIsFresh that takes that moment and is true while there is more than a minute left, so a program can refresh before a request fails rather than after.
Show a solution
global gAuth
command keepToken pAuth, pNow
global gAuth
put pAuth into gAuth
put pNow into gAuth["obtained"]
end keepToken
function tokenIsFresh pNow
global gAuth
return pNow < gAuth["obtained"] + gAuth["expires_in"] - 60
end tokenIsFresh
local tAuth
put "sl.abc123" into tAuth["access_token"]
put 14400 into tAuth["expires_in"]
keepToken tAuth, 1000000
put tokenIsFresh(1000000)
put tokenIsFresh(1000000 + 14000)
put tokenIsFresh(1000000 + 14400)
true true false
A real program passes the seconds as the moment; passing it in as a parameter, as here, lets you test the function with any moment you like.
Words in this lesson
OAuth2OAuth2Refreshthe httpHeadersAuthorizationdropboxListFolderdropboxUploaddropboxDownloadmessageAuthenticationCodehttpdStartEach one finds every lesson that uses it. The Dictionary has the whole story of each, with its syntax and more examples.