Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/wiki/cmd-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ Self-hosted users with no cloud account can skip this step, the CLI operates wit
- Accessing `cloud.nself.org` to provision or manage hosted stacks
- Syncing license keys from your account
- Pushing plugin submissions via `nself plugin submit`

### Auth host

The CLI talks to the nSelf auth server at `https://auth-server.nself.org`. Set `NSELF_AUTH_SERVER_URL` to use another host (a local stack or a self-hosted auth server); a trailing slash is ignored.

### Device flow

1. The CLI calls `POST /auth/device-code` and prints the login code and the verification URL (`verification_uri`).
2. You open the URL, sign in and approve the code in the browser.
3. The CLI polls `POST /auth/device-code/token` every 5 seconds. `authorization_pending` keeps waiting; `slow_down` adds 5 seconds to the interval; `expired_token` and `access_denied` stop the login with a message (run `nself login` again).
4. On approval the server returns a device token (90 days). The CLI reads your account (`GET /auth/session`) for the email and tier, then stores both in `~/.nself/auth.json`. If that lookup fails, the login fails and nothing is stored.

The account commands `nself account devices` and `nself account team` call `/account/devices` and `/account/team`, which the auth server does not provide. They fail with `E225` (exit 2) instead of a raw 404.
<!-- END PROSE:description -->

### Examples
Expand Down
1 change: 1 addition & 0 deletions .github/wiki/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ These errors occur when the CLI interacts with the PostgreSQL container, backups
| <a id="e222"></a>E222 | 2 | Backup identity could not be created or read | nSelf could not create, secure or read the age identity under ~/.config/nself (missing age-keygen, unwritable directory, a symlink or a file that is not an age identity). An existing identity is never overwritten. | Install age, fix the directory permissions (0700), or pass --recipient <age1...> and manage the key yourself. |
| <a id="e223"></a>E223 | 2 | Backup identity missing for decrypt | Backups encrypted to an auto-created identity can only be decrypted with that identity file. Without it they are unrecoverable. | Restore the identity file from your off-host copy to ~/.config/nself/<project>-age.key, or pass the command's key flag (restore --decrypt-key, restore-remote --key, drill --identity). |
| <a id="e224"></a>E224 | 2 | No backup recipient and automatic key creation is disabled | NSELF_BACKUP_NO_AUTO_KEY=1 is set, so nSelf will not create an identity, and no recipient is configured. Backups are never written in the clear by default. | Pass --recipient <age1...\|ssh-...\|github:user>, set BACKUP_AGE_RECIPIENTS, run nself backup init-key, or pass --no-encrypt to write in the clear. |
| <a id="e225"></a>E225 | 2 | Server does not provide this path | The server answered 404 for an account path this CLI version calls but the nSelf auth server does not serve. | Use the nself.org account pages for this action, or update the CLI to a version that matches the server. |
<!-- END GENERATED:codes-database -->

---
Expand Down
56 changes: 52 additions & 4 deletions cmd/commands/account_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package commands

import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
Expand All @@ -10,6 +12,7 @@ import (
"testing"

"github.com/nself-org/cli/internal/auth"
"github.com/nself-org/cli/internal/errs"
)

// writeTestAuthFile writes a minimal auth.json to the isolated home temp dir.
Expand Down Expand Up @@ -105,7 +108,7 @@ func TestAccountLoginCmd_DeviceCodeFlow(t *testing.T) {
dcResp := auth.DeviceCodeResponse{
DeviceCode: "device-code-abc",
UserCode: "TEST-1234",
VerificationURL: "https://nself.org/auth/cli?code=TEST-1234",
VerificationURL: "https://nself.org/device?code=TEST-1234",
ExpiresInSec: 300,
IntervalSec: 5,
}
Expand All @@ -120,10 +123,25 @@ func TestAccountLoginCmd_DeviceCodeFlow(t *testing.T) {
setAuthServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/auth/device/authorize":
case "/auth/device-code":
_ = json.NewEncoder(w).Encode(dcResp)
case "/auth/device/token":
_ = json.NewEncoder(w).Encode(tokenResp)
case "/auth/device-code/token":
// auth_server answers an approved poll with the device token only.
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": tokenResp.AccessToken,
"token_type": "Bearer",
"expires_in": 7776000,
})
case "/auth/session":
// Identity comes from GET /auth/session, not from the poll reply.
_ = json.NewEncoder(w).Encode(map[string]any{
"authenticated": true,
"account": map[string]any{
"id": "acct-1",
"email": tokenResp.Email,
"tier": tokenResp.Tier,
},
})
default:
http.NotFound(w, r)
}
Expand Down Expand Up @@ -461,3 +479,33 @@ func TestRequireLogin_NotLoggedIn(t *testing.T) {
t.Errorf("error should mention 'nself account login', got: %v", err)
}
}

