Skip to content

Commit a62febd

Browse files
committed
Generate SSO scaffold
1 parent 543885a commit a62febd

File tree

7 files changed

+294
-43
lines changed

7 files changed

+294
-43
lines changed

README.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,3 @@ Netlify Build plugin identity-sso - Protect a site with SSO via identity.
33
# Install
44

55
Please install this plugin from the Netlify app.
6-
7-
# Configuration
8-
9-
The following `inputs` options are available.

manifest.yml

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1 @@
1-
name: 'netlify-plugin-{{name}}'
2-
inputs: []
3-
# Example inputs:
4-
# - name: example
5-
# description: Example description
6-
# default: 5
7-
# required: false
1+
name: 'netlify-plugin-identity-sso'

package-lock.json

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030
"ava": "cross-env FORCE_COLOR=1 ava --verbose",
3131
"release": "release-it"
3232
},
33-
"dependencies": {},
33+
"dependencies": {
34+
"@iarna/toml": "^2.2.5"
35+
},
3436
"devDependencies": {
3537
"@netlify/build": "^4.7.2",
3638
"ava": "^3.13.0",

src/index.js

Lines changed: 109 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,99 @@
1+
const fs = require('fs').promises
2+
const path = require('path')
3+
const toml = require('@iarna/toml')
4+
5+
/**
6+
@typedef {{
7+
from: string,
8+
to: string,
9+
conditions?: {
10+
Role?: string[],
11+
},
12+
status?: number,
13+
force?: boolean,
14+
}} NetlifyRedirect
15+
*/
16+
17+
/**
18+
@typedef {{
19+
build: {
20+
publish: string,
21+
functions?: string,
22+
},
23+
redirects: NetlifyRedirect[],
24+
}} NetlifyConfig
25+
*/
26+
27+
const loginPage = '/_netlify-sso'
28+
const authFunc = 'sso-auth'
29+
30+
/**
31+
* @param {{ config: NetlifyConfig, functionsDir?: string, publishDir?: string }} params
32+
*/
33+
async function generateSSO({
34+
config /* &mut */,
35+
functionsDir = '_netlify_sso_functions',
36+
publishDir = '_netlify_sso_publish',
37+
}) {
38+
config.build = {
39+
...config.build,
40+
functions: functionsDir,
41+
publish: publishDir,
42+
}
43+
44+
await fs.mkdir(functionsDir, { recursive: true })
45+
await fs.mkdir(publishDir, { recursive: true })
46+
47+
const staticFileDir = path.resolve(__dirname, '../static')
48+
await fs.copyFile(
49+
path.join(staticFileDir, 'sso-login.html'),
50+
path.join(publishDir, `${loginPage}.html`),
51+
)
52+
await fs.copyFile(
53+
path.join(staticFileDir, 'sso-auth-function.js'),
54+
path.join(functionsDir, `${authFunc}.js`),
55+
)
56+
57+
/** @type {NetlifyRedirect[]} */
58+
const gatedRedirects = config.redirects.map((redirect) => ({
59+
...redirect,
60+
conditions: {
61+
Role: ['netlify'],
62+
},
63+
}))
64+
65+
/** @type {NetlifyRedirect[]} */
66+
const additionalRedirects = [
67+
// Serve content when logged in
68+
{
69+
from: '/*',
70+
to: '/:splat',
71+
conditions: {
72+
Role: ['netlify'],
73+
},
74+
// will be set to 200 when there is content
75+
// since we don't set `force`
76+
status: 404,
77+
},
78+
// Serve login page on root
79+
{
80+
from: '/',
81+
to: loginPage,
82+
status: 401,
83+
force: true,
84+
},
85+
// Redirect to login page otherwise
86+
{
87+
from: '/*',
88+
to: '/',
89+
status: 302,
90+
force: true,
91+
},
92+
]
93+
94+
config.redirects = [...gatedRedirects, ...additionalRedirects]
95+
}
96+
197
module.exports = {
298
// The plugin main logic uses `on...` event handlers that are triggered on
399
// each new Netlify Build.
@@ -8,35 +104,19 @@ module.exports = {
8104
// Whole configuration file. For example, content of `netlify.toml`
9105
netlifyConfig,
10106
// Build constants
11-
constants: {
12-
// Directory that contains the deploy-ready HTML files and assets
13-
// generated by the build. Its value is always defined, but the target
14-
// might not have been created yet.
15-
PUBLISH_DIR,
16-
// The directory where function source code lives.
17-
// `undefined` if not specified by the user.
18-
FUNCTIONS_SRC,
19-
},
20-
21-
// Core utilities
22-
utils: {
23-
// Utility to report errors.
24-
// See https://github.com/netlify/build#error-reporting
25-
build,
26-
// Utility to display information in the deploy summary.
27-
// See https://github.com/netlify/build#logging
28-
status,
29-
// Utility for caching files.
30-
// See https://github.com/netlify/build/blob/master/packages/cache-utils/README.md
31-
cache,
32-
// Utility for running commands.
33-
// See https://github.com/netlify/build/blob/master/packages/run-utils/README.md
34-
run,
35-
// Utility for dealing with modified, created, deleted files since a git commit.
36-
// See https://github.com/netlify/build/blob/master/packages/git-utils/README.md
37-
git,
38-
},
107+
constants: { PUBLISH_DIR, FUNCTIONS_SRC },
39108
}) {
40-
109+
console.log('Copying static assets...')
110+
111+
await generateSSO({
112+
config: netlifyConfig,
113+
functionsDir: FUNCTIONS_SRC,
114+
publishDir: PUBLISH_DIR,
115+
})
116+
const config_out = toml.stringify(netlifyConfig)
117+
await fs.writeFile(
118+
path.join(netlifyConfig.build.publish, 'netlify.toml'),
119+
config_out,
120+
)
41121
},
42122
}

