AzureAD / microsoft-authentication-library-for-go

The MSAL library for Go is part of the Microsoft identity platform for developers (formerly named Azure AD) v2.0. It enables you to acquire security tokens to call protected APIs. It uses industry standard OAuth2 and OpenID Connect.
MIT License
228 stars 87 forks source link

[Feature Request] code sample for hosted http service #468

Open gwynforthewyn opened 9 months ago

gwynforthewyn commented 9 months ago

Is your feature request related to a problem? Please describe. The Microsoft website for MSAL (https://learn.microsoft.com/en-us/entra/identity-platform/msal-overview#application-types-and-scenarios) says that it's suitable for web apps and web apis. I've been trying to understand how to use msal-go for an oauth proxy that can sit in a hosted environment, authenticate a user and then use on-behalf-of to authenticate the same user to a second API.

The msal-go implementation today might be able to support that, but it's really hard to figure out. The confidential client that implements on-behalf-of doesn't implement an equivalent of AcquireTokenInteractive.

The public client can perform redirects, but they're restricted to localhost, and by default they open the default system browser instead of sending a redirect to the currently used browser; it also doesn't seem to have an equivalent of the on-behalf-of workflow implemented. As best I can tell, the public client's http support is intended for desktop apps, not hosted apps.

Describe the solution you'd like I'd like code examples or some docs indicating how to use msal-go inside an http service that's intended to be hosted.

Describe alternatives you've considered I'm currently writing my proxy using raw http calls. I did try several open source oauth proxies, but each was deficient for various reasons. The most promising, oauth2-proxy, has a bunch of issues dating back over 12 months reported against it saying that azure support is broken, for example.

Additional context I'm an honest user trying my best here, but I've found this library pretty tough to work with in a web context. I'm happy to provide more context, or to be told that MSAL isn't designed for my use-case.

bgavrilMS commented 9 months ago

Hi @gwynforthewyn - I acknowledge that we don't have samples around web api. Will try to help, but today most of the MSAL GO users either focus on CLI applications (where AcquireTokenInteractive comes in) or web api requiring service to service auth, so for service principals, not for users (AcquireTokenByCredential). Both of these are "Confidential Client Apps", meaning that you must establish confidentiality between the service and the identity provider, by sharing a secret or a certificate. This is opposite to Public Client apps, such as CLI, mobile apps, desktop apps which cannot keep secrets.

From an OAUTH perspective, we see things as follows:

Web Site

e.g. ASP.NET, Spring, Java Servlet, Python Django, Flask, NodeJS Express, NextJS

In a web site framework, the backend has the ability to "challenge the user". If a "route" or "controller" requires the user to be logged in, it simply checks if an ID token has been obtained. If it hasn't, it redirects the user to the authorization page. The URI for this page can be obtained via AuthCodeURL API. After the user logs in, the identity provider (AAD) redirects the flow back to your website. The redirect URI also contains an auth code, which you then exchange for an ID Token (and optionally also for an Access token to be able to access some downstream APIs) via AcquireTokenByAuthCode.

Once tokens are obtained, you need to store them somewhere, e.g. in the session. When the user navigates the website, you always have the ID Token to prove they are logged in.

To access downstream APIs, you'd also call AcquireTokenSilent (again, from the confidential client!), which guarantees that your backend has a fresh access token. If AcquireTokenSilent fails, you must challenge the user again (e.g. if they require MFA).

Web API

In this scenario, the user logs in to a client - this can be a CLI or desktop app, a SPA or a web site. The client access then calls your web api, which must ensure the user is authenticated. The web api then calls some downstream API, for example Microsoft Graph.

Client ---> Middle Tier API (your webapi) ----> Downstream API (Graph)

In this case, the client needs a token for the web api itself - you can register your own API in the Azure Entra Portal. The web api then calls AcquireTokenOnBehalfOf(client_token, "graph scopes") to get tokens for the Downstream API.

We have an integration test that showcases this in some detail:

Please see what flow you need and let me know what other language / framework you are familiar with, and I will find a sample which goes in more detail.

npmitche commented 7 months ago

I would like to second this request.

chemeris commented 4 months ago

I also came here looking for an example but had to implement it myself, looking at the Python examples. See the resulting working code below. It uses Echo with SQLite session store and should be straightforward to port to any other framework.

I'm planning to create a proper Echo middleware from this. Not sure if it makes sense to release it open-source as well.

If you have any suggestions/fixes for this code - please share.

package main

import (
    "context"
    "fmt"
    "net/http"

    "encoding/gob"

    "github.com/AzureAD/microsoft-authentication-library-for-go/apps/confidential"
    "github.com/labstack/echo-contrib/session"
    "github.com/labstack/echo/v4"
    "github.com/michaeljs1990/sqlitestore"
)

// var account confidential.Account
var store *sqlitestore.SqliteStore

func init_sqlitestore() {
    var err error
    store, err = sqlitestore.NewSqliteStore("sessions.db", "sessions", "/", 3600, []byte("<SecretKey>"))
    if err != nil {
        panic(err)
    }
}

type contextKey string

const (
    confidentialClientKey contextKey = "confidentialClient"

    // Azure AD Config
    redirectURI  = "http://localhost:8000/"
    clientID     = "xxx"
    tenantID     = "xxx"
    clientSecret = "xxx"
    authority    = "https://login.microsoftonline.com/" + tenantID

    // HTTP Config
    // Note that Azure AD allows HTTP only for localhost, otherwise HTTPs is requried
    http_addr = ":8000"
    certFile  = ""
    keyFile   = ""
)

// This can't be a constant because it's a slice of strings
var scopes = []string{"User.Read"}

func ConfidentialClientMiddleware(client *confidential.Client) echo.MiddlewareFunc {
    return func(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            ctx := context.WithValue(c.Request().Context(), confidentialClientKey, client)
            c.SetRequest(c.Request().WithContext(ctx))
            return next(c)
        }
    }
}
func mainPage(c echo.Context) error {
    session, err := store.Get(c.Request(), "auth-session")
    ctx := c.Request().Context()
    confidentialClient := ctx.Value(confidentialClientKey).(*confidential.Client)

    account, _ := session.Values["account"].(*confidential.Account)
    options := []confidential.AcquireSilentOption{}
    if account != nil {
        options = append(options, confidential.WithSilentAccount(*account))
    }
    result, err := confidentialClient.AcquireTokenSilent(ctx, scopes, options...)
    if err != nil {
        if c.QueryParam("code") != "" {
            queryParams := c.QueryParams()
            for key, values := range queryParams {
                fmt.Printf("Query Parameter: %s\n", key)
                for _, value := range values {
                    fmt.Printf("Value: %s\n", value)
                }
            }
            authResult, err := confidentialClient.AcquireTokenByAuthCode(ctx, c.QueryParam("code"), redirectURI, scopes)
            if err != nil {
                return c.String(http.StatusInternalServerError, "Error acquiring token by auth code: "+err.Error())
            }
            fmt.Println("AcquireTokenByAuthCode returns: ", authResult)
            session.Values["account"] = authResult.Account
            err = session.Save(c.Request(), c.Response())
            if err != nil {
                return c.String(http.StatusInternalServerError, "Error saving session: "+err.Error())
            }
            return c.Redirect(http.StatusTemporaryRedirect, redirectURI)
        }

        // cache miss, authenticate with another AcquireToken... method
        authURL, err := confidentialClient.AuthCodeURL(ctx, clientID, redirectURI, scopes)
        fmt.Println("AuthCodeURL returns: ", authURL)
        if err != nil {
            return c.String(http.StatusInternalServerError, "Error acquiring auth URL: "+err.Error())
        }
        return c.Redirect(http.StatusTemporaryRedirect, authURL)
    }
    // accessToken := result.AccessToken
    accessTokenStr := fmt.Sprintf("%#v", result)

    return c.String(http.StatusOK, accessTokenStr)
}

