spauth

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 19 Imported by: 0

README

spauth

The SharePoint sign-in layer shared by the Excelano SharePoint tools: xql's sp backend and the five xfiles commands (xftp, xcp, xsync, xfind, xtree). It carries the device-code OAuth flow over MSAL, the on-disk token cache those tools share, 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 that family, not a general Graph SDK. The client ID, authority and scope are constants: every consumer signs in against the one "Excelano SharePoint tools" app registration, which is what lets consent and the cached session carry across all six binaries. To self-host, change the constants and rebuild the consumers.

Install

go get github.com/excelano/spauth

Usage

import "github.com/excelano/spauth"

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

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

graph := spauth.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.

The token cache

CachePath is ~/.config/excelano/sp-token.json (under $XDG_CONFIG_HOME when that is set), one file for the whole family, so signing in with any tool signs in all of them. The second argument to NewPublicClient names the per-tool cache a consumer kept before the 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. Pass "" for a consumer that never had one.

The file is MSAL's own format, written 0600 in a 0700 directory. Writes go 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.

The state command

Every tool in the family answers a bare auth with the state of the shared 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. AuthCommand is that subcommand, flag parsing and rendering included, so the six binaries agree by construction; 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. Author: David M. Anderson. Built with AI assistance (Claude, Anthropic).

Documentation

Overview

Package spauth is the SharePoint sign-in layer shared by the Excelano SharePoint tools: xql's sp backend and the five xfiles commands (xftp, xcp, xsync, xfind, xtree). It holds the device-code OAuth flow over MSAL, the on-disk token cache those tools share, the refusal that keeps an unattended caller from hanging on a code nobody will read, the AADSTS hint table, and a thin authenticated Microsoft Graph HTTP client.

The six binaries have always shared one app registration and one delegated scope, so consent carried across them. What they did not share was the session: each kept its own cache and asked for its own sign-in. This module exists so that one sign-in covers the family, and so that a fix to the flow lands once. Before it, the code lived as two drifting copies in xql/internal/sp and xfiles/internal/spauth.

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; the session is shared by " +
		"every Excelano SharePoint tool, so one sign-in covers them all")

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.

Functions

func AuthCommand

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

AuthCommand is the family's bare `auth` subcommand: it reports the shared 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 Authenticate

func 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 CachePath

func CachePath() string

CachePath returns the token cache every tool on the registration shares: $XDG_CONFIG_HOME/excelano/sp-token.json, or ~/.config/excelano/sp-token.json. The directory is named for the registration rather than for either repo, because xql is not an xfiles tool and the cache belongs to neither.

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.

func NewPublicClient

func 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 CachePath() so the family shares one session. legacyPath names the 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 WriteStatus

func WriteStatus(w io.Writer, st Status)

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

Types

type GraphClient

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

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

func NewGraphClient

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

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) 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) 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 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 the shared session, as the family's 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.

func CheckStatus

func 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.

Jump to

Keyboard shortcuts

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