The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Postini::SAML;
{
  $Postini::SAML::VERSION = '0.001';
}

use warnings;
use strict;

use Crypt::OpenSSL::RSA;
use MIME::Base64 qw( encode_base64 );
use XML::Spice;
use Date::Format qw( time2str );
use Data::Random qw( rand_chars );
use XML::CanonicalizeXML;
use Digest::SHA1 qw( sha1 );
use Carp qw( croak );

# Postini SAML ACS
my $ACS_URL = 'https://pfs.postini.com/pfs/spServlet';

sub get_acs_url {
    return $ACS_URL;
}

sub new {
    my ($class, $arg) = @_;

    my @missing = grep { not exists $arg->{$_} } qw( keyfile certfile issuer );
    if ( @missing )
    {
        croak "missing args: " . join( q{ }, @missing );
    }
    
    my $self = bless {}, $class;

    $self->_load_rsa_key( $arg->{'keyfile'}, $arg->{'certfile'} );

    $self->{'issuer'} = $arg->{'issuer'};

    return $self;
}

sub _load_rsa_key {
    my ($self, $key_file, $cert_file) = @_;

    # load the keyfile and prepare a context for signing
    open my $key_fh, '<', $key_file or croak "couldn't open $key_file for reading: $!";
    my $key_text = do { local $/; <$key_fh> };
    close $key_fh;

    my $key = Crypt::OpenSSL::RSA->new_private_key( $key_text );
    if ( not $key )
    {
        croak "failed to instantiate Crypt::OpenSSL::RSA object from $key_file";
    }

    $key->use_pkcs1_padding();
    $self->{'key'} = $key;

    # we need to include the certificate without headers in the signed XML, so
    # extract it
    open my $cert_fh, '<', $cert_file or croak "couldn't open $cert_file for reading: $!";
    my $cert_text = do { local $/; <$cert_fh> };
    close $cert_fh;

    my ($cert_pem) = $cert_text =~ m{
        -----BEGIN\sCERTIFICATE-----
        (.+)
        -----END\sCERTIFICATE-----
    }smx;
    $cert_pem =~ s{ [\r\n]+ }{}smxg;

    # build a XML fragment containing the key info. this will be included in
    # the signature XML
    $self->{'key_info_xml'} =
        x('ds:KeyInfo',
            x('ds:X509Data',
                x('ds:X509Certificate', $cert_pem),
            ),
        ),
    ;
}

# return the current signature xml (actually XML::Spice chunk). deliberately
# returns undef if its not available, causing it to be ignored during chunk
# expansion
sub _get_cached_signature_xml {
    my ($self) = @_;
    return $self->{'signature_xml'};
}

# generate a valid, signed response and return it
sub get_response_xml {
    my ($self, $mail) = @_;

    if ( not $mail )
    {
        croak "required email address not provided";
    }

    # INPUT: 
    #   T, text-to-be-signed, a byte string; 
    #   Ks, RSA private key; 
    #
    # 1. Canonicalize the text-to-be-signed, C = C14n(T).
    # 2. Compute the message digest of the canonicalized text, m = Hash(C).
    # 3. Encapsulate the message digest in an XML <SignedInfo> element, SI, in canonicalized form.
    # 4. Compute the RSA signatureValue of the canonicalized <SignedInfo> element, SV = RsaSign(Ks, SI).
    # 5. Compose the final XML document including the signatureValue, this time in non-canonicalized form.
    
    # get rid of any cached signature
    delete $self->{'signature_xml'};

    # get the response data and canonicalise it
    my $response_xml = $self->_response_xml( $mail );
    my $canonical_response_xml = $self->_canonicalize_xml( $response_xml );

    # compute digest
    my $response_digest = encode_base64( sha1( $canonical_response_xml ), q{} );

    # create a canonical signed info fragment
    my $signed_info_xml = $self->_signed_info_xml( $response_digest );
    my $canonical_signed_info_xml = $self->_canonicalize_xml( $signed_info_xml );

    # create the signature
    my $signature = encode_base64( $self->{'key'}->sign( $canonical_signed_info_xml ), q{} );

    # now create the signature xml fragment
    $self->{'signature_xml'} = $self->_signature_xml( $signed_info_xml, $signature );;

    # force the response chunk to be regenerated which will cause the
    # signature to be included
    $response_xml->forget;

    # stringify and return
    return "".$response_xml;
}

# generate a signature XML fragment, including the signature metadata fragment
# and the raw signature
sub _signature_xml {
    my ($self, $signed_info_xml, $signature) = @_;

    my $signature_xml =
        x('ds:Signature',
            {
                'xmlns:ds' => 'http://www.w3.org/2000/09/xmldsig#',
            },
            $signed_info_xml,
            x('ds:SignatureValue', $signature),
            $self->{'key_info_xml'},
        ),
    ;

    return $signature_xml;
}