// TestHandleAuthError_UnservedAccountPathIsE225 verifies that a 404 from the
// account paths auth_server does not serve reaches the user as E225 with exit
// class 2, never a raw 404, and that the token never appears in the message.
func TestHandleAuthError_UnservedAccountPathIsE225(t *testing.T) {
setAuthServer(t, func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
})
calls := map[string]func() error{
"/account/devices": func() error { _, err := auth.GetDevices(context.Background(), "tok-secret"); return err },
"/account/team": func() error { _, err := auth.GetTeamMembers(context.Background(), "tok-secret"); return err },
}
for path, call := range calls {
err := handleAuthError(call())
var ce *errs.CLIError
if !errors.As(err, &ce) || ce.Code != "E225" {
t.Fatalf("%s: want an E225 CLIError, got %v", path, err)
}
msg := err.Error()
if !strings.Contains(msg, "[E225]") || !strings.Contains(msg, "this server does not provide "+path) {
t.Errorf("%s: message %q", path, msg)
}
if strings.Contains(msg, "tok-secret") {
t.Errorf("%s: message leaks the token: %q", path, msg)
}
if got := errs.ExitCodeFor(err); got != 2 {
t.Errorf("%s: exit class = %d, want 2", path, got)
}
}
}
5 changes: 5 additions & 0 deletions cmd/commands/account_transfer.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"strings"

"github.com/nself-org/cli/internal/auth"
"github.com/nself-org/cli/internal/errs"
"github.com/nself-org/cli/internal/ui"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -88,6 +89,10 @@ func handleAuthError(err error) error {
if errors.Is(err, auth.ErrNotLoggedIn) {
return errAccountRelogin()
}
var lacks *auth.ServerLacksPathError
if errors.As(err, &lacks) {
return errs.Newf("E225", "this server does not provide %s", lacks.Path)
}
if apiErr, ok := err.(*auth.AuthAPIError); ok {
switch apiErr.Status {
case 403:
Expand Down
23 changes: 19 additions & 4 deletions cmd/commands/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func TestLoginCmd_DeviceCodeFlow(t *testing.T) {
dcResp := auth.DeviceCodeResponse{
DeviceCode: "test-device-code",
UserCode: "TEST-CODE",
VerificationURL: "https://nself.org/auth/cli?code=TEST-CODE",
VerificationURL: "https://nself.org/device?code=TEST-CODE",
ExpiresInSec: 300,
IntervalSec: 5,
}
Expand All @@ -111,10 +111,25 @@ func TestLoginCmd_DeviceCodeFlow(t *testing.T) {
setAuthServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/auth/device/authorize":
case "/auth/device-code":
_ = json.NewEncoder(w).Encode(dcResp)
case "/auth/device/token":
_ = json.NewEncoder(w).Encode(tokenResp)
case "/auth/device-code/token":
// auth_server answers an approved poll with the device token only.
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": tokenResp.AccessToken,
"token_type": "Bearer",
"expires_in": 7776000,
})
case "/auth/session":
// Identity comes from GET /auth/session, not from the poll reply.
_ = json.NewEncoder(w).Encode(map[string]any{
"authenticated": true,
"account": map[string]any{
"id": "acct-1",
"email": tokenResp.Email,
"tier": tokenResp.Tier,
},
})
default:
http.NotFound(w, r)
}
Expand Down
63 changes: 19 additions & 44 deletions internal/auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,21 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)

// AuthServerURL is the base URL of the auth server.
// Overridable via NSELF_AUTH_SERVER_URL for testing.
// DefaultAuthServerURL is the production auth_server host (D-0083). The old
// default, api.nself.org, is the Hasura host and never served the login routes.
const DefaultAuthServerURL = "https://auth-server.nself.org"

// AuthServerURL is the base URL of the auth server, without a trailing slash.
// Overridable via NSELF_AUTH_SERVER_URL for testing and self-hosted setups.
func AuthServerURL() string {
if url := getEnv("NSELF_AUTH_SERVER_URL"); url != "" {
return url
return strings.TrimRight(url, "/")
}
return "https://api.nself.org"
return DefaultAuthServerURL
}

// CLIAuthBaseURL is the web UI for the CLI device auth page.
Expand All @@ -30,8 +35,8 @@ func CLIAuthBaseURL() string {
// DeviceCodeResponse is returned by the device authorization endpoint.
type DeviceCodeResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"` // XXXX-YYYY format shown to user
VerificationURL string `json:"verification_url"` // nself.org/auth/cli?code=...
UserCode string `json:"user_code"` // 8-character code shown to the user
VerificationURL string `json:"verification_uri"` // page the user opens; auth_server sends it as verification_uri
ExpiresInSec int `json:"expires_in"`
IntervalSec int `json:"interval"`
}
Expand Down Expand Up @@ -90,12 +95,13 @@ var httpClient = &http.Client{
Timeout: 30 * time.Second,
}

// DeviceAuthorize initiates the device code flow.
// Returns a DeviceCodeResponse with the code to display to the user.
// DeviceAuthorize initiates the device code flow with POST /auth/device-code.
// Returns a DeviceCodeResponse with the code to display to the user. A reply
// without a device_code or user_code is an error, never a half-filled value.
func DeviceAuthorize(ctx context.Context) (*DeviceCodeResponse, error) {
url := fmt.Sprintf("%s/auth/device/authorize", AuthServerURL())
url := fmt.Sprintf("%s/auth/device-code", AuthServerURL())

req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(`{}`))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(`{"client_name":"nself-cli"}`))
if err != nil {
return nil, err
}
Expand All @@ -114,42 +120,11 @@ func DeviceAuthorize(ctx context.Context) (*DeviceCodeResponse, error) {
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("parsing device authorization response: %w", err)
}

