Skip to content

Commit

Permalink
feat(server-auth): Update getAuthenticationContext to support cookies…
Browse files Browse the repository at this point in the history
… and tokens both (#10465)
  • Loading branch information
dac09 committed Apr 17, 2024
1 parent 48ed453 commit 0442c39
Show file tree
Hide file tree
Showing 10 changed files with 208 additions and 49 deletions.
13 changes: 13 additions & 0 deletions .changesets/10465.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
- feat(server-auth): Update getAuthenticationContext to support cookies and tokens both (#10465) by @dac09

**1. Updates `getAuthenticationContext` to parse the cookie header and pass it to authDecoder.**

Note that the authentication context itself does not pull out the token from cookies, because with some providers (e.g. supabase) - we don't know the name of the cookie. This is left to the authDecoder implementation.

The return type from this function is actually just a deserialized cookie header i.e.
`cookie: auth-provider=one; session=xx/yy/zz; somethingElse=bsbs` => `{ 'auth-provider': 'one', session: 'xx/yy/zz', somethingElse: 'bsbs'`

**2. Retains support for header/token based auth**
See test on line 259 of `packages/api/src/auth/__tests__/getAuthenticationContext.test.ts`. If a the `authorization` and `auth-provider` headers are passed in the request (as we do for SPA based auth) - then cookies will take precedence.

The end result is that graphql requests will now work with middleware-based auth providers!
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@types/memjs": "1",
"@types/pascalcase": "1.0.3",
"@types/split2": "4.2.3",
"cookie": "0.6.0",
"memjs": "1.3.2",
"redis": "4.6.7",
"split2": "4.2.0",
Expand Down
117 changes: 102 additions & 15 deletions packages/api/src/auth/__tests__/getAuthenticationContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,12 @@ import { describe, it, expect } from 'vitest'

import { getAuthenticationContext } from '../index'

export const createMockedEvent = ({
authProvider,
}: {
authProvider: string
}): APIGatewayProxyEvent => {
export const createMockedEvent = (
headers: Record<string, string>,
): APIGatewayProxyEvent => {
return {
body: null,
headers: {
'auth-provider': authProvider,
authorization: 'Bearer auth-test-token',
},
headers,
multiValueHeaders: {},
httpMethod: 'POST',
isBase64Encoded: false,
Expand Down Expand Up @@ -56,7 +51,7 @@ export const createMockedEvent = ({
}
}

describe('getAuthenticationContext', () => {
describe('getAuthenticationContext with bearer tokens', () => {
it('Can take a single auth decoder for the given provider', async () => {
const authDecoderOne = async (_token: string, type: string) => {
if (type !== 'one') {
Expand All @@ -71,7 +66,10 @@ describe('getAuthenticationContext', () => {

const result = await getAuthenticationContext({
authDecoder: authDecoderOne,
event: createMockedEvent({ authProvider: 'one' }),
event: createMockedEvent({
'auth-provider': 'one',
authorization: 'Bearer auth-test-token',
}),
context: {} as Context,
})

Expand Down Expand Up @@ -104,7 +102,10 @@ describe('getAuthenticationContext', () => {

const result = await getAuthenticationContext({
authDecoder: authDecoderOne,
event: createMockedEvent({ authProvider: 'some-other' }),
event: createMockedEvent({
'auth-provider': 'some-other',
authorization: 'Bearer auth-test-token',
}),
context: {} as Context,
})

Expand All @@ -123,7 +124,10 @@ describe('getAuthenticationContext', () => {
it('Can take an empty array of auth decoders', async () => {
const result = await getAuthenticationContext({
authDecoder: [],
event: createMockedEvent({ authProvider: 'two' }),
event: createMockedEvent({
'auth-provider': 'two',
authorization: 'Bearer auth-test-token',
}),
context: {} as Context,
})

Expand Down Expand Up @@ -164,7 +168,10 @@ describe('getAuthenticationContext', () => {

const result = await getAuthenticationContext({
authDecoder: [authDecoderOne, authDecoderTwo],
event: createMockedEvent({ authProvider: 'two' }),
event: createMockedEvent({
'auth-provider': 'two',
authorization: 'Bearer auth-test-token',
}),
context: {} as Context,
})

Expand All @@ -185,7 +192,10 @@ describe('getAuthenticationContext', () => {

it('Works even without any auth decoders', async () => {
const result = await getAuthenticationContext({
event: createMockedEvent({ authProvider: 'two' }),
event: createMockedEvent({
'auth-provider': 'two',
authorization: 'Bearer auth-test-token',
}),
context: {} as Context,
})

Expand All @@ -201,3 +211,80 @@ describe('getAuthenticationContext', () => {
expect(token).toEqual('auth-test-token')
})
})

describe('getAuthenticationContext with cookies', () => {
const authDecoderOne = async (_token: string, type: string) => {
if (type !== 'one') {
return null
}

return {
iss: 'one',
sub: 'user-id',
}
}

it('Can take a single auth decoder for the given provider', async () => {
const fetchRequest = new Request('http://localhost:3000', {
method: 'POST',
body: '',
headers: {
cookie: 'auth-provider=one; session=xx/yy/zz',
},
})

const result = await getAuthenticationContext({
authDecoder: authDecoderOne,
event: fetchRequest,
context: {} as Context,
})

if (!result) {
fail('Result is undefined')
}

const [decoded, { type, schema, token }] = result

expect(decoded).toMatchObject({
iss: 'one',
sub: 'user-id',
})
expect(type).toEqual('one')
expect(schema).toEqual('cookie')
// @TODO we need to rename this. It's not actually the token, because
// some auth providers will have a cookie where we don't know the key
expect(token).toEqual('auth-provider=one; session=xx/yy/zz')
})

it('Cookie takes precendence over auth header, if both are present', async () => {
const fetchRequest = new Request('http://localhost:3000', {
method: 'POST',
body: '',
headers: {
cookie: 'auth-provider=one; session=xx/yy/zz',
'auth-provider': 'two',
authorization: 'Bearer im-a-two-token',
},
})

const result = await getAuthenticationContext({
authDecoder: authDecoderOne,
event: fetchRequest,
context: {} as Context,
})

if (!result) {
fail('Result is undefined')
}

const [decoded, { type, schema, token }] = result

expect(decoded).toMatchObject({
iss: 'one',
sub: 'user-id',
})
expect(type).toEqual('one')
expect(schema).toEqual('cookie')
expect(token).toEqual('auth-provider=one; session=xx/yy/zz')
})
})
61 changes: 53 additions & 8 deletions packages/api/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './parseJWT'

import type { APIGatewayProxyEvent, Context as LambdaContext } from 'aws-lambda'
import { parse as parseCookie } from 'cookie'

import { getEventHeader } from '../event'

Expand All @@ -27,6 +28,27 @@ export interface AuthorizationHeader {
token: string
}

export const parseAuthorizationCookie = (
event: APIGatewayProxyEvent | Request,
) => {
const cookie = getEventHeader(event, 'Cookie')

// Unauthenticated request
if (!cookie) {
return null
}

const parsedCookie = parseCookie(cookie)

return {
parsedCookie,
rawCookie: cookie,
// When not unauthenticated, this will be null/undefined
// Remember that the cookie header could contain other (unrelated) values!
type: parsedCookie['auth-provider'],
}
}

/**
* Split the `Authorization` header into a schema and token part.
*/
Expand Down Expand Up @@ -77,16 +99,38 @@ export const getAuthenticationContext = async ({
event: APIGatewayProxyEvent | Request
context: LambdaContext
}): Promise<undefined | AuthContextPayload> => {
const type = getAuthProviderHeader(event)
const cookieHeader = parseAuthorizationCookie(event)
const typeFromHeader = getAuthProviderHeader(event)

// No `auth-provider` header means that the user is logged out,
// and none of this auth malarky is required.
if (!type) {
// Short-circuit - if no auth-provider or cookie header, its
// an unauthenticated request
if (!typeFromHeader && !cookieHeader) {
return undefined
}

const { schema, token } = parseAuthorizationHeader(event)
let token: string | undefined
let type: string | undefined
let schema: string | undefined

// The actual session parsing is done by the auth decoder
if (cookieHeader) {
token = cookieHeader.rawCookie
type = cookieHeader.type
schema = 'cookie'
// If type is set in the header, use Bearer token auth (priority 2)
} else if (typeFromHeader) {
const parsedAuthHeader = parseAuthorizationHeader(event as any)
token = parsedAuthHeader.token
type = typeFromHeader
schema = parsedAuthHeader.schema
}

// Unauthenticated request
if (!token || !type || !schema) {
return undefined
}

// Run through decoders until one returns a decoded payload
let authDecoders: Array<Decoder> = []

if (Array.isArray(authDecoder)) {
Expand All @@ -100,13 +144,14 @@ export const getAuthenticationContext = async ({
let i = 0
while (!decoded && i < authDecoders.length) {
decoded = await authDecoders[i](token, type, {
// @TODO: We will need to make a breaking change to support `Request` objects.
// We can remove this typecast
event: event,
// @MARK: When called from middleware, the decoder will pass Request, not Lambda event
event,
context,
})
i++
}

// @TODO should we rename token? It's not actually the token - its the cookie header -because
// some auth providers will have a cookie where we don't know the key
return [decoded, { type, schema, token }, { event, context }]
}
10 changes: 8 additions & 2 deletions packages/web/src/apollo/links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function createUpdateDataLink() {
}
export function createAuthApolloLink(
authProviderType: string,
headers:
headersFromFetchProvider:
| {
'auth-provider'?: string | undefined
authorization?: string | undefined
Expand All @@ -74,10 +74,16 @@ export function createAuthApolloLink(
}
: {}

if (!token) {
// If there's no token i.e. it's using middleware auth
// remove the auth-provider header
delete headersFromFetchProvider?.['auth-provider']
}

operation.setContext(() => ({
headers: {
...operation.getContext().headers,
...headers,
...headersFromFetchProvider,
// Duped auth headers, because we may remove the `FetchConfigProvider` at a later date.
...authHeaders,
},
Expand Down
11 changes: 8 additions & 3 deletions packages/web/src/components/FetchConfigProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,18 @@ export const FetchConfigProvider: React.FC<Props> = ({
)
}

// @NOTE: See packages/web/src/apollo/links.tsx
// Where we remove the auth-provider header if token is null.
// Token === null means you're logged out OR are using cookie/middleware auth
const headers = {
'auth-provider': type,
}

return (
<FetchConfigContext.Provider
value={{
uri: getApiGraphQLUrl(),
headers: {
'auth-provider': type,
},
headers,
}}
{...rest}
/>
Expand Down
37 changes: 17 additions & 20 deletions packages/web/src/components/__tests__/FetchConfigProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ const FetchConfigToString: React.FunctionComponent = () => {
return <>{JSON.stringify(c)}</>
}

type UnknownAuthContext = AuthContextInterface<
unknown,
unknown,
unknown,
unknown,
unknown,
unknown,
unknown,
unknown,
unknown,
unknown,
unknown,
unknown
>

describe('FetchConfigProvider', () => {
test('Unauthenticated user does not receive headers', () => {
render(
Expand All @@ -22,16 +37,7 @@ describe('FetchConfigProvider', () => {
({
loading: false,
isAuthenticated: false,
}) as AuthContextInterface<
unknown,
unknown,
unknown,
unknown,
unknown,
unknown,
unknown,
unknown
>
}) as UnknownAuthContext
}
>
<FetchConfigToString />
Expand All @@ -51,16 +57,7 @@ describe('FetchConfigProvider', () => {
loading: false,
isAuthenticated: true,
type: 'custom',
}) as AuthContextInterface<
unknown,
unknown,
unknown,
unknown,
unknown,
unknown,
unknown,
unknown
>
}) as UnknownAuthContext
}
>
<FetchConfigToString />
Expand Down
Loading

0 comments on commit 0442c39

Please sign in to comment.