# generate a signature metadata XML fragement, including the message digest
sub _signed_info_xml {
    my ($self, $digest) = @_;

    my $signed_info_xml =
        x('ds:SignedInfo',
            {
                # we must include all the namespaces in use anywhere in the
                # document so they can be included in the signature
                'xmlns:ds'    => 'http://www.w3.org/2000/09/xmldsig#',
                'xmlns:saml'  => 'urn:oasis:names:tc:SAML:1.0:assertion',
                'xmlns:samlp' => 'urn:oasis:names:tc:SAML:1.0:protocol',
            },

            x('ds:CanonicalizationMethod',
                {
                    'Algorithm' => 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315',
                },
            ),
            x('ds:SignatureMethod',
                {
                    'Algorithm' => 'http://www.w3.org/2000/09/xmldsig#rsa-sha1',
                },
            ),

            x('ds:Reference',
                {
                    'URI' => "",
                },
                x('ds:Transforms',
                    x('ds:Transform',
                        {
                            'Algorithm' => 'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
                        },
                    ),
                ),
                x('ds:DigestMethod',
                    {
                        'Algorithm' => 'http://www.w3.org/2000/09/xmldsig#sha1',
                    }
                ),
                x('ds:DigestValue', $digest),
            ),
        ),
    ;

    return $signed_info_xml;
}

# build the SAML response, including the signature if available
sub _response_xml {
    my ($self, $mail) = @_;

    my $now = time();
    my $issue_instant = time2str( '%Y-%m-%dT%XZ', $now, 'UTC' );

    # assertion is valid for 60 seconds
    my $not_on_or_after = time2str( '%Y-%m-%dT%XZ', $now+60, 'UTC' );

    # first character must not be a number to match xsd:ID
    my $response_id  = join q{}, 'z', rand_chars( 'set' => 'alphanumeric', 'size' => 40 );
    my $assertion_id = join q{}, 'z', rand_chars( 'set' => 'alphanumeric', 'size' => 40 );
    my $name_id      = join q{}, 'z', rand_chars( 'set' => 'alphanumeric', 'size' => 40 );

    my $response_xml =
        x('samlp:Response',
            {
                'xmlns:saml'   => 'urn:oasis:names:tc:SAML:1.0:assertion',
                'xmlns:samlp'  => 'urn:oasis:names:tc:SAML:1.0:protocol',

                'MajorVersion' => '1',
                'MinorVersion' => '1',

                'IssueInstant' => $issue_instant,
                'ResponseID'   => $response_id,
                'Recipient'    => $ACS_URL,
            },

            # include the signature if its available. if not then it wil be
            # undef and will be ignored
            sub { $self->{'signature_xml'} },

            x('samlp:Status',
                x('samlp:StatusCode',
                    {
                        'Value' => 'samlp:Success',
                    },
                ),
            ),

            x('saml:Assertion',
                {
                    'MajorVersion' => '1',
                    'MinorVersion' => '1',

                    'IssueInstant' => $issue_instant,
                    'AssertionID'  => $assertion_id,
                    'Issuer'       => $self->{'issuer'},
                },

                x('saml:Conditions',
                    {
                        'NotBefore'    => $issue_instant,
                        'NotOnOrAfter' => $not_on_or_after,
                    },
                ),

                x('saml:AuthenticationStatement',
                    {
                        'AuthenticationInstant' => $issue_instant,
                        'AuthenticationMethod' => 'urn:oasis:names:tc:SAML:1.0:am:unspecified',
                    },

                    x('saml:Subject',
                        x('saml:NameIdentifier', $name_id),
                        x('saml:SubjectConfirmation',
                            x('saml:ConfirmationMethod', 'urn:oasis:names:tc:SAML:1.0:cm:bearer'),
                        ),
                    ),
                ),

                x('saml:AttributeStatement',
                    x('saml:Subject',
                        x('saml:NameIdentifier', $name_id),
                        x('saml:SubjectConfirmation',
                            x('saml:ConfirmationMethod', 'urn:oasis:names:tc:SAML:1.0:cm:bearer'),
                        ),
                    ),

                    x('saml:Attribute',
                        {
                            'AttributeName'      => 'personal_email',
                            'AttributeNamespace' => 'urn:mace:shibboleth:1.0:attributeNamespace:uri',
                        },
                        x('saml:AttributeValue', $mail),
                    ),
                ),
            ),
        ),
    ;

    return $response_xml;
}

# canonicalise XML using W3C REC-xml-c14n-20010315 algorithm
# returns a string, not a XML::Spice chunk
sub _canonicalize_xml {
    my ($self, $xml) = @_;

    my $xpath = '<XPath>(//. | //@* | //namespace::*)</XPath>';
    return XML::CanonicalizeXML::canonicalize( $xml, $xpath, [], 0, 0 );
}

