Skip to content

Commit d6560d8

Browse files
committed
Merge branch 'idp5' into idp4
2 parents 547ac32 + a588a0d commit d6560d8

File tree

8 files changed

+294
-6
lines changed

8 files changed

+294
-6
lines changed

README.md

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,66 @@ The key benefit of this design is that it requires zero changes to the back serv
161161
sudo -u {USER} /opt/shibboleth-idp/bin/plugin.sh -i $PWD/andrvotr-dist/target/idp-plugin-andrvotr-*-SNAPSHOT.tar.gz --noCheck
162162
```
163163

164-
<!-- TODO: ## Developing compatible front services -->
164+
## Developing front services
165+
166+
Front services which want to use Andrvotr to connect to a back service must follow this procedure.
167+
168+
Andrvotr currently does not have a reusable client library in any language. You must implement it yourself.
169+
170+
You will need:
171+
172+
- a SAML Service Provider module or library
173+
- an HTTP client library with cookie management
174+
- an HTML parser library
175+
176+
### Login procedure
177+
178+
1. Read the SAML attribute containing the Andrvotr Authority Token.
179+
- It is identified by `Name="tag:fmfi-svt.github.io,2024:andrvotr-authority-token"` and
180+
`NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"`.
181+
- How to read attributes depends on your SP software. In most cases it should be trivial.
182+
- If you use mod_shib (Shibboleth SP), it is not trivial because it discards any unknown attributes. Edit `/etc/shibboleth/attribute-map.xml` and add this:
183+
```xml
184+
<Attribute name="tag:fmfi-svt.github.io,2024:andrvotr-authority-token" id="ANDRVOTR_AUTHORITY_TOKEN" />
185+
```
186+
- If the IdP does not send you the attribute, the process cannot continue.
187+
This is most likely because the IdP is not configured correctly (andrvotr.allowedConnections, andrvotr.apiKeys).
188+
189+
2. Initialize an empty cookie jar. It will be used for all HTTP requests you send.
190+
191+
3. Send an HTTP request to the back service URL which initiates the login process.
192+
Disable automatic HTTP redirect processing in your client library.
193+
(If the URL is not known, use e.g. browser dev tools to see what happens when you click the login button.)
194+
195+
4. Process the response to decide what request should be sent next:
196+
- If it is a HTTP 3xx redirect, the *next request* will be a GET of that Location header.
197+
- If it is a pseudo-redirect (an invisible form which is immediately submitted by JavaScript), parse the HTML page.
198+
The *next request* will be a POST to that form's action with the form's hidden inputs.
199+
Detecting this case might need some finetuning, but searching for the string "`document.forms[0].submit()`" works well in practice.
200+
- If it is a success page (back service specific), return success.
201+
- Otherwise, return failure.
202+
203+
5. If the *next request* is `GET https://$your_idp/...`, instead set *next request* to `POST https://$your_idp/idp/profile/andrvotr/fabricate` with POST body parameters:
204+
- `front_entity_id` = the SAML SP entity ID of the front service
205+
- `api_key` = your Andrvotr API key (must match andrvotr.apiKeys)
206+
- `andrvotr_authority_token` = token from the SAML attribute
207+
- `target_url` = the original *next request* URL
208+
209+
6. Send the *next request*.
210+
211+
7. Go to step 4 to process the response.
212+
213+
### Example implementations
214+
215+
[demo/demo.py](/demo/demo.py) implements an Andrvotr client in 50 lines of Python.
216+
It is not integrated with an SP -- you must extract the token and run demo.py manually from the command line.
217+
218+
```shell
219+
./demo.py "$back_service_target_url" "$front_entity_id" "$api_key" "$andrvotr_authority_token"
220+
```
221+
222+
The demo requires some Python libraries. Either [install "uv"](https://docs.astral.sh/uv/getting-started/installation/) which will take care of it. Or `apt install python3-bs4 python3-requests` and run with `python3 demo.py ...`. Or create a venv with `beautifulsoup4` and `requests`.
223+
224+
[Votr](https://github.com/fmfi-svt/votr/blob/master/fladgejt/login.py) contains another implementation of this procedure (also in Python).
165225

166226
<!-- TODO: ## Similar projects -->
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package io.github.fmfi_svt.andrvotr;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import java.util.function.Function;
5+
import javax.annotation.Nonnull;
6+
import javax.annotation.Nullable;
7+
import net.shibboleth.idp.profile.context.SpringRequestContext;
8+
import net.shibboleth.shared.primitive.LoggerFactory;
9+
import net.shibboleth.shared.servlet.HttpServletSupport;
10+
import org.opensaml.profile.context.ProfileRequestContext;
11+
import org.slf4j.Logger;
12+
import org.springframework.webflow.context.ExternalContext;
13+
import org.springframework.webflow.execution.RequestContext;
14+
15+
/// Works around session address binding.
16+
///
17+
/// Shibboleth IdP sessions remember the client IP address. If someone sends a request with a cookie value from a
18+
/// different address, they will have to reauthenticate. (Note that by "Shibboleth IdP session" we mean interface
19+
/// `IdPSession`, class `StorageBackedIdPSession`. and config option `idp.session.cookieName`, *not* `JSESSIONID`.)
20+
///
21+
/// That's a good thing, but it presents a problem for Andrvotr. The IdP session is initially created by the real user
22+
/// and bound to their real IP address. When /.../andrvotr/fabricate sends a nested request, its remote address will be
23+
/// localhost or similar, which is not what Shibboleth expects.
24+
///
25+
/// This class works around the issue by locally disabling the session address check during nested Andrvotr requests.
26+
/// Normal requests are unaffected.
27+
///
28+
/// This class is registered as "shibboleth.SessionAddressLookupStrategy" by `AddressLookupStrategyInjector`. It is
29+
/// called by "PopulateSessionContext" via authn-beans.xml and "ProcessLogout" via logout-beans.xml. Interestingly, it
30+
/// is only called when reading existing IdP sessions, not for new ones. When StorageBackedSessionManager creates a new
31+
/// session, it just calls HttpServletSupport.getRemoteAddr(). This might be a Shibboleth bug.
32+
public final class AddressLookupStrategy implements Function<ProfileRequestContext, String> {
33+
private final @Nonnull Logger log = LoggerFactory.getLogger(AddressLookupStrategy.class);
34+
35+
private final @Nullable Function<ProfileRequestContext, String> nextStrategy;
36+
37+
public AddressLookupStrategy(@Nullable Function<ProfileRequestContext, String> nextStrategy) {
38+
log.info("initialized andrvotr AddressLookupStrategy, nextStrategy = {}", nextStrategy);
39+
this.nextStrategy = nextStrategy;
40+
}
41+
42+
public @Nullable String apply(ProfileRequestContext prc) {
43+
// Look up the necessary objects.
44+
// There is no known situation where these exceptions are thrown.
45+
SpringRequestContext shibSpringRequestContext = prc.getSubcontext(SpringRequestContext.class);
46+
if (shibSpringRequestContext == null) {
47+
throw new RuntimeException("SpringRequestContext is missing");
48+
}
49+
RequestContext webflowRequestContext = shibSpringRequestContext.getRequestContext();
50+
if (webflowRequestContext == null) {
51+
throw new RuntimeException("getRequestContext() is null");
52+
}
53+
ExternalContext externalContext = webflowRequestContext.getExternalContext();
54+
if (externalContext == null) {
55+
throw new RuntimeException("getExternalContext() is null");
56+
}
57+
Object nativeRequest = externalContext.getNativeRequest();
58+
if (nativeRequest == null) {
59+
throw new RuntimeException("getNativeRequest() is null");
60+
}
61+
if (!(nativeRequest instanceof HttpServletRequest)) {
62+
throw new RuntimeException("getNativeRequest() is not a HttpServletRequest");
63+
}
64+
HttpServletRequest httpRequest = (HttpServletRequest) nativeRequest;
65+
66+
// If this is a nested request sent by our HttpController to ourselves, return null.
67+
// When PopulateSessionContext sees that we returned null, it'll skip the checkAddress() call.
68+
if (webflowRequestContext.getRequestScope().contains(Constants.ANDRVOTR_FABRICATION_TOKEN_OK)) {
69+
log.info("forcing client address of nested request to null. original was {}", httpRequest.getRemoteAddr());
70+
return null;
71+
}
72+
73+
// Return the normal address. Logic copied from
74+
// java-identity-provider/idp-session-impl/src/main/java/net/shibboleth/idp/session/impl/PopulateSessionContext.java.
75+
if (nextStrategy != null) {
76+
String result = nextStrategy.apply(prc);
77+
log.trace("client address from nextStrategy is {}", result);
78+
return result;
79+
} else {
80+
String result = HttpServletSupport.getRemoteAddr(httpRequest);
81+
log.trace("client address from httpRequest is {}", result);
82+
return result;
83+
}
84+
}
85+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package io.github.fmfi_svt.andrvotr;
2+
3+
import net.shibboleth.shared.component.AbstractInitializableComponent;
4+
import org.springframework.beans.factory.config.BeanDefinition;
5+
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
6+
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
7+
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
8+
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
9+
10+
/// Registers `AddressLookupStrategy` as the "shibboleth.SessionAddressLookupStrategy" bean.
11+
///
12+
/// The bean is documented at https://shibboleth.atlassian.net/wiki/spaces/IDP5/pages/3199506072/SessionConfiguration,
13+
/// though barely. It has no built-in definition - it is is intended as an optional extension point for administrators.
14+
/// In the unlikely event an administrator defines their own "shibboleth.SessionAddressLookupStrategy" in their
15+
/// configuration, this postprocessor renames it to another id and chains it after our implementation.
16+
public final class AddressLookupStrategyInjector extends AbstractInitializableComponent
17+
implements BeanDefinitionRegistryPostProcessor {
18+
private static final String TARGET_BEAN_ID = "shibboleth.SessionAddressLookupStrategy";
19+
private static final String RENAMED_BEAN_ID = "andrvotr.original.shibboleth.SessionAddressLookupStrategy";
20+
21+
@Override
22+
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
23+
// Sadly no logging, because this class apparently runs too early for logging to work.
24+
25+
boolean exists = registry.containsBeanDefinition(TARGET_BEAN_ID);
26+
27+
if (exists) {
28+
BeanDefinition originalDefinition = registry.getBeanDefinition(TARGET_BEAN_ID);
29+
registry.removeBeanDefinition(TARGET_BEAN_ID);
30+
registry.registerBeanDefinition(RENAMED_BEAN_ID, originalDefinition);
31+
}
32+
33+
BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(AddressLookupStrategy.class);
34+
if (exists) {
35+
builder.addConstructorArgReference(RENAMED_BEAN_ID);
36+
} else {
37+
builder.addConstructorArgValue(null);
38+
}
39+
registry.registerBeanDefinition(TARGET_BEAN_ID, builder.getBeanDefinition());
40+
}
41+
42+
/// postProcessBeanFactory defaults to an empty method in Spring 6.1.0+, but our Spring is too old.
43+
@Override
44+
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {}
45+
}

andrvotr-impl/src/main/java/io/github/fmfi_svt/andrvotr/Constants.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ private Constants() {}
1515
// Token value used for internal communication between HttpController and FabricationWebflowListener.
1616
public static final String ANDRVOTR_FABRICATION_TOKEN_VALUE = "andrvotr-fabrication-token";
1717

18+
// RequestContext request scope key used for internal communication between FabricationWebflowListener and
19+
// AddressLookupStrategy.
20+
public static final String ANDRVOTR_FABRICATION_TOKEN_OK = "andrvotr_fabrication_token_ok";
21+
1822
// State and event names defined in the Shibboleth flow "SAML2/Redirect/SSO". Arguably an internal implementation
1923
// detail of Shibboleth. See class doc of FabricationWebflowListener.
2024
public static final String STATE_DECODE_MESSAGE = "DecodeMessage";

andrvotr-impl/src/main/java/io/github/fmfi_svt/andrvotr/FabricationWebflowListener.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@
3838
/// java-identity-provider/idp-conf-impl/src/main/resources/net/shibboleth/idp/flows/saml/saml-abstract-flow.xml.
3939
public final class FabricationWebflowListener extends AbstractInitializableComponent implements FlowExecutionListener {
4040

41-
private static final String ANDRVOTR_FABRICATION_TOKEN_OK = "andrvotr_fabrication_token_ok";
42-
4341
private final @Nonnull Logger log = LoggerFactory.getLogger(FabricationWebflowListener.class);
4442

4543
private Config config;
@@ -96,7 +94,7 @@ public void requestSubmitted(RequestContext context) {
9694
}
9795

9896
log.info("started {} as a nested request inside andrvotr/fabricate", request.getRequestURI());
99-
context.getRequestScope().put(ANDRVOTR_FABRICATION_TOKEN_OK, new Object());
97+
context.getRequestScope().put(Constants.ANDRVOTR_FABRICATION_TOKEN_OK, new Object());
10098
addTrace(context, Constants.TRACE_START);
10199
}
102100

@@ -106,7 +104,7 @@ public void eventSignaled(RequestContext context, Event event) {
106104
(HttpServletRequest) context.getExternalContext().getNativeRequest();
107105

108106
// If the request does not have the Andrvotr-Internal-Fabrication-Token header, do nothing.
109-
if (!context.getRequestScope().contains(ANDRVOTR_FABRICATION_TOKEN_OK)) return;
107+
if (!context.getRequestScope().contains(Constants.ANDRVOTR_FABRICATION_TOKEN_OK)) return;
110108

111109
// If we're leaving the "DecodeMessage" state with the "proceed" event (not an error), check whether our
112110
// configuration allows connections from the front entity ID (sent by HttpController in a header) to the back
@@ -139,7 +137,7 @@ public void eventSignaled(RequestContext context, Event event) {
139137
@Override
140138
public void stateEntered(RequestContext context, StateDefinition previousState, StateDefinition state) {
141139
// If the request does not have the Andrvotr-Internal-Fabrication-Token header, do nothing.
142-
if (!context.getRequestScope().contains(ANDRVOTR_FABRICATION_TOKEN_OK)) return;
140+
if (!context.getRequestScope().contains(Constants.ANDRVOTR_FABRICATION_TOKEN_OK)) return;
143141

144142
// When moving from "HandleOutboundMessage" to "end", it is expected that the response is already sent, and we
145143
// can't add response headers anymore. Avoid the warning in addTrace.

andrvotr-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,8 @@
3434
p:connectionDisregardTLSCertificate="%{andrvotr.httpclient.connectionDisregardTLSCertificate:false}"
3535
p:maxConnectionsTotal="%{andrvotr.httpclient.maxConnectionsTotal:100}"
3636
p:maxConnectionsPerRoute="%{andrvotr.httpclient.maxConnectionsPerRoute:100}" />
37+
38+
<!-- Spring will auto-detect and run this bean because it implements BeanDefinitionRegistryPostProcessor. -->
39+
<bean class="io.github.fmfi_svt.andrvotr.AddressLookupStrategyInjector" />
40+
3741
</beans>

demo/demo.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/env -S uv run
2+
3+
# /// script
4+
# dependencies = [
5+
# "beautifulsoup4",
6+
# "requests",
7+
# ]
8+
# ///
9+
10+
import os
11+
import re
12+
import sys
13+
14+
import requests
15+
import bs4
16+
17+
session = requests.Session()
18+
19+
VERIFY_TLS_CERTS = os.getenv("VERIFY_TLS_CERTS") != "false"
20+
21+
url, front_entity_id, api_key, andrvotr_authority_token = sys.argv[1:]
22+
post_data = None
23+
24+
while True:
25+
print("Requesting", ("POST" if post_data else "GET"), url, *(["with", list(post_data)] if post_data else []))
26+
27+
if re.match(r'^https://[^/]+/idp/profile/', url):
28+
idp_host = url.split("/")[2]
29+
assert post_data is None, post_data
30+
assert url.startswith("https://" + idp_host + '/idp/profile/SAML2/Redirect/SSO?'), url
31+
post_data = { 'front_entity_id': front_entity_id, 'api_key': api_key, 'andrvotr_authority_token': andrvotr_authority_token, 'target_url': url }
32+
url = "https://" + idp_host + "/idp/profile/andrvotr/fabricate"
33+
print("Nevermind, requesting POST", url, "with", list(post_data))
34+
35+
response = session.request("POST" if post_data else "GET", url, data=post_data, verify=VERIFY_TLS_CERTS, allow_redirects=False)
36+
37+
print("Received", response.status_code, response.reason)
38+
print()
39+
40+
if 300 <= response.status_code <= 399 and 'location' in response.headers:
41+
url = response.headers['location']
42+
post_data = None
43+
continue
44+
45+
if response.status_code == 200 and b"document.forms[0].submit()" in response.content:
46+
soup = bs4.BeautifulSoup(response.text)
47+
url = soup.form['action']
48+
post_data = { input['name']: input['value'] for input in soup.find_all('input') if input['type'] == 'hidden' }
49+
continue
50+
51+
print("Final headers:", response.headers)
52+
sys.stdout.buffer.write(b"Final content: [" + response.content + b"]\n")
53+
break

resources/release-new-version.sh

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/bin/bash
2+
3+
VERSION=$1
4+
5+
if [[ $VERSION =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then
6+
X="${BASH_REMATCH[1]}"
7+
Y="${BASH_REMATCH[2]}"
8+
Z="${BASH_REMATCH[3]}"
9+
SUFFIX="${BASH_REMATCH[4]}"
10+
else
11+
echo "invalid version argument"
12+
exit 1
13+
fi
14+
15+
cd "$(dirname "$0")/.."
16+
17+
if [[ "$(git status --porcelain)" ]]; then
18+
echo "git status has non-empty output, is your repo dirty?"
19+
exit 1
20+
fi
21+
22+
sed -r -i "1,/<name>/ s@<version>.*</version>@<version>$X.$Y.$Z</version>@" pom.xml */pom.xml
23+
sed -r -i "s@version = .*@version = $X.$Y.$Z@" andrvotr-impl/src/main/resources/io/github/fmfi_svt/andrvotr/plugin.properties
24+
25+
git commit -a -m "build: release $VERSION"
26+
git tag "$VERSION"
27+
28+
((Z++))
29+
30+
sed -r -i "1,/<name>/ s@<version>.*</version>@<version>$X.$Y.$Z-SNAPSHOT</version>@" pom.xml */pom.xml
31+
sed -r -i "s@version = .*@version = $X.$Y.$Z@" andrvotr-impl/src/main/resources/io/github/fmfi_svt/andrvotr/plugin.properties
32+
33+
git commit -a -m "build: bump version after $VERSION"
34+
35+
echo "Done"
36+
echo "Now push it with: git push --tags"
37+
echo "GitHub Actions should build it."
38+
echo "Then update plugins.properties on the meta branch (you can use GitHub web GUI)."
39+

0 commit comments

Comments
 (0)