Skip to content

Instantly share code, notes, and snippets.

@airtonix
Last active October 9, 2023 21:58
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save airtonix/293705c3d67ed6735f0e5e540d73b67e to your computer and use it in GitHub Desktop.
Save airtonix/293705c3d67ed6735f0e5e540d73b67e to your computer and use it in GitHub Desktop.
NetlifyCMS Github Auth with NextJS and Vercel
  1. drop all the files included (replacing _ with /)

  2. create three apps in github oauth settings: Local DEV, Staging, Prod

    1. https://github.com/settings/applications/new
    2. fillout the details: don't worry too much right now you can change all these later
      • name: ☝️
      • home page: your url (http://localhost:3000, https://staging.your.doma.in, https://your.doma.in)
      • description: whatever... but the user will see this
      • authorization callback url: as above but with /api/callback : (http://localhost:3000/api/callback, https://staging.your.doma.in/api/callback, https://your.doma.in/api/callback)
    3. on the application detail page:
      • generate a secret and store as OAUTH_CLIENT_SECRET
      • grab the clientid and store as OAUTH_CLIENT_ID
    4. On your deployment service provider

👍

{
"scripts": {
...
"build": "run-s 'css' 'build:*'",
"build:next": "next build",
"clean": "git clean -xdf && yarn install",
"content": "echo 'Some task that must run first'",
"dev": "CMS_BACKEND_USELOCAL=true npm-run-all -s 'content' -p 'dev:*'",
"dev:cms": "netlify-cms-proxy-server",
"dev:css": "yarn css",
"dev:next": "next dev",
"export": "yarn build && next export",
"lint": "next lint",
"start": "next start",
"test": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"crypto": "1.0.1",
"dedent": "0.7.0",
"dotenv": "16.0.3",
"emery": "1.4.1",
"http-status-codes": "2.2.0",
"netlify-cms": "2.10.192",
"netlify-cms-app": "2.15.72",
"netlify-cms-lib-widgets": "1.8.1",
"netlify-cms-proxy-server": "1.3.24",
"next": "13.1.6",
"npm-run-all": "4.1.5",
"simple-oauth2": "5.0.0",
"zod": "3.20.2"
}
}
import type { NextApiRequest, NextApiResponse } from "next";
import { AuthorizationCode } from "simple-oauth2";
import { assert } from "emery";
import {
getOauthConfig,
getOauthScopes,
protocol,
isProvider,
providers,
randomState,
} from "services/Auth";
import { Config } from "services/Config";
/** An endpoint to start an OAuth2 authentication */
const AuthApiRoute = (req: NextApiRequest, res: NextApiResponse) => {
const host = req.headers.host || Config.backend.baseUrl;
const { searchParams } = new URL(`https://${host}/${req.url || ""}`);
const provider = searchParams.get("provider");
assert(
isProvider(provider),
`expected [provider] to be one of ${Object.keys(providers).join(",")}`
);
const config = getOauthConfig(provider);
const scopes = getOauthScopes(provider);
const redirect_uri = `${protocol}://${host}/api/callback?provider=${provider}`;
const client = new AuthorizationCode(config);
const authUrl = client.authorizeURL({
redirect_uri,
scope: scopes,
state: randomState(),
});
res.redirect(authUrl);
};
export default AuthApiRoute;
import type { NextApiRequest, NextApiResponse } from "next";
import { AuthorizationCode } from "simple-oauth2";
import { assert } from "emery";
import { z } from "zod";
import { protocol } from "services/Auth";
import {
getOauthConfig,
isProvider,
providers,
renderResponse,
} from "services/Auth";
import { Config } from "services/Config";
const TokenWithAccessTokenSchema = z.object({
token: z.object({
access_token: z.string(),
}),
});
function isTokenWithAccessToken(
token: unknown
): token is z.infer<typeof TokenWithAccessTokenSchema> {
return TokenWithAccessTokenSchema.safeParse(token).success;
}
const CallbackApiRoute = async (req: NextApiRequest, res: NextApiResponse) => {
const host = req.headers.host || Config.backend.baseUrl;
const { searchParams } = new URL(`${protocol}://${host}/${req.url || ""}`);
const code = searchParams.get("code");
const provider = searchParams.get("provider");
console.debug(
"callback url=%o code=%o provider=%o",
searchParams,
code,
provider
);
assert(
isProvider(provider),
`expected [provider] to be one of ${Object.keys(providers).join(",")}`
);
assert(!!code, `expected [code] in querystring`);
const config = getOauthConfig(provider);
const client = new AuthorizationCode(config);
const tokenParams = {
code,
redirect_uri: `${protocol}://${host}/api/callback?provider=${provider}`,
};
try {
const accessToken = await client.getToken(tokenParams);
const token =
(isTokenWithAccessToken(accessToken) &&
accessToken.token["access_token"]) ||
null;
if (accessToken.token.error) {
res.status(200).send(renderResponse("error", accessToken.token));
return;
}
res.status(200).send(
renderResponse("success", {
token,
provider,
})
);
} catch (error) {
res.status(200).send(renderResponse("error", error));
}
};
export default CallbackApiRoute;
export { randomState } from "./randomState";
export { renderResponse } from "./renderResponse";
export {
providers,
isProvider,
getOauthConfig,
getOauthScopes,
} from "./providers";
export { protocol } from "./protocol";
import { Config } from "services/Config";
export const protocol = Config.isLocal ? "http" : "https";
import type { ModuleOptions } from "simple-oauth2";
import { assert } from "emery";
import { Config } from "services/Config";
export const providers = {
github: {
scopes: "repo,user",
urls: {
tokenHost: "https://github.com",
tokenPath: "/login/oauth/access_token",
authorizePath: "/login/oauth/authorize",
},
},
gitlab: {
scopes: "api",
urls: {
tokenHost: "https://gitlab.com",
tokenPath: "/oauth/token",
authorizePath: "/oauth/authorize",
},
},
};
export function isProvider(
provider?: string | null
): provider is keyof typeof providers {
return !!provider && Object.keys(providers).includes(provider);
}
export const getOauthConfig = (
provider: keyof typeof providers
): ModuleOptions => {
assert(
isProvider(provider),
`getOauthConfig expected [${provider}] to be one of ${Object.keys(
providers
).join(",")}`
);
assert(
!Config.useLocalBackend,
"You're using local git, so no need for oauth"
);
return Object.freeze({
client: Object.freeze({
id: Config.oauth.clientId,
secret: Config.oauth.clientSecret,
}),
auth: Object.freeze({
tokenHost: Config.oauth.tokenHost,
tokenPath: Config.oauth.tokenPath,
authorizePath: Config.oauth.authorizePath,
}),
});
};
export const getOauthScopes = (provider: keyof typeof providers) => {
assert(
isProvider(provider),
`getOauthScopes expected [${provider}] to be one of ${Object.keys(
providers
).join(",")}`
);
return providers[provider].scopes;
};
import { randomBytes } from "crypto";
export function randomState() {
return randomBytes(6).toString("hex");
}
import dedent from "dedent";
/** Render a html response with a script to finish a client-side github authentication */
export function renderResponse(status: "success" | "error", content: any) {
return dedent`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Authorizing ...</title>
</head>
<body>
<p id="message"></p>
<script>
// Output a message to the user
function sendMessage(message) {
document.getElementById("message").innerText = message;
document.title = message
}
// Handle a window message by sending the auth to the "opener"
function receiveMessage(message) {
console.debug("receiveMessage", message);
window.opener.postMessage(
'authorization:github:${status}:${JSON.stringify(content)}',
message.origin
);
window.removeEventListener("message", receiveMessage, false);
sendMessage("Authorized, closing ...");
}
sendMessage("Authorizing ...");
window.addEventListener("message", receiveMessage, false);
console.debug("postMessage", "authorizing:github", "*")
window.opener.postMessage("authorizing:github", "*");
</script>
</body>
</html>
`;
}
import { z } from "zod";
import { GITHUB_REPO } from "./constants";
export const BackendSchema = z.object({
name: z.union([z.literal("github"), z.literal("gitlab")]).default("github"),
repo: z.string().default(GITHUB_REPO),
branch: z.string().default("master"),
baseUrl: z.string().default("http://localhost:3000"),
authEndpoint: z.string().default("api/auth"),
});
import { z } from "zod";
export const BooleanOrDefault = (defaultTo: "true" | "false") =>
z
.enum(["true", "false"])
.default(defaultTo)
.transform((value) => value === "true");
import { z } from "zod";
import { OAuthSchema } from "./OAuthSchema";
import { BackendSchema } from "./BackendSchema";
export const ConfigSchema = z
.object({
isLocal: z.boolean(),
useLocalBackend: z.literal(false),
backend: BackendSchema,
oauth: OAuthSchema,
})
.or(
z.object({
isLocal: z.boolean(),
useLocalBackend: z.literal(true),
backend: BackendSchema,
})
);
export const GITHUB_REPO = "your-org/your-repo";
export const DEV_ENVIRONMENT_OAUTH_CLIENTIF = "DEADBEAF";
export const DEV_ENVIRONMENT_OAUTH_CLIENTSECRET = "DEADBEAF";
import { BooleanOrDefault } from "./BooleanOrDefault";
import { ConfigSchema } from "./ConfigSchema";
export const getConfig = () => {
const config = ConfigSchema.parse({
isLocal: BooleanOrDefault("false").parse(process.env.NEXT_PUBLIC_ISLOCAL),
useLocalBackend: BooleanOrDefault("false").parse(
process.env.NEXT_PUBLIC_CMS_BACKEND_USELOCAL
),
backend: {
name: process.env.NEXT_PUBLIC_CMS_BACKEND_NAME,
repo: process.env.NEXT_PUBLIC_CMS_BACKEND_REPO,
branch: process.env.NEXT_PUBLIC_CMS_BACKEND_BRANCH,
baseUrl: process.env.NEXT_PUBLIC_CMS_BACKEND_BASEURL,
authEndpoint: process.env.NEXT_PUBLIC_CMS_BACKEND_AUTHENDPOINT,
},
/**
* If we're using the local backend, then none of this is required
* as we're using the cms proxy which works on your local
* git store
*/
// these are intentionally not prefix with NEXT_PUBLIC_
oauth: {
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
tokenHost: process.env.OAUTH_TOKEN_HOST,
tokenPath: process.env.OAUTH_TOKEN_PATH,
authorizePath: process.env.OAUTH_AUTHORIZE_PATH,
},
});
return config;
};
import { getConfig } from "./getConfig";
export const Config = getConfig();
import { z } from "zod";
import {
DEV_ENVIRONMENT_OAUTH_CLIENTIF,
DEV_ENVIRONMENT_OAUTH_CLIENTSECRET,
} from "./constants";
export const OAuthSchema = z.object({
clientId: z.coerce.string().default(DEV_ENVIRONMENT_OAUTH_CLIENTIF),
clientSecret: z.coerce.string().default(DEV_ENVIRONMENT_OAUTH_CLIENTSECRET),
tokenHost: z.coerce.string().default("https://github.com"),
tokenPath: z.coerce.string().default("/login/oauth/access_token"),
authorizePath: z.coerce.string().default("/login/oauth/authorize"),
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment