Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server-auth): Update getAuthenticationContext to support cookies and tokens both #10465

Merged
merged 18 commits into from
Apr 17, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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 .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 this 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')
})
})
62 changes: 54 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')
dac09 marked this conversation as resolved.
Show resolved Hide resolved

// 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,39 @@ 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
dthyresson marked this conversation as resolved.
Show resolved Hide resolved

// The actual session parsing is done by the auth decoder
// Priority given to cookie
dthyresson marked this conversation as resolved.
Show resolved Hide resolved
if (cookieHeader) {
token = cookieHeader.rawCookie
type = cookieHeader.type
dthyresson marked this conversation as resolved.
Show resolved Hide resolved
schema = 'cookie'
dthyresson marked this conversation as resolved.
Show resolved Hide resolved
// 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 +145,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 }]
}
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7504,6 +7504,7 @@ __metadata:
"@types/pascalcase": "npm:1.0.3"
"@types/split2": "npm:4.2.3"
"@whatwg-node/fetch": "npm:0.9.17"
cookie: "npm:0.6.0"
core-js: "npm:3.36.1"
humanize-string: "npm:2.1.0"
jsonwebtoken: "npm:9.0.2"
Expand Down
Loading