func main() {
    // confidential clients have a credential, such as a secret or a certificate
    cred, err := confidential.NewCredFromSecret(clientSecret)
    if err != nil {
        // TODO: handle error
    }
    confidentialClient, err := confidential.New(authority, clientID, cred)

    e := echo.New()
    init_sqlitestore()
    gob.Register(&confidential.Account{})
    e.Use(session.Middleware(store))
    e.Use(ConfidentialClientMiddleware(&confidentialClient)) // Add the middleware here
    e.GET("/", mainPage)
    if certFile != "" && keyFile != "" {
        e.Logger.Fatal(e.StartTLS(http_addr, certFile, keyFile))
    } else {
        e.Logger.Fatal(e.Start(http_addr))
    }
}
catgoose commented 3 weeks ago

I also came here looking for an example but had to implement it myself, looking at the Python examples. See the resulting working code below. It uses Echo with SQLite session store and should be straightforward to port to any other framework.

I'm planning to create a proper Echo middleware from this. Not sure if it makes sense to release it open-source as well.

If you have any suggestions/fixes for this code - please share.

package main

import (
  "context"
  "fmt"
  "net/http"

  "encoding/gob"

  "github.com/AzureAD/microsoft-authentication-library-for-go/apps/confidential"
  "github.com/labstack/echo-contrib/session"
  "github.com/labstack/echo/v4"
  "github.com/michaeljs1990/sqlitestore"
)

// var account confidential.Account
var store *sqlitestore.SqliteStore

func init_sqlitestore() {
  var err error
  store, err = sqlitestore.NewSqliteStore("sessions.db", "sessions", "/", 3600, []byte("<SecretKey>"))
  if err != nil {
      panic(err)
  }
}

type contextKey string

const (
  confidentialClientKey contextKey = "confidentialClient"

  // Azure AD Config
  redirectURI  = "http://localhost:8000/"
  clientID     = "xxx"
  tenantID     = "xxx"
  clientSecret = "xxx"
  authority    = "https://login.microsoftonline.com/" + tenantID

  // HTTP Config
  // Note that Azure AD allows HTTP only for localhost, otherwise HTTPs is requried
  http_addr = ":8000"
  certFile  = ""
  keyFile   = ""
)

// This can't be a constant because it's a slice of strings
var scopes = []string{"User.Read"}

func ConfidentialClientMiddleware(client *confidential.Client) echo.MiddlewareFunc {
  return func(next echo.HandlerFunc) echo.HandlerFunc {
      return func(c echo.Context) error {
          ctx := context.WithValue(c.Request().Context(), confidentialClientKey, client)
          c.SetRequest(c.Request().WithContext(ctx))
          return next(c)
      }
  }
}
func mainPage(c echo.Context) error {
  session, err := store.Get(c.Request(), "auth-session")
  ctx := c.Request().Context()
  confidentialClient := ctx.Value(confidentialClientKey).(*confidential.Client)

  account, _ := session.Values["account"].(*confidential.Account)
  options := []confidential.AcquireSilentOption{}
  if account != nil {
      options = append(options, confidential.WithSilentAccount(*account))
  }
  result, err := confidentialClient.AcquireTokenSilent(ctx, scopes, options...)
  if err != nil {
      if c.QueryParam("code") != "" {
          queryParams := c.QueryParams()
          for key, values := range queryParams {
              fmt.Printf("Query Parameter: %s\n", key)
              for _, value := range values {
                  fmt.Printf("Value: %s\n", value)
              }
          }
          authResult, err := confidentialClient.AcquireTokenByAuthCode(ctx, c.QueryParam("code"), redirectURI, scopes)
          if err != nil {
              return c.String(http.StatusInternalServerError, "Error acquiring token by auth code: "+err.Error())
          }
          fmt.Println("AcquireTokenByAuthCode returns: ", authResult)
          session.Values["account"] = authResult.Account
          err = session.Save(c.Request(), c.Response())
          if err != nil {
              return c.String(http.StatusInternalServerError, "Error saving session: "+err.Error())
          }
          return c.Redirect(http.StatusTemporaryRedirect, redirectURI)
      }

      // cache miss, authenticate with another AcquireToken... method
      authURL, err := confidentialClient.AuthCodeURL(ctx, clientID, redirectURI, scopes)
      fmt.Println("AuthCodeURL returns: ", authURL)
      if err != nil {
          return c.String(http.StatusInternalServerError, "Error acquiring auth URL: "+err.Error())
      }
      return c.Redirect(http.StatusTemporaryRedirect, authURL)
  }
  // accessToken := result.AccessToken
  accessTokenStr := fmt.Sprintf("%#v", result)

  return c.String(http.StatusOK, accessTokenStr)
}

func main() {
  // confidential clients have a credential, such as a secret or a certificate
  cred, err := confidential.NewCredFromSecret(clientSecret)
  if err != nil {
      // TODO: handle error
  }
  confidentialClient, err := confidential.New(authority, clientID, cred)

  e := echo.New()
  init_sqlitestore()
  gob.Register(&confidential.Account{})
  e.Use(session.Middleware(store))
  e.Use(ConfidentialClientMiddleware(&confidentialClient)) // Add the middleware here
  e.GET("/", mainPage)
  if certFile != "" && keyFile != "" {
      e.Logger.Fatal(e.StartTLS(http_addr, certFile, keyFile))
  } else {
      e.Logger.Fatal(e.Start(http_addr))
  }
}

Were you able to create echo middleware? I would be interested in seeing how you did that.

Thanks!

catgoose commented 3 weeks ago

To future people who are trying to auth with Azure in a hosted http service, I made a go library:

https://github.com/catgoose/crooner