Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit e3bfec3

Browse files
committed
fix: align auth failure error messages for oauth
In the Typescript CLI, when OAuth fails due to an invalid or expired token, throw the same "missing auth" error that one would get if not logged in (no auth token). The resulting error message will prompt the user to re-authenticate. This has to be mediated by the legacycli http proxy because the Typescript side of the CLI is not involved in the OAuth flow at all; it is handled transparently by the go-application-framework's networking middleware. A special HTTP request and response header is used by the legacycli proxy to indicate whether that middleware auth has failed. If it has, the functions which perform HTTP client requests with needle will throw an error that indicates "unauthenticated", resulting in an experience consistent with the legacy token auth flow in the CLI. CLI-392
1 parent 2aad8bc commit e3bfec3

File tree

9 files changed

+169
-6
lines changed

9 files changed

+169
-6
lines changed

cliv2/internal/proxy/proxy.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"crypto/tls"
66
"crypto/x509"
7+
"errors"
78
"fmt"
89
"log"
910
"net"
@@ -18,6 +19,7 @@ import (
1819
pkg_utils "github.com/snyk/go-application-framework/pkg/utils"
1920

2021
"github.com/snyk/go-application-framework/pkg/networking/certs"
22+
"github.com/snyk/go-application-framework/pkg/networking/middleware"
2123
"github.com/snyk/go-httpauth/pkg/httpauth"
2224

2325
"github.com/snyk/cli/cliv2/internal/constants"
@@ -143,9 +145,28 @@ func (p *WrapperProxy) ProxyInfo() *ProxyInfo {
143145
}
144146
}
145147

