Skip to content

Commit b20a47d

Browse files
committed
WIP
1 parent cffbb86 commit b20a47d

File tree

1 file changed

+298
-13
lines changed
  • public/content/developers/tutorials/ethereum-for-web2-auth

1 file changed

+298
-13
lines changed

public/content/developers/tutorials/ethereum-for-web2-auth/index.md

Lines changed: 298 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@ published: 2025-04-15
1414

1515
In this tutorial you learn how to integrate Ethereum signatures with SAML to allow users to use their Ethereum wallets to authenticate themselves to web2 services that don't support Ethereum natively yet.
1616

17+
Note that this tutorial is written for two separate audiences:
18+
19+
- Ethereum people who understand Ethereum and need to learn SAML
20+
- Web2 people who understand SAML and web2 authentication and need to learn Ethereum
21+
22+
As a result, it is going to contain a lot of introductory material that you already know. Feel free to skip it.
23+
1724
### SAML for Ethereum people
1825

19-
SAML is a centralized protocol. A service provider (SP) only accepts assertions (such as "this is my user John, he should have permissions to do A, B, and C") from an identity provider (IdP) if it has a pre-existing trust relationship with it.
26+
SAML is a centralized protocol. A service provider (SP) only accepts assertions (such as "this is my user John, he should have permissions to do A, B, and C") from an identity provider (IdP) if it has a pre-existing trust relationship either with it, or with the [certificate authority](https://www.ssl.com/article/what-is-a-certificate-authority-ca/) that signed that IdP's certificate.
2027

2128
For example, the SP can be a travel agency providing travel services to companies, and the IdP can be a company's internal web site. When employees need to book business travel, the travel agency sends them for authentication by the company before letting them actually book travel.
2229

@@ -86,21 +93,299 @@ Because of the decentralized nature of Ethereum, any user can make attestations.
8693

8794
## Setup
8895

89-
Create keys with self-signed certificates.
96+
The first step is to have SAML SP and IdP communicating between themselves.
97+
98+
1. Download the software. The sample software for this article is [on github](https://github.com/qbzzt/250420-saml-ethereum). Different stages are stored in different branches, for this stage you want `saml-only`
99+
100+
```sh
101+
git clone https://github.com/qbzzt/250420-saml-ethereum -b saml-only
102+
cd 250420-saml-ethereum
103+
pnpm install
104+
```
105+
106+
2. Create keys with self-signed certificates. This means that the key is its own certificate authority, and needs to be imported manually to the service provider. See [the OpenSSL docs](https://docs.openssl.org/master/man1/openssl-req/) for more information.
107+
108+
```sh
109+
mkdir keys
110+
cd keys
111+
openssl req -new -x509 -days 365 -nodes -sha256 -out saml-sp.crt -keyout saml-sp.pem -subj /CN=sp/
112+
openssl req -new -x509 -days 365 -nodes -sha256 -out saml-idp.crt -keyout saml-idp.pem -subj /CN=idp/
113+
cd ..
114+
```
115+
116+
3. Start the servers
117+
118+
```sh
119+
pnpm start
120+
```
121+
122+
4. Browse to the SP at URL [http://localhost:3000/](http://localhost:3000/) and click the button.
123+
124+
5. Provide the IdP with your e-mail address and click **Login to the service provider**. See that you get redirected to the service provider (port 3000) and that it knows you by your e-mail address.
125+
126+
### Detailed explanation
127+
128+
#### src/config.mts
129+
130+
```typescript
131+
const fs = await import("fs")
132+
133+
const protocol="http"
134+
135+
export const spCert = fs.readFileSync("keys/saml-sp.crt").toString()
136+
export const idpCert = fs.readFileSync("keys/saml-idp.crt").toString()
137+
138+
export const spPort = 3000
139+
export const spHostname = "localhost"
140+
export const spDir = "sp"
141+
142+
export const idpPort = 3001
143+
export const idpHostname = "localhost"
144+
export const idpDir = "idp"
145+
146+
export const spUrl = `${protocol}://${spHostname}:${spPort}/${spDir}`
147+
export const idpUrl = `${protocol}://${idpHostname}:${idpPort}/${idpDir}`
148+
149+
export const spPublicData = {
150+
entityID: `${spUrl}/metadata`,
151+
wantAssertionsSigned: true,
152+
authnRequestsSigned: false,
153+
signingCert: spCert,
154+
allowCreate: true,
155+
assertionConsumerService: [{
156+
Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
157+
Location: `${spUrl}/assertion`,
158+
}]
159+
}
160+
161+
export const idpPublicData = {
162+
entityID: `${idpUrl}/metadata`,
163+
signingCert: idpCert,
164+
wantAuthnRequestsSigned: false,
165+
singleSignOnService: [{
166+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
167+
Location: `${idpUrl}/login`
168+
}],
169+
singleLogoutService: [{
170+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
171+
Location: `${idpUrl}/logout`
172+
}],
173+
}
174+
```
175+
176+
#### src/SP.mts
177+
178+
```typescript
179+
import * as config from "./config.mts"
180+
const fs = await import("fs")
181+
const saml = await import("samlify")
182+
import * as validator from "@authenio/samlify-node-xmllint"
183+
saml.setSchemaValidator(validator)
184+
const express = (await import("express")).default
185+
const spRouter = express.Router()
186+
const app = express()
187+
188+
const spPrivateKey = fs.readFileSync("keys/saml-sp.pem").toString()
189+
190+
const sp = saml.ServiceProvider({
191+
privateKey: spPrivateKey,
192+
...config.spPublicData
193+
})
90194
91-
See https://docs.openssl.org/master/man1/openssl-req/ .
195+
const idp = saml.IdentityProvider(config.idpPublicData);
92196
93-
```sh
94-
mkdir keys
95-
cd keys
96-
openssl req -new -x509 -days 365 -nodes -sha256 -out saml-sp.crt -keyout saml-sp.pem -subj /CN=sp/
97-
openssl req -new -x509 -days 365 -nodes -sha256 -out saml-idp.crt -keyout saml-idp.pem -subj /CN=idp/
98-
cd ..
197+
spRouter.get(`/metadata`,
198+
(req, res) => res.header("Content-Type", "text/xml").send(sp.getMetadata())
199+
)
200+
201+
spRouter.post(`/assertion`,
202+
async (req, res) => {
203+
// console.log(`SAML response:\n${Buffer.from(req.body.SAMLResponse, 'base64').toString('utf-8')}`)
204+
205+
try {
206+
const loginResponse = await sp.parseLoginResponse(idp, 'post', req);
207+
res.send(`
208+
<html>
209+
<body>
210+
<h2>Hello ${loginResponse.extract.nameID}</h2>
211+
</body>
212+
</html>
213+
`)
214+
res.send();
215+
} catch (err) {
216+
console.error('Error processing SAML response:', err);
217+
res.status(400).send('SAML authentication failed');
218+
}
219+
}
220+
)
221+
222+
spRouter.get('/login',
223+
async (req, res) => {
224+
const loginRequest = await sp.createLoginRequest(idp, "post")
225+
res.send(`
226+
<html>
227+
<body>
228+
<script>
229+
window.onload = function () { document.forms[0].submit(); }
230+
</script>
231+
232+
<form method="post" action="${loginRequest.entityEndpoint}">
233+
<input type="hidden" name="${loginRequest.type}" value="${loginRequest.context}" />
234+
</form>
235+
</body>
236+
</html>
237+
`)
238+
}
239+
)
240+
241+
app.use(express.urlencoded({extended: true}))
242+
app.use(`/${config.spDir}`, spRouter)
243+
244+
app.get("/", (req, res) => {
245+
res.send(`
246+
<html>
247+
<body>
248+
<button onClick="document.location.href='${config.spUrl}/login'">
249+
Click here to log on
250+
</button>
251+
</body>
252+
</html>
253+
`)
254+
})
255+
256+
app.listen(config.spPort, () => {
257+
console.log(`service provider is running on http://${config.spHostname}:${config.spPort}`)
258+
})
259+
```
260+
261+
#### src/IdP.mts
262+
263+
```typescript
264+
import * as config from "./config.mts"
265+
const fs = await import("fs")
266+
const saml = await import("samlify")
267+
import * as validator from "@authenio/samlify-node-xmllint"
268+
saml.setSchemaValidator(validator)
269+
const express = (await import("express")).default
270+
const app = express()
271+
const xmlParser = new (await import("fast-xml-parser")).XMLParser(
272+
{
273+
ignoreAttributes: false, // Preserve attributes
274+
attributeNamePrefix: "@_", // Prefix for attributes
275+
}
276+
)
277+
278+
const idpPrivateKey = fs.readFileSync("keys/saml-idp.pem").toString()
279+
280+
const idp = saml.IdentityProvider({
281+
privateKey: idpPrivateKey,
282+
...config.idpPublicData
283+
})
284+
285+
const sp = saml.ServiceProvider(config.spPublicData)
286+
287+
const getLoginPage = requestId => `
288+
<html>
289+
<head>
290+
<title>Login page</title>
291+
</head>
292+
<body>
293+
<h2>Login page</h2>
294+
<form method="post" action="./loginSubmitted">
295+
<input type="hidden" name="requestId" value="${requestId}" />
296+
Email address: <input name="email" />
297+
<br />
298+
<button type="Submit">
299+
Login to the service provider
300+
</button>
301+
</form>
302+
</body>
303+
</html>
304+
`
305+
306+
const idpRouter = express.Router()
307+
308+
idpRouter.post("/loginSubmitted", async (req, res) => {
309+
const loginResponse = await idp.createLoginResponse(
310+
sp,
311+
{
312+
authnContextClassRef: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
313+
audience: sp.entityID,
314+
extract: {
315+
request: {
316+
id: req.body.requestId
317+
}
318+
},
319+
signingKey: { privateKey: idpPrivateKey, publicKey: config.idpCert } // Ensure signing
320+
},
321+
"post",
322+
{
323+
email: req.body.email
324+
}
325+
);
326+
327+
// const samlResponseDecoded = Buffer.from(loginResponse.context, "base64").toString("utf8");
328+
// console.log("Decoded SAML Response:", samlResponseDecoded);
329+
330+
res.send(`
331+
<html>
332+
<body>
333+
<script>
334+
window.onload = function () { document.forms[0].submit(); }
335+
</script>
336+
337+
<form method="post" action="${loginResponse.entityEndpoint}">
338+
<input type="hidden" name="${loginResponse.type}" value="${loginResponse.context}" />
339+
</form>
340+
</body>
341+
</html>
342+
`)
343+
344+
})
345+
346+
// IdP endpoint for login requests
347+
idpRouter.post(`/login`,
348+
async (req, res) => {
349+
try {
350+
// Workaround because I couldn't get parseLoginRequest to work.
351+
// const loginRequest = await idp.parseLoginRequest(sp, 'post', req)
352+
const samlRequest = xmlParser.parse(Buffer.from(req.body.SAMLRequest, 'base64').toString('utf-8'))
353+
res.send(getLoginPage(samlRequest["samlp:AuthnRequest"]["@_ID"]))
354+
} catch (err) {
355+
console.error('Error processing SAML response:', err);
356+
res.status(400).send('SAML authentication failed');
357+
}
358+
}
359+
)
360+
361+
idpRouter.get(`/metadata`,
362+
(req, res) => res.header("Content-Type", "text/xml").send(idp.getMetadata())
363+
)
364+
365+
app.use(express.urlencoded({extended: true}))
366+
app.use(`/${config.idpDir}`, idpRouter)
367+
368+
app.get("/", (req, res) => {
369+
res.send(`
370+
<html>
371+
<body>
372+
<button onClick="document.location.href='${config.spUrl}/login'">
373+
Click here to log on
374+
</button>
375+
</body>
376+
</html>
377+
`)
378+
})
379+
380+
app.listen(config.idpPort, () => {
381+
console.log(`identity provider is running on http://${config.idpHostname}:${config.idpPort}`)
382+
})
99383
```
100384
101-
1. Introduction: Why do this?
102-
1. SAML for Ethereum people
103-
1. Ethereum for SAML people
385+
386+
387+
388+
104389
1. Setup
105390
1. Creating a SAML service provider (SP)
106391
1. Creating a (for now) traditional SAML identity provider (IdP)
@@ -113,4 +398,4 @@ cd ..
113398
1. Passing those user attributes to the SP.
114399
1. Conclusion
115400
1. When is this a good solution?
116-
2. Using [MPC](https://ethresear.ch/c/cryptography/mpc/14) to remove the IdP's ability to cheat (just the idea, but I might implement it in a sequel article
401+
2. Using [MPC](https://ethresear.ch/c/cryptography/mpc/14) to remove the IdP's ability to cheat (just the idea, but I might implement it in a sequel article)

0 commit comments

Comments
 (0)