Commit 5c712160 authored by Mario Carrion's avatar Mario Carrion
parent e73d11c8
# Server and Clients using Azure Active Directory for Authentication/Authorization
* [`server`](server/) is based on [original the code](https://gitlab.com/MarioCarrion/blog-examples/tree/master/2018/07/16) from my [Azure Active Directory + JWT](https://mariocarrion.com/2018/07/16/azure-active-directory-jwt.html), the main difference is that we indicate if the JWT has no _valid_ GUIDs but it is a valid signed JWT.
* [`client`](client/) is brand new, using the [Server to Server workflow](https://docs.microsoft.com/en-us/azure/active-directory/develop/v1-oauth2-client-creds-grant-flow) using shared secret.
package main
import (
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
)
type (
appConfig struct {
Tenant string
ClientApplicationID string
ClientSecret string
ServerApplicationID string
ServerURL string
}
)
func main() {
config := appConfig{}
flag.StringVar(&config.Tenant, "tenant", "", "Azure Tenant")
flag.StringVar(&config.ClientApplicationID, "cappid", "", "Client Azure Application ID")
flag.StringVar(&config.ClientSecret, "csecret", "", "Client Azure Application Secret")
flag.StringVar(&config.ServerApplicationID, "sappid", "", "Server Azure Application ID")
flag.StringVar(&config.ServerURL, "surl", "", "Server URL")
flag.Parse()
if config.Tenant == "" || config.ClientApplicationID == "" || config.ClientSecret == "" ||
config.ServerApplicationID == "" || config.ServerURL == "" {
log.Fatal("tenant, cappid, csecret, surl and sappid are required")
}
jwt := struct {
AccessToken string `json:"access_token"`
}{}
{
tokenURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/token", config.Tenant)
data := url.Values{}
data.Set("grant_type", "client_credentials")
data.Set("client_id", config.ClientApplicationID)
data.Set("client_secret", config.ClientSecret)
data.Set("resource", config.ServerApplicationID)
encoded := data.Encode()
client := &http.Client{}
r, _ := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(encoded))
r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
r.Header.Add("Content-Length", strconv.Itoa(len(encoded)))
resp, err := client.Do(r)
if err != nil {
log.Fatalf("error posting request %s", err)
}
defer func() {
if err = resp.Body.Close(); err != nil {
fmt.Fprintf(os.Stderr, "there was an error closing body %s\n", err)
}
}()
if err := json.NewDecoder(resp.Body).Decode(&jwt); err != nil {
log.Fatalf("error decoding response %s", err)
}
}
{
client := &http.Client{}
r, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/information", config.ServerURL), nil)
r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt.AccessToken))
resp, err := client.Do(r)
if err != nil {
log.Fatalf("error posting request %s", err)
}
res, err := ioutil.ReadAll(resp.Body) // XXX
if err != nil {
log.Fatalf("error reading all%s", err)
}
defer func() {
if err = resp.Body.Close(); err != nil {
fmt.Fprintf(os.Stderr, "there was an error closing body %s\n", err)
}
}()
fmt.Println(string(res))
}
}
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "github.com/google/uuid"
packages = ["."]
revision = "064e2069ce9c359c118179501254f67d7d37ba24"
version = "0.2"
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
packages = [
"ed25519",
"ed25519/internal/edwards25519"
]
revision = "a49355c7e3f8fe157a85be2f77e6e269a0f89602"
[[projects]]
name = "gopkg.in/square/go-jose.v2"
packages = [
".",
"cipher",
"json",
"jwt"
]
revision = "76dd09796242edb5b897103a75df2645c028c960"
version = "v2.1.6"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "4424e184e41786537fea0f78adb2469def3d6b76e85552a68b9c617a6177ad48"
solver-name = "gps-cdcl"
solver-version = 1
[[constraint]]
name = "github.com/google/uuid"
version = "0.2.0"
[[constraint]]
name = "gopkg.in/square/go-jose.v2"
version = "2.1.6"
[prune]
go-tests = true
unused-packages = true
# Web API implementing the "Authentication flow using OpenID Connect"
Using the [official documentation](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-protocols-openid-connect-code) as reference.
## Running
Make sure you `dep ensure -vendor-only` before running:
```go
go run main.go -tenant <AZURE-AD-tenant> -appid <AZURE-AD-application-ID> -guids <comma-separated-group-guids>
```
## Using
This program defines 4 endpoints:
* `/login` for our users to log in
* `/logout` for our users to log out
* `/token` used as the _redirect_ URL, returns the computed JWT, it must be _saved_ by used and send it as the `Authentation Beader <token>` value
* `/information` authorized-only resource
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"strings"
"github.com/google/uuid"
jose "gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
)
const (
defaultPort = 9876
openIDURL = `https://login.microsoftonline.com/%s/.well-known/openid-configuration` // %s=tenant
)
type (
appConfig struct {
Tenant string
ApplicationID string
GUIDs string
}
// OpenID Connect metadata document
openIDMetadataDocument struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
EndSessionEndpoint string `json:"end_session_endpoint"`
JWKSURI string `json:"jwks_uri"`
}
// Response returned as a POST after signing in
signInResponse struct {
State string `json:"-"`
Token string `json:"token,omitempty"`
Error string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-token-and-claims#claims-in-idtokens
activeDirectoryClaims struct {
*jwt.Claims
Nonce string `json:"nonce"`
UPN string `json:"upn"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
NotBefore int64 `json:"nbf"`
NotOnOrAfter int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
Groups []string `json:"groups"`
}
personalInformation struct {
Name string
PhoneNumber string
}
)
var (
cachedJWKSet *jose.JSONWebKeySet
cachedOpenIDDoc = openIDMetadataDocument{}
config = appConfig{}
sessions = map[string]struct{}{}
guids = map[string]struct{}{}
)
func main() {
flag.StringVar(&config.Tenant, "tenant", "", "Azure Tenant")
flag.StringVar(&config.ApplicationID, "appid", "", "Azure Application ID")
flag.StringVar(&config.GUIDs, "guids", "", "Comma Separated Azure Group IDs")
flag.Parse()
if config.Tenant == "" || config.ApplicationID == "" { // XXX disabling GUIs for blog post || config.GUIDs == "" {
log.Fatal("tenant, appid and guids are required")
}
splittedGuids := strings.Split(config.GUIDs, ",")
for _, guid := range splittedGuids {
guids[guid] = struct{}{}
}
h := http.NewServeMux()
h.HandleFunc("/login", loginHandler)
h.HandleFunc("/logout", logoutHandler)
h.HandleFunc("/token", tokenHandler)
h.HandleFunc("/information", privateMiddleware(informationHandler))
h.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintln(w, "Resource not found")
})
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", defaultPort), h))
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
if ok := expectedMethod(w, r.Method, http.MethodGet); !ok {
return
}
nonce, state, err := newUUIDs()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := initOpenIDDocument(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
req, err := http.NewRequest(http.MethodGet, cachedOpenIDDoc.AuthorizationEndpoint, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
q := req.URL.Query()
q.Add("client_id", config.ApplicationID)
q.Add("response_type", "id_token")
q.Add("redirect_uri", fmt.Sprintf("http://localhost:%d/token", defaultPort))
q.Add("response_mode", "form_post")
q.Add("scope", "openid")
q.Add("nonce", nonce.String())
q.Add("state", state.String())
req.URL.RawQuery = q.Encode()
// FIXME Primitive way to mitigate token replay attacks, use a real data store
sessions[fmt.Sprintf("%s_%s", nonce.String(), state.String())] = struct{}{}
http.Redirect(w, r, req.URL.String(), http.StatusFound)
}
func logoutHandler(w http.ResponseWriter, r *http.Request) {
if ok := expectedMethod(w, r.Method, http.MethodGet); !ok {
return
}
if err := initOpenIDDocument(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, cachedOpenIDDoc.EndSessionEndpoint, http.StatusFound)
}
func tokenHandler(w http.ResponseWriter, r *http.Request) {
if ok := expectedMethod(w, r.Method, http.MethodPost); !ok {
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
payload := signInResponse{
State: r.Form.Get("state"),
Token: r.Form.Get("id_token"),
Error: r.Form.Get("error"),
ErrorDescription: r.Form.Get("error_description"),
}
if payload.Error != "" {
respondWithJSON(w, payload, http.StatusBadRequest)
return
}
var adClaims *activeDirectoryClaims
if adClaims, err = parseJWT(payload.Token); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// FIXME Primitive way to mitigate token replay attacks, use a real data store
_, ok := sessions[fmt.Sprintf("%s_%s", adClaims.Nonce, payload.State)]
if !ok {
http.Error(w, "Login token has expired, log in again", http.StatusInternalServerError)
return
}
delete(sessions, fmt.Sprintf("%s_%s", adClaims.Nonce, payload.State))
respondWithJSON(w, payload, http.StatusOK)
}
func informationHandler(w http.ResponseWriter, r *http.Request) {
respondWithJSON(w,
personalInformation{
Name: "Juan Perez",
PhoneNumber: "123-456-7890",
},
http.StatusOK)
}
func privateMiddleware(next func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if ok := expectedMethod(w, r.Method, http.MethodGet); !ok {
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization header missing", http.StatusBadRequest)
return
}
values := strings.Split(authHeader, " ")
if len(values) != 2 || values[0] != "Bearer" {
http.Error(w, "Authorization header value is invalid", http.StatusBadRequest)
return
}
_, err := parseJWT(values[1])
// adClaims, err := parseJWT(values[1]) // XXX Originall we were doing this
if err != nil {
fmt.Println(err.Error())
http.Error(w, fmt.Sprintf("Access Denied: %s", err.Error()), http.StatusUnauthorized)
return
}
/*
// XXX Originally we were doing this
found := 0
for _, group := range adClaims.Groups {
if _, ok := guids[group]; ok {
found++
if found == len(guids) {
break
}
}
}
if found != len(guids) {
http.Error(w, "Access Denied", http.StatusUnauthorized)
return
}*/
next(w, r)
}
}
func newUUIDs() (uuid.UUID, uuid.UUID, error) {
nonce, err := uuid.NewUUID()
if err != nil {
return uuid.UUID{}, uuid.UUID{}, err
}
state, err := uuid.NewUUID()
return nonce, state, nil
}
func initOpenIDDocument() error {
// FIXME Mutex this
// FIXME Bust cache accordingly
if cachedOpenIDDoc.AuthorizationEndpoint == "" {
if err := decodeRequest(fmt.Sprintf(openIDURL, config.Tenant), &cachedOpenIDDoc); err != nil {
return fmt.Errorf("error decoding OpenID request: %s", err)
}
}
return nil
}
func requestKeysSet() (*jose.JSONWebKeySet, error) {
// FIXME Mutex this
if err := initOpenIDDocument(); err != nil {
return nil, fmt.Errorf("error decoding OpenID URL")
}
if cachedJWKSet != nil {
return cachedJWKSet, nil // FIXME add actual cache-busting logic
}
cachedJWKSet = &jose.JSONWebKeySet{}
if err := decodeRequest(cachedOpenIDDoc.JWKSURI, cachedJWKSet); err != nil {
return nil, fmt.Errorf("error decoding JWKS URL")
}
return cachedJWKSet, nil
}
func decodeRequest(url string, v interface{}) (err error) {
var req *http.Request
if req, err = http.NewRequest(http.MethodGet, url, nil); err != nil {
return err
}
var resp *http.Response
var client http.Client
if resp, err = client.Do(req); err != nil {
return err
}
defer func() {
if err = resp.Body.Close(); err != nil {
fmt.Fprintf(os.Stderr, "there was an error closing body %s\n", err)
}
}()
return json.NewDecoder(resp.Body).Decode(v)
}
func respondWithJSON(w http.ResponseWriter, payload interface{}, code int) {
response, err := json.Marshal(payload)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if _, err := w.Write(response); err != nil {
fmt.Fprintf(os.Stderr, "Received error %s\n", err)
}
}
func parseJWT(token string) (*activeDirectoryClaims, error) {
var jwkSet *jose.JSONWebKeySet
var err error
if jwkSet, err = requestKeysSet(); err != nil {
return nil, fmt.Errorf("error requesting JWK Set %s", err)
}
var jwtSigned *jwt.JSONWebToken
if jwtSigned, err = jwt.ParseSigned(token); err != nil {
return nil, err
}
var kid string
for _, header := range jwtSigned.Headers {
if kid == "" {
kid = header.KeyID
break
}
}
jsonKey := jwkSet.Key(kid)
claimsMap := make(map[string]interface{})
claims := activeDirectoryClaims{}
if err = jwtSigned.Claims(jsonKey[0].Key, &claims, &claimsMap); err != nil {
return nil, fmt.Errorf("error getting claims: %s", err)
}
return &claims, nil
}
func expectedMethod(w http.ResponseWriter, received, expected string) bool {
if received == expected {
return true
}
http.Error(w, fmt.Sprintf("Invalid method %s", received), http.StatusBadRequest)
return false
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment