OAuth2 mit der NitrAPI
TL;DR:
Mit dem Launch der Cloud Server bei Nitrado ist die NitrAPI (die frei verfügbare API) noch interessanter geworden. Diese wird auch intern für die Webseite, die Smartphone-Apps und das Webinterface verwendet und bietet einige interessante Features an, die es in dieser Branche so noch nicht gibt. Die Autorisierung erfolgt über OAuth2, was leider für die meisten eine zu große Einstigeshürde darstellt. In dem folgenden Artikel will ich deshalb versuchen, OAuth2 (den code Workflow) anhand der NitrAPI zu erklären. Da OAuth2 ein Industrie-Standard ist, lässt sich das Folgende nicht nur auf die Schnittstelle von Nitrado anwenden.
OAuth2 bietet mehrere Autorisierungsmethoden an. Bei der NitrAPI wird die authorization code Methode verwendet. Die Grafik im OAuth2 Native Apps
-Draft ist für unsere Fälle etwas einfacher zu verstehen:
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
| User Device |
| |
| +--------------------------+ | (5) Authorization +---------------+
| | | | Code | |
| | Client App |---------------------->| Token |
| | |<----------------------| Endpoint |
| +--------------------------+ | (6) Access Token, | |
| | ^ | Refresh Token +---------------+
| | | |
| | | |
| | (1) | (4) |
| | Authorizat- | Authoriza- |
| | ion Request | tion Code |
| | | |
| | | |
| v | |
| +---------------------------+ | (2) Authorization +---------------+
| | | | Request | |
| | Browser |--------------------->| Authorization |
| | |<---------------------| Endpoint |
| +---------------------------+ | (3) Authorization | |
| | Code +---------------+
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
Von der Client App (also unserer Anwendung) machen wir eine Autorisierungsanfrage (1) an den Browser. Wir Autorisieren uns über den Browser mit unseren Zugangsdaten (User, Passwort, 2-Factor-Auth, ...) (2). Zurück bekommen wir einen Autorisierungscode (3), den wir auf irgend einem Wege vom Browser zurück in unsere Anwendung bekommen müssen (4). Diesen Autorisierungscode können wir wiederum über die NitrAPI gegen einen Access Token eintauschen (5)(6). Diesen Workflow werden wir nun in Clojure implementieren. (Ist das Prinzip einmal verstanden, kann jede beliebige Programmiersprache verwendet werden)
Schauen wir uns den entsprechenden Endpoint in der NitrAPI etwas genauer an. Wir müssen also den Browser mit einer speziell präparierten URL aufrufen. Wir benötigen dafür allerdings eine client_id
(und später auch ein client_secret
). Um diese Infos zu erhalten, müssen wir zuvor unsere Client App bei Nitrado anlegen. Hierzu auf nitrado.net einloggen (Account anlegen wenn noch nicht vorhanden) und dann unter Mein Account -> Entwickler-Portal unter Meine Anwendungen
auf das +
klicken.
Wichtig dabei ist die Weiterleitungs-URL. Da wir die Anwendung auf dem lokalen PC starten und nutzen, habe ich hier http://localhost:9292/
verwendet. An diese Stelle wird später unser Autorisierungscode geschickt. Nach dem Speichern taucht die Anwendung in der Liste darunter auf. In den Eigenschaften lässt sich die client_id
und das client_secret
einsehen.
Die nächste Information, die wir noch benötigen ist der scope
-- also der Bereich in dem der Access Token verwendet werden kann. Es ist ratsam, den Scope so eng wie möglich zu halten, um das Risiko eines Missbrauchs zu verringern. Angenommen, unsere Anwendung enthält eine kritische Sicherheitslücke, über die ein Fremder an den Access Token kommt. Wenn der angeforderte Access Token nur den Scope user_info
besitzt, kann damit nicht auf die Services zugegriffen werden oder Bestellungen aufgegeben werden. Die möglichen Scopes sind user_info
, service
, service_order
und ssh_keys
.
Als letztes fehlt uns noch der state
. Dies ist ein Sicherheitsfeature, damit wirklich nur unsere Anwendung die Autorisierung durchführen kann. Wir vergeben hier einen geheimen String (zufällig generiert), den nur wir kennen.
Rufen wir also den Browser mit unserer präparierten URL auf:
(defn oauth2 [client-id scopes]
(let [state (java.util.UUID/randomUUID)
(browse-url (str "https://oauth.nitrado.net/oauth/v2/auth"
"?redirect_uri=http://localhost:9292/"
"&client_id=" client-id
"&response_type=code"
"&scope=" (string/join " " scopes)
"&state=" state))
Die Funktion browse-url
kommt aus dem Paket clojure.java.browse
und öffnet den Standard-Browser mit der angegebenen URL.
(oauth2 "xxxclient_idxxx" ["user_info"])
Nach dem Aufruf werden wir auf die Login-Seite von Nitrado geleitet, welche Benutzername und Passwort fordert. Im nächsten Schritt (falls aktiviert), muss die 2FA Authentifizierung mit einem Pin oder einem YubiKey getätigt werden. War dies erfolgreich, müssen wir die Berechtigung bestätigen. Hier sehen wir auch die angeforderten Scopes.
Nach einem Klick auf Erlauben
werden wir auf http://localhost:9292/
weitergeleitet -- was natürlich zu einem nicht vorhandenen Webserver führt. Schaut man sich die URL genauer an, sieht man dort, dass hier ein code
und unser zuvor angegebener state
enthalten ist. Wir müssen also nichts weiter tun als einen kleinen Webserver starten, der diesen einen Request verarbeitet und die beiden Informationen ausließt. Dank der Clojure Lib HTTP Kit ist das mit ein paar Zeilen erledigt.
(defn- handler [request]
{:status 200
:headers {"Content-Type" "text/html"}
:body "Hi there!"})
(org.httpkit.server/run-server handler {:port 9292})
Damit starten wir einen HTTP Server, der auf alle Anfragen mit dem Statuscode 200 und einem kleinen Gruß antwortet. Da wir den Webserver nur für diesen einen Request benötigen, können wir ihn danach direkt wieder schließen. Verbinden wir nun den Code für den Webserver und den Request-Handler mit unserer oauth2
Funktion. Dies erfüllt Schritt (1) bis (4) des OAuth2 Workflows.
(defn- handler [request state client-id client-secret code]
(if (= (:uri request) "/")
(let [query (:query-string request)
[_ auth-code returned-state] (re-find #"code=(.+)&state=(.+)" query)]
(if (= (str state) returned-state)
(>!! code auth-code))))
{:status 200
:headers {"Content-Type" "text/html"}
:body "You can close the browser now."})
(defn oauth2 [client-id client-secret scopes]
(let [state (java.util.UUID/randomUUID)
code (chan)
request-handler #(handler % state client-id client-secret code)
http-server (org.httpkit.server/run-server request-handler {:port 9292})]
(browse-url (str "https://oauth.nitrado.net/oauth/v2/auth"
"?redirect_uri=http://localhost:9595/"
"&client_id=" client-id
"&response_type=code"
"&scope=" (string/join " " scopes)
"&state=" state))
(let [auth-code (<!! code)]
(http-server :timeout 500)
auth-code)))
Da unsere oauth2
Funktion so lange blockieren soll, bis wir den auth-code erhalten haben, aber der ganze Vorgang asynchron läuft, müssen wir uns ein paar Tricks bedienen. In der oauth2
Funktion öffnen wir einen neuen Channel mit (chan)
, den wir anschließend (zusammen mit dem state
, der client-id
und dem client-secret
) an den Request handler übergeben. Somit haben wir im Request-Handler handler
die Möglichkeit, den auth-code an die oauth2
Funktion zu übergeben. In der oauth2
Funktion selbst warten wir so lange auf den code
, bis er vom handler
abgelegt wird. Ist das der Fall, können wir den http-server
schließen und den auth-code
zurückgeben. (run-server
gibt eine Funktion zurück, die den HTTP-Server wieder schließt.)
Im handler
holen wir uns code
und state
aus dem Request, prüfen ob der state
unserem gespeicherten state entspricht und schieben dann den auth-code
in den Channel. Das if
zu Beginn dient übrigens dazu, dass wir nur Requests verarbeiten, die für uns relevant sind. Einige Browser machen noch zusätzliche Requests auf /favicon.ico
o.ä. Diese wollen wir natürlich nicht verarbeiten.
Nun fehlen uns nur noch die Schritte (5) und (6), also der Austausch vom auth code mit einem Access Token. Die NitrAPI hat hierfür einen Endpunkt. Für den POST-Request können wir wieder die HTTP Kit Bibliothek verwenden. Da dieser Vorgang ebenfalls asynchron ist und in unseren Workflow integriert werden muss, müssen wir zudem ein paar Modifikationen an unserem bisherigen Code machen:
In der oauth2
Funktion sind wir nun nicht mehr am code
interessiert, sondern am token
. Deshalb öffnen wir hier einen Channel für den token
, und übergeben diesen an den handler
. Dieser wiederum verarbeitet den den Request, steckt aber den code nicht in den Channel, sondern ruft die access-token
Funktion auf. Diese übernimmt den Austausch von code und token und steckt anschließend den Access Token in den token
Channel.
Hier der komplette Code (42 Zeilen), inklusive Namespace und Requires:
(ns clj-nitrapi.auth
(:require
[clojure.java.browse :refer [browse-url]]
[clojure.core.async :refer [>!! <!! chan]]
[clojure.string :as string]
[clojure.data.json :as json]
[org.httpkit.client :refer [post]]
[org.httpkit.server :refer [run-server]])
(:import
(java.util UUID)))
(defn- access-token [code client-id client-secret token]
(post "https://oauth.nitrado.net/oauth/v2/token"
{:query-params
{:client_id client-id
:client_secret client-secret
:grant_type "authorization_code"
:code code}}
(fn [{:keys [body]}]
(>!! token (json/read-str body :key-fn keyword)))))
(defn- handler [request state client-id client-secret token]
(if (= (:uri request) "/")
(let [query (:query-string request)
[_ code returned-state] (re-find #"code=(.+)&state=(.+)" query)]
(if (= (str state) returned-state)
(access-token code client-id client-secret token))))
{:status 200
:headers {"Content-Type" "text/html"}
:body "You can close the browser now."})
(defn oauth2 [client-id client-secret scopes]
(let [state (UUID/randomUUID)
token (chan)
request-handler #(handler % state client-id client-secret token)
http-server (run-server request-handler {:port 9292})]
(browse-url (str "https://oauth.nitrado.net/oauth/v2/auth"
"?redirect_uri=http://localhost:9595/"
"&client_id=" client-id
"&response_type=code"
"&scope=" (string/join " " scopes)
"&state=" state))
(let [token-data (<!! token)]
(http-server :timeout 500)
token-data)))
Zusammen mit dem Access Token erhalten wir auch einen Refresh Token. Diesen können wir gegen einen neuen Access Token eintauschen, ohne dass wir uns erneut authentifizieren müssen. Hierfür können wir den selben Endpunkt aus Schritt (5) und (6) nutzen. Nur geben wir hier nicht den code
mit, sondern den refresh-token
. Die Implementierung ist trivial:
(defn refresh [refresh-token client-id client-secret]
(let [resp (chan)]
(post "https://auth.nitrado.net/oauth/v2/token"
{:query-params
{:client_id client-id
:client_secret client-secret
:grant_type "refresh_token"
:refresh_token refresh-token}}
(fn [{:keys [body]}]
(>!! resp (json/read-str body :key-fn keyword))))
(<!! resp)))
Mit dem Access Token können wir nun auf die komplette NitrAPI zugreifen und uns unsere eigene Nitrado Webseite bauen, die Funktionen vom Nitrado Webinterface ins eigene Tool integrieren oder kleine Scripte schreiben, die Dinge automatisiert. Mein Kollege Stefan hat für die Android-App bereits eine Java-Lib gebaut, die wir problemlos in Clojure verwenden können. Mehr dazu evtl. in einem folgenden Artikel.
Natürlich spricht nichts dagegen, den OAuth2 code Workflow in einer anderen Sprache zu implementieren. Der Vorgang bleibt der gleiche. Befindet man sich in einer asynchronen, eventbasierten Umgebung wie JavaScript oder PHP, ist die Implementierung noch einfacher.