static/sso-auth-function.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//@ts-check
2+
'use strict'
3+
4+
const crypto = require('crypto')
5+
6+
/**
7+
* @param {string} base64
8+
*/
9+
function cleanupBase64(base64) {
10+
return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
11+
}
12+
13+
/**
14+
* @param {string} token
15+
* @param {string} secret
16+
* @returns {boolean}
17+
*/
18+
function verifyJWS(token, secret) {
19+
/** @type {string[]} */
20+
const [params, payload, signature] = token.split('.')
21+
if (!params || !payload || !signature) {
22+
return false
23+
}
24+
25+
// extract first two elements of JWT and sign
26+
let signedPayload = [params, payload].join('.')
27+
const hmac = crypto.createHmac('sha256', secret)
28+
hmac.update(signedPayload)
29+
const expected = cleanupBase64(hmac.digest('base64'))
30+
31+
return expected == signature
32+
}
33+
34+
/**
35+
* @param {{ headers: { [x: string]: any; }; body: string; }} event
36+
*/
37+
async function handler(event) {
38+
if ('WEBHOOK_SECRET' in process.env) {
39+
const signature = event.headers['x-webhook-signature']
40+
if (!verifyJWS(signature, process.env.WEBHOOK_SECRET)) {
41+
console.log('Webhook signature invalid:', signature)
42+
return {
43+
statusCode: 401,
44+
}
45+
}
46+
}
47+
48+
/** @type {{user: { email: string, app_metadata: { roles?: string[]}}}} */
49+
let payload = null
50+
try {
51+
payload = JSON.parse(event.body)
52+
} catch (e) {
53+
return {
54+
statusCode: 400,
55+
}
56+
}
57+
const {
58+
user: { email, app_metadata },
59+
} = payload
60+
61+
// User is part of Netlify and already has role
62+
const found =
63+
app_metadata.roles &&
64+
app_metadata.roles.find((r) => r == 'netlify') !== undefined
65+
66+
if (found) {
67+
return {
68+
statusCode: 200,
69+
}
70+
}
71+
72+
if (email && email.endsWith('@netlify.com')) {
73+
console.log('User is part of Netlify without role. assigning...')
74+
const roles = (app_metadata && app_metadata.roles) || []
75+
const metadata = {
76+
...app_metadata,
77+
roles: [...roles, 'netlify'],
78+
}
79+
return {
80+
statusCode: 200,
81+
body: JSON.stringify({ app_metadata: metadata }),
82+
}
83+
}
84+
85+
console.log('User is not part of Netlify.')
86+
return {
87+
statusCode: 401,
88+
}
89+
}
90+
91+
exports.handler = handler

static/sso-login.html

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
6+
<meta property="og:title" content="Netlify Rust Docs" />
7+
<meta property="og:description" content="Documentation for x" />
8+
<meta property="og:type" content="website" />
9+
10+
<meta
11+
name="viewport"
12+
content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no, viewport-fit=cover"
13+
/>
14+
<meta name="distribution" content="Global" />
15+
<meta name="rating" content="General" />
16+
17+
<title>Netlify Rust Docs</title>
18+
</head>
19+
20+
<body style="margin: 0; width: 100vw; height: 100vh">
21+
<div
22+
style="
23+
display: flex;
24+
flex-direction: column;
25+
justify-content: center;
26+
align-items: center;
27+
width: 100%;
28+
height: 100%;
29+
"
30+
>
31+
<p>This page is protected. Login below to access it.</p>
32+
<button id="login">Login with Google</button>
33+
<p id="error-msg"></p>
34+
</div>
35+
36+
<script
37+
type="text/javascript"
38+
src="https://identity.netlify.com/v1/netlify-identity-widget.js"
39+
></script>
40+
<script>
41+
const loginBtn = document.querySelector('#login')
42+
loginBtn.addEventListener('click', () => {
43+
const url = netlifyIdentity.gotrue.loginExternalUrl('google')
44+
window.open(url, '_self')
45+
})
46+
47+
const errorMessage = document.getElementById('error-msg')
48+
const checkToken = () => {
49+
const user = netlifyIdentity.gotrue.currentUser() // refresh user object
50+
const d = Date.now()
51+
const exp = user.token.expires_at
52+
if (d > exp) {
53+
console.log(
54+
"Your identity session has expired and the token couldn't be refreshed!",
55+
)
56+
errorMessage.textContent =
57+
'Your session has expired. Please login again'
58+
user.logout()
59+
return
60+
}
61+
62+
console.log('the token is valid')
63+
window.open('/', '_self')
64+
}
65+
const ensureFreshToken = (user) => {
66+
user
67+
.jwt()
68+
.then(() => {
69+
setTimeout(checkToken, 100)
70+
})
71+
.catch((err) => {
72+
user.logout()
73+
console.error(err)
74+
})
75+
}
76+
77+
netlifyIdentity.on('login', (user) => {
78+
console.log(user)
79+
if (
80+
user &&
81+
user.app_metadata.roles &&
82+
user.app_metadata.roles.includes('netlify')
83+
) {
84+
ensureFreshToken(user)
85+
}
86+
})
87+
</script>
88+
</body>
89+
</html>

0 commit comments

Comments
 (0)