Help us shape the future of Go! Take the 2026 Go Developer Survey

graphauth

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 20, 2026 License: MIT Imports: 20 Imported by: 0

README

graphauth

The Microsoft Graph sign-in layer shared by Excelano's command-line tools, among them xql and xfiles. It carries the device-code OAuth flow over MSAL, a sealed on-disk token cache, the refusal that keeps an unattended caller from hanging on a code nobody will read, a table of AADSTS error hints, and a thin authenticated Microsoft Graph HTTP client.

It is a library for those tools, not a general Graph SDK. Everything hangs off an App: one Entra app registration, the delegated scopes its tools request, the name of its token cache, and the tools that sign in against it. Tools on the same App share consent and one cached session; tools on different Apps never touch each other's. graphauth.SharePoint is the "Excelano SharePoint tools" registration; a tool on another registration declares its own App.

Install

go get github.com/excelano/graphauth

Usage

import "github.com/excelano/graphauth"

app := graphauth.SharePoint

client, err := app.NewPublicClient(app.CachePath(), legacyCachePath)
if err != nil { /* setup failure */ }

result, err := app.Authenticate(ctx, client)
if err != nil {
    fmt.Fprintf(os.Stderr, "authentication failed: %v%s\n", err, graphauth.HintForAuthError(err))
    os.Exit(1)
}

graph := app.NewGraphClient(client, result.Account)
body, err := graph.Get(ctx, "/sites/"+siteID+"/lists", nil)

Authenticate tries a silent refresh against the cached account first and falls back to device code, printing the code and URL on stderr. When that fallback would be needed and stderr is not a terminal it returns ErrNoTerminal at once instead of polling for fifteen minutes: the remedy is in the message, and a cached refresh token still renews unattended, so the guard only ever bites the first sign-in.

NewGraphClient takes options. WithTimeout bounds each request; the default of five minutes is sized for file content. WithHeader adds a header to every authenticated request, which is how xql sends the Prefer header SharePoint needs before it will $filter on non-indexed list columns.

Resources with optimistic concurrency, Planner's among them, refuse a write that does not name the version it replaces. PatchIfMatch and DeleteIfMatch send the resource's @odata.etag as If-Match. A non-2xx answer comes back as an *HTTPError, so a caller can reach the status with errors.As and tell a stale etag (412) from a missing resource (404).

The token cache

App.CachePath is ~/.config/excelano/<Cache>.json (under $XDG_CONFIG_HOME when that is set), one file per App, so signing in with any of its tools signs in all of them. The second argument to NewPublicClient names a per-tool cache a consumer kept before its cache was shared; when the shared file does not exist yet and that one does, it is copied into place on first use and nobody signs in again. Where the cache is sealed, the legacy file is then deleted, since it would be a plaintext copy of the same token. Pass "" for a consumer that never had one.

The file holds MSAL's own format, sealed through atrest with the operating system's data protection where the platform has one, and plaintext where it does not. A plaintext cache is read as it is and stored sealed on the same read, and a build that cannot open a sealed cache reads it as empty and asks for one sign-in rather than failing. The file is written 0600 in a 0700 directory through a temp file and a rename, so a crash or a second process writing the same file leaves the previous cache intact rather than a truncated one.

Testing against a tenant

go test ./... covers the cache, the refusal, the hints and the state command without a token. The Graph client is covered by a live suite behind a build tag, which runs on a machine that has signed in to the SharePoint registration once and needs a test site to work in:

GRAPHAUTH_LIVE_SITE=https://<tenant>.sharepoint.com/sites/<test-site> go test -tags live ./...

Every request shape the client offers runs once against that site's default library, in a folder the test creates and removes. It does not run in CI: device-code sign-in needs a human, and a refresh token in a public repository's secrets would be a live credential to the tenant.

The state command

