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 ¶
- Variables
- func HintForAuthError(err error) string
- type App
- func (app App) AuthCommand(ctx context.Context, tool, legacyCachePath string, args []string, ...) int
- func (app App) Authenticate(ctx context.Context, client public.Client) (public.AuthResult, error)
- func (app App) CachePath() string
- func (app App) CheckStatus(ctx context.Context, client public.Client, cachePath string) (Status, error)
- func (app App) NewGraphClient(msal public.Client, account public.Account, opts ...Option) *GraphClient
- func (app App) NewPublicClient(cachePath, legacyPath string) (public.Client, error)
- func (app App) WriteStatus(w io.Writer, st Status)
- type GraphClient
- func (g *GraphClient) CancelUploadSession(ctx context.Context, uploadURL string)
- func (g *GraphClient) Delete(ctx context.Context, path string) error
- func (g *GraphClient) DeleteIfMatch(ctx context.Context, path, etag string) error
- func (g *GraphClient) Get(ctx context.Context, path string, query url.Values) ([]byte, error)
- func (g *GraphClient) GetAll(ctx context.Context, path string, query url.Values) ([]json.RawMessage, error)
- func (g *GraphClient) GetStream(ctx context.Context, path string) (io.ReadCloser, error)
- func (g *GraphClient) Patch(ctx context.Context, path string, body interface{}) ([]byte, error)
- func (g *GraphClient) PatchIfMatch(ctx context.Context, path, etag string, body interface{}) ([]byte, error)
- func (g *GraphClient) Post(ctx context.Context, path string, body interface{}) ([]byte, error)
- func (g *GraphClient) PutRaw(ctx context.Context, path, contentType string, data []byte) ([]byte, error)
- func (g *GraphClient) UploadChunk(ctx context.Context, uploadURL string, chunk []byte, start, total int64) (int, []byte, error)
- type HTTPError
- type Option
- type Status
Constants ¶
This section is empty.
Variables ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
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) 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) 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 ¶
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.
type Option ¶
type Option func(*GraphClient)
Option adjusts a GraphClient at construction.
func WithHeader ¶
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 ¶
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.