return &result, nil
}

// PollToken polls the auth server for the device code exchange result.
// Returns (nil, nil) if the user hasn't authorized yet (authorization_pending).
// Returns an error on timeout or other failure.
func PollToken(ctx context.Context, deviceCode string) (*TokenResponse, error) {
url := fmt.Sprintf("%s/auth/device/token", AuthServerURL())

body, _ := json.Marshal(map[string]string{"device_code": deviceCode})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body))
if err != nil {
return nil, err
if result.DeviceCode == "" || result.UserCode == "" {
return nil, fmt.Errorf("auth server device authorization reply is missing device_code or user_code")
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("polling token: %w", err)
}
defer func() { _ = resp.Body.Close() }()

// authorization_pending → user hasn't clicked "Authorize" yet
if resp.StatusCode == http.StatusAccepted {
return nil, nil
}

if resp.StatusCode == http.StatusOK {
var result TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("parsing token response: %w", err)
}
return &result, nil
}

return nil, parseAPIError(resp)
return &result, nil
}

// RefreshToken exchanges an existing session for a new access token.
Expand Down
20 changes: 19 additions & 1 deletion internal/auth/client_devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ package auth
// Purpose: list and revoke authorized devices, transfer a license between devices, and parse auth server error responses, used by device-related commands, split out of client.go for file size.
// Inputs: an account/session token and, for transfer/revoke, the target device id.
// Outputs: DeviceEntry values, or an error from the auth server.
// Constraints: pure move from client.go (CLI-R12 Batch E); no behaviour change.
// Constraints: GetDevices maps a 404 to E225 (auth_server serves no /account/devices route); the rest is a pure move from client.go.

import (
"bytes"
Expand Down Expand Up @@ -40,6 +40,9 @@ func GetDevices(ctx context.Context, accessToken string) ([]DeviceEntry, error)
return nil, fmt.Errorf("getting devices: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil, errServerLacksPath("/account/devices")
}
if resp.StatusCode != http.StatusOK {
return nil, parseSessionError(resp)
}
Expand Down Expand Up @@ -112,6 +115,21 @@ func parseSessionError(resp *http.Response) error {
return parseAPIError(resp)
}

// ServerLacksPathError means the auth server answered 404 for a path the CLI
// calls but the server does not provide (never a raw 404, never an empty
// list). Path is the route only, never a token or query. The command layer maps
// it to E225 (internal/auth stays free of internal/errs, which keeps the
// package fan-in of errs flat).
type ServerLacksPathError struct{ Path string }

func (e *ServerLacksPathError) Error() string {
return "this server does not provide " + e.Path
}

func errServerLacksPath(path string) error {
return &ServerLacksPathError{Path: path}
}

func parseAPIError(resp *http.Response) error {
body, _ := io.ReadAll(resp.Body)
var apiErr AuthAPIError
Expand Down
5 changes: 4 additions & 1 deletion internal/auth/client_team.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ package auth
// Purpose: list, invite, remove and change the role of team members, and activate a license for the account, used by team-related commands, split out of client.go for file size.
// Inputs: an account/session token and the target team member's details.
// Outputs: TeamMember values, or an error from the auth server.
// Constraints: pure move from client.go (CLI-R12 Batch E); no behaviour change.
// Constraints: GetTeamMembers maps a 404 to E225 (auth_server serves no /account/team route); the rest is a pure move from client.go.

import (
"bytes"
Expand Down Expand Up @@ -38,6 +38,9 @@ func GetTeamMembers(ctx context.Context, accessToken string) ([]TeamMember, erro
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode == http.StatusNotFound {
return nil, errServerLacksPath("/account/team")
}
if resp.StatusCode != http.StatusOK {
return nil, parseSessionError(resp)
}
Expand Down
Loading
Loading