Using Ethereum for web2 authentication
Introduction
SAML is a standard used on web2 to allow an identity provider (IdP) to provide user information for service providers (SP).
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.
Note that this tutorial is written for two separate audiences:
- Ethereum people who understand Ethereum and need to learn SAML
- Web2 people who understand SAML and web2 authentication and need to learn Ethereum
As a result, it is going to contain a lot of introductory material that you already know. Feel free to skip it.
SAML for Ethereum people
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 that signed that IdP's certificate.
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.
This is the way the three entities, the browser, SP, and IdP, negotiate for access. The SP does not need to know anything about the user using the browser in advance, just to trust the IdP.
Ethereum for SAML people
Ethereum is a decentralized system.
Users have a a private key (typically held in a browser extension). From the private key you can derive a public key, and from that a 20-byte address. When users need to log into a system, they are requested to sign a message with a nonce (a single-use value). The server can verify the signature was created by that address.
The signature only verifies the Ethereum address. To get other user attributes, you typically use attestations. An attestation typically has these fields:
- Attestor, the address that made the attestation
- Recipient, the address to which the attestation applies
- Data, the data being attested, such as name, permissions, etc.
- Schema, the ID of the schema used to interpret the data.
Because of the decentralized nature of Ethereum, any user can make attestations. The attestor's identity is important to identify which attestations we consider reliable.
Setup
The first step is to have a SAML SP and a SAML IdP communicating between themselves.
-
Download the software. The sample software for this article is on github. Different stages are stored in different branches, for this stage you want
saml-only
1git clone https://github.com/qbzzt/250420-saml-ethereum -b saml-only2cd 250420-saml-ethereum3pnpm install -
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 for more information.
1mkdir keys2cd keys3openssl req -new -x509 -days 365 -nodes -sha256 -out saml-sp.crt -keyout saml-sp.pem -subj /CN=sp/4openssl req -new -x509 -days 365 -nodes -sha256 -out saml-idp.crt -keyout saml-idp.pem -subj /CN=idp/5cd .. -
Start the servers (both SP and IdP)
1pnpm start -
Browse to the SP at URL http://localhost:3000/ and click the button to be redirected to the IdP (port 3001).
-
Provide the IdP with your email address and click Login to the service provider. See that you get redirected back to the service provider (port 3000) and that it knows you by your email address.
Detailed explanation
This is what happens, step by step:
src/config.mts
This file contains the configuration for both the Identity Provider and the Service Provider. Normally these two would be different entities, but here we can share code for simplicity.
1const fs = await import("fs")23const protocol="http"
For now we're just testing, so it's fine to use HTTP.
1export const spCert = fs.readFileSync("keys/saml-sp.crt").toString()2export const idpCert = fs.readFileSync("keys/saml-idp.crt").toString()
Read the public keys, which are normally available to both components (and either trusted directly, or signed by a trusted certificate authority).
1export const spPort = 30002export const spHostname = "localhost"3export const spDir = "sp"45export const idpPort = 30016export const idpHostname = "localhost"7export const idpDir = "idp"89export const spUrl = `${protocol}://${spHostname}:${spPort}/${spDir}`10export const idpUrl = `${protocol}://${idpHostname}:${idpPort}/${idpDir}`Паказаць усё
The URLs for both components.
1export const spPublicData = {
The public data for the service provider.
1 entityID: `${spUrl}/metadata`,
By convention, in SAML the entityID
is the URL where the metadata of the entity is available. This metadata corresponds to the public data here, except it is in XML form.
1 wantAssertionsSigned: true,2 authnRequestsSigned: false,3 signingCert: spCert,4 allowCreate: true,5 assertionConsumerService: [{6 Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',7 Location: `${spUrl}/assertion`,8 }]9 }Паказаць усё
The most important definition for our purposes is the assertionConsumerServer
. It means that to assert something (for example, "the user who sends you this information is somebody@example.com") to the service provider we need to use HTTP POST to URL http://localhost:3000/sp/assertion
.
1export const idpPublicData = {2 entityID: `${idpUrl}/metadata`,3 signingCert: idpCert,4 wantAuthnRequestsSigned: false,5 singleSignOnService: [{6 Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",7 Location: `${idpUrl}/login`8 }],9 singleLogoutService: [{10 Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",11 Location: `${idpUrl}/logout`12 }],13 }Паказаць усё
The public data for the identity provider is similar. It specifies that to log a user in you POST to http://localhost:3001/idp/login
and to log a user out you POST to http://localhost:3001/idp/logout
.
src/sp.mts
This is the code that implements a service provider.
1import * as config from "./config.mts"2const fs = await import("fs")3const saml = await import("samlify")
We use the samlify
library to implement SAML.
1import * as validator from "@authenio/samlify-node-xmllint"2saml.setSchemaValidator(validator)
The samlify
library expects to have a package validate that XML is correct, signed with the expected public key, etc. We use @authenio/samlify-node-xmllint
for this purpose.
1const express = (await import("express")).default2const spRouter = express.Router()3const app = express()
An express
Router
is a "mini web site" that can be mounted inside a web site. In this case, we use it to group all the service provider definitions together.
1const spPrivateKey = fs.readFileSync("keys/saml-sp.pem").toString()23const sp = saml.ServiceProvider({4 privateKey: spPrivateKey, 5 ...config.spPublicData6})
The service provider's own representation of itself is all the public data, and the private key it uses to sign information.
1const idp = saml.IdentityProvider(config.idpPublicData);
The public data contains everything the service provider needs to know about the identity provider.
1spRouter.get(`/metadata`, 2 (req, res) => res.header("Content-Type", "text/xml").send(sp.getMetadata())3)
To enable interoperability with other SAML components, service and identity providers should have their public data (called the metadata) available in XML format in /metadata
.
1spRouter.post(`/assertion`,
This is the page accessed by the browser to identify itself. The assertion includes the user identifier (here we use email address), and can include additional attributes. This is the handler for step 7 in the sequence diagram above.
1 async (req, res) => {2 // console.log(`SAML response:\n${Buffer.from(req.body.SAMLResponse, 'base64').toString('utf-8')}`)
You can use the commented out command to see the XML data provided in the assertion. It is base64 encoded.
1 try {2 const loginResponse = await sp.parseLoginResponse(idp, 'post', req);
Parse the login request from the identity server.
1 res.send(`2 <html>3 <body>4 <h2>Hello ${loginResponse.extract.nameID}</h2>5 </body>6 </html>7 `)8 res.send();
Send an HTML response, just to show the user we got the login.
1 } catch (err) {2 console.error('Error processing SAML response:', err);3 res.status(400).send('SAML authentication failed');4 }5 }6)
Inform the user in case of failure.
1spRouter.get('/login',
Create a login request when the browser attempts to get this page. This is the handler for step 1 in the sequence diagram above.
1 async (req, res) => {2 const loginRequest = await sp.createLoginRequest(idp, "post")
Get the information to post a login request.
1 res.send(`2 <html>3 <body>4 <script>5 window.onload = function () { document.forms[0].submit(); } 6 </script>
This page submits the form (see below) automatically. This way the user does not have to do anything to be redirected. This is step 2 in the sequence diagram above.
1 <form method="post" action="${loginRequest.entityEndpoint}">
Post to loginRequest.entityEndpoint
(the URL of the identity provider endpoint).
1 <input type="hidden" name="${loginRequest.type}" value="${loginRequest.context}" />
The input name is loginRequest.type
(SAMLRequest
). The content for that field is loginRequest.context
, which is again XML that is base64 encoded.
1 </form>2 </body>3 </html>4 `) 5 }6)78app.use(express.urlencoded({extended: true}))
This middleware reads the body of the HTTP request. By default express ignores it, because most requests don't require it. We need it because POST does use the body.
1app.use(`/${config.spDir}`, spRouter)
Mount the router in the service provider directory (/sp
).
1app.get("/", (req, res) => {2 res.send(`3 <html>4 <body>5 <button onClick="document.location.href='${config.spUrl}/login'">6 Click here to log on7 </button>8 </body>9 </html>10 `)11})Паказаць усё
If a browser tries to get the root directory, provide it with a link to the login page.
1app.listen(config.spPort, () => {2 console.log(`service provider is running on http://${config.spHostname}:${config.spPort}`)3})
Listen to the spPort
with this express application.
src/idp.mts
This is the identity provider. It is very similar to the service provider, the explanations below are for the parts that are different.
1const xmlParser = new (await import("fast-xml-parser")).XMLParser(2 {3 ignoreAttributes: false, // Preserve attributes4 attributeNamePrefix: "@_", // Prefix for attributes5 }6)
We need to read and understand the XML request we receive from the service provider.
1const getLoginPage = requestId => `
This function creates the page with the auto-submitted form that is returned in step 4 of the sequence diagram above.
1<html>2 <head>3 <title>Login page</title>4 </head>5 <body>6 <h2>Login page</h2>7 <form method="post" action="./loginSubmitted">8 <input type="hidden" name="requestId" value="${requestId}" />9 Email address: <input name="email" />10 <br />11 <button type="Submit">12 Login to the service provider13 </button>Паказаць усё
There are two fields we send to the service provider:
- The
requestId
to which we are responding. - The user identifier (we use the email address the user provides for now).
1 </form>2 </body>3</html>45const idpRouter = express.Router()67idpRouter.post("/loginSubmitted", async (req, res) => {8 const loginResponse = await idp.createLoginResponse(
This is the handler for step 5 of the sequence diagram above. idp.createLoginResponse
creates the login response.
1 sp, 2 {3 authnContextClassRef: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',4 audience: sp.entityID,
The audience is the service provider.
1 extract: {2 request: {3 id: req.body.requestId4 }5 },
Information extracted from the request. The one parameter we care about in the request is the requestId, which lets the service provider match requests and their responses.
1 signingKey: { privateKey: idpPrivateKey, publicKey: config.idpCert } // Ensure signing
We need signingKey
to have the data to sign the response. The service provider doesn't trust unsigned requests.
1 },2 "post",3 {4 email: req.body.email
This is the field with the user information we send back to the service provider.
1 }2 );34 res.send(`5 <html>6 <body>7 <script>8 window.onload = function () { document.forms[0].submit(); }9 </script>10 11 <form method="post" action="${loginResponse.entityEndpoint}">12 <input type="hidden" name="${loginResponse.type}" value="${loginResponse.context}" />13 </form>14 </body>15 </html>16 `)17})Паказаць усё
Again, use an auto-submitted form. This is step 6 of the sequence diagram above.
12// IdP endpoint for login requests3idpRouter.post(`/login`,
This is the endpoint that receives a login request from the service provider. This is the handler the step 3 of the sequence diagram above.
1 async (req, res) => {2 try {3 // Workaround because I couldn't get parseLoginRequest to work.4 // const loginRequest = await idp.parseLoginRequest(sp, 'post', req)5 const samlRequest = xmlParser.parse(Buffer.from(req.body.SAMLRequest, 'base64').toString('utf-8'))6 res.send(getLoginPage(samlRequest["samlp:AuthnRequest"]["@_ID"]))
We should be able to use idp.parseLoginRequest
to read the authentication request's ID. However, I couldn't get it working and it wasn't worth spending a lot of time on it so I just use a general-purpose XML parser. The information we need is the ID
attribute inside the <samlp:AuthnRequest>
tag, which is at the top level of the XML.
Using Ethereum signatures
Now that we can send a user identity to the service provider, the next step is to obtain the user identity in a trusted manner. Viem allows us to just ask the wallet for the user address, but this means asking the browser for the information. We don't control the browser, so we can't automatically trust the response we get from it.
Instead, the IdP is going to send the browser a string to sign. If the wallet in the browser signs this string, it means that it really is that address (that is, it knows the private key that corresponds to the address).
To see this in action, stop the existing IdP and SP and run these commands:
1git checkout eth-signatures2pnpm install3pnpm start
Then browse to the SP and follow the directions.
Note that at this point we don't know how to get the email address from the Ethereum address, so instead we report <ethereum address>@bad.email.address
to the SP.
Detailed explanation
The changes are in steps 4-5 in the previous diagram.
The only file we changed is idp.mts
. Here are the changed parts.
1import { v4 as uuidv4 } from 'uuid'2import { verifyMessage } from 'viem'
We need these two additional libraries. We use uuid
to create the nonce value. The value itself does not matter, just the fact it is only used once.
The viem
library lets us use Ethereum definitions. Here we need it to verify that the signature is indeed valid.
1const loginPrompt = "To access the service provider, sign this nonce: "
The wallet asks the user for permission to sign the message. A message that is just a nonce could confuse users, so we include this prompt.
1// Keep requestIDs here2let nonces = {}
We need the request information to be able to respond to it. We could send it with the request (step 4), and receive it back (step 5). However, we cannot trust the information we get from the browser, which is under the control of a potentially hostile user. So it's better to store it here, with the nonce as key.
Note that we are doing it here as a variable for the sake of simplicity. However, this has several disadvantages:
- We are vulnerable to a denial of service attack. A malicious user could attempt to log on multiple times, filling up our memory.
- If the IdP process needs to be restarted, we lose the existing values.
- We cannot load balance across multiple processes, because each would have its own variable.
On a production system we'd use a database and implement some kind of expiry mechanism.
1const getSignaturePage = requestId => {2 const nonce = uuidv4()3 nonces[nonce] = requestId
Create a nonce, and store the requestId
for future use.
1 return `2<html>3 <head>4 <script type="module">
This JavaScript gets executed automatically when the page is loaded.
1 import { createWalletClient, custom, getAddress } from 'https://esm.sh/viem'
We need several functions from viem
.
1 if (!window.ethereum) {2 alert("Please install MetaMask or a compatible wallet and then reload")3 }
We can only work if there is a wallet on the browser.
1 const [account] = await window.ethereum.request({method: 'eth_requestAccounts'})
Request the list of accounts from the wallet (window.ethereum
). Assume there is at least one, and only store the first one.
1 const walletClient = createWalletClient({2 account,3 transport: custom(window.ethereum)4 })
Create a wallet client to interact with the browser wallet.
1 window.goodSignature = () => {2 walletClient.signMessage({3 message: "${loginPrompt}${nonce}"
Ask the user to sign a message. Because this whole HTML is in a template string, we can use variables defined in the idp process. This is step 4.5 in the sequence diagram.
1 }).then(signature => {2 const path= "/${config.idpDir}/signature/${nonce}/" + account + "/" + signature3 window.location.href = path4 })5 }
Redirect to /idp/signature/<nonce>/<address>/<signature>
. This is step 5 in the sequence diagram.
1 window.badSignature = () => {2 const path= "/${config.idpDir}/signature/${nonce}/" + 3 getAddress("0x" + "BAD060A7".padEnd(40, "0")) + 4 "/0x" + "BAD0516".padStart(130, "0")5 window.location.href = path6 }
The signature is sent back by the browser, which is potentially malicious (there is nothing to stop you from just opening http://localhost:3001/idp/signature/bad-nonce/bad-address/bad-signature
in the browser). Therefore, it is important to verify the IdP process handles bad signatures correctly.
1 </script>2 </head>3 <body>4 <h2>Please sign</h2>5 <button onClick="window.goodSignature()">6 Submit a good (valid) signature7 </button>8 <br/>9 <button onClick="window.badSignature()">10 Submit a bad (invalid) signature11 </button>12 </body>13</html> 14`15}Паказаць усё
The rest is just standard HTML.
1idpRouter.get("/signature/:nonce/:account/:signature", async (req, res) => {
This is the handler for step 5 in the sequence diagram.
1 const requestId = nonces[req.params.nonce]2 if (requestId === undefined) {3 res.send("Bad nonce")4 return ;5 } 6 7 nonces[req.params.nonce] = undefined
Get the request ID, and delete the nonce from nonces
to make sure it cannot be reused.
1 try {
Becasue there are so many ways in which the signature can be invalid, we wrap this in a try ... catch
block to catch any thrown errors.
1 const validSignature = await verifyMessage({2 address: req.params.account,3 message: `${loginPrompt}${req.params.nonce}`,4 signature: req.params.signature5 })
Use verifyMessage
to implement step 5.5 in the sequence diagram.
1 if (!validSignature)2 throw("Bad signature")3 } catch (err) {4 res.send("Error:" + err)5 return ;6 }
The rest of the handler is equivalent to what we've done in the /loginSubmitted
handler previously, except for one small change.
1 const loginResponse = await idp.createLoginResponse(2 .3 .4 .5 {6 email: req.params.account + "@bad.email.address"7 }8 );
We don't have the actual email address (we will get it in the next section), so for now we return the Ethereum address and mark it clearly as not an email address.
1// IdP endpoint for login requests2idpRouter.post(`/login`,3 async (req, res) => {4 try {5 // Workaround because I couldn't get parseLoginRequest to work.6 // const loginRequest = await idp.parseLoginRequest(sp, 'post', req)7 const samlRequest = xmlParser.parse(Buffer.from(req.body.SAMLRequest, 'base64').toString('utf-8'))8 res.send(getSignaturePage(samlRequest["samlp:AuthnRequest"]["@_ID"]))9 } catch (err) {10 console.error('Error processing SAML response:', err);11 res.status(400).send('SAML authentication failed');12 }13 }14)Паказаць усё
Instead of getLoginPage
, now use getSignaturePage
in the step 3 handler.
Getting the email address
The next step is to obtain the email address, the identifier requested by the service provider. To do that, we use Ethereum Attestation Service (EAS).
The easiest way to get attestations is to use the GraphQL API. We use this query:
1query GetAttestationsByRecipient {2 attestations(3 where: { 4 recipient: { equals: "${getAddress(ethAddr)}" }5 schemaId: { equals: "0xfa2eff59a916e3cc3246f9aec5e0ca00874ae9d09e4678e5016006f07622f977" }6 }7 take: 18 ) { 9 data10 id11 attester12 }13}Паказаць усё
This schemaId
includes just an e-mail address. This query asks for attestations of this schema. The subject of the attestation is called the recipient
. It is always an Ethereum address.
Warning: The way we are getting attestations here has two security issues.
-
We are going to the API endpoint,
https://optimism.easscan.org/graphql
, which is a centralized component. We can get theid
attribute and then do a lookup onchain to verify that an attestation is real, but the API endpoint can still censor attestations by not telling us about them.This problem is not impossible to solve, we could run our own GraphQL endpoint and get the attestations from the chain logs, but that is excessive for our purposes.
-
We don't look at the attester identity. Anybody can feed us false information. In a real world implementation we would have a set of trusted attesters and only look at their attestations.
To see this in action, stop the existing IdP and SP and run these commands:
1git checkout email-address2pnpm install3pnpm start
Then provide your e-mail address. You have two ways to do that:
-
Import a wallet using a private key, and use the testing private key
0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
. -
Add an attestation for your own e-mail address:
-
Browse to the schema in the attestation explorer.
-
Click Attest with Schema.
-
Enter your Ethereum address as the recipient, your e-mail address as email address, and select Onchain. Then click Make Attestation.
-
Approve the transaction in your wallet. You will need some ETH on the Optimism Blockchain to pay for gas.
-
Either way, after you do this browse to http://localhost:3000 and follow the directions. If you imported the testing private key, the e-mail you receive is test_addr_0@example.com
. If you used your own address, it should be whatever you attested.
Detailed explanation
The new steps are the GraphQL communication, steps 5.6 and 5.7.
Again, here are the changed parts of idp.mts
.
1import { GraphQLClient } from 'graphql-request'2import { SchemaEncoder } from '@ethereum-attestation-service/eas-sdk'
Import the libraries we need.
1const graphqlEndpointUrl = "https://optimism.easscan.org/graphql"
There is a separate endpoint for each blockchain.
1const graphqlClient = new GraphQLClient(graphqlEndpointUrl, { fetch })
Create a new GraphQLClient
client we can use for querying the endpoint.
1const graphqlSchema = 'string emailAddress'2const graphqlEncoder = new SchemaEncoder(graphqlSchema)
GraphQL only gives us an opaque data object with bytes. To understand it we need the schema.
1const ethereumAddressToEmail = async ethAddr => {
A function to get from an Ethereum address to an e-mail address.
1 const query = `2 query GetAttestationsByRecipient {
This is a GraphQL query.
1 attestations(
We are looking for attestations.
1 where: { 2 recipient: { equals: "${getAddress(ethAddr)}" }3 schemaId: { equals: "0xfa2eff59a916e3cc3246f9aec5e0ca00874ae9d09e4678e5016006f07622f977" }4 }
The attestations we want are those in our schema, where the recipient is getAddress(ethAddr)
. The getAddress
function makes sure our address has the correct checksum. This is necessary about GraphQL is case-significant. "0xBAD060A7", "0xBad060A7", and "0xbad060a7" are differemt values.
1 take: 1
Regardless of how many attestations we find, we only want the first one.
1 ) {2 data3 id4 attester5 }6 }`
The fields we want to receive.
attester
: The address that submitted the attestation. Normally this is used to decide whether to trust the attestation or not.id
: The attestation ID. You can use this value to read the attestation onchain to verify that the information from the GraphQL query is correct.data
: The schema data (in this case, the e-mail address).
1 const queryResult = await graphqlClient.request(query)23 if (queryResult.attestations.length == 0)4 return "no_address@available.is"
If there is no attestation, return a value that is obviously incorrect, but that would appear valid to the service provider.
1 const attestationDataFields = graphqlEncoder.decodeData(queryResult.attestations[0].data)2 return attestationDataFields[0].value.value3}
If there is a value, use decodeData
to decode the data. We don't need the metadata it provides, just the value itself.
1 const loginResponse = await idp.createLoginResponse(2 sp, 3 {4 .5 .6 .7 },8 "post",9 {10 email: await ethereumAddressToEmail(req.params.account)11 }12 );Паказаць усё
Use the new function to get the e-mail address.
What about decentralization?
In this configuration users cannot pretend to be somebody they are not, as long as we rely on trustworthy attesters for the Ethereum to e-mail address mapping. However, our identity provider is still a centralized component. Whoever has the private key of the identity provider can send false information to the service provider.
There may be a solution using multi-party computation (MPC). I hope to write about it in a future tutorial.
Conclusion
Adoption of a log on standard, such as Ethereum signatures, faces a chicken and egg problem. Service providers want to appeal to the broadest possible market. Users want to be able to access services without having to worry about supporting their log on standard. Creating adapters, such as an Ethereum IdP, can help us get over this hurdle.