Every consuming tool answers a bare auth with the state of its App's session — account, tenant, token expiry and scopes — and exits 0 whether or not one exists, so a caller that would rather ask than find out can branch on the report instead of on a failed attempt. App.AuthCommand is that subcommand, flag parsing and rendering included, so the tools agree by construction; App.CheckStatus is the underlying question for a program that wants the Status value. Neither starts a sign-in: with no cached account the answer is offline, and with one it is a silent token renewal, which proves the refresh token still works. --json prints the same fields as an object.

Not a consumer

blick-cli hand-rolls x/oauth2 against per-tenant mailbox scopes. That is a different design on purpose and should not be folded in here.

License

MIT.

Documentation

Overview

Package graphauth is the Microsoft Graph sign-in layer shared by Excelano's command-line tools. It holds the device-code OAuth flow over MSAL, a sealed on-disk token cache, the refusal that keeps an unattended caller from hanging on a code nobody will read, the AADSTS hint table, and a thin authenticated Graph HTTP client.

Each App is one Entra app registration and the delegated scopes its tools request. Tools on the same App share one cache, so one sign-in covers them all; tools on different Apps never touch each other's session.

blick-cli is not a consumer. It hand-rolls x/oauth2 against per-tenant mailbox scopes, a deliberately different design, and should stay that way.

Index

Constants

This section is empty.

Variables

View Source
var ErrNoTerminal = errors.New(
	"no cached token, and no terminal is attached to complete device-code sign-in. " +
		"Run this command once from an interactive terminal to sign in")

ErrNoTerminal is the refusal Authenticate returns when sign-in is needed and nobody is there to complete it. The first sentence is the fact and the second is the remedy, in that order, so the remedy sits in the first line a caller prints. Consumers' documentation quotes the opening clause; keep it stable.

View Source
var SharePoint = App{
	Name:     "Excelano SharePoint tools",
	ClientID: "13be0775-ed76-4407-bb2c-b7a07a189bf6",
	Scopes:   []string{"https://graph.microsoft.com/Sites.ReadWrite.All"},
	Cache:    "sp-token",
	Tools:    []string{"xql sp", "xftp", "xcp", "xfind", "xsync", "xtree"},
}

SharePoint is the "Excelano SharePoint tools" registration, multi-tenant, in Excelano's tenant. Sites.ReadWrite.All covers list items and document-library content alike, so it is the one scope the family needs.

Functions

func HintForAuthError

func HintForAuthError(err error) string

HintForAuthError returns a "\nHint (CODE): …" string suffix matching the first AADSTS code found in err's message, or "" if none match (or err is nil). Codes are tested in length-descending order so AADSTS7000218 is matched ahead of the prefix-shared AADSTS70002.

Types

type App

type App struct {
	// Name is the registration's display name, for messages.
	Name     string
	ClientID string
	// Scopes lists only resource-specific scopes. MSAL Go automatically
	// appends openid, offline_access, and profile via AppendDefaultScopes, and
	// flags any user-supplied scope absent from the response as "declined".
	// Since Azure doesn't echo offline_access back in the scope claim (it's a
	// modifier that just unlocks refresh-token issuance), adding it here
	// triggers a spurious "declined scopes" failure even when refresh works
	// correctly. Changing Scopes on a published App invalidates the consent
	// every existing user has granted.
	Scopes []string
	// Cache names the token cache: <Cache>.json under the excelano config
	// directory, sealed under excelano/<Cache>. Changing it costs every user
	// of the App a sign-in.
	Cache string
	// Tools lists every binary that signs in against the App, for the
	// messages that tell a caller where a sign-in can be done.
	Tools []string
}

App is one app registration and the tools that sign in against it.

func (App) AuthCommand

func (app App) AuthCommand(ctx context.Context, tool, legacyCachePath string, args []string, stdout, stderr io.Writer) int

AuthCommand is the bare `auth` subcommand: it reports the App's session and exits 0 whether or not one exists, so a caller can ask before it tries rather than learning the state from a failed attempt. args are the arguments after the subcommand name; the one flag is --json. tool is the binary's name, for the usage text. legacyCachePath is the per-tool cache the binary kept before the cache was shared, adopted here the same way it is on a real run, so `xftp auth` after an upgrade reports the session it inherits.