sub get_form {
    my ($self, $mail) = @_;

    my $saml_response = encode_base64( $self->get_response_xml( $mail ), q{} );

    my $html = join( q{},
        qq{<form action="$ACS_URL" method="post">},
        qq{<input type="hidden" name="SAMLResponse" value="$saml_response" />},
        qq{<input type="hidden" name="TARGET" value="$ACS_URL" />},
        qq{<input type="submit" name="Submit" value="Submit" />},
        qq{</form>},
    );

    return $html;
}

__END__

=head1 NAME

Postini::SAML - Do SAML-based sign-in to Postini services

=head1 VERSION

version 0.001

=head1 SYNOPSIS

    use Postini::SAML;

    # setup keys and certificates
    my $saml = Postini::SAML->new({
        keyfile  => 'postini.key',
        certfile => 'postini.crt',
        issuer   => 'example.com',
    });

    # get a signed SAML response that will sign in the given user
    my $response_xml = $saml->get_response_xml('user@example.com');
 
    # quick and dirty HTML form for testing
    print $saml->get_form('user@example.com');

=head1 DESCRIPTION

L<Postini::SAML> creates signed SAML responses suitable for signing your users
into Postini services (aka "Google Message Security" and/or "Google Message
Discovery").

It is not a complete SAML implementation or SSO solution. It implements just
enough of the SAML spec to get you into Postini. The author is not an expert
on SAML, XML or security in general. Don't be afraid though; this module does
work and is production use at at least one site :)

Postini offers two modes of operation for SAML SSO - "push" (or "post") and
"pull" (or "artifact). This modules implements the push model.

The typical SAML flow for Postini is slightly different to a standard SAML
flow in that it is not initiated on the Postini side. Instead you need to set
up a sign-in page on your website or application server and direct your users
to it. The flow is as follows:

=over 4

=item *

User accesses a web page that you provide. If they are not already identified
(signed in), they work through some sign-in process.

=item *

Page uses L<Postini::SAML> to generate a HTML form containing the signed
response with the user's Postini username (email address). The form target is
the Postini ACS URL.

=item *

User submits the form (either explicitly or implicitly eg via Javascript) to
Postini.

=item *

Postini verifies the signature and if valid, signs the user in.

=back

See the discussion of L</get_form> for information on how to generate your own
form.

=head1 SETUP

Before you use this module its necessary to have SSO configured for your
Postini organisation. The Postini docs are a bit thin on what you need to do.
A full explanation is well outside the scope of this document, but here's list
of things you should have in place before trying to use this module:

=over 4

=item *

Create a certificate and key pair.

=item *

Upload the certificate/public key to Postini. Set an appropriate value for the
issuer (typically your domain name).

=item *

Enable SSO login for one or more of your user organisations. Make sure you
keep at least one admin user in another organisation that uses password login,
otherwise you may find you can't get back in if something goes wrong.

=back

=head1 CONSTRUCTOR

    my $saml = Postini::SAML->new({
        keyfile  => 'postini.key',
        certfile => 'postini.crt',
        issuer   => 'example.com',
    });

Creates an object that can produce SAML responses. You need to provide three arguments:

=over 4

=item keyfile

Name of file containing the private key in PEM format.

=item certfile

Name of file containing the certificate in PEM format.

=item issuer

The issuer attached to this certificate in the Postini configuration.

=back

=head1 METHODS

=head2 get_response_xml

    my $response_xml = $saml->get_response_xml('user@example.com');

Create a signed SAML response document that, when submitted to the Postini ACS
URL, will sign in the specified user. The response is valid for 60 seconds and
so should be returned to the user for submission immediately.

=head2 get_form

    print $saml->get_form('user@example.com');

Creates a basic HTML form that, when submitted, will sign in the specified
user. This is provided for testing purposes only. While you could use it as
part of a larger page you're probably better to make something tailored to
your environment.

To submit the SAML response to Postini you need to perform a HTTP POST to the
ACS URL, which can be obtained using L</get_acs_url>. The request takes two
arguments:

=over 4

=item SAMLResponse

The Base64-encoded response XML returned by L</get_response_xml>.

=item TARGET

The Postini ACS URL, obtained using L</get_acs_url>.

=back

=head2 get_acs_url

    my $acs_url = $saml->get_acs_url;

Get the ACS URL to submit the SAML response document to. The is hardcoded to
C<https://pfs.postini.com/pfs/spServlet>.

=head1 BUGS

None known. Please report bugs via the CPAN Request Tracker at
L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Postini-SAML>

=head1 FEEDBACK

If you find this module useful, please consider rating it on the CPAN Ratings
service at L<http://cpanratings.perl.org/rate?distribution=Postini-SAML>.

If you like (or hate) this module, please tell the author! Send mail to
E<lt>rob@eatenbyagrue.orgE<gt>.

=head1 SEE ALSO

L<Google::SAML::Response>

=head1 AUTHOR

Robert Norris E<lt>rob@eatenbyagrue.orgE<gt>

=head1 COPYRIGHT AND LICENSE

Copyright (c) 2011 Monash University.

This module is free software, you can redistribute it and/or modify it under the same terms as Perl itself.

=cut

1;