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

refactor(authlify): get authlify token from header #326

Merged
merged 8 commits into from
Apr 4, 2022
2 changes: 1 addition & 1 deletion src/function/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ export { Context as HandlerContext } from './context'
export { Event as HandlerEvent } from './event'
export { Handler, HandlerCallback } from './handler'
export { Response as HandlerResponse } from './response'
export { getSecrets, withSecrets } from '../lib/secrets'
export { getSecrets, withSecrets, getNetlifyGraphToken, GraphTokenResponse, HasHeaders } from '../lib/graph'
export { NetlifySecrets } from '../lib/secrets_helper'
7 changes: 4 additions & 3 deletions src/lib/secrets.ts → src/lib/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { Event as HandlerEvent } from '../function/event'
import { BaseHandler, HandlerCallback } from '../function/handler'
import { Response } from '../function/response'

import { getSecrets, HandlerEventWithOneGraph, NetlifySecrets } from './secrets_helper'
import { getSecrets, NetlifySecrets } from './secrets_helper'
// Fine-grained control during the preview, less necessary with a more proactive OneGraph solution
export { getSecrets } from './secrets_helper'
export { getNetlifyGraphToken, GraphTokenResponse, HasHeaders } from './graph_token'

export interface ContextWithSecrets extends Context {
secrets: NetlifySecrets
Expand All @@ -17,12 +18,12 @@ export type HandlerWithSecrets = BaseHandler<Response, ContextWithSecrets>
export const withSecrets =
(handler: BaseHandler<Response, ContextWithSecrets>) =>
async (
event: HandlerEventWithOneGraph | HandlerEvent,
event: HandlerEvent,
context: HandlerContext,
// eslint-disable-next-line promise/prefer-await-to-callbacks
callback: HandlerCallback<Response>,
) => {
const secrets = await getSecrets(event as HandlerEventWithOneGraph)
const secrets = await getSecrets(event)

return handler(event, { ...context, secrets }, callback)
}
55 changes: 55 additions & 0 deletions src/lib/graph_request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Buffer } from 'buffer'
import { request } from 'https'
import { env } from 'process'

const siteId = env.SITE_ID

const GRAPH_HOST = 'graph.netlify.com'

