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 docs/src/content/docs/authoring/blocks/AwsAuth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ The AwsAuth block provides three ways to authenticate:
| **AWS SSO** | Use AWS IAM Identity Center (formerly AWS SSO) |
| **Local Profile** | Use a profile from `~/.aws/credentials` |

Static Credentials is the tab users land on. Set `defaultTab` to open on a different one:

```mdx
<AwsAuth
id="aws-auth"
defaultTab="sso"
ssoStartUrl="https://my-company.awsapps.com/start"
/>
```

The user can still switch tabs — `defaultTab` only chooses the starting one.

## Props

| Prop | Type | Default | Description |
Expand All @@ -39,6 +51,7 @@ The AwsAuth block provides three ways to authenticate:
| `title` | string | "AWS Authentication" | Display title shown in the UI |
| `description` | string | - | Description of the authentication purpose |
| `defaultRegion` | string | "us-east-1" | Default AWS region for CLI commands. Sets `AWS_REGION` environment variable |
| `defaultTab` | `'credentials'`, `'sso'`, or `'profile'` | `'credentials'` | Which authentication tab the block opens on. An unrecognized value falls back to `'credentials'` |
| `detectCredentials` | `false` or `CredentialSource[]` | `['env']` | Whether and how to detect existing credentials. See [Credential Detection](#credential-detection) |
| `ssoStartUrl` | string | - | AWS SSO start URL (e.g., `https://my-company.awsapps.com/start`). Required for SSO |
| `ssoRegion` | string | "us-east-1" | AWS region where IAM Identity Center is configured |
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/authoring/blocks/GitAuth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Only `gitlab.com` and `github.com` are supported today. Self-managed GitLab and
| `oauthClientId` | `string` | No | Gruntwork's app | Custom GitHub OAuth App client ID. GitHub only. |
| `oauthScopes` | `string[]` | No | `['repo']` (GitHub) | OAuth scopes to request. |
| `detectCredentials` | `false \| GitCredentialSource[]` | No | `['env', 'cli']` | Credential auto-detection sources, applied to the selected provider. Set to `false` to disable. |
| `defaultTab` | `'oauth' \| 'pat'` | No | `'oauth'` (GitHub), `'pat'` (GitLab) | Which authentication tab the block opens on. GitLab has no OAuth flow, so `'oauth'` falls back to `'pat'` there. Re-applied when the user switches providers. |
| `inputsId` | `string \| string[]` | No | — | Reference one or more `<Inputs>` blocks for template expressions in props. |

## Usage
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/authoring/blocks/GitHubAuth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ The GitHubAuth block supports GitHub.com, GitHub Enterprise Cloud and GitHub Ent
| `detectCredentials` | `false` or `CredentialSource[]` | `['env', 'cli']` | Whether and how the block should automatically detect credentials in the user's environment |
| `oauthClientId` | string | Gruntwork default | Custom OAuth App client ID |
| `oauthScopes` | string[] | `["repo"]` | OAuth scopes to request |
| `defaultTab` | `'oauth'` or `'pat'` | `'oauth'` | Which authentication tab the block opens on |

## Usage

Expand Down
9 changes: 9 additions & 0 deletions docs/src/content/docs/authoring/blocks/GoogleAuth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ The GoogleAuth block provides three ways to authenticate:

All three tabs also offer an optional **Default Region** picker, which seeds the region environment variables for subsequent commands.

Service Account Key is the tab users land on. Set `defaultTab` to open on a different one:

```mdx
<GoogleAuth id="gcp-auth" defaultTab="gcloud" />
```

The user can still switch tabs — `defaultTab` only chooses the starting one.

<Aside type="note">
Runbooks never shells out to the `gcloud` binary. Service account keys are validated by minting a real access token, sign-in uses a loopback OAuth redirect, and the gcloud tab reads gcloud's own configuration files directly. You do not need the Google Cloud CLI installed for any of the three tabs — though the gcloud tab is only useful if you have used it before to create credentials.
</Aside>
Expand All @@ -48,6 +56,7 @@ Runbooks never shells out to the `gcloud` binary. Service account keys are valid
| `defaultRegion` | `string` | — | Default compute region for subsequent commands. Sets `GOOGLE_CLOUD_REGION`, `CLOUDSDK_COMPUTE_REGION`, and `GOOGLE_REGION` |
| `defaultZone` | `string` | — | Default compute zone for subsequent commands. Sets `CLOUDSDK_COMPUTE_ZONE` and `GOOGLE_ZONE` |
| `gcloudConfiguration` | `string` | — | Pre-select a named gcloud configuration in the gcloud Config tab (supports template expressions) |
| `defaultTab` | `'service_account'`, `'oauth'`, or `'gcloud'` | `'service_account'` | Which authentication tab the block opens on. An unrecognized value falls back to `'service_account'` |
| `scopes` | `string[]` | `cloud-platform`, `userinfo.email`, `openid` | OAuth scopes requested by Google Sign-In. When set, also **required** of any auto-detected or gcloud user ADC this block will accept (service-account keys are exempt) |
| `oauthClientId` | `string` | — | Client ID of a Google Cloud "Desktop app" OAuth client. Must be paired with `oauthClientSecret`. Mutually exclusive with `oauthClientFile`. See [Using your own OAuth client](#using-your-own-oauth-client) |
| `oauthClientSecret` | `string` | — | Client secret issued alongside the Desktop OAuth client. Required whenever `oauthClientId` is set. Per RFC 8252 this value is not confidential; Google simply issues one with every Desktop client |
Expand Down
19 changes: 19 additions & 0 deletions testdata/feature-demos/aws-auth/runbook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ The simplest form of AwsAuth. With no `detectCredentials` prop, it defaults to `
usePty={false}
/>

### 1a. Choosing the Default Tab

The block opens on the Static Credentials tab. `defaultTab` picks a different starting tab -- `"credentials"`, `"sso"`, or `"profile"`. The user can still switch tabs; this only chooses which one is selected first.

```mdx
<AwsAuth id="aws-auth-sso-first" defaultTab="sso" ssoStartUrl="..." />
```

<AwsAuth
id="aws-auth-sso-first"
title="SSO First"
description="Opens on the AWS SSO tab instead of Static Credentials."
defaultTab="sso"
defaultRegion="us-west-2"
ssoRegion="us-west-2"
ssoStartUrl="https://d-9267d384ee.awsapps.com/start"
detectCredentials={false}
/>

---

## 2. Credential Detection Options
Expand Down
16 changes: 16 additions & 0 deletions testdata/feature-demos/google-auth/runbook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ The simplest form of GoogleAuth. With no `detectCredentials` prop, it defaults t
failMessage="No usable Google Cloud credentials in the session environment."
/>

### 1a. Choosing the Default Tab

The block opens on the Service Account Key tab. `defaultTab` picks a different starting tab -- `"service_account"`, `"oauth"`, or `"gcloud"`. The user can still switch tabs; this only chooses which one is selected first.

```mdx
<GoogleAuth id="google-auth-gcloud-first" defaultTab="gcloud" />
```

<GoogleAuth
id="google-auth-gcloud-first"
title="gcloud Config First"
description="Opens on the gcloud Config tab instead of Service Account Key."
defaultTab="gcloud"
detectCredentials={false}
/>

---

## 2. Credential Detection Options
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/mdx/AwsAuth/AwsAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function AwsAuthInteractive({
ssoRoleName,
defaultRegion = "us-east-1",
detectCredentials = ['env'], // Default: auto-detect from env vars
defaultTab,
inputsId,
}: AwsAuthProps) {
const validationError = useMemo((): AppError | null => {
Expand Down Expand Up @@ -78,6 +79,7 @@ function AwsAuthInteractive({
ssoRoleName,
defaultRegion,
detectCredentials,
defaultTab,
})

// Track block render on mount
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useAwsAuth } from '../useAwsAuth'

/**
* Which tab the block opens on is decided once, at mount, from the author's
* `defaultTab` prop. Detection is switched off in these tests so no IPC runs —
* the starting tab is the only behaviour under test.
*/

vi.mock('@/contexts/useRunbook', () => ({
useRunbookContext: () => ({ registerOutputs: vi.fn(), blockOutputs: {} }),
}))
vi.mock('@/contexts/useSession', () => ({
useSession: () => ({ isReady: true }),
}))

const originalApi = window.api

afterEach(() => {
window.api = originalApi
})

const renderAwsAuth = (defaultTab?: string) =>
renderHook(() =>
useAwsAuth({
id: 'aws',
ssoRegion: 'us-east-1',
defaultRegion: 'us-east-1',
detectCredentials: false,
defaultTab,
}),
)

describe('useAwsAuth — defaultTab', () => {
it('opens on Static Credentials when no defaultTab is set', () => {
expect(renderAwsAuth().result.current.authMethod).toBe('credentials')
})

it('opens on the tab the author asked for', () => {
expect(renderAwsAuth('sso').result.current.authMethod).toBe('sso')
expect(renderAwsAuth('profile').result.current.authMethod).toBe('profile')
})

it('falls back to Static Credentials for an unrecognized tab name', () => {
// MDX props are untyped, so a typo must not leave the block formless.
expect(renderAwsAuth('sso-tab').result.current.authMethod).toBe('credentials')
})
})
9 changes: 8 additions & 1 deletion web/src/components/mdx/AwsAuth/hooks/useAwsAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
AwsCredentialSource,
DetectedAwsCredentials,
} from "../types"
import { resolveDefaultAuthMethod } from "../utils"

interface UseAwsAuthOptions {
id: string
Expand All @@ -23,6 +24,8 @@ interface UseAwsAuthOptions {
ssoRoleName?: string
defaultRegion: string
detectCredentials?: false | AwsCredentialSource[]
/** Tab to open on; validated by resolveDefaultAuthMethod. */
defaultTab?: string
}

export function useAwsAuth({
Expand All @@ -33,12 +36,16 @@ export function useAwsAuth({
ssoRoleName,
defaultRegion,
detectCredentials = ['env'], // Default: auto-detect from env vars
defaultTab,
}: UseAwsAuthOptions) {
const { registerOutputs, blockOutputs } = useRunbookContext()
const { isReady: sessionReady } = useSession()

// Core auth state
const [authMethod, setAuthMethod] = useState<AuthMethod>('credentials')
// The starting tab is the author's `defaultTab` (validated), not a constant.
// Only the initial value is taken from the prop — the user's tab clicks own
// it from then on.
const [authMethod, setAuthMethod] = useState<AuthMethod>(() => resolveDefaultAuthMethod(defaultTab))
const [authStatus, setAuthStatus] = useState<AuthStatus>('pending')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [warningMessage, setWarningMessage] = useState<string | null>(null)
Expand Down
6 changes: 6 additions & 0 deletions web/src/components/mdx/AwsAuth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ export interface AwsAuthProps {
ssoRoleName?: string
/** Default AWS region for CLI commands that don't specify a region */
defaultRegion?: string
/**
* Which authentication tab the block opens on: 'credentials' (Static
* Credentials), 'sso' (AWS SSO), or 'profile' (Local Profile).
* Default: 'credentials'. An unrecognized value falls back to the default.
*/
defaultTab?: AuthMethod
/**
* Credential detection configuration.
* - `false`: Disable auto-detection, show manual auth only
Expand Down
19 changes: 18 additions & 1 deletion web/src/components/mdx/AwsAuth/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CheckCircle, XCircle, Loader2, KeyRound, User } from "lucide-react"
import type { AuthStatus, AwsDetectionSource } from "./types"
import type { AuthMethod, AuthStatus, AwsDetectionSource } from "./types"
import { makeStatusStyles } from "../_shared/lib/statusStyles"

// Status-based styling for the container, icon, and icon color. Maps are
Expand Down Expand Up @@ -45,3 +45,20 @@ export function getSourceLabel(source: AwsDetectionSource): string | null {
return null
}
}

// The tab the block opens on when the author sets no `defaultTab`.
const FALLBACK_AUTH_METHOD: AuthMethod = 'credentials'

const AUTH_METHODS: readonly AuthMethod[] = ['credentials', 'sso', 'profile']

/**
* Resolve the `defaultTab` prop to the tab the block opens on. Runbook authors
* write raw MDX with no type checking, so the value is validated here: an
* unrecognized tab name falls back to Static Credentials rather than leaving
* the block with no form showing at all.
*/
export function resolveDefaultAuthMethod(defaultTab: string | undefined): AuthMethod {
return AUTH_METHODS.includes(defaultTab as AuthMethod)
? (defaultTab as AuthMethod)
: FALLBACK_AUTH_METHOD
}
9 changes: 6 additions & 3 deletions web/src/components/mdx/GitAuth/GitAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { AppError } from "@/types/error"
import type { GitAuthProps, GitProvider } from "./types"
import { PROVIDERS } from "./providers"
import { useGitAuth } from "./hooks/useGitAuth"
import { getStatusClasses, getStatusIcon, getStatusIconClasses } from "./utils"
import { getStatusClasses, getStatusIcon, getStatusIconClasses, resolveDefaultAuthMethod } from "./utils"
import { ProviderSelect } from "./components/ProviderSelect"
import { HostSelect } from "./components/HostSelect"
import { AuthTabs } from "./components/AuthTabs"
Expand All @@ -41,6 +41,7 @@ function GitAuthInteractive({
oauthScopes,
detectCredentials,
host,
defaultTab,
inputsId,
__registryType = 'GitAuth',
}: GitAuthInternalProps) {
Expand Down Expand Up @@ -87,19 +88,21 @@ function GitAuthInteractive({
oauthScopes: effectiveOAuthScopes,
detectCredentials,
host,
defaultTab,
})

// Switch providers: cancel any in-flight OAuth poll, drop the prior
// provider's outputs, reset transient + detection state so detection re-runs,
// and reset the auth method to the new provider's default (GitLab has no
// OAuth, so a leftover 'oauth' method would render no form at all).
// OAuth, so a leftover 'oauth' method would render no form at all). The
// author's `defaultTab` applies again here when the new provider offers it.
const handleSelectProvider = (next: GitProvider) => {
if (next === provider) return
auth.cancelOAuth()
auth.clearRegisteredOutputs(next)
auth.resetAuth()
auth.resetDetectionState()
auth.setAuthMethod(PROVIDERS[next].supportsOAuth ? 'oauth' : 'pat')
auth.setAuthMethod(resolveDefaultAuthMethod(PROVIDERS[next], defaultTab))
setCustomOAuthDismissed(false)
setUseDefaultOAuth(false)
setProvider(next)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,45 @@ describe('GitAuth — provider switch (real hook)', () => {
})
})
})

describe('GitAuth — defaultTab (real hook)', () => {
it('opens on the PAT form when the author asks for it', () => {
render(
<TestWrapper>
<GitAuth id="git" defaultTab="pat" detectCredentials={false} />
</TestWrapper>,
)

expect(screen.getByPlaceholderText(/github_pat_/i)).toBeInTheDocument()
expect(screen.queryByText(/redirected to authorize/i)).toBeNull()
})

it('re-applies defaultTab after a provider switch', async () => {
render(
<TestWrapper>
<GitAuth id="git" provider="gitlab" defaultTab="pat" detectCredentials={false} />
</TestWrapper>,
)

expect(screen.getByPlaceholderText(/GitLab access token/i)).toBeInTheDocument()

fireEvent.click(screen.getByRole('tab', { name: /GitHub/ }))

// GitHub defaults to OAuth, but the author pinned the PAT tab.
await waitFor(() => {
expect(screen.getByPlaceholderText(/github_pat_/i)).toBeInTheDocument()
})
expect(screen.queryByText(/redirected to authorize/i)).toBeNull()
})

it('ignores a tab the provider does not offer', () => {
// GitLab has no OAuth device flow — the PAT form must still render.
render(
<TestWrapper>
<GitAuth id="git" provider="gitlab" defaultTab="oauth" detectCredentials={false} />
</TestWrapper>,
)

expect(screen.getByPlaceholderText(/GitLab access token/i)).toBeInTheDocument()
})
})
30 changes: 29 additions & 1 deletion web/src/components/mdx/GitAuth/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest"
import { normalizeInstanceBaseUrl } from "../utils"
import { normalizeInstanceBaseUrl, resolveDefaultAuthMethod } from "../utils"
import { PROVIDERS } from "../providers"

// Mirrors the backend's normalizeGitLabBaseUrl (src/domain/git/gitlab-host.ts),
// but returns null (rather than the gitlab.com default) so the PAT form can fall
Expand Down Expand Up @@ -32,3 +33,30 @@ describe("normalizeInstanceBaseUrl", () => {
expect(normalizeInstanceBaseUrl("ftp://gitlab.acme.com")).toBeNull()
})
})

// Which tab the block opens on. The valid set is provider-dependent — GitLab
// has no OAuth flow — so a tab the provider does not offer must fall back
// rather than render an empty pane.
describe("resolveDefaultAuthMethod", () => {
it("defaults to the provider's own tab when no defaultTab is set", () => {
expect(resolveDefaultAuthMethod(PROVIDERS.github, undefined)).toBe("oauth")
expect(resolveDefaultAuthMethod(PROVIDERS.gitlab, undefined)).toBe("pat")
})

it("honors a tab the provider offers", () => {
expect(resolveDefaultAuthMethod(PROVIDERS.github, "pat")).toBe("pat")
expect(resolveDefaultAuthMethod(PROVIDERS.github, "oauth")).toBe("oauth")
expect(resolveDefaultAuthMethod(PROVIDERS.gitlab, "pat")).toBe("pat")
})

it("falls back when the provider does not offer the requested tab", () => {
// GitLab has no OAuth device flow; 'oauth' would render no form at all.
expect(resolveDefaultAuthMethod(PROVIDERS.gitlab, "oauth")).toBe("pat")
})

it("falls back for an unrecognized tab name", () => {
// MDX props are untyped, so a typo must not leave the block formless.
expect(resolveDefaultAuthMethod(PROVIDERS.github, "token")).toBe("oauth")
expect(resolveDefaultAuthMethod(PROVIDERS.gitlab, "")).toBe("pat")
})
})
Loading
Loading