Skip to main content

Passive users migration

Context

Migrating a big user base is a non-trivial task especially without causing downtime. Gate enables you to migrate your users passively without invalidating their sessions or resetting their credentials.

Solution

Gate can inspect HTTP requests and every time an unknown identity token is sent, Gate can create a corresponding user in SlashID. After a user is created in SlashID, Gate adds custom headers containing the SlashID person ID to the request and forwards it to your backend.

To execute the migration, you can use the Users mirroring plugin.

UserYour systemLoad balancerToken mapping endpointDestination endpointGateSlashID APIToken from configured headerPerson handlesCreate personCreated person IDHTTP requestHTTP requestHTTP request

Most of the functionality is provided by Gate out of the box. You only need to implement a token mapping endpoint.

Example

Configuring Gate

This is an example configuration for Gate such that when a user navigates to */api/generic, Gate generates a new user in SlashID.

Note how the translator plugin invokes the webhook at http://backend:8000/map_token to perform the translation.

slashid_config: &slashid_config
slashid_org_id: { { .env.SLASHID_ORG_ID } }
slashid_api_key: { { .env.SLASHID_API_KEY } }
slashid_base_url: { { .env.SLASHID_BASE_URL } }

gate:
port: 8080
log:
format: text
level: trace

default:
target: http://backend:8000

plugins:
- id: translator_up
type: token-translation-upgrade
enable_http_caching: true
enabled: false
parameters:
<<: *slashid_config
header_with_token: Authorization
map_token_endpoint: http://backend:8000/map_token

urls:
# The /api/generic endpoint names the translation upgrade plugin and
# the endpoint will receive a SlashID token
- pattern: "*/api/generic"
target: http://backend:8000
plugins:
translator_up:
enabled: true

An example token mapping endpoint

For this example, let's assume the incoming legacy token is of the form:

{
"typ": "internal_token_format_1",
"username": "user@example.com",
"name": "Regular YourBrand User"
}

The map_token function takes in the legacy token, extracts the user by looking up the username in the legacy token and extracts the user from it (get_user). The webhook returns a json object of the form:

{
"handles": {
"email_address": "user@example.com"
},

"custom_claims": {
"foo": [1, 2, 3],
"orig_token": ""
}
}

In the background, SlashID checks whether a user with that handle exists if so it mints a token for that user including the custom claims passed in. Otherwise it first creates a new user with SlashID and then returns the token.

def get_user(username: Optional[str]) -> Optional[DatabaseUser]:
try:
if username == f"user@{vendor_domain}":
return DatabaseUser(
username=username,
name=f"Regular {vendor_name} User",
street_address="1234 Main Street, Apartment 101, Manvel 77578, Texas, USA",
password_hash=pbkdf2_sha256.hash(username),
user_roles=["user"],
)
if username == f"admin@{vendor_domain}":
return DatabaseUser(
username=username,
name=f"Admin {vendor_name} User",
street_address="666 Greenwich Street, New York 10009, New York, USA",
password_hash=pbkdf2_sha256.hash(username),
user_roles=["user", "admin"],
)
except:
pass
return None

async def map_token(request: MapTokenRequest) -> MapTokenResponse | Response:
logger.info(f"/map_token: request={request}")

req_token = request.token
if not req_token.lower().startswith("bearer "):
return Response(status_code=status.HTTP_204_NO_CONTENT)

req_token = req_token[len("bearer ") :]
logger.info(f"/map_token: req_token={req_token}")

try:
token = jwt.decode(req_token, TOKEN_SIGNING_KEY, algorithms=["HS256"])
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Bearer token {req_token} is invalid: {e}",
)

username: Optional[str] = token.get("username")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Bearer token {req_token} doesn't contain username",
)

user = get_user(username)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"User {user} is not mapped to /id person",
)

custom_claims= vars(user)
custom_claims["foo"] = [1, 2, 3]
custom_claims["orig_token"] = req_token

return MapTokenResponse(
person_id=None,
handles=[Handle(type="email_address", value=username)],
custom_claims=custom_claims,
)

Conclusion

Your services have gracefully switched over from your legacy IdP tokens to SlashID tokens without any downtime or any significant risk to your infrastructure.