export const graphRequest = function (secretToken: string, requestBody: Uint8Array): Promise<string> {
return new Promise((resolve, reject) => {
const port = 443

const options = {
host: GRAPH_HOST,
path: `/graphql?app_id=${siteId}`,
port,
method: 'POST',
headers: {
Authorization: `Bearer ${secretToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
'Content-Length': requestBody ? Buffer.byteLength(requestBody) : 0,
},
}

const req = request(options, (res) => {
if (res.statusCode !== 200) {
return reject(new Error(String(res.statusCode)))
}

const body: Array<Uint8Array> = []

res.on('data', (chunk) => {
body.push(chunk)
})

res.on('end', () => {
const data = Buffer.concat(body).toString()
try {
resolve(data)
} catch (error) {
reject(error)
}
})
})

req.on('error', (error) => {
reject(error)
})

req.write(requestBody)

req.end()
})
}
127 changes: 127 additions & 0 deletions src/lib/graph_token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { env } from 'process'

export type GraphTokenResponseError = {
type: 'missing-event-in-function' | 'provided-event-in-build'
message: string
}

export type GraphTokenResponse = {
errors?: GraphTokenResponseError[]
token?: string | null
}

const TOKEN_HEADER = 'X-Nf-Graph-Token'

// Matches Web API Headers type (https://developer.mozilla.org/en-US/docs/Web/API/Headers)
interface RequestHeaders {
get(name: string): string | null
}

// Matches http.IncomingHttpHeaders
interface IncomingHttpHeaders {
[key: string]: string | string[] | undefined
}

export interface HasHeaders {
headers: RequestHeaders | IncomingHttpHeaders
}

const hasRequestStyleHeaders = function (headers: RequestHeaders | IncomingHttpHeaders): headers is RequestHeaders {
return (headers as RequestHeaders).get !== undefined && typeof headers.get === 'function'
}

const graphTokenFromIncomingHttpStyleHeaders = function (
headers: RequestHeaders | IncomingHttpHeaders,
): string | null | undefined {
if (TOKEN_HEADER in headers) {
const header = headers[TOKEN_HEADER]
if (header == null || typeof header === 'string') {
return header
}
return header[0]
}
}

// Backwards compatibility with older version of cli that doesn't inject header
const authlifyTokenFallback = function (event: HasHeaders): GraphTokenResponse {
const token = (event as { authlifyToken?: string | null })?.authlifyToken
return { token }
}

const graphTokenFromEvent = function (event: HasHeaders): GraphTokenResponse {
const { headers } = event
// Check if object first in case there is a header with key `get`
const token = graphTokenFromIncomingHttpStyleHeaders(headers)
if (token) {
return { token }
}

if (hasRequestStyleHeaders(headers)) {
return { token: headers.get(TOKEN_HEADER) }
}

return authlifyTokenFallback(event)
}

const graphTokenFromEnv = function (): GraphTokenResponse {
// _NETLIFY_GRAPH_TOKEN injected by next plugin
// eslint-disable-next-line no-underscore-dangle
const token = env._NETLIFY_GRAPH_TOKEN || env.NETLIFY_GRAPH_TOKEN
return { token }
}

const isEventRequired = function (): boolean {
const localDev = env.NETLIFY_DEV === 'true'
const localBuild = !localDev && env.NETLIFY_LOCAL === 'true'
const remoteBuild = env.NETLIFY === 'true'
// neither `localBuild` nor `remoteBuild` will be true in the on-demand builder case
const inBuildPhase = localBuild || remoteBuild

const inGetStaticProps =
// Set by the nextjs plugin
// eslint-disable-next-line no-underscore-dangle
typeof env._NETLIFY_GRAPH_TOKEN !== 'undefined'

return !inBuildPhase && !inGetStaticProps
}

const incorrectArgumentsErrors = function (
event: HasHeaders | null | undefined,
): undefined | GraphTokenResponseError[] {
const requiresEvent = isEventRequired()

if (requiresEvent && event == null) {
const errorMessage =
'You must provide an event or request to `getNetlifyGraphToken` when used in functions and on-demand builders.'
return [{ type: 'missing-event-in-function', message: errorMessage }]
}

if (!requiresEvent && event != null) {
const errorMessage = 'You must not pass arguments to `getNetlifyGraphToken` when used in builds.'
return [{ type: 'provided-event-in-build', message: errorMessage }]
}
}

const logErrors = function (errors: GraphTokenResponseError[]) {
for (const error of errors) {
// Log errors to help guide developer
console.error(error.message)
}
}

export const getNetlifyGraphToken = function (
event?: HasHeaders | null | undefined,
// caller can prevent error log. Allows getSecrets to provide better errors
supressLog?: boolean,
): GraphTokenResponse {
const errors = incorrectArgumentsErrors(event)

if (errors) {
if (!supressLog) {
logErrors(errors)
}
return { errors }
}

return event ? graphTokenFromEvent(event) : graphTokenFromEnv()
}
105 changes: 35 additions & 70 deletions src/lib/secrets_helper.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { Buffer } from 'buffer'
import { request } from 'https'
import { env } from 'process'

import { Event as HandlerEvent } from '../function/event'
import { graphRequest } from './graph_request'
import { getNetlifyGraphToken, GraphTokenResponseError, HasHeaders } from './graph_token'

const services = {
gitHub: null,
Expand Down Expand Up @@ -40,7 +37,7 @@ export type NetlifySecrets = {
[K in ServiceKey]?: Service
} & { [key: string]: Service }

type OneGraphSecretsResponse = {
type GraphSecretsResponse = {
data?: {
me?: {
serviceMetadata?: {
Expand All @@ -50,8 +47,6 @@ type OneGraphSecretsResponse = {
}
}

const siteId = env.SITE_ID

const camelize = function (text: string) {
const safe = text.replace(/[-_\s.]+(.)?/g, (_, sub) => (sub ? sub.toUpperCase() : ''))
return safe.slice(0, 1).toLowerCase() + safe.slice(1)
Expand All @@ -69,56 +64,7 @@ const serviceNormalizeOverrides: ServiceNormalizeOverrides = {
GITHUB: 'gitHub',
}

const oneGraphRequest = function (secretToken: string, requestBody: Uint8Array): Promise<OneGraphSecretsResponse> {
return new Promise((resolve, reject) => {
const port = 443

const options = {
host: 'serve.onegraph.com',
path: `/graphql?app_id=${siteId}`,
port,
method: 'POST',
headers: {
Authorization: `Bearer ${secretToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
'Content-Length': requestBody ? Buffer.byteLength(requestBody) : 0,
},
}

const req = request(options, (res) => {
if (res.statusCode !== 200) {
return reject(new Error(String(res.statusCode)))
}

const body: Array<Uint8Array> = []

res.on('data', (chunk) => {
body.push(chunk)
})

res.on('end', () => {
const data = Buffer.concat(body).toString()
try {
const result: OneGraphSecretsResponse = JSON.parse(data)
resolve(result)
} catch (error) {
reject(error)
}
})
})

req.on('error', (error) => {
reject(error)
})

req.write(requestBody)

req.end()
})
}

const formatSecrets = (result: OneGraphSecretsResponse | undefined) => {
const formatSecrets = (result: GraphSecretsResponse | undefined) => {
const responseServices = result?.data?.me?.serviceMetadata?.loggedInServices

if (!responseServices) {
Expand All @@ -133,20 +79,38 @@ const formatSecrets = (result: OneGraphSecretsResponse | undefined) => {
return newSecrets
}

type OneGraphPayload = { authlifyToken: string | undefined }

export type HandlerEventWithOneGraph = HandlerEvent & OneGraphPayload
const logErrors = function (errors: GraphTokenResponseError[]) {
for (const error of errors) {
let errorMessage
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we only changing the function name for the error message?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. I thought of making a function to create the error message, but thought it was overkill.

switch (error.type) {
case 'missing-event-in-function':
errorMessage =
'You must provide an event or request to `getSecrets` when used in functions and on-demand builders.'
break
case 'provided-event-in-build':
errorMessage = 'You must not pass arguments to `getSecrets` when used in builds.'
break
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const exhaustiveCheck: never = error.type
errorMessage = error.type
break
}
}
const message: string = errorMessage
console.error(message)
}
}

// Note: We may want to have configurable "sets" of secrets,
// e.g. "dev" and "prod"
export const getSecrets = async (
event?: HandlerEventWithOneGraph | HandlerEvent | undefined,
): Promise<NetlifySecrets> => {
// Allow us to get the token from event if present, else fallback to checking the env
const eventToken = (event as HandlerEventWithOneGraph)?.authlifyToken
const secretToken = eventToken || env.ONEGRAPH_AUTHLIFY_TOKEN

if (!secretToken) {
export const getSecrets = async (event?: HasHeaders | null | undefined): Promise<NetlifySecrets> => {
const graphTokenResponse = getNetlifyGraphToken(event, true)
const graphToken = graphTokenResponse.token
if (!graphToken) {
if (graphTokenResponse.errors) {
logErrors(graphTokenResponse.errors)
}
return {}
}

Expand Down Expand Up @@ -182,7 +146,8 @@ export const getSecrets = async (
const body = JSON.stringify({ query: doc })

// eslint-disable-next-line node/no-unsupported-features/node-builtins
const result = await oneGraphRequest(secretToken, new TextEncoder().encode(body))
const resultBody = await graphRequest(graphToken, new TextEncoder().encode(body))
const result: GraphSecretsResponse = JSON.parse(resultBody)

const newSecrets = formatSecrets(result)

Expand Down