Exit codes follow the family's contract: 0 reported, 1 the state could not be determined, 2 bad invocation.

func (App) Authenticate

func (app App) Authenticate(ctx context.Context, client public.Client) (public.AuthResult, error)

Authenticate returns a usable AuthResult, attempting silent refresh against any cached account first and falling back to interactive device code flow. Device code instructions are printed to stderr so they don't pollute stdout-bound results.

The fallback is refused outright when no terminal is attached. Device code is a polling flow: it would print a code nobody can see and then block until the code expires, which for an unattended caller — a script, cron, or a coding agent — is a multi-minute hang ending in failure. Failing in the first second with the step that fixes it is strictly better. Only that path is gated: a cached refresh token still renews unattended.

func (App) CachePath

func (app App) CachePath() string

CachePath returns the token cache every tool on the App shares: $XDG_CONFIG_HOME/excelano/<Cache>.json, or ~/.config/excelano/<Cache>.json. The directory is named for the publisher rather than for any one repo, because an App's tools can span repos and the cache belongs to none of them.

func (App) CheckStatus

func (app App) CheckStatus(ctx context.Context, client public.Client, cachePath string) (Status, error)

CheckStatus reports the state of the session cached at cachePath. It never starts a sign-in, so it is safe unattended: with no cached account it answers offline, and with one it asks MSAL for a token silently, which proves the refresh token still works and costs one round trip when the access token has lapsed.

The error is non-nil only when the answer could not be determined — the cache is unreadable, or the sign-in server could not be reached. A server that answers and rejects the session is a determined answer: not signed in, with the rejection as the reason.

func (App) NewGraphClient

func (app App) NewGraphClient(msal public.Client, account public.Account, opts ...Option) *GraphClient

NewGraphClient returns a client that acquires its tokens for app's scopes.

func (App) NewPublicClient

func (app App) NewPublicClient(cachePath, legacyPath string) (public.Client, error)

NewPublicClient constructs the MSAL public client used for both silent and device code token acquisition. Refresh tokens are persisted at cachePath across runs; consumers pass app.CachePath() so the App's tools share one session. legacyPath names a per-tool cache the consumer kept before the cache was shared, or "" if it never had one; when cachePath does not exist yet and the legacy file does, the legacy file is read once and copied into place.

func (App) WriteStatus

func (app App) WriteStatus(w io.Writer, st Status)

WriteStatus renders st for a human. The layout is the same in every tool so a reader learns it once.

type GraphClient

type GraphClient struct {
	// contains filtered or unexported fields
}

GraphClient is a thin authenticated HTTP wrapper for the small Graph surface the tools use: JSON requests, streamed downloads, and the upload-session protocol for large files. Token refresh is delegated to MSAL on every request.

func (*GraphClient) CancelUploadSession

func (g *GraphClient) CancelUploadSession(ctx context.Context, uploadURL string)

CancelUploadSession best-effort deletes an in-progress upload session so an aborted large upload doesn't leave a dangling session on the server. The URL is pre-authenticated; errors are ignored since this is cleanup.

func (*GraphClient) Delete

func (g *GraphClient) Delete(ctx context.Context, path string) error

Delete issues an authenticated DELETE.

func (*GraphClient) DeleteIfMatch

func (g *GraphClient) DeleteIfMatch(ctx context.Context, path, etag string) error

DeleteIfMatch issues a DELETE that Graph applies only while the resource still carries etag, or unconditionally when etag is "".

func (*GraphClient) Get

func (g *GraphClient) Get(ctx context.Context, path string, query url.Values) ([]byte, error)

Get issues an authenticated GET. path is everything after graphBaseURL. It also serves file downloads: a GET to an item's /content endpoint returns a 302 to a pre-authenticated download URL, which the http client follows (Go strips our Authorization header on the cross-host hop, which is correct — the redirect target is already signed).

func (*GraphClient) GetAll

func (g *GraphClient) GetAll(ctx context.Context, path string, query url.Values) ([]json.RawMessage, error)

