Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
gwynforthewyn opened this issue Dec 10, 2023 · 5 comments
Open

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

gwynforthewyn opened this issue Dec 10, 2023 · 5 comments
Labels
enhancement New feature or request

Comments

@gwynforthewyn
Copy link

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.

@gwynforthewyn gwynforthewyn added the enhancement New feature or request label Dec 10, 2023
@bgavrilMS
Copy link
Member

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
Copy link

npmitche commented Feb 9, 2024

I would like to second this request.

@chemeris
Copy link

chemeris commented Jun 3, 2024

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
Copy link

catgoose commented Sep 6, 2024

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
Copy link

catgoose commented Sep 9, 2024

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

5 participants