148+
// headerSnykAuthFailed is used to indicate there was a failure to establish
149+
// authorization in a legacycli proxied HTTP request and response.
150+
//
151+
// The request header is used to propagate this indication from
152+
// NetworkAccess.AddHeaders all the way through proxy middleware into the
153+
// response.
154+
//
155+
// The response header is then used by the Typescript CLI to surface an
156+
// appropriate authentication failure error back to the user.
157+
//
158+
// These layers of indirection are necessary because the Typescript CLI is not
159+
// involved in OAuth authentication at all, but needs to know that an auth
160+
// failure specifically occurred. HTTP status and error catalog codes aren't
161+
// adequate for this purpose because there are non-authentication reasons an API
162+
// request might 401 or 403, such as permissions or entitlements.
163+
const headerSnykAuthFailed = "snyk-auth-failed"
164+
146165
func (p *WrapperProxy) replaceVersionHandler(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
147-
err := p.addHeaderFunc(r)
148-
if err != nil {
166+
if err := p.addHeaderFunc(r); err != nil {
167+
if errors.Is(err, middleware.ErrAuthenticationFailed) {
168+
r.Header.Set(headerSnykAuthFailed, "true")
169+
}
149170
p.DebugLogger.Printf("Failed to add header: %s", err)
150171
}
151172

@@ -177,6 +198,12 @@ func (p *WrapperProxy) Start() error {
177198
proxy.Logger = log.New(&pkg_utils.ToZeroLogDebug{Logger: p.DebugLogger}, "", 0)
178199
proxy.OnRequest().DoFunc(p.replaceVersionHandler)
179200
proxy.OnRequest().HandleConnect(p)
201+
proxy.OnResponse().DoFunc(func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
202+
if authFailed := resp.Request.Header.Get(headerSnykAuthFailed); authFailed != "" {
203+
resp.Header.Set(headerSnykAuthFailed, authFailed)
204+
}
205+
return resp
206+
})
180207
proxy.Verbose = true
181208
proxyServer := &http.Server{
182209
Handler: proxy,

cliv2/internal/proxy/proxy_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/rs/zerolog"
1414
"github.com/snyk/go-application-framework/pkg/configuration"
1515
"github.com/snyk/go-application-framework/pkg/networking/certs"
16+
"github.com/snyk/go-application-framework/pkg/networking/middleware"
1617
gafUtils "github.com/snyk/go-application-framework/pkg/utils"
1718
"github.com/snyk/go-httpauth/pkg/httpauth"
1819

@@ -256,3 +257,39 @@ func Test_appendExtraCaCert(t *testing.T) {
256257
// cleanup
257258
os.Remove(file.Name())
258259
}
260+
261+
func Test_proxyPropagatesAuthFailureHeader(t *testing.T) {
262+
basecache := "testcache"
263+
version := "1.1.1"
264+
config := configuration.NewInMemory()
265+
config.Set(configuration.CACHE_PATH, basecache)
266+
config.Set(configuration.INSECURE_HTTPS, false)
267+
268+
setup(t, basecache, version)
269+
defer teardown(t, basecache)
270+
271+
wp, err := proxy.NewWrapperProxy(config, version, &debugLogger)
272+
assert.Nil(t, err)
273+
wp.SetHeaderFunction(func(r *http.Request) error {
274+
// Simulate a wrapped authentication failure, such as oauth refresh.
275+
return fmt.Errorf("nope: %w", middleware.ErrAuthenticationFailed)
276+
})
277+
278+
err = wp.Start()
279+
assert.Nil(t, err)
280+
281+
useProxyAuth := true
282+
proxiedClient, err := helper_getHttpClient(wp, useProxyAuth)
283+
assert.Nil(t, err)
284+
285+
res, err := proxiedClient.Get("https://static.snyk.io/cli/latest/version")
286+
assert.Nil(t, err)
287+
// Assert that the proxy propagates the auth failed marker header to the response.
288+
assert.Equal(t, res.Header.Get("snyk-auth-failed"), "true")
289+
290+
wp.Close()
291+
292+
// assert cert file is deleted on Close
293+
_, err = os.Stat(wp.CertificateLocation)
294+
assert.NotNil(t, err) // this means the file is gone
295+
}

src/lib/ecosystems/resolve-test-facts.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,14 @@ export async function resolveAndTestFacts(
4343
try {
4444
return await resolveAndTestFactsUnmanagedDeps(scans, options);
4545
} catch (error) {
46-
const unauthorized = error.code === 401 || error.code === 403;
47-
48-
if (unauthorized) {
46+
const unauthorizedErrorCode = error.code === 401 || error.code === 403;
47+
const missingApiToken = error.isMissingApiToken;
48+
49+
// Decide if the error is an authorization error other than being
50+
// unauthenticated (missing or invalid API token). An account lacking
51+
// permission, for example.
52+
const otherUnauthorized = unauthorizedErrorCode && !missingApiToken;
53+
if (otherUnauthorized) {
4954
throw AuthFailedError(
5055
'Unauthorized request to unmanaged service',
5156
error.code,

src/lib/errors/missing-api-token.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ export class MissingApiTokenError extends CustomError {
66
private static ERROR_MESSAGE =
77
'`snyk` requires an authenticated account. Please run `snyk auth` and try again.';
88

9+
/**
10+
* isMissingApiToken returns whether the error instance is a missing API token
11+
* error.
12+
*
13+
* Defined as a property so that the same expression resolves as "falsy"
14+
* (undefined) when other error types are tested.
15+
*/
16+
public get isMissingApiToken(): boolean {
17+
return this.strCode === MissingApiTokenError.ERROR_STRING_CODE;
18+
}
19+
920
constructor() {
1021
super(MissingApiTokenError.ERROR_MESSAGE);
1122
this.code = MissingApiTokenError.ERROR_CODE;

src/lib/request/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const headerSnykAuthFailed = 'snyk-auth-failed';

src/lib/request/promise.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { getAuthHeader } from '../api-token';
2+
import { MissingApiTokenError } from '../errors';
3+
import { headerSnykAuthFailed } from './constants';
24
import * as request from './index';
35

46
export async function makeRequest<T>(payload: any): Promise<T> {
@@ -36,6 +38,9 @@ export async function makeRequestRest<T>(payload: any): Promise<T> {
3638
if (error) {
3739
return reject(error);
3840
}
41+
if (res?.headers?.[headerSnykAuthFailed] === 'true') {
42+
return reject(new MissingApiTokenError());
43+
}
3944
if (res.statusCode === 400) {
4045
return reject({
4146
code: res.statusCode,

src/lib/request/request.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { getVersion } from '../version';
1212
import * as https from 'https';
1313
import * as http from 'http';
1414
import { jsonStringifyLargeObject } from '../json';
15+
import { MissingApiTokenError } from '../errors';
16+
import { headerSnykAuthFailed } from './constants';
1517

1618
const debug = debugModule('snyk:req');
1719
const snykDebug = debugModule('snyk');
@@ -143,6 +145,9 @@ export async function makeRequest(
143145

144146
return new Promise((resolve, reject) => {
145147
needle.request(method, url, data, options, (err, res, respBody) => {
148+
if (res?.headers?.[headerSnykAuthFailed] === 'true') {
149+
return reject(new MissingApiTokenError());
150+
}
146151
// respBody potentially very large, do not output it in debug
147152
debug('response (%s)', (res || {}).statusCode);
148153
if (err) {
@@ -173,7 +178,10 @@ export async function streamRequest(
173178

174179
async function getStatusCode(stream: needle.ReadableStream): Promise<number> {
175180
return new Promise((resolve, reject) => {
176-
stream.on('header', (statusCode: number) => {
181+
stream.on('header', (statusCode: number, headers: any) => {
182+
if (headers?.[headerSnykAuthFailed] === 'true') {
183+
return reject(new MissingApiTokenError());
184+
}
177185
resolve(statusCode);
178186
});
179187
stream.on('err', (err: Error) => {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
const mockMakeRequest = jest.fn();
2+
3+
import { Options } from '../../../../../src/lib/types';
4+
import { MissingApiTokenError } from '../../../../../src/lib/errors';
5+
6+
import { resolveAndTestFacts } from '../../../../../src/lib/ecosystems/resolve-test-facts';
7+
8+
jest.mock('../../../../../src/lib/request/request', () => {
9+
return {
10+
makeRequest: mockMakeRequest,
11+
};
12+
});
13+
14+
describe('oauth failure', () => {
15+
afterEach(() => {
16+
jest.resetAllMocks();
17+
});
18+
19+
it('rethrows same error for missing api token', async () => {
20+
mockMakeRequest.mockRejectedValue(new MissingApiTokenError());
21+
await expect(resolveAndTestFacts('cpp', {}, {} as Options)).rejects.toThrow(
22+
expect.objectContaining({
23+
message:
24+
'`snyk` requires an authenticated account. Please run `snyk auth` and try again.',
25+
}),
26+
);
27+
});
28+
29+
it('rethrows general error for other api auth failures', async () => {
30+
const err: any = new Error('nope');
31+
err.code = 403;
32+
mockMakeRequest.mockRejectedValue(err);
33+
await expect(resolveAndTestFacts('cpp', {}, {} as Options)).rejects.toThrow(
34+
expect.objectContaining({
35+
message: 'Unauthorized request to unmanaged service',
36+
}),
37+
);
38+
});
39+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const mockNeedleRequest = jest.fn();
2+
3+
import { makeRequest } from '../../../../../src/lib/request/request';
4+
import { Payload } from '../../../../../src/lib/request/types';
5+
6+
jest.mock('needle', () => {
7+
return {
8+
request: mockNeedleRequest,
9+
};
10+
});
11+
12+
describe('needle header auth failed', () => {
13+
afterEach(() => {
14+
jest.resetAllMocks();
15+
});
16+
17+
it('throws missing api token on auth failed marker header', async () => {
18+
mockNeedleRequest.mockImplementation((method, url, data, options, fn) => {
19+
fn(null, { headers: { 'snyk-auth-failed': 'true' } }, {});
20+
});
21+
await expect(
22+
makeRequest({ url: 'https://example.com' } as Payload),
23+
).rejects.toThrow(
24+
expect.objectContaining({
25+
message:
26+
'`snyk` requires an authenticated account. Please run `snyk auth` and try again.',
27+
}),
28+
);
29+
});
30+
});

0 commit comments

Comments
 (0)