GetAll follows @odata.nextLink and returns the concatenated value array as raw JSON messages. Caller unmarshals each entry as needed.

func (*GraphClient) GetStream

func (g *GraphClient) GetStream(ctx context.Context, path string) (io.ReadCloser, error)

GetStream issues an authenticated GET and returns the response body unread, for streaming large downloads straight to disk. Used for file content: a GET to an item's /content endpoint 302s to a pre-authed URL, which the http client follows (dropping our Authorization header on the cross-host hop, as intended). The caller must Close the returned reader. Unlike Get, this does not retry on 429 — content reads through a signed CDN URL, where throttling is rare.

func (*GraphClient) Patch

func (g *GraphClient) Patch(ctx context.Context, path string, body interface{}) ([]byte, error)

Patch issues an authenticated PATCH with a JSON body.

func (*GraphClient) PatchIfMatch

func (g *GraphClient) PatchIfMatch(ctx context.Context, path, etag string, body interface{}) ([]byte, error)

PatchIfMatch issues a PATCH that Graph applies only while the resource still carries etag. Resources with optimistic concurrency, such as Planner tasks, refuse a PATCH without one; a stale etag comes back as a 412 HTTPError.

func (*GraphClient) Post

func (g *GraphClient) Post(ctx context.Context, path string, body interface{}) ([]byte, error)

Post issues an authenticated POST with a JSON body.

func (*GraphClient) PutRaw

func (g *GraphClient) PutRaw(ctx context.Context, path, contentType string, data []byte) ([]byte, error)

PutRaw issues an authenticated PUT with an arbitrary byte body and content type. Used for simple (<=250MB) file uploads to an item's /content endpoint. Larger files go through an upload session (UploadChunk).

func (*GraphClient) UploadChunk

func (g *GraphClient) UploadChunk(ctx context.Context, uploadURL string, chunk []byte, start, total int64) (int, []byte, error)

UploadChunk PUTs one byte range of a file to a pre-authenticated upload-session URL (returned by a createUploadSession POST). That URL is already signed, so no Authorization header is sent. start is the zero-based offset of this chunk and total the full file size; Graph reads the Content-Range header to assemble the file and to recognize the final chunk. The returned status is 202 while more chunks are expected and 200/201 once the upload is complete (body is then the finished driveItem). Transient 429/5xx responses are retried with backoff; re-sending the same range is how an interrupted session resumes.

type HTTPError

type HTTPError struct {
	Method string
	Path   string
	Status int
	Body   string
}

HTTPError is a non-2xx answer from Graph. Callers that need to tell one status from another, such as a 412 from a stale If-Match, reach it with errors.As.

func (*HTTPError) Error

func (e *HTTPError) Error() string

type Option

type Option func(*GraphClient)

Option adjusts a GraphClient at construction.

func WithHeader

func WithHeader(name, value string) Option

WithHeader adds a header to every authenticated request. xql uses it for the Prefer header that SharePoint requires before it will $filter on non-indexed list columns; the drive endpoints the xfiles tools call have no use for it, so it is a consumer's choice rather than a default.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout bounds each request, body read included. The default is five minutes, sized for file content moving through PutRaw and GetStream; a consumer that only exchanges JSON can set something shorter.

type Status

type Status struct {
	SignedIn bool   `json:"signed_in"`
	Account  string `json:"account,omitempty"`
	Tenant   string `json:"tenant,omitempty"`
	// TokenExpires is when the current access token lapses. The session
	// outlives it: MSAL renews the token from the cached refresh token
	// without a prompt for as long as the refresh token is honoured.
	TokenExpires time.Time `json:"token_expires,omitzero"`
	Scopes       []string  `json:"scopes,omitempty"`
	// Reason says why SignedIn is false, in words a caller can print.
	Reason string `json:"reason,omitempty"`
	Cache  string `json:"cache"`
}

Status is the state of an App's session, as its tools' bare `auth` command reports it. An absent session is an answer, not a failure, so the zero SignedIn carries a Reason rather than an error.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL