Authentication
Overview
Reboot has native support for authenticating clients using the widely
used Authorization: Bearer
header.
Depending on your application's requirements, clients might use access tokens (such as JWTs) or long-lived API keys -- produced by your choice of authentication backend.
Client
To include a token on all requests made from a particular client context, a token can be set at the context level:
- Python
- React
- TypeScript
from reboot.aio.external import ExternalContext
context = ExternalContext(
name='example-context',
gateway=..,
bearer_token=token,
)
import { useRebootContext } from "@reboot-dev/reboot-react";
const { setAuthorizationBearer } = useRebootContext();
setAuthorizationBearer(token);
const context = rbt.createExternalContext("example-context", {
bearerToken: token,
});
A token may also be specified for all calls made to a particular state instance
by specifying it in lookup
:
- Python
- TypeScript
from_account = Account.lookup(
request.from_account_id,
bearer_token=bearer_token,
)
await Greeter.lookup(greeter.stateId, {
bearerToken: TOKEN_FOR_TEST,
}).greet(context, {});
... or in construct
:
- Python
- TypeScript
bank, _ = await Bank.construct(
id=SINGLETON_BANK_ID,
bearer_token=VALID_JWT,
).Create(context)
const [greeter] = await Greeter.construct({
bearerToken: TOKEN_FOR_TEST,
}).create(context, {});
Backend
To authenticate and authorize using a token on the backend, Reboot
applications use two interfaces: TokenVerifier
(which handles parsing
and validation of tokens) and Authorizer
(which decides whether a caller
has access to a particular state or state method).
TokenVerifier
The TokenVerifier
interface has a single method, which receives the token
from the
Authorization: Bearer
header, and which should return an Auth
object if the token
was valid:
- Python
- TypeScript
@abstractmethod
async def verify_token(
self,
context: ReaderContext,
token: str,
) -> Optional[Auth]:
abstract verifyToken(
context: ReaderContext,
token: string
): Promise<Auth | null>;
A TokenVerifier
is set globally on your Reboot Application
(or on Reboot.up
in the case of unit tests):
- Python
- TypeScript
async def main():
application = Application(
servicers=[...],
token_verifier=MyTokenVerifier(...),
)
await application.run()
new Application({
servicers: [...],
...,
tokenVerifier: new MyTokenVerifier(...),
}).run();
When a TokenVerifier
is installed, Authorizer
s must also be installed in order to
consume the Auth
objects that are produced.
Authorizer
Authorizer
s are associated with state Servicer
s, and the
authorizer
method returns the Authorizer
that will be used:
- Python
- TypeScript
class AuthAccountServicer(AccountServicer):
"""Servicer that shares implementation with `AccountServicer` but uses
a custom Authorizer.
"""
def authorizer(self) -> Optional[Account.Authorizer]:
return AccountAuthorizer()
class AuthedGreeterServicer extends GreeterServicer {
authorizer() {
return new TestAuthorizer();
}
}
There are two ways to implement the Authorizer
interface:
Per-state authorization
If most of the methods of your servicer have the same authorization
strategy and don't need access to the method arguments, then you can directly
implement the Authorizer
interface.
The built-in AllowAllIfAuthenticated
Authorizer
can be used unmodified,
but also serves as a good example of implementing the Authorizer
interface:
- Python
- TypeScript
class AllowAllIfAuthenticated(Authorizer[Message, Message]):
"""An authorizer that allows all requests if the caller is authenticated."""
async def authorize(
self,
*,
method_name: str,
context: ReaderContext,
state: Optional[Message] = None,
request: Optional[Message] = None,
) -> Authorizer.Decision:
if context.auth is None:
return rbt.v1alpha1.errors_pb2.Unauthenticated()
return rbt.v1alpha1.errors_pb2.Ok()
/**
* An authorizer that allows all requests, as long as the caller is authenticated.
*/
export class AllowAllIfAuthenticated extends Authorizer<
protobuf_es.Message,
protobuf_es.Message
> {
async authorize(
methodName: string,
context: ReaderContext,
state?: protobuf_es.Message,
request?: protobuf_es.Message
): Promise<AuthorizerDecision> {
if (!context.auth) {
return new errors_pb.Unauthenticated();
}
return new errors_pb.Ok();
}
}
Per-method authorization
Alternatively, if the methods of your state have a variety of different
authorization strategies or need access to the method arguments, then you
can use an instance of the Authorizer
interface which is generated for each
Servicer
.
For example, a writer
method in the Bank
state like:
rpc SignUp(SignUpRequest) returns (SignUpResponse) {
option (rbt.v1alpha1.method).transaction = {
};
}
... results in a generated abstract Bank.Authorizer
subclass with a
authorization method that can be implemented like so:
- Python
- TypeScript
async def SignUp(
self,
context: ReaderContext,
state: Bank.State,
request: bank_rbt.SignUpRequest,
) -> Bank.Authorizer.Decision:
if context.auth is None:
return Unauthenticated()
if context.auth.user_id != TEST_USER:
return PermissionDenied()
return Ok()
async signUp(
context: ReaderContext,
state: Bank.State,
request: SignUpRequest,
): Promise<reboot.AuthorizerDecision> {
if (!context.auth) {
return new Unauthenticated();
}
if (context.auth.user_id != TEST_USER) {
return new PermissionDenied();
}
return new Ok();
}
Authorizer
methods should not have effects, so they always receive a
ReaderContext
regardless of the kind of method
that they are authorizing for.
Thirdparty auth providers
Integrating a thirdparty auth provider like Auth0 or Ory into your backend usually involves:
- implementing a
TokenVerifier
that validates access tokens, and producesAuth
objects containing any relevant permissions. - implementing an
Authorizer
that authorizes calls at either the method or state level with those permissions.